From 01a0b3f6361eec9979bbdcce62ba00a96aee430e Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:04:36 +0800 Subject: [PATCH 1/9] feat(runtime): report background task process and endpoint health --- packages/core/src/events.ts | 1 + packages/core/src/shell-run-result.ts | 1 + packages/core/src/shell-run.ts | 7 ++- .../src/server/execution-composition.ts | 3 + .../runtime-host/src/server/web-fetch-tool.ts | 14 +++++ packages/runtime/package.json | 1 + .../background-task-health-tool.test.ts | 24 ++++++++ .../src/background-task-health-tool.ts | 60 +++++++++++++++++++ packages/runtime/src/shell-run-manager.ts | 1 + packages/runtime/src/shell-run-tool-result.ts | 1 + 10 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/background-task-health-tool.test.ts create mode 100644 packages/runtime/src/background-task-health-tool.ts diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index d103137354..4c14c169e7 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -782,6 +782,7 @@ type ShellRunResultMetadata = { kind: 'shell_run'; ref: string; status: ShellRunStatus; + pid?: number; cwd: string; cmd: string; startedAt: number; diff --git a/packages/core/src/shell-run-result.ts b/packages/core/src/shell-run-result.ts index e10aa47194..cf3bb564c6 100644 --- a/packages/core/src/shell-run-result.ts +++ b/packages/core/src/shell-run-result.ts @@ -111,6 +111,7 @@ const CURRENT_TERMINAL_RESULT_SHAPE = defineObjectShape()( const CURRENT_SHELL_RUN_RESULT_SHAPE = defineObjectShape()( ['kind', 'ref', 'mode', 'status', 'cwd', 'cmd', 'startedAt', 'updatedAt', 'revision'], [ + 'pid', 'completedAt', 'exitCode', 'failureMessage', diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 7c5ae2da33..73fd3a031e 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -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; @@ -159,7 +161,7 @@ export interface ShellRunRecord { export type ShellRunPatch = Partial< Pick< ShellRunRecord, - 'status' | 'exitCode' | 'failureMessage' | 'updatedAt' | 'completedAt' | 'observedAt' | 'output' + 'status' | 'pid' | 'exitCode' | 'failureMessage' | 'updatedAt' | 'completedAt' | 'observedAt' | 'output' > >; @@ -307,6 +309,7 @@ const SHELL_RUN_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const SHELL_RUN_PATCH_KEYS: ReadonlySet = new Set([ 'status', + 'pid', 'exitCode', 'failureMessage', 'updatedAt', @@ -325,6 +328,7 @@ const SHELL_RUN_RECORD_KEYS: ReadonlySet = new Set([ 'cwd', 'command', 'status', + 'pid', 'startedAt', 'updatedAt', 'completedAt', @@ -380,6 +384,7 @@ export function normalizeShellRunRecord( record.sessionId === sessionId && record.shellRunId === shellRunId && isShellRunStatus(record.status) && + (record.pid === undefined || isPositiveInteger(record.pid)) && isFiniteNumber(record.startedAt) && isFiniteNumber(record.updatedAt) && isPositiveInteger(record.revision) && diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b989d93587..95fdc9c405 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -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 { @@ -653,6 +654,7 @@ 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 } : {}) }), @@ -675,6 +677,7 @@ export async function createExecutionRuntimeHostComposition( const childHostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), + backgroundTaskHealthTool, ...runtimePolicy.modelTools, ]; const hostTools = [...childHostTools, ...historyTools]; diff --git a/packages/runtime-host/src/server/web-fetch-tool.ts b/packages/runtime-host/src/server/web-fetch-tool.ts index 956af4b517..77fd83d2cd 100644 --- a/packages/runtime-host/src/server/web-fetch-tool.ts +++ b/packages/runtime-host/src/server/web-fetch-tool.ts @@ -39,6 +39,7 @@ export interface HostWebFetchService { readonly sessionId: string; readonly abortSignal?: AbortSignal; }): Promise; + probe(input: { url: string; sessionId: string; abortSignal: AbortSignal }): Promise<{ status: number; statusText?: string; elapsedMs: number }>; } export function createHostWebFetchService(input: HostWebFetchServiceInput): HostWebFetchService { @@ -65,6 +66,19 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host await transport.close(); } }, + probe: async ({ url, abortSignal }) => { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Health endpoint must use HTTP or HTTPS.'); + 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(); + try { + const response = await transport.fetch(parsed, { method: 'HEAD', redirect: 'manual', signal: abortSignal }); + return { status: response.status, ...(response.statusText ? { statusText: response.statusText } : {}), elapsedMs: Date.now() - started }; + } finally { await transport.close(); } + }, }; } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 5c27fdefa1..915c7c8f4d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -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", diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts new file mode 100644 index 0000000000..d7257c94c6 --- /dev/null +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +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, pid: 1234 }, endpoint: { status: '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, pid: 1234 }, endpoint: { status: 204, statusText: 'No Content', elapsedMs: 4, health: 'healthy' } }); + assert.equal(called, 1); +}); diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts new file mode 100644 index 0000000000..004dbee5ab --- /dev/null +++ b/packages/runtime/src/background-task-health-tool.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +import { z } from 'zod'; +import type { ToolResultContent } from '@maka/core/events'; +import type { MakaTool, MakaToolContext } from './tool-runtime.js'; + +export interface BackgroundTaskHealthReader { + readRuntimeResource(sessionId: string, ref: string, abortSignal: AbortSignal): Promise; +} + +export interface BackgroundTaskEndpointProbe { + probe(input: { url: string; sessionId: string; abortSignal: AbortSignal }): Promise<{ + status: number; + statusText?: string; + elapsedMs: number; + }>; +} + +/** + * Produces an explicit two-axis result. A tracked process is never described + * as endpoint-ready unless the caller supplied a URL and the probe succeeded. + */ +export function buildBackgroundTaskHealthTool( + reader: BackgroundTaskHealthReader, + probe: BackgroundTaskEndpointProbe, +): MakaTool { + return { + name: 'BackgroundTaskHealth', + displayName: 'Background task health', + categoryHint: 'web_read', + description: + 'Check a tracked background task and, when given its HTTP endpoint, verify that the endpoint is reachable. Process tracking and endpoint readiness are reported separately.', + parameters: z.object({ + ref: z.string().describe('The maka://runtime/background-tasks/ ref returned by Bash'), + url: z.string().url().refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), 'Health endpoint must use HTTP or HTTPS').optional().describe('The HTTP or HTTPS endpoint to probe'), + }).strict(), + impl: async ({ ref, url }, context) => { + const resource = await reader.readRuntimeResource(context.sessionId, ref, context.abortSignal); + if (!resource || typeof resource !== 'object' || Array.isArray(resource) || resource.kind !== 'shell_run') { + throw new Error('BackgroundTaskHealth requires a shell_run runtime resource'); + } + const shell = resource as Extract; + const process = { + status: shell.status, + tracked: true, + ...(shell.pid !== undefined ? { pid: shell.pid } : {}), + }; + if (!url) return JSON.stringify({ process, endpoint: { status: 'not_checked' } }); + const endpoint = await probe.probe({ url, sessionId: context.sessionId, abortSignal: context.abortSignal }); + return JSON.stringify({ + process, + endpoint: { ...endpoint, health: endpoint.status >= 200 && endpoint.status < 400 ? 'healthy' : 'unhealthy' }, + }); + }, + }; +} diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index f9ad47bd31..fa2c887281 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -972,6 +972,7 @@ export class ShellRunProcessManager private async markRunning(live: LiveShellRun): Promise { live.record = await this.input.store.updateShellRun(live.sessionId, live.shellRunId, { status: 'running', + ...(live.driver.pid !== undefined ? { pid: live.driver.pid } : {}), output: (await this.snapshotAtCut(live, false)).output, updatedAt: this.input.now(), }); diff --git a/packages/runtime/src/shell-run-tool-result.ts b/packages/runtime/src/shell-run-tool-result.ts index c7def411c2..884c6ae9ec 100644 --- a/packages/runtime/src/shell-run-tool-result.ts +++ b/packages/runtime/src/shell-run-tool-result.ts @@ -146,6 +146,7 @@ function shellRunStateContent(record: ShellRunRecord): ShellRunCompactResult { cmd: record.command, startedAt: record.startedAt, updatedAt: record.updatedAt, + ...(record.pid !== undefined ? { pid: record.pid } : {}), ...(record.completedAt !== undefined ? { completedAt: record.completedAt } : {}), ...(record.timeoutMs !== undefined ? { timeoutMs: record.timeoutMs } : {}), ...(record.exitCode !== undefined ? { exitCode: record.exitCode } : {}), From ac058fcdeb9e893b2a127a1cb32d77cd206d47ec Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:06:29 +0800 Subject: [PATCH 2/9] fix(runtime): normalize ASF headers --- .../background-task-health-tool.test.ts | 17 +++++++++++++++-- .../runtime/src/background-task-health-tool.ts | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts index d7257c94c6..e5c41d2e3e 100644 --- a/packages/runtime/src/__tests__/background-task-health-tool.test.ts +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -1,7 +1,20 @@ /* * 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. + * 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'; diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts index 004dbee5ab..07a0242858 100644 --- a/packages/runtime/src/background-task-health-tool.ts +++ b/packages/runtime/src/background-task-health-tool.ts @@ -1,7 +1,20 @@ /* * 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. + * 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 { z } from 'zod'; From f77e95ac933201c35a3f871ba95c59f2ae77efd7 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:08:52 +0800 Subject: [PATCH 3/9] style: format background task health changes --- packages/core/src/shell-run.ts | 9 ++- .../src/server/execution-composition.ts | 5 +- .../runtime-host/src/server/web-fetch-tool.ts | 35 ++++++++--- .../background-task-health-tool.test.ts | 63 +++++++++++++++++-- .../src/background-task-health-tool.ts | 48 +++++++++++--- 5 files changed, 135 insertions(+), 25 deletions(-) diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 73fd3a031e..7a434ceda2 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -161,7 +161,14 @@ export interface ShellRunRecord { export type ShellRunPatch = Partial< Pick< ShellRunRecord, - 'status' | 'pid' | 'exitCode' | 'failureMessage' | 'updatedAt' | 'completedAt' | 'observedAt' | 'output' + | 'status' + | 'pid' + | 'exitCode' + | 'failureMessage' + | 'updatedAt' + | 'completedAt' + | 'observedAt' + | 'output' > >; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 95fdc9c405..bba8259bbf 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -654,7 +654,10 @@ export async function createExecutionRuntimeHostComposition( const webFetchService = createHostWebFetchService({ policy: runtimePolicyStores.operations, }); - const backgroundTaskHealthTool = buildBackgroundTaskHealthTool(runtimeResources!, webFetchService); + const backgroundTaskHealthTool = buildBackgroundTaskHealthTool( + runtimeResources!, + webFetchService, + ); pluginWeb.bindRuntime({ search: ({ query, limit, abortSignal }) => webSearchService.search({ query, limit, ...(abortSignal ? { abortSignal } : {}) }), diff --git a/packages/runtime-host/src/server/web-fetch-tool.ts b/packages/runtime-host/src/server/web-fetch-tool.ts index 77fd83d2cd..251ae041f5 100644 --- a/packages/runtime-host/src/server/web-fetch-tool.ts +++ b/packages/runtime-host/src/server/web-fetch-tool.ts @@ -39,7 +39,11 @@ export interface HostWebFetchService { readonly sessionId: string; readonly abortSignal?: AbortSignal; }): Promise; - probe(input: { url: string; sessionId: string; abortSignal: AbortSignal }): Promise<{ status: number; statusText?: string; elapsedMs: number }>; + probe(input: { + url: string; + sessionId: string; + abortSignal: AbortSignal; + }): Promise<{ status: number; statusText?: string; elapsedMs: number }>; } export function createHostWebFetchService(input: HostWebFetchServiceInput): HostWebFetchService { @@ -68,16 +72,31 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host }, probe: async ({ url, abortSignal }) => { const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Health endpoint must use HTTP or HTTPS.'); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + throw new Error('Health endpoint must use HTTP or HTTPS.'); 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)); + 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(); try { - const response = await transport.fetch(parsed, { method: 'HEAD', redirect: 'manual', signal: abortSignal }); - return { status: response.status, ...(response.statusText ? { statusText: response.statusText } : {}), elapsedMs: Date.now() - started }; - } finally { await transport.close(); } + const response = await transport.fetch(parsed, { + method: 'HEAD', + redirect: 'manual', + signal: abortSignal, + }); + return { + status: response.status, + ...(response.statusText ? { statusText: response.statusText } : {}), + elapsedMs: Date.now() - started, + }; + } finally { + await transport.close(); + } }, }; } diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts index e5c41d2e3e..1fd072ef52 100644 --- a/packages/runtime/src/__tests__/background-task-health-tool.test.ts +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -21,17 +21,68 @@ 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 } : {}) }); +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, pid: 1234 }, endpoint: { status: 'not_checked' } }); + 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, pid: 1234 }, + endpoint: { status: '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, pid: 1234 }, endpoint: { status: 204, statusText: 'No Content', elapsedMs: 4, health: 'healthy' } }); + 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, pid: 1234 }, + endpoint: { status: 204, statusText: 'No Content', elapsedMs: 4, health: 'healthy' }, + }, + ); assert.equal(called, 1); }); diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts index 07a0242858..da718f41d1 100644 --- a/packages/runtime/src/background-task-health-tool.ts +++ b/packages/runtime/src/background-task-health-tool.ts @@ -22,7 +22,11 @@ import type { ToolResultContent } from '@maka/core/events'; import type { MakaTool, MakaToolContext } from './tool-runtime.js'; export interface BackgroundTaskHealthReader { - readRuntimeResource(sessionId: string, ref: string, abortSignal: AbortSignal): Promise; + readRuntimeResource( + sessionId: string, + ref: string, + abortSignal: AbortSignal, + ): Promise; } export interface BackgroundTaskEndpointProbe { @@ -47,13 +51,32 @@ export function buildBackgroundTaskHealthTool( categoryHint: 'web_read', description: 'Check a tracked background task and, when given its HTTP endpoint, verify that the endpoint is reachable. Process tracking and endpoint readiness are reported separately.', - parameters: z.object({ - ref: z.string().describe('The maka://runtime/background-tasks/ ref returned by Bash'), - url: z.string().url().refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), 'Health endpoint must use HTTP or HTTPS').optional().describe('The HTTP or HTTPS endpoint to probe'), - }).strict(), + parameters: z + .object({ + ref: z.string().describe('The maka://runtime/background-tasks/ ref returned by Bash'), + url: z + .string() + .url() + .refine( + (value) => ['http:', 'https:'].includes(new URL(value).protocol), + 'Health endpoint must use HTTP or HTTPS', + ) + .optional() + .describe('The HTTP or HTTPS endpoint to probe'), + }) + .strict(), impl: async ({ ref, url }, context) => { - const resource = await reader.readRuntimeResource(context.sessionId, ref, context.abortSignal); - if (!resource || typeof resource !== 'object' || Array.isArray(resource) || resource.kind !== 'shell_run') { + const resource = await reader.readRuntimeResource( + context.sessionId, + ref, + context.abortSignal, + ); + if ( + !resource || + typeof resource !== 'object' || + Array.isArray(resource) || + resource.kind !== 'shell_run' + ) { throw new Error('BackgroundTaskHealth requires a shell_run runtime resource'); } const shell = resource as Extract; @@ -63,10 +86,17 @@ export function buildBackgroundTaskHealthTool( ...(shell.pid !== undefined ? { pid: shell.pid } : {}), }; if (!url) return JSON.stringify({ process, endpoint: { status: 'not_checked' } }); - const endpoint = await probe.probe({ url, sessionId: context.sessionId, abortSignal: context.abortSignal }); + const endpoint = await probe.probe({ + url, + sessionId: context.sessionId, + abortSignal: context.abortSignal, + }); return JSON.stringify({ process, - endpoint: { ...endpoint, health: endpoint.status >= 200 && endpoint.status < 400 ? 'healthy' : 'unhealthy' }, + endpoint: { + ...endpoint, + health: endpoint.status >= 200 && endpoint.status < 400 ? 'healthy' : 'unhealthy', + }, }); }, }; From 24235c14aa01eab26b61c8416b259ec3af64afc4 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:19:44 +0800 Subject: [PATCH 4/9] feat(runtime): include lifecycle evidence in health reports --- .../__tests__/background-task-health-tool.test.ts | 12 +++++++++--- packages/runtime/src/background-task-health-tool.ts | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts index 1fd072ef52..3f1d8f0172 100644 --- a/packages/runtime/src/__tests__/background-task-health-tool.test.ts +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -53,7 +53,7 @@ test('reports process tracking separately when endpoint is not checked', async ( assert.deepEqual( JSON.parse(String(await tool.impl({ ref: 'maka://runtime/background-tasks/run-1' }, context))), { - process: { status: 'running', tracked: true, pid: 1234 }, + process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 }, endpoint: { status: 'not_checked' }, }, ); @@ -80,8 +80,14 @@ test('reports endpoint health only from the probe result', async () => { ), ), { - process: { status: 'running', tracked: true, pid: 1234 }, - endpoint: { status: 204, statusText: 'No Content', elapsedMs: 4, health: 'healthy' }, + process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 }, + endpoint: { + status: 204, + statusText: 'No Content', + elapsedMs: 4, + target: 'http://127.0.0.1:8765', + health: 'healthy', + }, }, ); assert.equal(called, 1); diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts index da718f41d1..404afba72d 100644 --- a/packages/runtime/src/background-task-health-tool.ts +++ b/packages/runtime/src/background-task-health-tool.ts @@ -83,7 +83,11 @@ export function buildBackgroundTaskHealthTool( const process = { status: shell.status, tracked: true, + startedAt: shell.startedAt, + updatedAt: shell.updatedAt, ...(shell.pid !== undefined ? { pid: shell.pid } : {}), + ...(shell.completedAt !== undefined ? { completedAt: shell.completedAt } : {}), + ...(shell.failureMessage !== undefined ? { failureMessage: shell.failureMessage } : {}), }; if (!url) return JSON.stringify({ process, endpoint: { status: 'not_checked' } }); const endpoint = await probe.probe({ @@ -95,6 +99,7 @@ export function buildBackgroundTaskHealthTool( process, endpoint: { ...endpoint, + target: new URL(url).origin, health: endpoint.status >= 200 && endpoint.status < 400 ? 'healthy' : 'unhealthy', }, }); From 2520b0cf7bcf4630993a91731625ea3c19f8ade7 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:20:13 +0800 Subject: [PATCH 5/9] fix(runtime): report failed endpoint probes explicitly --- .../src/background-task-health-tool.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts index 404afba72d..607e5213e8 100644 --- a/packages/runtime/src/background-task-health-tool.ts +++ b/packages/runtime/src/background-task-health-tool.ts @@ -88,13 +88,33 @@ export function buildBackgroundTaskHealthTool( ...(shell.pid !== undefined ? { pid: shell.pid } : {}), ...(shell.completedAt !== undefined ? { completedAt: shell.completedAt } : {}), ...(shell.failureMessage !== undefined ? { failureMessage: shell.failureMessage } : {}), + ...(shell.output + ? { + logs: + shell.output.mode === 'pipes' + ? { stdout: shell.output.stdout, stderr: shell.output.stderr } + : { screen: shell.output.screen, scrollback: shell.output.scrollback }, + } + : {}), }; if (!url) return JSON.stringify({ process, endpoint: { status: 'not_checked' } }); - const endpoint = await probe.probe({ - url, - sessionId: context.sessionId, - abortSignal: context.abortSignal, - }); + let endpoint; + try { + endpoint = await probe.probe({ + url, + sessionId: context.sessionId, + abortSignal: context.abortSignal, + }); + } catch (error) { + return JSON.stringify({ + process, + endpoint: { + health: 'unknown', + target: new URL(url).origin, + error: error instanceof Error ? error.message : String(error), + }, + }); + } return JSON.stringify({ process, endpoint: { From bc97134dbea0c78b668e517fa9f43bfd5ba5901b Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:20:34 +0800 Subject: [PATCH 6/9] test(runtime): cover failed endpoint health --- .../background-task-health-tool.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts index 3f1d8f0172..b28f142168 100644 --- a/packages/runtime/src/__tests__/background-task-health-tool.test.ts +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -92,3 +92,28 @@ test('reports endpoint health only from the probe result', async () => { ); 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: { health: 'unknown', target: 'http://127.0.0.1:8765', error: 'connection refused' }, + }, + ); +}); From e92b62a4143bb1d28a7b47ceef18ef54cab056b6 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 13 Sep 2026 22:28:23 +0800 Subject: [PATCH 7/9] docs(runtime): document background task readiness semantics --- packages/runtime/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/runtime/README.md b/packages/runtime/README.md index cd3bc72a32..3ec96ec7d2 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -42,6 +42,16 @@ 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 once startup is admitted. `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. From 0e45fac66c53a5c399f63c2aa9ad62dccd61fc6c Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 14 Sep 2026 07:26:31 +0800 Subject: [PATCH 8/9] fix(runtime): harden background health probing --- packages/core/src/shell-run.ts | 1 + .../src/__tests__/web-fetch-tool.test.ts | 63 ++++++++++++++++++- .../runtime-host/src/server/web-fetch-tool.ts | 23 +++++-- .../background-task-health-tool.test.ts | 10 +-- .../src/__tests__/shell-run-manager.test.ts | 2 + .../src/background-task-health-tool.ts | 29 ++++++--- packages/runtime/src/local-web-fetch.ts | 2 +- packages/runtime/src/shell-run-manager.ts | 3 +- 8 files changed, 110 insertions(+), 23 deletions(-) diff --git a/packages/core/src/shell-run.ts b/packages/core/src/shell-run.ts index 7a434ceda2..e5b937719b 100644 --- a/packages/core/src/shell-run.ts +++ b/packages/core/src/shell-run.ts @@ -527,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 } : {}), diff --git a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts index c0fe8521da..34bf6d76c6 100644 --- a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts +++ b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts @@ -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'; @@ -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((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((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); test('Host WebFetch uses the resolved proxy snapshot and closes its transport', async () => { const networkProxy = { diff --git a/packages/runtime-host/src/server/web-fetch-tool.ts b/packages/runtime-host/src/server/web-fetch-tool.ts index 251ae041f5..76b17b7460 100644 --- a/packages/runtime-host/src/server/web-fetch-tool.ts +++ b/packages/runtime-host/src/server/web-fetch-tool.ts @@ -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, @@ -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; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -72,8 +73,8 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host }, probe: async ({ url, abortSignal }) => { const parsed = new URL(url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') - throw new Error('Health endpoint must use HTTP or HTTPS.'); + 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.'); @@ -83,18 +84,30 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host 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 { - const response = await transport.fetch(parsed, { + let response = await transport.fetch(parsed, { method: 'HEAD', redirect: 'manual', - signal: abortSignal, + 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(); } }, diff --git a/packages/runtime/src/__tests__/background-task-health-tool.test.ts b/packages/runtime/src/__tests__/background-task-health-tool.test.ts index b28f142168..d13c0815b1 100644 --- a/packages/runtime/src/__tests__/background-task-health-tool.test.ts +++ b/packages/runtime/src/__tests__/background-task-health-tool.test.ts @@ -54,7 +54,7 @@ test('reports process tracking separately when endpoint is not checked', async ( 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: { status: 'not_checked' }, + endpoint: { state: 'not_checked' }, }, ); }); @@ -82,10 +82,10 @@ test('reports endpoint health only from the probe result', async () => { { process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 }, endpoint: { - status: 204, - statusText: 'No Content', + state: 'checked', + httpStatus: 204, elapsedMs: 4, - target: 'http://127.0.0.1:8765', + target: 'http://127.0.0.1:8765/', health: 'healthy', }, }, @@ -113,7 +113,7 @@ test('does not convert a failed probe into a ready claim', async () => { ), { process: { status: 'running', tracked: true, startedAt: 1, updatedAt: 2, pid: 1234 }, - endpoint: { health: 'unknown', target: 'http://127.0.0.1:8765', error: 'connection refused' }, + endpoint: { state: 'unknown', target: 'http://127.0.0.1:8765/', error: 'connection refused' }, }, ); }); diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index 212ea60474..dc7c325cbd 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -342,6 +342,7 @@ describe('ShellRunProcessManager', () => { assert.equal(initial.kind, 'shell_run'); assert.equal(initial.mode, 'pipes'); assert.equal(initial.output, undefined); + assert.ok(initial.pid === undefined || initial.pid > 0); assert.equal((await store.readShellRun('session-1', 'shell-run-1')).timeoutMs, undefined); await waitForShellRun( manager, @@ -357,6 +358,7 @@ describe('ShellRunProcessManager', () => { assert.ok(runningUpdate); const running = await manager.readRuntimeResource('session-1', initial.ref, NO_ABORT); assertShellRun(running); + assert.ok(running.pid === undefined || running.pid > 0); assert.equal(running.output?.mode, 'pipes'); if (running.output?.mode !== 'pipes') throw new Error('expected pipes output'); assert.equal(running.output.stdout, 'start'); diff --git a/packages/runtime/src/background-task-health-tool.ts b/packages/runtime/src/background-task-health-tool.ts index 607e5213e8..959cdfd8a3 100644 --- a/packages/runtime/src/background-task-health-tool.ts +++ b/packages/runtime/src/background-task-health-tool.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; import type { ToolResultContent } from '@maka/core/events'; -import type { MakaTool, MakaToolContext } from './tool-runtime.js'; +import type { MakaTool } from './tool-runtime.js'; export interface BackgroundTaskHealthReader { readRuntimeResource( @@ -50,10 +50,11 @@ export function buildBackgroundTaskHealthTool( displayName: 'Background task health', categoryHint: 'web_read', description: - 'Check a tracked background task and, when given its HTTP endpoint, verify that the endpoint is reachable. Process tracking and endpoint readiness are reported separately.', + 'Check a tracked background task and an optional HTTP endpoint. Uses HEAD with one GET fallback for 405/501; discards the body. Reports HTTP status only, not browser loading or ownership of the listener. Redirects are not followed. Logs are omitted by default; use Read(ref) for full logs.', parameters: z .object({ ref: z.string().describe('The maka://runtime/background-tasks/ ref returned by Bash'), + include_logs: z.boolean().optional().describe('Include captured task logs in this report'), url: z .string() .url() @@ -65,7 +66,7 @@ export function buildBackgroundTaskHealthTool( .describe('The HTTP or HTTPS endpoint to probe'), }) .strict(), - impl: async ({ ref, url }, context) => { + impl: async ({ ref, url, include_logs }, context) => { const resource = await reader.readRuntimeResource( context.sessionId, ref, @@ -88,7 +89,7 @@ export function buildBackgroundTaskHealthTool( ...(shell.pid !== undefined ? { pid: shell.pid } : {}), ...(shell.completedAt !== undefined ? { completedAt: shell.completedAt } : {}), ...(shell.failureMessage !== undefined ? { failureMessage: shell.failureMessage } : {}), - ...(shell.output + ...(include_logs && shell.output ? { logs: shell.output.mode === 'pipes' @@ -97,7 +98,7 @@ export function buildBackgroundTaskHealthTool( } : {}), }; - if (!url) return JSON.stringify({ process, endpoint: { status: 'not_checked' } }); + if (!url) return JSON.stringify({ process, endpoint: { state: 'not_checked' } }); let endpoint; try { endpoint = await probe.probe({ @@ -106,11 +107,12 @@ export function buildBackgroundTaskHealthTool( abortSignal: context.abortSignal, }); } catch (error) { + context.abortSignal.throwIfAborted(); return JSON.stringify({ process, endpoint: { - health: 'unknown', - target: new URL(url).origin, + state: 'unknown', + target: new URL(url).href, error: error instanceof Error ? error.message : String(error), }, }); @@ -118,9 +120,16 @@ export function buildBackgroundTaskHealthTool( return JSON.stringify({ process, endpoint: { - ...endpoint, - target: new URL(url).origin, - health: endpoint.status >= 200 && endpoint.status < 400 ? 'healthy' : 'unhealthy', + state: 'checked', + httpStatus: endpoint.status, + elapsedMs: endpoint.elapsedMs, + target: new URL(url).href, + health: + endpoint.status >= 200 && endpoint.status < 300 + ? 'healthy' + : endpoint.status < 400 + ? 'unknown' + : 'unhealthy', }, }); }, diff --git a/packages/runtime/src/local-web-fetch.ts b/packages/runtime/src/local-web-fetch.ts index 069644d1c0..a465049e5b 100644 --- a/packages/runtime/src/local-web-fetch.ts +++ b/packages/runtime/src/local-web-fetch.ts @@ -132,7 +132,7 @@ function responseLimitError(): Error { return new Error('WebFetch response exceeds the 5 MB response limit.'); } -function assertAllowedTarget(url: URL): void { +export function assertAllowedTarget(url: URL): void { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error('WebFetch URL must use HTTP or HTTPS.'); } diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index fa2c887281..c88d82c8ad 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -970,9 +970,10 @@ export class ShellRunProcessManager } private async markRunning(live: LiveShellRun): Promise { + const pid = live.driver.pid; live.record = await this.input.store.updateShellRun(live.sessionId, live.shellRunId, { status: 'running', - ...(live.driver.pid !== undefined ? { pid: live.driver.pid } : {}), + ...(pid !== undefined && Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}), output: (await this.snapshotAtCut(live, false)).output, updatedAt: this.input.now(), }); From 9d26eef0c0cebc1c2d3d6c673e15d2c8929611d8 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 14 Sep 2026 10:03:59 +0800 Subject: [PATCH 9/9] fix(runtime): persist late-published background task PIDs --- packages/runtime/README.md | 4 +- .../src/__tests__/shell-run-manager.test.ts | 93 ++++++++++++++++++- packages/runtime/src/shell-run-manager.ts | 15 ++- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 3ec96ec7d2..c6cd22f429 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -45,7 +45,9 @@ Shared execution composition — where `BackendRegistry` and `SessionManager` ar ## Background-task readiness `Bash` background runs return a durable runtime-task ref and expose the native -process id once startup is admitted. `BackgroundTaskHealth` deliberately keeps +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 diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index dc7c325cbd..d798e9b54a 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -38,6 +38,7 @@ import { type ShellRunUpdate, type ToolResultContent } from '@maka/core/events'; import { createSqliteShellRunStore } from '@maka/storage/shell-run-store'; import { ShellRunProcessManager } from '../shell-run-manager.js'; +import { buildBackgroundTaskHealthTool } from '../background-task-health-tool.js'; import { ShellRunPtyControlClosedError, type ShellRunPtyDataEvent, @@ -328,6 +329,94 @@ describe('ShellRunProcessManager', () => { ); }); + for (const observation of ['read', 'exit'] as const) { + test(`persists a late ConPTY PID on ${observation} without new output`, async (t) => { + const cwd = await workspace(); + const exitGate = join(cwd, 'exit-gate'); + const store = sqliteShellRunStore(cwd); + const flushes = manualFlushScheduler(); + const manager = createManager(store, undefined, { scheduleFlush: flushes.schedule }); + const nativePid = Object.getOwnPropertyDescriptor(PtyProcessDriver.prototype, 'pid')!.get!; + let publishPid = false; + let driver: PtyProcessDriver | undefined; + t.mock.getter(PtyProcessDriver.prototype, 'pid', function (this: PtyProcessDriver) { + driver = this; + return publishPid ? nativePid.call(this) : 0; + }); + let ref: string | undefined; + try { + const initial = await manager.runBackgroundBash( + shellInput({ + cwd, + command: nodeCommand(` + const { existsSync } = require('node:fs'); + process.stdout.write('READY\\n'); + setInterval(() => { + if (existsSync(${JSON.stringify(exitGate)})) process.exit(0); + }, 10); + `), + pty: true, + timeoutMs: 30_000, + }), + ); + ref = initial.ref; + assert.equal(initial.status, 'running'); + assert.equal(initial.pid, undefined); + await waitForPtyText(manager, ref, /READY/, 15_000); + const before = await store.readShellRun('session-1', 'shell-run-1'); + assert.equal(before.pid, undefined); + assert.ok(driver); + const expectedPid = nativePid.call(driver); + assert.ok(expectedPid > 0, 'the native PTY has published its real PID'); + publishPid = true; + + if (observation === 'exit') { + await writeFile(exitGate, 'exit'); + await waitUntil(() => manager.liveCount() === 0, 15_000); + } + const result = await manager.readRuntimeResource('session-1', ref, NO_ABORT); + assertShellRun(result); + assert.equal(result.pid, expectedPid); + assert.equal(result.status, observation === 'exit' ? 'completed' : 'running'); + const stored = await store.readShellRun('session-1', 'shell-run-1'); + assert.equal(stored.pid, expectedPid); + assert.deepEqual(stored.output, before.output); + const tool = buildBackgroundTaskHealthTool(manager, { + probe: async () => { + throw new Error('must not probe'); + }, + }); + const health = JSON.parse( + String( + await tool.impl( + { ref }, + { + sessionId: 'session-1', + turnId: 'turn-1', + toolCallId: 'health-1', + cwd, + abortSignal: NO_ABORT, + emitOutput: () => {}, + }, + ), + ), + ); + assert.equal(health.process.pid, expectedPid); + assert.deepEqual(health.endpoint, { state: 'not_checked' }); + if (observation === 'read') { + const repeated = await manager.readRuntimeResource('session-1', ref, NO_ABORT); + assertShellRun(repeated); + assert.equal(repeated.revision, stored.revision); + } + } finally { + t.mock.restoreAll(); + if (ref && manager.liveCount() > 0) { + await manager.stopBackgroundTask('session-1', ref, NO_ABORT); + } + } + }); + } + test('hands off a long pipe command without output and publishes monotonic revisions', async () => { const updates: ShellRunUpdate[] = []; const store = sqliteShellRunStore(await workspace()); @@ -342,7 +431,7 @@ describe('ShellRunProcessManager', () => { assert.equal(initial.kind, 'shell_run'); assert.equal(initial.mode, 'pipes'); assert.equal(initial.output, undefined); - assert.ok(initial.pid === undefined || initial.pid > 0); + assert.ok(initial.pid !== undefined && initial.pid > 0); assert.equal((await store.readShellRun('session-1', 'shell-run-1')).timeoutMs, undefined); await waitForShellRun( manager, @@ -358,7 +447,7 @@ describe('ShellRunProcessManager', () => { assert.ok(runningUpdate); const running = await manager.readRuntimeResource('session-1', initial.ref, NO_ABORT); assertShellRun(running); - assert.ok(running.pid === undefined || running.pid > 0); + assert.equal(running.pid, initial.pid); assert.equal(running.output?.mode, 'pipes'); if (running.output?.mode !== 'pipes') throw new Error('expected pipes output'); assert.equal(running.output.stdout, 'start'); diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index c88d82c8ad..5fc60c9839 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -970,10 +970,9 @@ export class ShellRunProcessManager } private async markRunning(live: LiveShellRun): Promise { - const pid = live.driver.pid; live.record = await this.input.store.updateShellRun(live.sessionId, live.shellRunId, { status: 'running', - ...(pid !== undefined && Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}), + ...this.processPidPatch(live), output: (await this.snapshotAtCut(live, false)).output, updatedAt: this.input.now(), }); @@ -984,6 +983,11 @@ export class ShellRunProcessManager } } + private processPidPatch(live: LiveShellRun): Pick { + const pid = live.driver.pid; + return pid !== undefined && Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}; + } + private onPipeData(live: LivePipeShellRun, stream: 'stdout' | 'stderr', data: string): void { if (live.driverExit || live.finalizeOnce) return; live.collector.accept(stream, data); @@ -1143,12 +1147,13 @@ export class ShellRunProcessManager failureStage = 'persist'; if (live.persistFailure && !options.bestEffort) throw live.persistFailure; const current = live.record; - const candidate: ShellRunRecord = { ...current, ...patch, output: snapshot.output }; + // ConPTY can publish its PID after admission, even without new output. + const update = { ...patch, ...this.processPidPatch(live), output: snapshot.output }; + const candidate: ShellRunRecord = { ...current, ...update }; let updated = current; if (!isDeepStrictEqual(candidate, current)) { updated = await this.input.store.updateShellRun(live.sessionId, live.shellRunId, { - ...patch, - output: snapshot.output, + ...update, updatedAt: this.input.now(), }); live.record = updated;