fix(scope): harden lifecycle ownership foundation

Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement.

Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior.
This commit is contained in:
Tianyi Cui
2026-07-12 08:57:05 +08:00
parent 197f7237d2
commit c5b1a7941f
39 changed files with 2945 additions and 461 deletions

View File

@@ -23,7 +23,7 @@ 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 (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) 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` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through.
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 through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward.
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.

View File

@@ -1,9 +1,10 @@
/**
* The host half of one worker-engine run: spawn the Worker, bridge its child
* RPC onto `ctx.subagents`, fan its observer messages into the engine's
* events, and own cancellation, the settle-within-grace guarantee, and child
* cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always
* ends with `worker.terminate()`, so no thread outlives its run.
* RPC onto the holder-bound subagent service, fan its observer messages into
* the engine's events, and own cancellation, the settle-within-grace
* guarantee, and child cleanup. The worker's lifetime IS the run's lifetime:
* `dispose()` always ends with `worker.terminate()`, so no thread outlives its
* run.
*
* The run's `result` promise settles exactly once, from whichever of these
* lands first: the worker's `result` message (a host-side cancellation in
@@ -47,6 +48,7 @@ import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import { renderThrown } from './realm.ts'
@@ -121,7 +123,9 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
* `start()` directly. Owns the Worker, the child registry, and the result
* settlement; `result` never rejects. `meta` is this handle's OWN clone
* (event payloads carry separate clones), so a consumer mutating it corrupts
* nothing.
* nothing. The holder-bound SubagentService handle is captured before the
* engine returns this run, so unloading the engine removes only the ability to
* start another workflow; this run can still start and clean up its children.
*/
export class WorkerRun implements WorkflowRun {
/** Settles exactly once with the run's outcome; never rejects. */
@@ -151,6 +155,7 @@ export class WorkerRun implements WorkflowRun {
constructor(
private readonly ctx: Context,
private readonly subagents: SubagentService,
readonly id: WorkflowRunId,
readonly meta: WorkflowMeta,
private readonly parent: Agent,
@@ -329,7 +334,7 @@ export class WorkerRun implements WorkflowRun {
this.hostStarted += 1
let run: SubagentRun
try {
run = this.ctx.subagents.start(this.provider, {
run = this.subagents.start(this.provider, {
prompt: [{ type: 'text', text: request.prompt }],
parent: this.parent,
signal: this.controller.signal,

View File

@@ -168,8 +168,17 @@ export class WorkerWorkflowEngine extends WorkflowService {
...request.args !== undefined ? { args: request.args } : {},
limits,
}
// Capture the dependency while this service call is still traced through
// the start() holder. Cordis strips the engine-provider shadow when it
// returns the SubagentService handle, so an already-returned run can keep
// starting children after an engine HMR unload removes ctx.workflows.
// Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
const runCtx = this.ctx
const subagents = runCtx.subagents
const workerRun = new WorkerRun(
this.ctx,
runCtx,
subagents,
id,
structuredClone(meta),
request.parent,

View File

@@ -148,8 +148,8 @@ async function setup(options?: SetupOptions) {
// A fixed concurrency ceiling: the auto-resolved default is machine-derived
// (cores - 2, floored at 1), so tests that expect N children in flight
// would wedge on small CI runners.
await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
return { ctx, provider, parent: fakeParent() }
const engineFiber = await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
return { ctx, provider, parent: fakeParent(), engineFiber }
}
/** The standard test meta plus a body, spread into a start request. */
@@ -1235,6 +1235,34 @@ describe('dsh-workflow-workerthread', () => {
expect(ctx.get('workflows')).toBeUndefined()
})
it('keeps a holder-owned run usable when the engine unloads before its child starts', async () => {
const { ctx, parent, provider, engineFiber } = await setup({ reply: () => text('survived reload') })
let handle!: ReturnType<typeof ctx.workflows.start>
const holder = await ctx.plugin(Object.assign((inner: Context) => {
handle = inner.workflows.start({ ...scripted("return await agent('after reload')"), parent })
}, { inject: ['workflows'] }))
try {
// A real worker cannot deliver child-start in the synchronous start()
// slice. Unload the provider before that message arrives: the returned
// run belongs to `holder`, not to the engine fiber being reloaded.
expect(provider.runs).toHaveLength(0)
await engineFiber.dispose()
expect(ctx.get('workflows')).toBeUndefined()
await expect(handle.result).resolves.toEqual({
value: 'survived reload',
stopReason: 'completed',
agentsStarted: 1,
})
expect(provider.runs).toHaveLength(1)
} finally {
await handle.dispose()
await holder.dispose()
await ctx.fiber.dispose()
}
})
it('has the class-plugin export shape (default = the engine service class)', () => {
expect(workerEngineModule.default).toBe(WorkerWorkflowEngine)
const loader = Object.create(Loader.prototype) as Loader