Skip to content
1 change: 1 addition & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ type ShellRunResultMetadata = {
kind: 'shell_run';
ref: string;
status: ShellRunStatus;
pid?: number;
cwd: string;
cmd: string;
startedAt: number;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/shell-run-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const CURRENT_TERMINAL_RESULT_SHAPE = defineObjectShape<TerminalToolResult>()(
const CURRENT_SHELL_RUN_RESULT_SHAPE = defineObjectShape<ShellRunToolResultRecord>()(
['kind', 'ref', 'mode', 'status', 'cwd', 'cmd', 'startedAt', 'updatedAt', 'revision'],
[
'pid',
'completedAt',
'exitCode',
'failureMessage',
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/shell-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export interface ShellRunRecord {
cwd: string;
command: string;
status: ShellRunStatus;
/** Native root process id, when admitted by the process driver. */
pid?: number;
exitCode?: number;
failureMessage?: string;
startedAt: number;
Expand All @@ -159,7 +161,14 @@ export interface ShellRunRecord {
export type ShellRunPatch = Partial<
Pick<
ShellRunRecord,
'status' | 'exitCode' | 'failureMessage' | 'updatedAt' | 'completedAt' | 'observedAt' | 'output'
| 'status'
| 'pid'
| 'exitCode'
| 'failureMessage'
| 'updatedAt'
| 'completedAt'
| 'observedAt'
| 'output'
Comment on lines +164 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Formatting churn: please revert. This region has no logic change — the original single-line Pick<...> list was reformatted to one key per line. It passes biome (I ran the repo-pinned Biome 2.5.11 format/lint over all 11 changed files: zero violations), but it is meaningless diff noise: it obscures the actual change, burdens review, and pollutes future git blame. Please restore the original layout and just insert 'pid' into the existing list.

>
>;

Expand Down Expand Up @@ -307,6 +316,7 @@ const SHELL_RUN_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;

const SHELL_RUN_PATCH_KEYS: ReadonlySet<string> = new Set([
'status',
'pid',
'exitCode',
'failureMessage',
'updatedAt',
Expand All @@ -325,6 +335,7 @@ const SHELL_RUN_RECORD_KEYS: ReadonlySet<string> = new Set([
'cwd',
'command',
'status',
'pid',
'startedAt',
'updatedAt',
'completedAt',
Expand Down Expand Up @@ -380,6 +391,7 @@ export function normalizeShellRunRecord(
record.sessionId === sessionId &&
record.shellRunId === shellRunId &&
isShellRunStatus(record.status) &&
(record.pid === undefined || isPositiveInteger(record.pid)) &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Cross-version compat to declare. normalizeShellRunRecord validates strictly via hasOnlyKeys(record, SHELL_RUN_RECORD_KEYS). Once a new version persists a record containing pid, older code throws on read ('pid' not in the old keys set). New-reads-old is fine; old-reads-new breaks. If this one-way upgrade is acceptable, please state it in the PR description.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the PID when canonicalizing the record. This validator accepts record.pid, but canonicalShellRunRecord() rebuilds the object without that field. I ran a real ShellRunProcessManager with the SQLite-backed store and a live sleep 30; the initial background result, stored record, and later runtime-resource read all had pid === undefined while the task was running. As a result, the headline PID evidence never reaches BackgroundTaskHealth on the normal path. Please copy the optional PID in canonicalShellRunRecord() and cover the manager/store/read path rather than injecting a prebuilt result into the tool test.

isFiniteNumber(record.startedAt) &&
isFiniteNumber(record.updatedAt) &&
isPositiveInteger(record.revision) &&
Expand Down Expand Up @@ -515,6 +527,7 @@ function isShellRunSandboxEscalation(value: unknown, execution: unknown): boolea

function canonicalShellRunRecord(record: ShellRunRecord): ShellRunRecord {
return {
...(record.pid !== undefined ? { pid: record.pid } : {}),
shellRunId: record.shellRunId,
sessionId: record.sessionId,
...(record.sourceRunId !== undefined ? { sourceRunId: record.sourceRunId } : {}),
Expand Down
63 changes: 62 additions & 1 deletion packages/runtime-host/src/__tests__/web-fetch-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { test } from 'node:test';
import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy';
import type { MakaToolContext } from '@maka/runtime/tool-runtime';
Expand All @@ -26,7 +27,67 @@ import type {
ResolveHostOutboundExecutionResult,
RuntimePolicyOperationCoordinator,
} from '@maka/storage/runtime-policy-stores';
import { createHostWebFetchTool } from '../server/web-fetch-tool.js';
import { createHostWebFetchService, createHostWebFetchTool } from '../server/web-fetch-tool.js';

test('health probes reject metadata before creating a transport', async () => {
const service = createHostWebFetchService({
policy: resolver({
kind: 'ready',
networkProxy: createDefaultRuntimePolicy().networkProxy,
secretMaterial: {},
}),
createFetchTransport: () => {
throw new Error('must not create transport');
},
});
for (const url of [
'http://169.254.169.254/latest/meta-data/',
'http://metadata.google.internal/',
]) {
await assert.rejects(
service.probe({ url, sessionId: 'session-1', abortSignal: new AbortController().signal }),
/metadata/,
);
}
});

test('real health probe falls back to GET and bounds stalled responses', async () => {
const methods: string[] = [];
const server = createServer((req, res) => {
methods.push(req.method!);
if (req.url === '/stalled') return;
res.writeHead(req.method === 'HEAD' ? 405 : 200);
res.end('ready');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert.ok(address && typeof address !== 'string');
const service = createHostWebFetchService({
policy: resolver({
kind: 'ready',
networkProxy: createDefaultRuntimePolicy().networkProxy,
secretMaterial: {},
}),
probeTimeoutMs: 100,
});
const input = { sessionId: 'session-1', abortSignal: new AbortController().signal };
try {
assert.equal(
(await service.probe({ ...input, url: `http://127.0.0.1:${address.port}/ready` })).status,
200,
);
assert.deepEqual(methods, ['HEAD', 'GET']);
await assert.rejects(
service.probe({ ...input, url: `http://127.0.0.1:${address.port}/stalled` }),
/timed out/,
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
});

test('Host WebFetch uses the resolved proxy snapshot and closes its transport', async () => {
const networkProxy = {
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ import {
shouldResolveHostTavilyWebSearchReadiness,
} from './web-search-tool.js';
import { createHostWebFetchService, createHostWebFetchToolFromService } from './web-fetch-tool.js';
import { buildBackgroundTaskHealthTool } from '@maka/runtime/background-task-health-tool';
import { createHostExecutionArtifactServices } from './execution-artifacts.js';
import { openToolResultArchiveEvidenceReader } from '@maka/storage/tool-result-archive-evidence';
import {
Expand Down Expand Up @@ -653,6 +654,10 @@ export async function createExecutionRuntimeHostComposition(
const webFetchService = createHostWebFetchService({
policy: runtimePolicyStores.operations,
});
const backgroundTaskHealthTool = buildBackgroundTaskHealthTool(
runtimeResources!,
webFetchService,
);
pluginWeb.bindRuntime({
search: ({ query, limit, abortSignal }) =>
webSearchService.search({ query, limit, ...(abortSignal ? { abortSignal } : {}) }),
Expand All @@ -675,6 +680,7 @@ export async function createExecutionRuntimeHostComposition(
const childHostTools = [
createHostWebSearchToolFromService(webSearchService),
createHostWebFetchToolFromService(webFetchService),
backgroundTaskHealthTool,
...runtimePolicy.modelTools,
];
const hostTools = [...childHostTools, ...historyTools];
Expand Down
48 changes: 47 additions & 1 deletion packages/runtime-host/src/server/web-fetch-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import { buildWebFetchTool } from '@maka/runtime/web-fetch-tool';
import { createLocalWebFetchExecutor } from '@maka/runtime/local-web-fetch';
import { assertAllowedTarget, createLocalWebFetchExecutor } from '@maka/runtime/local-web-fetch';
import {
createProxiedFetchTransport,
type ProxiedFetchProxy,
Expand All @@ -29,6 +29,7 @@ import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-po
import { toRuntimePolicyProxy } from './runtime-policy-proxy.js';

interface HostWebFetchServiceInput {
readonly probeTimeoutMs?: number;
readonly policy: Pick<RuntimePolicyOperationCoordinator, 'resolveHostOutboundExecution'>;
readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport;
}
Expand All @@ -39,6 +40,11 @@ export interface HostWebFetchService {
readonly sessionId: string;
readonly abortSignal?: AbortSignal;
}): Promise<string>;
probe(input: {
url: string;
sessionId: string;
abortSignal: AbortSignal;
}): Promise<{ status: number; statusText?: string; elapsedMs: number }>;
}

export function createHostWebFetchService(input: HostWebFetchServiceInput): HostWebFetchService {
Expand All @@ -65,6 +71,46 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host
await transport.close();
}
},
probe: async ({ url, abortSignal }) => {
const parsed = new URL(url);
assertAllowedTarget(parsed);
abortSignal.throwIfAborted();
const resolved = await input.policy.resolveHostOutboundExecution();
if (resolved.kind === 'privacy_mode')
throw new Error('Endpoint health checks are disabled while privacy mode is active.');
if (resolved.kind === 'credential_not_configured')
throw new Error('Configure the network proxy credential before checking an endpoint.');
const transport = createFetchTransport(
toRuntimePolicyProxy(resolved.networkProxy, resolved.secretMaterial.networkProxy?.secret),
);
const started = Date.now();
const timeout = new AbortController();
const timer = setTimeout(
() => timeout.abort(new Error('Endpoint health probe timed out.')),
input.probeTimeoutMs ?? 30_000,
);
const signal = AbortSignal.any([abortSignal, timeout.signal]);
try {
let response = await transport.fetch(parsed, {
method: 'HEAD',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] A HEAD-only response is not sufficient to classify endpoint health. A local service whose registered route returned 200 to GET and 405 to HEAD was reported by the real BackgroundTaskHealth tool as health: "unhealthy", even though the endpoint was reachable and serving normally. Retry with a bounded GET (discarding or cancelling the body) for 405/501, or define a probe contract that distinguishes reachable/listening from application health.

redirect: 'manual',
signal,
});
await response.body?.cancel();
if (response.status === 405 || response.status === 501) {
response = await transport.fetch(parsed, { method: 'GET', redirect: 'manual', signal });
await response.body?.cancel();
}
return {
status: response.status,
...(response.statusText ? { statusText: response.statusText } : {}),
elapsedMs: Date.now() - started,
};
} finally {
clearTimeout(timer);
await transport.close();
}
},
};
}

Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ The main integration points are:

Shared execution composition — where `BackendRegistry` and `SessionManager` are constructed — lives in the Runtime Host at [`packages/runtime-host/src/server/execution-composition.ts`](../runtime-host/src/server/execution-composition.ts). Clients, including Desktop, execute Maka through Runtime Host rather than composing Runtime directly.

## Background-task readiness

`Bash` background runs return a durable runtime-task ref and expose the native
process id when available. A process id published after startup is captured on
the next task observation, output flush, or finalization.
`BackgroundTaskHealth` deliberately keeps
the process lifecycle (`starting`, `running`, or terminal, with timestamps and
captured output) separate from endpoint readiness. An endpoint is `healthy`
only after an explicit HTTP(S) probe succeeds; an omitted probe is
`not_checked`, and a failed or policy-blocked probe is `unknown`. Consumers must
not infer HTTP readiness from the process status alone.

## Extension rules

- Add backend behavior behind `AgentBackend` and register it through the existing registry.
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"./builtin-tools": "./dist/builtin-tools.js",
"./shell-tools": "./dist/shell-tools.js",
"./shell-run-manager": "./dist/shell-run-manager.js",
"./background-task-health-tool": "./dist/background-task-health-tool.js",
"./deep-research-tools": "./dist/deep-research-tools.js",
"./durable-tool-result-projection": "./dist/durable-tool-result-projection.js",
"./tool-artifacts": "./dist/tool-artifacts.js",
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/__tests__/background-task-health-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildBackgroundTaskHealthTool } from '../background-task-health-tool.js';

const context = {
sessionId: 'session-1',
turnId: 'turn-1',
toolCallId: 'tool-1',
cwd: '/tmp',
abortSignal: new AbortController().signal,
} as any;
const shell = (status: string, pid?: number) => ({
kind: 'shell_run',
ref: 'maka://runtime/background-tasks/run-1',
status,
mode: 'pipes',
cwd: '/tmp',
cmd: 'python -m http.server',
startedAt: 1,
updatedAt: 2,
revision: 2,
...(pid ? { pid } : {}),
});

test('reports process tracking separately when endpoint is not checked', async () => {
const tool = buildBackgroundTaskHealthTool(
{ readRuntimeResource: async () => shell('running', 1234) } as any,
{
probe: async () => {
throw new Error('must not probe');
},
},
);
assert.deepEqual(
JSON.parse(String(await tool.impl({ ref: 'maka://runtime/background-tasks/run-1' }, context))),
{
process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 },
endpoint: { state: 'not_checked' },
},
);
});

test('reports endpoint health only from the probe result', async () => {
let called = 0;
const tool = buildBackgroundTaskHealthTool(
{ readRuntimeResource: async () => shell('running', 1234) } as any,
{
probe: async () => {
called += 1;
return { status: 204, statusText: 'No Content', elapsedMs: 4 };
},
},
);
assert.deepEqual(
JSON.parse(
String(
await tool.impl(
{ ref: 'maka://runtime/background-tasks/run-1', url: 'http://127.0.0.1:8765/' },
context,
),
),
),
{
process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 },
endpoint: {
state: 'checked',
httpStatus: 204,
elapsedMs: 4,
target: 'http://127.0.0.1:8765/',
health: 'healthy',
},
},
);
assert.equal(called, 1);
});

test('does not convert a failed probe into a ready claim', async () => {
const tool = buildBackgroundTaskHealthTool(
{ readRuntimeResource: async () => shell('running', 1234) } as any,
{
probe: async () => {
throw new Error('connection refused');
},
},
);
assert.deepEqual(
JSON.parse(
String(
await tool.impl(
{ ref: 'maka://runtime/background-tasks/run-1', url: 'http://127.0.0.1:8765/' },
context,
),
),
),
{
process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 },
endpoint: { state: 'unknown', target: 'http://127.0.0.1:8765/', error: 'connection refused' },
},
);
});
Loading