docs: trim generated prose
This commit is contained in:
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven
|
||||
* over the Agent Client Protocol (ACP) as the client. The parent process is the
|
||||
* ACP client; the child is any ACP agent (point the configured command at the
|
||||
* `acp-agent` example to "talk to our own process").
|
||||
*
|
||||
* Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share
|
||||
* this cordis context — it is a separate process with its own session, model
|
||||
* client, and tools. So this backend injects only `subagents` (no `agents`),
|
||||
* advertises NO start-time capabilities (an out-of-process child cannot enforce
|
||||
* the parent's depth/tool-filter), and ignores `request.parent`.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child agent in a spawned SUBPROCESS, driven over the Agent
|
||||
* Client Protocol (ACP) as the client.
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent run driver. Spawns a child agent as a
|
||||
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
|
||||
* CLIENT, drives one session to completion, and shapes the result into a
|
||||
* {@link SubagentResult}. The mirror image of the server-side bridge in
|
||||
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
|
||||
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
|
||||
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
|
||||
*
|
||||
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
|
||||
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
|
||||
* Persistent-process pooling is a future optimization (see the RFC).
|
||||
*
|
||||
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
|
||||
* distinct replay shape — each child is its own PROCESS with its own
|
||||
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
|
||||
* sessions-root + fixture), unlike the in-process per-session keying in
|
||||
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
|
||||
* scripted mock ACP server subprocess, and the with-key e2e drives the real
|
||||
* `acp-agent` example. See the ACP-subagent-backend RFC.
|
||||
*
|
||||
* The out-of-process ACP subagent run driver.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/run
|
||||
*/
|
||||
|
||||
@@ -96,16 +77,9 @@ export interface AcpRunSpec {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the
|
||||
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
|
||||
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
|
||||
* escalation) before the parent escalates to a signal. Deliberately LARGER than
|
||||
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
|
||||
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
|
||||
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
|
||||
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
|
||||
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
|
||||
* a standalone generous default, NOT derived from any child's internals.
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config)
|
||||
* — the window for it to flush persistence and tear down its own nested subprocesses (which
|
||||
* may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal.
|
||||
*/
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
@@ -176,13 +150,6 @@ function toError(value: unknown): Error {
|
||||
/**
|
||||
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
|
||||
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
|
||||
* `agent_message_chunk` text is the result output; the prompt's terminal
|
||||
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
|
||||
* failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
* @param request - the start request; the driver consumes `prompt` and `signal`
|
||||
* (an already-aborted signal yields an inert `aborted` run with no spawn).
|
||||
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
||||
@@ -227,12 +194,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
|
||||
// Accumulate the child's streamed assistant text — the SubagentResult output.
|
||||
const output: string[] = []
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a
|
||||
// run torn down before the prompt resolves settles `aborted` rather than the
|
||||
// generic error mapping. Held on a mutable object so the async closures that
|
||||
// set it (the abort listener) and the IIFE that reads it don't fight TS's
|
||||
// control-flow narrowing of a bare `let` (which would type the catch-time read
|
||||
// as always-`false`).
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a run torn down
|
||||
// before the prompt resolves settles `aborted` rather than the generic error mapping.
|
||||
const flags = { cancelled: false }
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
@@ -268,27 +231,14 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
// Resolves when a cancel is requested, so `result` can settle `aborted` even
|
||||
// if the child never cooperates with `session/cancel` (it ignores the notify,
|
||||
// or the prompt wedges). The result path races this against the ACP drive: the
|
||||
// FIRST to settle wins, so `cancel()` always honors the contract (`result`
|
||||
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
|
||||
// still kills the process and reaps it; this only unblocks `result`. The
|
||||
// executor runs synchronously, so `signalCancelSettled` is assigned before the
|
||||
// Promise constructor returns (the `!` asserts the definite assignment).
|
||||
// Resolves when a cancel is requested, so `result` can settle `aborted` even if the child
|
||||
// never cooperates with `session/cancel` (it ignores the notify, or the prompt wedges).
|
||||
let signalCancelSettled!: () => void
|
||||
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
|
||||
const requestCancel = (): void => {
|
||||
flags.cancelled = true
|
||||
signalCancelSettled()
|
||||
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
|
||||
// rejection — the session may not exist yet, or the pipe may be gone; the
|
||||
// dispose path kills the process regardless. If the session has NOT been
|
||||
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
|
||||
// alone carries it: the result path re-checks the flag after each await and
|
||||
// settles `aborted` without running the prompt. The `.catch` swallow is
|
||||
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
|
||||
// because dispose kills the process regardless, so it can't be hit in tests.
|
||||
// Best-effort: tell the child to cancel the in-flight turn.
|
||||
/* v8 ignore next */
|
||||
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
|
||||
}
|
||||
@@ -303,12 +253,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
// A provider is "started" only once the remote child has completed ACP
|
||||
// initialization and published a session. SubagentService gates its
|
||||
// `subagent/start` notification on this boundary, just as the in-process
|
||||
// provider gates it on local Agent publication. Failure or cancellation
|
||||
// before this point rejects readiness and therefore produces no paired
|
||||
// lifecycle events for a child that never became live.
|
||||
// A provider is "started" only once the remote child has completed ACP initialization and
|
||||
// published a session.
|
||||
const started: Promise<void> = Promise.race([
|
||||
(async (): Promise<void> => {
|
||||
await conn.initialize({
|
||||
@@ -327,20 +273,13 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
// Readiness is the initialize → newSession phase above. Awaiting the SAME
|
||||
// promise immediately observes its rejection even without the service,
|
||||
// and guarantees the prompt phase never starts before the provider can
|
||||
// truthfully announce a live child.
|
||||
// Readiness is the initialize → newSession phase above.
|
||||
await started
|
||||
|
||||
// Race two post-start outcomes, first to settle wins:
|
||||
// - prompt: the normal remote turn;
|
||||
// - cancelSettled: a cancel was requested — settle `aborted` immediately
|
||||
// rather than waiting on a child that may ignore `session/cancel` or
|
||||
// wedge the prompt (the `cancel()` contract: `result` settles `aborted`).
|
||||
// A spawn error can only precede readiness and is already one arm of
|
||||
// `started`; after `newSession` succeeds, transport/process failure rejects
|
||||
// the in-flight prompt RPC through the connection.
|
||||
// Race two post-start outcomes, first to settle wins: - prompt: the normal remote turn; -
|
||||
// cancelSettled: a cancel was requested — settle `aborted` immediately rather than
|
||||
// waiting on a child that may ignore `session/cancel` or wedge the prompt (the `cancel()`
|
||||
// contract: `result` settles `aborted`).
|
||||
const prompt = async (): Promise<SubagentResult> => {
|
||||
// `started` cannot fulfill without assigning the session id; the cast
|
||||
// records that local invariant without an unreachable defensive arm.
|
||||
@@ -353,12 +292,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// The seam contract: result resolves (never rejects) on a child-level
|
||||
// failure. A cancellation is recognized by the flag above even when it
|
||||
// wins during readiness; every other rejection is a genuine child-level
|
||||
// error — initialize/newSession/prompt transport/RPC failure or ENOENT.
|
||||
// Flatten to `error` and surface the original via onError so a real fault
|
||||
// is preserved rather than silently lost.
|
||||
// The seam contract: result resolves (never rejects) on a child-level failure.
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
@@ -379,15 +313,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM →
|
||||
// SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the
|
||||
// one that matters: our acp-agent has NO SIGTERM handler in a normal
|
||||
// session — it tears down via the server bridge's connection-close path
|
||||
// (conn.closed → per-agent dispose → final session/flush), driven by the
|
||||
// stdin EOF, NOT by a signal — and a prompt response can resolve from a
|
||||
// turn/end BEFORE that post-turn flush lands, so the child still has
|
||||
// durable work owed when dispose runs (hence the wide EOF grace; see
|
||||
// DEFAULT_DISPOSE_EOF_GRACE_MS).
|
||||
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → SIGKILL, awaiting the
|
||||
// actual exit).
|
||||
await disposeChildProcess(child, {
|
||||
disposeEofGraceMs: spec.disposeEofGraceMs,
|
||||
disposeGraceMs: spec.disposeGraceMs,
|
||||
|
||||
@@ -1,44 +1,7 @@
|
||||
/**
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless
|
||||
* `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
|
||||
* fully scripted by environment variables — no model, no network:
|
||||
*
|
||||
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
|
||||
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
|
||||
* (`end_turn` default, or `max_tokens`/`refusal`/…).
|
||||
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
|
||||
* a `session/cancel`), to exercise the client's cancel path.
|
||||
* - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
|
||||
* `session/cancel` but NEVER resolves the pending prompt
|
||||
* and never exits — a non-cooperative child. The backend's
|
||||
* `result` must still settle `aborted` on its own and
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
* an arbitrary timeout (subprocess cold-start is variable).
|
||||
* - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
|
||||
* (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
|
||||
* acp-agent's EOF-driven quiesce+flush, then touches this
|
||||
* path and exits ON ITS OWN — no signal. Stands in for a
|
||||
* child whose durable flush completes only if dispose
|
||||
* gives EOF a real window before escalating to SIGTERM.
|
||||
* - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare
|
||||
* timer) but install a SIGTERM handler that exits (and, if
|
||||
* MOCK_SIGTERM_FILE is set, touches it as an observable
|
||||
* proof the SIGTERM rung fired). The child ignores the
|
||||
* graceful EOF window yet dies cooperatively on SIGTERM —
|
||||
* exercising dispose's middle tier (exit during the SIGTERM
|
||||
* grace, before the SIGKILL escalation). Touches
|
||||
* MOCK_READY_FILE once armed.
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
*
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless `dsh-subagent-acp` tests. It
|
||||
* speaks the agent side of ACP over stdio and is fully scripted by environment variables — no
|
||||
* model, no network.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
@@ -157,11 +120,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
process.exit(1)
|
||||
}
|
||||
if (IGNORE_CANCEL) {
|
||||
// A NON-COOPERATIVE child: receive session/cancel but never resolve the
|
||||
// pending prompt and never exit. The backend's `result` must still settle
|
||||
// `aborted` on its own (the cancel-settle race), and `dispose()` must
|
||||
// still kill the process — proving cancellation does not depend on the
|
||||
// child cooperating.
|
||||
// A NON-COOPERATIVE child: receive session/cancel but never resolve the pending prompt
|
||||
// and never exit.
|
||||
return Promise.resolve()
|
||||
}
|
||||
resolveCancel?.('cancelled')
|
||||
@@ -178,12 +138,9 @@ new AgentSideConnection(
|
||||
),
|
||||
)
|
||||
|
||||
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
|
||||
// neither quiesces on EOF nor dies on the graceful signal — exercising the
|
||||
// backend dispose path's SIGKILL escalation. Without this the process exits
|
||||
// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
|
||||
// a test waits for that CONDITION before disposing (the trap must be in place,
|
||||
// not merely the process spawned — otherwise SIGTERM hits the default handler).
|
||||
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces
|
||||
// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL
|
||||
// escalation.
|
||||
if (process.env.MOCK_TRAP_SIGTERM === '1') {
|
||||
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
|
||||
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
|
||||
@@ -191,13 +148,9 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') {
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
|
||||
}
|
||||
|
||||
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on
|
||||
// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to
|
||||
// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The
|
||||
// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before
|
||||
// the beat completes (no graceful window, or an EOF grace shorter than the
|
||||
// flush) default-terminates this process and the marker is missing; a dispose
|
||||
// that gives the EOF quiesce enough window first lets the flush land.
|
||||
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the
|
||||
// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and
|
||||
// exit ON OUR own — no signal involved.
|
||||
if (FLUSH_ON_EOF !== undefined) {
|
||||
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
|
||||
process.stdin.on('end', () => {
|
||||
@@ -208,14 +161,7 @@ if (FLUSH_ON_EOF !== undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF
|
||||
// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the
|
||||
// child ignores the graceful EOF window yet dies cooperatively on SIGTERM,
|
||||
// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the
|
||||
// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an
|
||||
// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle
|
||||
// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs
|
||||
// and the marker is missing. Touch READY_FILE once armed (a test waits on it).
|
||||
// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier.
|
||||
if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
const sigtermFile = process.env.MOCK_SIGTERM_FILE
|
||||
process.on('SIGTERM', () => {
|
||||
@@ -225,4 +171,3 @@ if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as acp from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP
|
||||
* server. The backend spawns the real `acp-agent` example as a child PROCESS,
|
||||
* speaks ACP to it over stdio, and the child runs the REAL model in its own
|
||||
* process to answer a prompt. We verify the child's real answer comes back
|
||||
* through the seam — the "talk to our own process" smoke the design called for.
|
||||
* Key-gated (self-skips without DEEPSEEK_API_KEY).
|
||||
*
|
||||
* This is the out-of-process analogue of the in-process spawn e2e: there a
|
||||
* parent agent on the same context drove a child; here the child is a separate
|
||||
* process reached over ACP, proving the seam generalizes across the boundary.
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP server.
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
|
||||
@@ -188,9 +188,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
||||
// term alone would hang dispose forever. With a short grace, dispose must
|
||||
// escalate to SIGKILL and return once the process is actually gone.
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would
|
||||
// hang dispose forever.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
@@ -224,15 +223,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
|
||||
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
|
||||
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
|
||||
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
|
||||
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
|
||||
// The mock models a flush that takes LONGER than the SIGTERM grace but well
|
||||
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
|
||||
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
|
||||
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on
|
||||
// connection close, not on a signal) — and it has no SIGTERM handler.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const flushed = join(tmp, 'flushed')
|
||||
@@ -242,10 +234,7 @@ describe('dsh-subagent-acp', () => {
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
|
||||
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
|
||||
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
|
||||
// wider grace.
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live child.
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
@@ -267,12 +256,9 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window
|
||||
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
|
||||
// — dispose returns there, never reaching the SIGKILL tier. The child touches
|
||||
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
|
||||
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
|
||||
// run and the marker would be absent — making this a GENUINE middle-tier guard.
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window times out) but
|
||||
// exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there,
|
||||
// never reaching the SIGKILL tier.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const sigterm = join(tmp, 'sigterm')
|
||||
@@ -307,9 +293,6 @@ describe('dsh-subagent-acp', () => {
|
||||
|
||||
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
|
||||
// Gate the child at newSession: it signals `ready` and blocks until `go`.
|
||||
// We cancel WHILE newSession is pending (sessionId still undefined, so the
|
||||
// backend cannot send session/cancel) — the `cancelled` flag alone must
|
||||
// settle the run aborted after newSession resolves, never issuing the prompt.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const go = join(tmp, 'go')
|
||||
@@ -457,10 +440,9 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is flattened
|
||||
// to a stop reason — onError must still surface the original error so a real
|
||||
// fault is logged, not swallowed. A nonexistent command triggers the spawn
|
||||
// failure path; the spy records the error + the chosen stop reason.
|
||||
// The seam forbids `result` rejecting, so a child-level failure is flattened to a stop
|
||||
// reason — onError must still surface the original error so a real fault is logged, not
|
||||
// swallowed.
|
||||
const errors: { message: string; stopReason: string }[] = []
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
@@ -506,10 +488,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard
|
||||
// — the pending prompt RPC rejects. With a cancel already requested, the
|
||||
// backend's catch path must settle `aborted` (the failure is the cancel
|
||||
// surfacing as a torn pipe), not `error`.
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard — the pending
|
||||
// prompt RPC rejects.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
@@ -526,10 +506,7 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
|
||||
// The contract: run.cancel() → result settles `aborted`. A child that hangs
|
||||
// its prompt AND ignores session/cancel must not wedge the parent — the
|
||||
// backend's own cancel-settle path resolves `aborted` without the child's
|
||||
// cooperation, and dispose() still reaps the process.
|
||||
// The contract: run.cancel() → result settles `aborted`.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user