fix(workflow): honor subagent readiness boundary
This commit is contained in:
@@ -431,17 +431,17 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:96`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:98`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/agent-start` — emit
|
||||
|
||||
One `agent()` call started a child run. Paired with Events['workflow/agent-end'] by `agent.seq`.
|
||||
One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never crosses the provider's publication/readiness boundary emits neither event in this pair.
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:87`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/end` — emit
|
||||
|
||||
@@ -451,7 +451,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:108`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/log` — emit
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ Semantics every implementation must honor:
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:210`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:214`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## Inherited `ctx` members (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:166`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:98`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:87`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:108`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -553,8 +553,18 @@ SubagentService.start(...):
|
||||
attach result settlement handlers immediately
|
||||
await returnedRun.started
|
||||
emit subagent/start; later emit the buffered or eventual subagent/end
|
||||
|
||||
Workflow worker bridge after receiving returnedRun:
|
||||
register the run so cancellation can reach pre-publication work
|
||||
attach result settlement handlers immediately and snapshot the outcome
|
||||
if returnedRun.started fulfills:
|
||||
send ChildStarted; then send the buffered or eventual outcome
|
||||
else:
|
||||
send ChildStartError and dispose the attempt
|
||||
```
|
||||
|
||||
Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, and ensures `workflow/agent-start` never names an unpublished child.
|
||||
|
||||
Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers.
|
||||
|
||||
### Persona, filtering, and lifetime use ordinary registrations
|
||||
|
||||
@@ -453,7 +453,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'workflow/agent-start',
|
||||
mode: 'emit',
|
||||
signature: '\'workflow/agent-start\'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void',
|
||||
summary: 'One `agent()` call started a child run.',
|
||||
summary: 'One `agent()` call established a ready child run.',
|
||||
},
|
||||
{
|
||||
name: 'workflow/end',
|
||||
|
||||
@@ -23,7 +23,11 @@ What the seam guarantees regardless, because benign scripts hit these constantly
|
||||
|
||||
`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`.
|
||||
|
||||
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all.
|
||||
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through.
|
||||
|
||||
The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC.
|
||||
|
||||
Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all.
|
||||
|
||||
## The value boundary
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
* (a script that never settles is force-settled `cancelled` and its worker
|
||||
* terminated — the real kill an in-process engine could not perform).
|
||||
*
|
||||
* Children live in a host-side registry (callId → run): the worker drives
|
||||
* their disposal by RPC on the graceful path, `dispose()` host-drives every
|
||||
* registered child's disposal immediately (a wedged worker can relay no
|
||||
* dispose RPC, and child teardown must overlap the grace, not start after
|
||||
* it), and the registry is what lets the host abort and dispose every
|
||||
* survivor when the worker dies or is terminated mid-flight. The three
|
||||
* Children live in a host-side registry (callId → run) as soon as the provider
|
||||
* accepts them, so cancellation reaches even a pre-publication attempt. The
|
||||
* host observes `result` immediately but acknowledges the child to the worker
|
||||
* only after `started` fulfills; readiness failure is a start error and the
|
||||
* host disposes the attempt because the worker never received a handle. The
|
||||
* worker drives disposal by RPC on the graceful path, `dispose()` host-drives
|
||||
* every registered child's disposal immediately (a wedged worker can relay no
|
||||
* dispose RPC, and child teardown must overlap the grace, not start after it),
|
||||
* and the registry lets the host abort and dispose every survivor when the
|
||||
* worker dies or is terminated mid-flight. The three
|
||||
* paths share ONE disposal per child (memoized by callId; the seam's
|
||||
* dispose() is idempotent anyway, the memo keeps the bookkeeping and the
|
||||
* containment warn single). Lifecycle pairing is host-guaranteed the same
|
||||
@@ -46,7 +50,7 @@ import { renderThrown } from './realm.ts'
|
||||
import type { ExecutionObserver } from './runtime.ts'
|
||||
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
|
||||
import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts'
|
||||
import type { ChildStartRequest, WorkerInit } from './types.ts'
|
||||
import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts'
|
||||
|
||||
/**
|
||||
* Resolve the worker entry and spawn options for the current runtime shape.
|
||||
@@ -307,19 +311,49 @@ export class WorkerRun implements WorkflowRun {
|
||||
return
|
||||
}
|
||||
this.children.set(callId, run)
|
||||
this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id })
|
||||
run.result.then(
|
||||
const childId = run.id
|
||||
|
||||
// Observe settlement IMMEDIATELY, before readiness. A provider may reject
|
||||
// result and started in the same turn; delaying this handler would make the
|
||||
// result transiently unhandled. Buffer a forwarding closure so the worker
|
||||
// still sees ChildStarted before ChildSettled/ChildFailed. Snapshot a
|
||||
// resolved result now: a provider mutating its resolved object while
|
||||
// publication is pending must not change what crosses the worker boundary.
|
||||
const forwardResult = run.result.then<() => void, () => void>(
|
||||
(result) => {
|
||||
this.post(HostToWorkerType.ChildSettled, {
|
||||
callId,
|
||||
result: {
|
||||
try {
|
||||
const snapshot: ChildResult = structuredClone({
|
||||
output: result.output,
|
||||
...result.structured !== undefined ? { structured: result.structured } : {},
|
||||
stopReason: result.stopReason,
|
||||
},
|
||||
})
|
||||
})
|
||||
return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) }
|
||||
} catch (error: unknown) {
|
||||
const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}`
|
||||
return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) }
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
const rendered = renderThrown(error)
|
||||
return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) }
|
||||
},
|
||||
)
|
||||
|
||||
// The provider owns the publication boundary. Only acknowledge the child
|
||||
// after it is real, then flush any result that settled unusually early. A
|
||||
// readiness rejection is a START failure, not AGENT_RESULT: the worker
|
||||
// never receives a handle, so the host must also dispose the registered
|
||||
// attempt. A concurrent host disposal may already have removed it; the
|
||||
// identity guard preserves the one-disposal memo in that race.
|
||||
void run.started.then(
|
||||
() => {
|
||||
this.post(HostToWorkerType.ChildStarted, { callId, childId })
|
||||
void forwardResult.then((forward) => { forward() })
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) })
|
||||
if (this.children.get(callId) === run) void this.disposeChild(callId, run)
|
||||
},
|
||||
(error: unknown) => { this.post(HostToWorkerType.ChildFailed, { callId, rendered: renderThrown(error) }) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,7 +384,10 @@ export class WorkerRun implements WorkflowRun {
|
||||
private disposeChild(callId: number, run: SubagentRun): Promise<void> {
|
||||
let disposal = this.childDisposals.get(callId)
|
||||
if (disposal === undefined) {
|
||||
disposal = run.dispose().then(
|
||||
// The seam promises a Promise, but invoke inside an async boundary so a
|
||||
// contract-violating synchronous throw is contained exactly like a
|
||||
// rejected disposal and cannot break host quiescence.
|
||||
disposal = (async () => { await run.dispose() })().then(
|
||||
() => { this.finishChild(callId) },
|
||||
(error: unknown) => {
|
||||
this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`)
|
||||
|
||||
@@ -69,9 +69,9 @@ export enum HostToWorkerType {
|
||||
Go = 'go',
|
||||
/** Cancel the run: hooks start throwing and the script dies at its next await. */
|
||||
Cancel = 'cancel',
|
||||
/** Child RPC reply: the start succeeded (exactly one of ChildStarted/ChildStartError per ChildStart). */
|
||||
/** Child RPC reply: provider publication/readiness fulfilled (exactly one start reply per ChildStart). */
|
||||
ChildStarted = 'child-started',
|
||||
/** Child RPC reply: the start was refused or threw. */
|
||||
/** Child RPC reply: synchronous start or asynchronous publication/readiness failed. */
|
||||
ChildStartError = 'child-start-error',
|
||||
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
|
||||
ChildSettled = 'child-settled',
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
* (a benign-bug guard; the postMessage clone already isolated the caller).
|
||||
*
|
||||
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
||||
* unsupported options/schemas, tripped caps, host start refusals and child
|
||||
* result rejections, cancellation) ALWAYS propagate through
|
||||
* unsupported options/schemas, tripped caps, synchronous start refusal,
|
||||
* pre-publication readiness failure, ready-child result rejection, and
|
||||
* cancellation) ALWAYS propagate through
|
||||
* `parallel`/`pipeline` — recognized by `instanceof` against this realm's
|
||||
* class, which a script inside the vm context cannot forge — and the per-item
|
||||
* `null` is reserved for child-run failures and ordinary in-stage script
|
||||
|
||||
@@ -89,24 +89,26 @@ class ChildRpcBridge implements ChildPort {
|
||||
settled: Promise.withResolvers<ChildResult>(),
|
||||
disposed: Promise.withResolvers<void>(),
|
||||
}
|
||||
// Containment: when the start is refused (or the run torn down) the
|
||||
// settled promise may never gain a consumer — it must not surface as an
|
||||
// unhandled rejection and kill the worker.
|
||||
entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ })
|
||||
// Containment: when synchronous start or asynchronous readiness fails (or
|
||||
// the run is torn down), the settled promise may never gain a consumer —
|
||||
// it must not surface as an unhandled rejection and kill the worker.
|
||||
entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after failed start/readiness */ })
|
||||
this.pending.set(callId, entry)
|
||||
this.post(WorkerToHostType.ChildStart, { callId, request })
|
||||
const childId = await entry.started.promise
|
||||
return new RpcChildHandle(this.post, callId, entry, childId)
|
||||
}
|
||||
|
||||
/** The host started the child; releases the `startAgent` await. */
|
||||
/** The host established a ready child; releases the `startAgent` await. */
|
||||
onChildStarted(callId: number, childId: string): void {
|
||||
this.pending.get(callId)?.started.resolve(childId)
|
||||
}
|
||||
|
||||
/** The host refused the start; `startAgent` rejects with the rendered cause. */
|
||||
/** Synchronous start or asynchronous readiness failed; reject and retire the pending RPC. */
|
||||
onChildStartError(callId: number, rendered: string): void {
|
||||
this.pending.get(callId)?.started.reject(new Error(rendered))
|
||||
const entry = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
entry?.started.reject(new Error(rendered))
|
||||
}
|
||||
|
||||
/** The child's terminal result arrived. */
|
||||
|
||||
@@ -91,7 +91,8 @@ export interface ChildPort {
|
||||
/**
|
||||
* Start one child agent on the host (the `agent()` hook's start half).
|
||||
* @param request - the prompt and validated options.
|
||||
* @returns the child handle; rejects when the host refuses the start.
|
||||
* @returns the ready child handle; rejects when synchronous start or the
|
||||
* provider's asynchronous publication/readiness boundary fails.
|
||||
*/
|
||||
startAgent(request: ChildStartRequest): Promise<ChildHandle>
|
||||
}
|
||||
@@ -48,7 +48,12 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { verdict: 'real', confidence: 0.9 }),
|
||||
])
|
||||
const childIds: string[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) })
|
||||
ctx.on('workflow/agent-start', (_info, agent) => {
|
||||
// The workflow bridge must honor SubagentRun.started: a start observer
|
||||
// sees the real spawn child already published, never a reserved id.
|
||||
expect(ctx.agents.get(agent.childId)).toBeDefined()
|
||||
childIds.push(agent.childId)
|
||||
})
|
||||
const run = ctx.workflows.start({
|
||||
meta: { name: 'integration', description: 'plain + structured children' },
|
||||
script: `phase('Read')
|
||||
|
||||
@@ -8,7 +8,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
|
||||
import * as workerEngineModule from '../src/index.ts'
|
||||
import WorkerWorkflowEngine, { type Config } from '../src/index.ts'
|
||||
import WorkerWorkflowEngine, { HostToWorkerType, type Config } from '../src/index.ts'
|
||||
|
||||
/** A minimal parent stand-in: the engine only threads it through to the provider. */
|
||||
function fakeParent(): Agent {
|
||||
@@ -47,7 +47,12 @@ const ESCAPE = "globalThis.constructor.constructor('return process')()"
|
||||
/** One controllable child run: the test (or auto mode) settles it. */
|
||||
interface ControlledRun {
|
||||
request: SubagentStartRequest
|
||||
/** Fulfill the provider publication/readiness boundary. */
|
||||
publish(): void
|
||||
/** Reject the provider publication/readiness boundary. */
|
||||
rejectStart(error: unknown): void
|
||||
settle(result: SubagentResult): void
|
||||
rejectResult(error: unknown): void
|
||||
cancelled: string | undefined
|
||||
disposed: boolean
|
||||
disposeCalls: number
|
||||
@@ -68,26 +73,37 @@ class StubProvider implements SubagentProvider {
|
||||
readonly name: string,
|
||||
private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult,
|
||||
private readonly disposeDelayMs = 0,
|
||||
private readonly deferStart = false,
|
||||
) {}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
let settle!: (result: SubagentResult) => void
|
||||
const result = new Promise<SubagentResult>((resolve) => { settle = resolve })
|
||||
const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false, disposeCalls: 0 }
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
const terminal = Promise.withResolvers<SubagentResult>()
|
||||
const controlled: ControlledRun = {
|
||||
request,
|
||||
publish: () => { readiness.resolve(undefined) },
|
||||
rejectStart: (error) => { readiness.reject(error) },
|
||||
settle: (result) => { terminal.resolve(result) },
|
||||
rejectResult: (error) => { terminal.reject(error) },
|
||||
cancelled: undefined,
|
||||
disposed: false,
|
||||
disposeCalls: 0,
|
||||
}
|
||||
this.runs.push(controlled)
|
||||
const index = this.runs.length - 1
|
||||
request.signal?.addEventListener('abort', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true })
|
||||
request.signal?.addEventListener('abort', () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, { once: true })
|
||||
if (!this.deferStart) readiness.resolve(undefined)
|
||||
if (this.reply) {
|
||||
const reply = this.reply
|
||||
queueMicrotask(() => { settle(reply(request, index)) })
|
||||
queueMicrotask(() => { terminal.resolve(reply(request, index)) })
|
||||
}
|
||||
return {
|
||||
id: AgentId(`stub-child-${index}`),
|
||||
started: Promise.resolve(),
|
||||
result,
|
||||
started: readiness.promise,
|
||||
result: terminal.promise,
|
||||
cancel: (reason?: string) => {
|
||||
controlled.cancelled = reason ?? 'cancelled'
|
||||
settle({ output: [], stopReason: 'aborted' })
|
||||
terminal.resolve({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: () => {
|
||||
controlled.disposeCalls += 1
|
||||
@@ -116,6 +132,7 @@ interface SetupOptions {
|
||||
reply?: (request: SubagentStartRequest, index: number) => SubagentResult
|
||||
manual?: boolean
|
||||
disposeDelayMs?: number
|
||||
deferStart?: boolean
|
||||
}
|
||||
|
||||
async function setup(options?: SetupOptions) {
|
||||
@@ -125,6 +142,7 @@ async function setup(options?: SetupOptions) {
|
||||
'stub',
|
||||
options?.manual ? undefined : options?.reply ?? (() => text('stub reply')),
|
||||
options?.disposeDelayMs ?? 0,
|
||||
options?.deferStart ?? false,
|
||||
)
|
||||
ctx.subagents.registerProvider(provider)
|
||||
// A fixed concurrency ceiling: the auto-resolved default is machine-derived
|
||||
@@ -214,6 +232,116 @@ describe('dsh-workflow-workerthread', () => {
|
||||
expect(result.error).toContain('agent() could not start a child')
|
||||
})
|
||||
|
||||
it('waits for child readiness before announcing it and snapshots a result that settled early', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
|
||||
const order: string[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) })
|
||||
ctx.on('workflow/agent-end', (_info, agent) => { order.push(`end:${agent.outcome}`) })
|
||||
ctx.on('workflow/end', () => { order.push('run-end') })
|
||||
|
||||
const handle = ctx.workflows.start({ ...scripted("return await agent('p')"), parent })
|
||||
await waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
const early = text('accepted value')
|
||||
provider.runs[0]!.settle(early)
|
||||
// Let the host observe + snapshot result while readiness remains pending.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const earlyText = early.output[0] as { type: 'text'; text: string }
|
||||
earlyText.text = 'mutated after settlement'
|
||||
expect(order).toEqual([])
|
||||
|
||||
provider.runs[0]!.publish()
|
||||
const result = await handle.result
|
||||
expect(result.value).toBe('accepted value')
|
||||
expect(order).toEqual(['start:1', 'end:completed', 'run-end'])
|
||||
await handle.dispose()
|
||||
expect(provider.runs[0]!.disposeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('observes an early result rejection but sends ChildStarted before ChildFailed after readiness', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
|
||||
ctx.on('workflow/agent-end', (_info, agent) => { lifecycle.push(`end:${agent.outcome}`) })
|
||||
const handle = ctx.workflows.start({
|
||||
...scripted("try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }"),
|
||||
parent,
|
||||
})
|
||||
const worker = (handle as unknown as { worker: { postMessage(message: unknown): void } }).worker
|
||||
const post = vi.spyOn(worker, 'postMessage')
|
||||
const childMessageTypes = (): HostToWorkerType[] => post.mock.calls
|
||||
.map(([message]) => (message as { type: HostToWorkerType }).type)
|
||||
.filter(type => type === HostToWorkerType.ChildStarted || type === HostToWorkerType.ChildFailed)
|
||||
|
||||
await waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
provider.runs[0]!.rejectResult(new Error('backend failed before publication'))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(childMessageTypes()).toEqual([])
|
||||
expect(lifecycle).toEqual([])
|
||||
|
||||
provider.runs[0]!.publish()
|
||||
const result = await handle.result
|
||||
expect(result.value).toMatchObject({ code: 'AGENT_RESULT' })
|
||||
expect((result.value as { message: string }).message).toContain('backend failed before publication')
|
||||
expect(childMessageTypes()).toEqual([HostToWorkerType.ChildStarted, HostToWorkerType.ChildFailed])
|
||||
expect(lifecycle).toEqual(['start', 'end:failed'])
|
||||
post.mockRestore()
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('classifies readiness rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
|
||||
ctx.on('workflow/agent-end', () => { lifecycle.push('end') })
|
||||
|
||||
const handle = ctx.workflows.start({
|
||||
...scripted("try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }"),
|
||||
parent,
|
||||
})
|
||||
await waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
// ACP-style failure can settle result(error) before its session/publication
|
||||
// boundary rejects. Readiness must dominate that buffered child outcome.
|
||||
provider.runs[0]!.settle({ output: [], stopReason: 'error' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
provider.runs[0]!.rejectStart(new Error('publication rolled back'))
|
||||
|
||||
const result = await handle.result
|
||||
expect(result.value).toMatchObject({ code: 'AGENT_START' })
|
||||
expect((result.value as { message: string }).message).toContain('publication rolled back')
|
||||
expect(lifecycle).toEqual([])
|
||||
await waitFor(() => {
|
||||
expect(provider.runs[0]!.disposed).toBe(true)
|
||||
expect(provider.runs[0]!.disposeCalls).toBe(1)
|
||||
})
|
||||
await handle.dispose()
|
||||
expect(provider.runs[0]!.disposeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('cancels and disposes a readiness-pending child once without publishing workflow lifecycle', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true, config: { disposeGraceMs: 500 } })
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('workflow/agent-start', () => { lifecycle.push('start') })
|
||||
ctx.on('workflow/agent-end', () => { lifecycle.push('end') })
|
||||
|
||||
const handle = ctx.workflows.start({ ...scripted("return await agent('pending')"), parent })
|
||||
await waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
const disposal = handle.dispose()
|
||||
await waitFor(() => {
|
||||
expect(provider.runs[0]!.cancelled).toBe('workflow disposed')
|
||||
expect(provider.runs[0]!.disposed).toBe(true)
|
||||
})
|
||||
// Ensure the host-driven disposal removed the registry entry before the
|
||||
// late readiness rejection; its callback must not invoke dispose again.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
provider.runs[0]!.rejectStart(new Error('cancelled before publication'))
|
||||
|
||||
const result = await handle.result
|
||||
await disposal
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(provider.runs[0]!.disposeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
@@ -238,7 +366,18 @@ describe('dsh-workflow-workerthread', () => {
|
||||
expect((result.value as { message: string }).message).toContain('backend exploded')
|
||||
})
|
||||
|
||||
it('a child whose dispose() rejects cannot wedge the script (the host acks anyway)', async () => {
|
||||
it('maps an uncloneable ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => {
|
||||
const { ctx, parent } = await setup({
|
||||
reply: () => ({ output: [], structured: () => { /* deliberately not cloneable */ }, stopReason: 'completed' }),
|
||||
})
|
||||
const result = await run(ctx, parent, scripted(`
|
||||
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }
|
||||
`))
|
||||
expect(result.value).toMatchObject({ code: 'AGENT_RESULT' })
|
||||
expect((result.value as { message: string }).message).toContain('could not cross the worker boundary')
|
||||
})
|
||||
|
||||
it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const provider: SubagentProvider = {
|
||||
@@ -250,7 +389,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
||||
cancel: () => { /* settled already */ },
|
||||
dispose: () => Promise.reject(new Error('dispose exploded')),
|
||||
dispose: () => { throw new Error('dispose exploded') },
|
||||
}),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
|
||||
@@ -22,7 +22,7 @@ All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta)
|
||||
|
||||
- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value.
|
||||
- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration.
|
||||
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call that STARTED a child run (a call rejected at validation or caps, refused at start, or cancelled while queued for a slot emits no pair), correlated by `seq`.
|
||||
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — ready-child lifecycle correlated by `seq`; the [generated event contract](../../../docs/cordis-catalog/events.md#workflowagent-start--emit) defines publication and pairing.
|
||||
|
||||
## Non-goals (this cut)
|
||||
|
||||
|
||||
@@ -76,8 +76,10 @@ declare module 'cordis' {
|
||||
*/
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
/**
|
||||
* One `agent()` call started a child run. Paired with
|
||||
* {@link Events['workflow/agent-end']} by `agent.seq`.
|
||||
* One `agent()` call established a ready child run. Paired with
|
||||
* {@link Events['workflow/agent-end']} by `agent.seq`. A call that never
|
||||
* crosses the provider's publication/readiness boundary emits neither
|
||||
* event in this pair.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call's sequence number, label, phase, and child id.
|
||||
* @mode emit
|
||||
@@ -129,10 +131,12 @@ export type WorkflowEventName =
|
||||
* - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output
|
||||
* subset (see dsh-tools).
|
||||
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
|
||||
* - `AGENT_START` — the subagent seam refused to start a child.
|
||||
* - `AGENT_RESULT` — a child's `result` REJECTED: an infrastructure fault at
|
||||
* the subagent seam, distinct from a child that failed and resolved (which
|
||||
* is the per-item `null`, never an error).
|
||||
* - `AGENT_START` — synchronous subagent start or the provider's asynchronous
|
||||
* publication/readiness boundary failed before cancellation took precedence.
|
||||
* - `AGENT_RESULT` — a run whose readiness FULFILLED had its `result` REJECT: an
|
||||
* infrastructure fault at the subagent seam, even if the rejection settled
|
||||
* before readiness. This is distinct from a child that failed and resolved
|
||||
* (which is the per-item `null`, never an error).
|
||||
* - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary
|
||||
* is not plain JSON data.
|
||||
* - `CANCELLED` — the run was cancelled; pending and future hooks reject
|
||||
|
||||
Reference in New Issue
Block a user