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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user