Merge remote-tracking branch 'origin/worktree-process-service-seam' into subprocess-simpl/c-one-termination-verb

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md
#	.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/subprocess.i18n.yaml
#	docs/core-data-structures/subprocess.md
#	docs/core-data-structures/subprocess.zh.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/subprocess/subprocess-local/README.i18n.yaml
#	packages/subprocess/subprocess-local/README.md
#	packages/subprocess/subprocess-local/README.zh.md
#	packages/subprocess/subprocess/README.i18n.yaml
#	packages/subprocess/subprocess/README.md
#	packages/subprocess/subprocess/README.zh.md
#	packages/subprocess/subprocess/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-27 11:22:21 +08:00
32 changed files with 224 additions and 296 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 0c21e179bd5ca405057b682786c41c8447a78317
README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be
README.md: e573e22a02a301e18e341ea13c967718f6a7f625
README.zh.md: 0522706fe0f0e3263c0c229812871ea17ebe8143

View File

@@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented.
## Capabilities and context

View File

@@ -14,7 +14,7 @@ ACPAgent Client Protocol提供方会在全新的子进程中运行每个 s
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM等待 `disposeGraceMs` 后再使用 SIGKILLWindows 直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。
## 能力与上下文

View File

@@ -91,6 +91,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, ms)
try {
return await child.waitForExit(controller.signal)
} finally {
clearTimeout(timer)
}
}
/**
* Cooperative teardown ladder for an out-of-process agent, over the seam's
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
* window to flush persistence and reap its own descendants), then the
* terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
* confirmation wait.
* @param child - the spawned ACP child's handle.
* @param eofGraceMs - tier-1 window after stdin EOF.
* @param graceMs - confirmation window after the escalation's SIGKILL.
* @throws when the tree still has not exited `graceMs` after forced termination.
*/
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
child.stdin?.end()
if (await treeExitsWithin(child, eofGraceMs)) return
// terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
// (this plugin passes disposeGraceMs there), so the bound covers both the
// escalation window and an equal confirmation window after the SIGKILL.
child.terminate()
if (!(await treeExitsWithin(child, graceMs * 2))) {
throw new Error('ACP child process tree did not exit within its dispose windows')
}
}
/**
* Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
* @param reason - the terminal reason from the child's `session/prompt` response.
@@ -197,10 +237,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= child.dispose({
eofGraceMs: spec.disposeEofGraceMs,
graceMs: spec.disposeGraceMs,
}))
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []

View File

@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
@@ -136,6 +136,71 @@ describe('child env layering (through the subprocess seam)', () => {
})
})
describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => {
const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
argv: ['bash', '-c', command],
cwd: process.cwd(),
stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const child = bash('read -r line; exit 0')
await disposeAcpChild(child, 5_000, 200)
const outcome = await child.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
})
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
const child = bash('sleep 60')
await disposeAcpChild(child, 100, 5_000)
const outcome = await child.done
expect(outcome.signal).toBe('SIGTERM')
})
it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => {
const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore')
// Wait for the trap to arm so SIGTERM cannot race the default handler.
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await disposeAcpChild(child, 50, 2_000)
const outcome = await child.done
expect(outcome.signal).toBe('SIGKILL')
})
it('throws when the tree survives even the escalation window', async () => {
// A handle whose tree never exits (waitForExit only ever aborts): the
// ladder must fail loud instead of resolving over survivors. Built as a
// stub because the ladder composes only public verbs.
const never: Parameters<typeof disposeAcpChild>[0] = {
pid: 1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: {},
done: new Promise(() => {}),
terminate: () => {},
waitForExit: (signal?: AbortSignal) => new Promise((resolve) => {
signal?.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
}
await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/)
})
it('observes a spawn-level rejection and returns without a process to reap', async () => {
const child = spawnSubprocess({
argv: ['bash', '-c', 'true'],
cwd: '/nonexistent-dir-dsh-acp-ladder-test',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined()
await expect(child.done).rejects.toThrow()
})
})
describe('cwd resolution', () => {
it('falls back to the parent session cwd for the child process AND its ACP session', async () => {
// realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var),