Merge remote-tracking branch 'origin/worktree-process-service-seam' into subprocess-simpl/d-one-env-channel
# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/subprocess.i18n.yaml # packages/lsp/lsp-local/src/connection.ts # packages/subagent/subagent-acp/README.i18n.yaml # packages/subprocess/subprocess-local/README.i18n.yaml # packages/subprocess/subprocess-local/src/spawn.ts # packages/subprocess/subprocess/README.i18n.yaml # packages/subprocess/subprocess/README.md # packages/subprocess/subprocess/README.zh.md # packages/subprocess/subprocess/tests/service.spec.ts
This commit is contained in:
@@ -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: 044a4c5a71d32224262acef9e2c849f375198c2c
|
||||
README.zh.md: 918df12ea0ea50e6f59b144e74601ff6beacbe86
|
||||
README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0
|
||||
README.zh.md: 41723398ffa34995282814011cd03b2fc1cc5a4b
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s
|
||||
|
||||
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。
|
||||
|
||||
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。
|
||||
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
|
||||
@@ -90,6 +90,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.
|
||||
@@ -195,10 +235,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[] = []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -135,6 +135,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),
|
||||
|
||||
Reference in New Issue
Block a user