refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
3281 files changed
+21730
-21592
No files matched your search
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* Host side of one workflow run. The first worker result, unexpected death, or
|
||||
* cancellation-grace expiry owns settlement and closes message admission.
|
||||
* Pending starts share one abort signal; published children share idempotent
|
||||
* cleanup, and quiescence waits for both while synthesizing any missing end events.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/host
|
||||
*/
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { WorkerOptions } from 'node:worker_threads'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/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 SubagentRuntime 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'
|
||||
import type { ExecutionObserver } from './runtime.ts'
|
||||
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
|
||||
import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts'
|
||||
import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts'
|
||||
|
||||
/** One published child and its shared quiescent-disposal transaction. */
|
||||
interface ChildRecord {
|
||||
readonly run: SubagentRun
|
||||
disposal?: Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx
|
||||
* transforms inside the worker. Both shapes clear `execArgv` and the ambient
|
||||
* environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path
|
||||
* resolution.
|
||||
* @param init - the run payload, passed as `workerData`.
|
||||
* @returns the entry path or URL and the Worker options to spawn it with.
|
||||
*/
|
||||
function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } {
|
||||
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
|
||||
if (!import.meta.url.endsWith('.ts')) {
|
||||
return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } }
|
||||
}
|
||||
// Resolve tsx only for unbuilt consumers and install it before importing TS.
|
||||
const workerEntry = new URL('./worker.ts', import.meta.url)
|
||||
const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api')
|
||||
const tsxCjsApiEntry = import.meta.resolve('tsx/cjs/api')
|
||||
const bootstrap = [
|
||||
`import { register as registerEsm } from ${JSON.stringify(tsxEsmApiEntry)}`,
|
||||
`import { register as registerCjs } from ${JSON.stringify(tsxCjsApiEntry)}`,
|
||||
'registerCjs()',
|
||||
'registerEsm()',
|
||||
`await import(${JSON.stringify(workerEntry.href)})`,
|
||||
].join('\n')
|
||||
return {
|
||||
entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
|
||||
options: {
|
||||
workerData: init,
|
||||
env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
|
||||
execArgv: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live worker-engine run — the seam's {@link WorkflowRun}, returned by
|
||||
* `start()` directly. Owns the Worker, the child registry, and the result
|
||||
* settlement; `result` never rejects. `meta` is trusted same-process data
|
||||
* borrowed as immutable by the handle and lifecycle events. The holder-bound
|
||||
* SubagentRuntime 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. */
|
||||
readonly result: Promise<WorkflowResult>
|
||||
private settleResolve!: (result: WorkflowResult) => void
|
||||
private settled = false
|
||||
/** A Result/death/grace outcome atomically won before teardown callbacks. */
|
||||
private terminalClaimed = false
|
||||
/** The first death signal closes worker-message admission and owns failure-time cleanup. */
|
||||
private workerDeathObserved = false
|
||||
private cancelReason: string | undefined
|
||||
private graceTimer: NodeJS.Timeout | undefined
|
||||
private readonly worker: Worker
|
||||
/** Set on `exit`: the thread is gone, so posting has nowhere to go. */
|
||||
private workerGone = false
|
||||
/** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */
|
||||
private hostStarted = 0
|
||||
/** Published children by callId; an entry leaves only after disposal settles. */
|
||||
private readonly children = new Map<number, ChildRecord>()
|
||||
/** Provider starts that have not yet fulfilled or rejected. */
|
||||
private readonly pendingStarts = new Set<Promise<void>>()
|
||||
/** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */
|
||||
private readonly liveAgents = new Map<number, WorkflowAgentInfo>()
|
||||
private readonly quiescenceWaiters: (() => void)[] = []
|
||||
/** The per-run abort fanout every child start request carries. */
|
||||
private readonly controller = new AbortController()
|
||||
/** External start signal and the exact callback installed on it, retained only until first settle/teardown. */
|
||||
private inputSignal: AbortSignal | undefined
|
||||
private inputSignalAbort: (() => void) | undefined
|
||||
private disposed: Promise<void> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly subagents: SubagentRuntime,
|
||||
readonly id: WorkflowRunId,
|
||||
readonly meta: WorkflowMeta,
|
||||
private readonly parent: Agent,
|
||||
init: WorkerInit,
|
||||
private readonly provider: string,
|
||||
private readonly disposeGraceMs: number,
|
||||
private readonly observer: ExecutionObserver,
|
||||
signal: AbortSignal | undefined,
|
||||
) {
|
||||
this.result = new Promise<WorkflowResult>((resolve) => { this.settleResolve = resolve })
|
||||
// workerData rides the structured clone: args are plain JSON by the seam
|
||||
// contract, so the clone is total and doubles as the caller-isolation
|
||||
// copy (a clone failure throws loud out of start()).
|
||||
const { entry, options } = resolveWorkerSpawn(init)
|
||||
this.worker = new Worker(entry, options)
|
||||
this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) })
|
||||
this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`, false) })
|
||||
/* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */
|
||||
this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`, false) })
|
||||
this.worker.on('exit', (code) => {
|
||||
this.workerGone = true
|
||||
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`, true)
|
||||
})
|
||||
if (signal?.aborted) {
|
||||
this.cancel('workflow start signal already aborted')
|
||||
} else if (signal !== undefined) {
|
||||
const onAbort = (): void => {
|
||||
this.detachInputSignal()
|
||||
this.cancel('workflow signal aborted')
|
||||
}
|
||||
this.inputSignal = signal
|
||||
this.inputSignalAbort = onAbort
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: the worker is told (its hooks start throwing and the
|
||||
* script dies at its next await), the required signal shared by every child
|
||||
* start is aborted, and the grace timer
|
||||
* arms: a run still unsettled `disposeGraceMs` later force-settles
|
||||
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
|
||||
* wins.
|
||||
* @param reason - human-readable cause (default `'workflow cancelled'`).
|
||||
*/
|
||||
cancel(reason?: string): void {
|
||||
// A settled run has nothing left to cancel, and a terminal source claimed
|
||||
// before its cleanup callbacks must exclude cancellation reentered by one
|
||||
// of those callbacks. Without the settled guard the
|
||||
// ordinary consumer path (await result, then dispose -> cancel) would arm
|
||||
// a grace timer nothing ever clears, pinning the run and its Worker
|
||||
// closure until the grace expires - a bounded leak per completed run.
|
||||
if (this.settled || this.terminalClaimed || this.cancelReason !== undefined) return
|
||||
this.cancelReason = reason ?? 'workflow cancelled'
|
||||
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
|
||||
this.abortChildren(this.cancelReason)
|
||||
this.graceTimer = setTimeout(() => {
|
||||
// Cancellation already owns the race through cancelReason; close the
|
||||
// terminal boundary explicitly before observer teardown callbacks.
|
||||
this.terminalClaimed = true
|
||||
// The worker may no longer speak (it is about to be terminated): pair
|
||||
// every stranded start before the run settles, so ends precede
|
||||
// workflow/end.
|
||||
this.endStrandedAgents()
|
||||
this.settleResult(this.cancelledResult(this.hostStarted))
|
||||
void this.worker.terminate()
|
||||
}, this.disposeGraceMs)
|
||||
// unref'd: an armed grace timer must never hold the process open.
|
||||
this.graceTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel + bounded settle + termination. Host-drives every registered
|
||||
* child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC,
|
||||
* and deferring child teardown to the post-terminate reap would spend the
|
||||
* whole grace waiting for a quiescence that cannot start, then return with
|
||||
* the disposals still in flight — so child disposal overlaps the same
|
||||
* grace the worker gets to settle (the worker's own dispose RPCs join the
|
||||
* shared per-child disposal). Waits (at most the grace) for the result and
|
||||
* child quiescence, then terminates the worker unconditionally — the
|
||||
* thread never outlives its run — and reaps whatever children remain
|
||||
* (their disposal is contained, not awaited past the grace, the same
|
||||
* abandonment the seam documents for a slow-disposing child). Idempotent;
|
||||
* safe on every path.
|
||||
* @returns resolves when the run's resources are released or abandoned.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
if (this.disposed !== undefined) return this.disposed
|
||||
// Claim the public transaction BEFORE its body invokes child/provider
|
||||
// disposal. A raw provider callback can reenter handle.dispose(); it must
|
||||
// join this promise rather than start a second traversal.
|
||||
const claimed = Promise.withResolvers<undefined>()
|
||||
this.disposed = claimed.promise
|
||||
void (async () => {
|
||||
this.detachInputSignal()
|
||||
this.cancel('workflow disposed')
|
||||
// cancel() deliberately becomes a no-op after terminal settlement, but
|
||||
// disposal still owns every registered child. Reap independently so an
|
||||
// already-settled workflow cannot wait on child quiescence before it has
|
||||
// started the surviving children's disposals. On an unsettled run this
|
||||
// joins the cancel path through the per-call cancellation/disposal gates.
|
||||
this.reapChildren('workflow disposed')
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await this.result
|
||||
await this.childQuiescence()
|
||||
})(),
|
||||
sleep(this.disposeGraceMs),
|
||||
])
|
||||
await this.worker.terminate()
|
||||
this.reapChildren('workflow disposed')
|
||||
})().then(
|
||||
() => { claimed.resolve(undefined) },
|
||||
/* v8 ignore next -- result/quiescence never reject and Worker.terminate is the only external promise */
|
||||
(error: unknown) => { claimed.reject(error) },
|
||||
)
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
/** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */
|
||||
private post<T extends HostToWorkerType>(type: T, payload: HostToWorkerPayloads[T]): void {
|
||||
if (this.workerGone || this.workerDeathObserved) return
|
||||
try {
|
||||
this.worker.postMessage({ type, ...payload })
|
||||
} catch (error: unknown) {
|
||||
// Only a teardown race can land here (every engine message is JSON
|
||||
// data, so serialization cannot fail); there is nothing left to
|
||||
// deliver to — log and move on.
|
||||
/* v8 ignore next -- postMessage teardown race (a throw between exit and its event): not constructible in-process */
|
||||
this.ctx.logger.warn(`workflow-worker-thread: postMessage failed: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(message: WorkerToHostMessage): void {
|
||||
// Node may emit `error`, then deliver an already-queued `message`, then
|
||||
// emit `exit`. The first death signal is the host's logical delivery
|
||||
// barrier: nothing arriving afterward may create a child, narrate after
|
||||
// workflow/end, or compete with the chosen outcome.
|
||||
if (this.workerDeathObserved) return
|
||||
switch (message.type) {
|
||||
case WorkerToHostType.Ready:
|
||||
this.post(HostToWorkerType.Go, {})
|
||||
break
|
||||
case WorkerToHostType.Phase:
|
||||
// Post-cancel narration is suppressed host-side: worker-side the
|
||||
// hooks throw once the cancel message is PROCESSED, but narration
|
||||
// already in flight (or emitted while the cancel crossed the
|
||||
// boundary) must not reach observers — nothing is emitted after
|
||||
// cancel() returns.
|
||||
if (this.cancelReason === undefined) this.observer.phase(message.title)
|
||||
break
|
||||
case WorkerToHostType.Log:
|
||||
if (this.cancelReason === undefined) this.observer.log(message.message)
|
||||
break
|
||||
case WorkerToHostType.AgentStart:
|
||||
this.liveAgents.set(message.info.seq, message.info)
|
||||
this.observer.agentStart(message.info)
|
||||
break
|
||||
case WorkerToHostType.AgentEnd:
|
||||
// NOT suppressed on cancel: cancelled children report their paired
|
||||
// agent-end with outcome 'cancelled'. The gate (with the termination
|
||||
// paths' synthesis) is what makes the one-pair-per-started-child
|
||||
// contract hold on every stop path.
|
||||
this.endAgent(message.info)
|
||||
break
|
||||
case WorkerToHostType.ChildStart:
|
||||
this.onChildStart(message.callId, message.request)
|
||||
break
|
||||
case WorkerToHostType.ChildDispose:
|
||||
this.onChildDispose(message.callId)
|
||||
break
|
||||
case WorkerToHostType.Result:
|
||||
this.onResult(message.result)
|
||||
break
|
||||
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
|
||||
default:
|
||||
assertNever(message, 'worker-to-host message')
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a ready provider result may no longer be admitted to the worker. */
|
||||
private childAdmissionFailure(): { reason: string; rendered: string } | undefined {
|
||||
if (this.cancelReason !== undefined) {
|
||||
return { reason: this.cancelReason, rendered: `workflow run cancelled: ${this.cancelReason}` }
|
||||
}
|
||||
if (this.workerDeathObserved) {
|
||||
return { reason: 'workflow worker gone', rendered: 'workflow worker is no longer available' }
|
||||
}
|
||||
if (this.terminalClaimed) {
|
||||
return { reason: 'workflow settled', rendered: 'workflow run already settled' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private onChildStart(callId: number, request: ChildStartRequest): void {
|
||||
const initialFailure = this.childAdmissionFailure()
|
||||
if (initialFailure !== undefined) {
|
||||
// Refuse after a terminal boundary: a child must never start on an
|
||||
// already-aborted signal (a provider subscribing only to future abort
|
||||
// events would never observe it).
|
||||
this.post(HostToWorkerType.ChildStartError, { callId, rendered: initialFailure.rendered })
|
||||
return
|
||||
}
|
||||
this.hostStarted += 1
|
||||
const task = this.startChild(callId, request)
|
||||
this.pendingStarts.add(task)
|
||||
void task.then(
|
||||
() => { this.finishPendingStart(task) },
|
||||
/* v8 ignore next -- startChild contains provider and cleanup failures */
|
||||
() => { this.finishPendingStart(task) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Await one provider-owned startup transaction and publish only while admitted. */
|
||||
private async startChild(callId: number, request: ChildStartRequest): Promise<void> {
|
||||
let run: SubagentRun
|
||||
try {
|
||||
run = await this.subagents.start(this.provider, {
|
||||
prompt: [{ type: 'text', text: request.prompt }],
|
||||
parent: this.parent,
|
||||
signal: this.controller.signal,
|
||||
...request.schema !== undefined ? { outputSchema: request.schema } : {},
|
||||
...request.provider !== undefined || request.model !== undefined
|
||||
? {
|
||||
agentOptions: {
|
||||
...request.provider !== undefined ? { provider: request.provider } : {},
|
||||
...request.model !== undefined ? { model: request.model } : {},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const failure = this.childAdmissionFailure()
|
||||
this.post(HostToWorkerType.ChildStartError, {
|
||||
callId,
|
||||
rendered: failure?.rendered ?? renderThrown(error),
|
||||
})
|
||||
return
|
||||
}
|
||||
const failure = this.childAdmissionFailure()
|
||||
if (failure !== undefined) {
|
||||
this.post(HostToWorkerType.ChildStartError, { callId, rendered: failure.rendered })
|
||||
try {
|
||||
await run.dispose()
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`workflow-worker-thread: refused child dispose failed: ${renderThrown(error)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const record: ChildRecord = { run }
|
||||
this.children.set(callId, record)
|
||||
// Attach result forwarding before publishing the child handle. Because the
|
||||
// callback itself runs in a later microtask, ChildStarted is still posted
|
||||
// first even for an already-settled scripted provider.
|
||||
const forwardResult = run.result.then<() => void, () => void>(
|
||||
(result) => {
|
||||
try {
|
||||
const snapshot = snapshotJsonValue<ChildResult>({
|
||||
output: result.output,
|
||||
...result.structured !== undefined ? { structured: result.structured } : {},
|
||||
stopReason: result.stopReason,
|
||||
})
|
||||
if (snapshot === undefined) throw new TypeError('child result is not losslessly JSON-serializable')
|
||||
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 }) }
|
||||
},
|
||||
)
|
||||
this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id })
|
||||
void forwardResult.then((forward) => { forward() })
|
||||
}
|
||||
|
||||
private onChildDispose(callId: number): void {
|
||||
const record = this.children.get(callId)
|
||||
if (record === undefined) {
|
||||
// Already disposed host-side (a dispose() drive or a death reap beat
|
||||
// the RPC) — the ack is still owed (the worker-side wrapper awaits it).
|
||||
this.post(HostToWorkerType.ChildDisposed, { callId })
|
||||
return
|
||||
}
|
||||
// disposeChild never rejects (containment is inside), so the ack always follows.
|
||||
void this.disposeChild(callId, record).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or join) one registered child's disposal; the registry entry
|
||||
* leaves when it settles. Memoized per callId: the worker's dispose RPC,
|
||||
* the dispose() host drive, and the reap can all land on the same child —
|
||||
* the child's `dispose()` runs once and every caller awaits that one
|
||||
* settlement. A rejection is contained (the subagent seam's dispose() is
|
||||
* not supposed to reject, but a backend that does anyway must not break
|
||||
* quiescence): logged, and the child still leaves the registry.
|
||||
* @param callId - the child's registry key.
|
||||
* @param record - the registered child (the caller looked it up).
|
||||
* @returns resolves when the disposal settled either way; never rejects.
|
||||
*/
|
||||
private disposeChild(callId: number, record: ChildRecord): Promise<void> {
|
||||
if (record.disposal !== undefined) return record.disposal
|
||||
record.disposal = Promise.resolve()
|
||||
.then(() => record.run.dispose())
|
||||
.catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`workflow-worker-thread: child dispose failed: ${renderThrown(error)}`)
|
||||
})
|
||||
.then(() => { this.finishChild(callId) })
|
||||
return record.disposal
|
||||
}
|
||||
|
||||
/** Drop a child record and release quiescence waiters when all work ends. */
|
||||
private finishChild(callId: number): void {
|
||||
this.children.delete(callId)
|
||||
this.notifyChildQuiescence()
|
||||
}
|
||||
|
||||
/** Retire one provider startup transaction. */
|
||||
private finishPendingStart(task: Promise<void>): void {
|
||||
this.pendingStarts.delete(task)
|
||||
this.notifyChildQuiescence()
|
||||
}
|
||||
|
||||
/** Release waiters only after both pending starts and published children end. */
|
||||
private notifyChildQuiescence(): void {
|
||||
if (this.children.size !== 0 || this.pendingStarts.size !== 0) return
|
||||
for (const waiter of this.quiescenceWaiters.splice(0)) waiter()
|
||||
}
|
||||
|
||||
/** Resolves once every pending start and published child has reached quiescence. */
|
||||
private childQuiescence(): Promise<void> {
|
||||
if (this.children.size === 0 && this.pendingStarts.size === 0) return Promise.resolve()
|
||||
return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) })
|
||||
}
|
||||
|
||||
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
|
||||
private reapChildren(reason: string): void {
|
||||
this.abortChildren(this.cancelReason ?? reason)
|
||||
for (const [callId, record] of [...this.children]) {
|
||||
void this.disposeChild(callId, record)
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort the one canonical signal shared by pending and published children. */
|
||||
private abortChildren(reason: string): void {
|
||||
if (!this.controller.signal.aborted) this.controller.abort(reason)
|
||||
}
|
||||
|
||||
private onResult(result: WorkflowResult): void {
|
||||
// The owned worker session sends one Result. Keep a late duplicate or a
|
||||
// Result queued behind another terminal source completely side-effect-free.
|
||||
if (this.terminalClaimed) return
|
||||
// First-wins is decided when the Result message reaches the host. If no
|
||||
// external cancellation was already in flight, this result won. Reaping a
|
||||
// stray child below may synchronously reenter cancel() through provider
|
||||
// callbacks, but that internal post-result cleanup must not retroactively
|
||||
// rewrite the worker result that arrived first.
|
||||
const cancellationWasRequested = this.cancelReason !== undefined
|
||||
// Claim before settlement cleanup invokes provider disposal. Once Result
|
||||
// won, a later cancellation cannot rewrite it.
|
||||
this.terminalClaimed = true
|
||||
// Abort pending starts and begin disposing published children before the
|
||||
// workflow becomes externally settled. Cleanup remains independently
|
||||
// tracked by childQuiescence and the holder's dispose().
|
||||
this.reapChildren('workflow settled')
|
||||
if (!cancellationWasRequested) {
|
||||
this.settleResult(result)
|
||||
return
|
||||
}
|
||||
if (result.stopReason !== 'cancelled') {
|
||||
// The script settled while our cancel was crossing the thread boundary
|
||||
// — the seam-visible result had NOT settled when cancellation was
|
||||
// requested, so report cancelled (the vm drive()'s post-settle check,
|
||||
// relocated to the receiving side of the race).
|
||||
this.settleResult(this.cancelledResult(result.agentsStarted))
|
||||
return
|
||||
}
|
||||
this.settleResult(result)
|
||||
}
|
||||
|
||||
/** Process an error/messageerror/exit signal; `exit` also performs the final disposal sweep. */
|
||||
private onWorkerDeath(message: string, isExit: boolean): void {
|
||||
if (!this.workerDeathObserved) {
|
||||
// Close message admission BEFORE cleanup callbacks: Node can deliver a
|
||||
// message queued before the crash after its `error` event. Treating the
|
||||
// first death signal as a logical barrier prevents that late message
|
||||
// from creating work or narrating after workflow/end.
|
||||
this.workerDeathObserved = true
|
||||
const outcomeWasClaimed = this.terminalClaimed
|
||||
const cancellationWasRequested = this.cancelReason !== undefined
|
||||
// When death is itself the terminal source, claim BEFORE child reap or
|
||||
// synthesized observer callbacks. Either can reenter cancel(); a death
|
||||
// that arrived first remains an error, while a cancellation already
|
||||
// accepted before death remains cancelled. If Result/grace already won,
|
||||
// preserve it while still performing prompt failure-time cleanup.
|
||||
if (!outcomeWasClaimed) this.terminalClaimed = true
|
||||
if (this.children.size > 0 || this.pendingStarts.size > 0) this.reapChildren('workflow worker gone')
|
||||
this.endStrandedAgents()
|
||||
if (!outcomeWasClaimed) {
|
||||
if (cancellationWasRequested) {
|
||||
this.settleResult(this.cancelledResult(this.hostStarted))
|
||||
} else {
|
||||
this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isExit) return
|
||||
// `error` is not Node's physical delivery barrier: a queued message may
|
||||
// precede `exit`. Admission is already closed, so this final sweep only
|
||||
// joins/starts disposal for registry survivors; it deliberately does not
|
||||
// repeat explicit provider cancellation.
|
||||
for (const [callId, record] of [...this.children]) void this.disposeChild(callId, record)
|
||||
this.endStrandedAgents()
|
||||
}
|
||||
|
||||
/**
|
||||
* The single agent-end emission gate: forwards `end` iff its start is still
|
||||
* unpaired in the ledger, so every forwarded `workflow/agent-start` gets
|
||||
* EXACTLY one `workflow/agent-end` — the worker's own report where it can
|
||||
* speak, a host-synthesized one where it cannot ({@link endStrandedAgents}).
|
||||
* @param end - the settlement to emit (worker-reported or synthesized).
|
||||
*/
|
||||
private endAgent(end: WorkflowAgentEndInfo): void {
|
||||
/* v8 ignore next -- a real end still in flight across the grace force-settle: not orderable in-process */
|
||||
if (!this.liveAgents.delete(end.seq)) return
|
||||
this.observer.agentEnd(end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize the missing `agent-end` for every started-but-unpaired agent,
|
||||
* outcome `'cancelled'`: the reap cancels every child, and a real
|
||||
* settlement racing the force-settle loses to that already-started external
|
||||
* cancellation. The atomic terminal boundaries in {@link onResult} and
|
||||
* {@link onWorkerDeath} deliberately exclude teardown callbacks as contenders.
|
||||
* Called where the worker can no longer speak (the grace force-settle,
|
||||
* worker death, physical exit). When grace/death is the terminal source it
|
||||
* runs before settleResult, so already-known pairs precede `workflow/end`;
|
||||
* after an earlier Result, exit cleanup may close a survivor afterward.
|
||||
* The ledger preserves exactly-once pairing in both orders.
|
||||
*/
|
||||
private endStrandedAgents(): void {
|
||||
for (const info of [...this.liveAgents.values()]) {
|
||||
this.endAgent({ ...info, outcome: 'cancelled' })
|
||||
}
|
||||
}
|
||||
|
||||
private cancelledResult(agentsStarted: number): WorkflowResult {
|
||||
// cancel() is the only writer of cancelReason and every caller checks it
|
||||
// first; the fallback guards the type, not a reachable path.
|
||||
/* v8 ignore next */
|
||||
const reason = this.cancelReason ?? 'workflow cancelled'
|
||||
return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted }
|
||||
}
|
||||
|
||||
/** Remove the exact abort callback installed on the caller's start signal. */
|
||||
private detachInputSignal(): void {
|
||||
const signal = this.inputSignal
|
||||
const onAbort = this.inputSignalAbort
|
||||
if (signal === undefined || onAbort === undefined) return
|
||||
this.inputSignal = undefined
|
||||
this.inputSignalAbort = undefined
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
/** First settle wins; disarms the grace timer and releases the caller signal. */
|
||||
private settleResult(result: WorkflowResult): void {
|
||||
// Every current terminal source claims ownership before calling here; keep
|
||||
// the fallback local so a future caller cannot resolve twice.
|
||||
/* v8 ignore next -- defensive fallback outside the claimed state machine */
|
||||
if (this.settled) return
|
||||
this.terminalClaimed = true
|
||||
this.settled = true
|
||||
this.detachInputSignal()
|
||||
clearTimeout(this.graceTimer)
|
||||
this.settleResolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
timer.unref()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Worker-thread workflow engine. Each run executes its model-written script in
|
||||
* an escapable vm context on a fresh worker and bridges `agent()` calls to host
|
||||
* subagents. The thread prevents synchronous script work from blocking the host
|
||||
* and permits forced termination, but it is containment rather than a security boundary.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import * as vm from 'node:vm'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import WorkflowEngine, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { WorkerRun } from './host.ts'
|
||||
import { validateMeta } from './meta.ts'
|
||||
import type { WorkerInit, WorkerLimits } from './types.ts'
|
||||
|
||||
export { validateMeta } from './meta.ts'
|
||||
export { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
export type {
|
||||
ChildHandle,
|
||||
ChildPort,
|
||||
ChildResult,
|
||||
ChildStartRequest,
|
||||
WorkerInit,
|
||||
WorkerLimits,
|
||||
} from './types.ts'
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** The `ctx.subagents` provider children run on (default `spawn`). */
|
||||
provider?: string
|
||||
/** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
|
||||
maxConcurrentAgents?: number
|
||||
/** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
|
||||
maxTotalAgents?: number
|
||||
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
|
||||
maxItemsPerCall?: number
|
||||
/** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
|
||||
syncTimeoutMs?: number
|
||||
/**
|
||||
* How long after a cancellation an unsettled script may keep running before
|
||||
* the run force-settles `cancelled` and its worker is TERMINATED (default
|
||||
* 5000 ms); also bounds `dispose()`.
|
||||
*/
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
|
||||
const META_STATEMENT = /^\s*export\s+const\s+meta\b/
|
||||
|
||||
/**
|
||||
* Parse-check the body with the SAME wrapper the worker-side runtime
|
||||
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
|
||||
* (the worker's own compile happens a thread away, after `start()` returned).
|
||||
* One redundant parse per run, bought deliberately for the contract. A body
|
||||
* opening with `export const meta` gets a pointed message instead of the
|
||||
* wrapper's bare SyntaxError — the model's likeliest authoring slip.
|
||||
*/
|
||||
function assertBodyParses(body: string, name: string): void {
|
||||
if (META_STATEMENT.test(body)) {
|
||||
throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE')
|
||||
}
|
||||
try {
|
||||
// Parse only — the script object is discarded, nothing executes.
|
||||
void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one run's provider route before publishing work. */
|
||||
function resolveSubagentProvider(ctx: Context, configured: string, override: string | undefined): string {
|
||||
const provider = override ?? configured
|
||||
if (provider.length === 0 || provider !== provider.trim()) {
|
||||
throw new WorkflowError(
|
||||
'workflow subagentProvider must be a non-empty normalized string',
|
||||
'INVALID_ARGUMENT',
|
||||
)
|
||||
}
|
||||
if (ctx.subagents.getProvider(provider) === undefined) {
|
||||
throw new WorkflowError(`no subagent provider registered for "${provider}"`, 'AGENT_START')
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Resolve one run's total-child cap against the engine deployment ceiling. */
|
||||
function resolveMaxTotalAgents(requested: number | undefined, ceiling: number): number {
|
||||
if (requested === undefined) return ceiling
|
||||
if (!Number.isSafeInteger(requested) || requested < 1) {
|
||||
throw new WorkflowError('workflow maxTotalAgents must be a positive safe integer', 'INVALID_ARGUMENT')
|
||||
}
|
||||
if (requested > ceiling) {
|
||||
throw new WorkflowError(
|
||||
`workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`,
|
||||
'INVALID_ARGUMENT',
|
||||
)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker-thread engine service. `start()` validates the script up front
|
||||
* (meta + a host-side body parse) and returns a {@link WorkflowRun} whose
|
||||
* `result` never rejects; the `workflow/*` events fire around the run per
|
||||
* the seam contract.
|
||||
*/
|
||||
class WorkerThreadWorkflowEngine extends WorkflowEngine {
|
||||
static inject = ['subagents']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().default('spawn'),
|
||||
maxConcurrentAgents: z.natural().default(0),
|
||||
maxTotalAgents: z.natural().min(1).default(1000),
|
||||
maxItemsPerCall: z.natural().min(1).default(4096),
|
||||
syncTimeoutMs: z.natural().min(1).default(5000),
|
||||
disposeGraceMs: z.natural().default(5000),
|
||||
})
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// schemastery (static Config) has already filled the defaulted fields;
|
||||
// the assertion records that resolution, not a hidden fallback.
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and execute a workflow script in a fresh worker thread. Throws
|
||||
* {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
|
||||
* block, `SCRIPT_PARSE` for a body that does not compile) for a request
|
||||
* that cannot begin; once a run is returned, every failure resolves through
|
||||
* `result.stopReason` instead.
|
||||
* @param request - the script body, its meta data and `args`, the parent
|
||||
* agent, and an optional cancel signal.
|
||||
* @returns the live run (its `result` resolves when the script settles).
|
||||
*/
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
const meta = validateMeta(request.meta)
|
||||
assertBodyParses(request.script, meta.name)
|
||||
const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider)
|
||||
const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents)
|
||||
const id = WorkflowRunId(randomUUID())
|
||||
const info: WorkflowRunInfo = { id, meta }
|
||||
const limits: WorkerLimits = {
|
||||
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
|
||||
? Math.min(16, Math.max(1, availableParallelism() - 2))
|
||||
: this.config.maxConcurrentAgents,
|
||||
maxTotalAgents,
|
||||
maxItemsPerCall: this.config.maxItemsPerCall,
|
||||
syncTimeoutMs: this.config.syncTimeoutMs,
|
||||
}
|
||||
const init: WorkerInit = {
|
||||
meta,
|
||||
body: request.script,
|
||||
...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 SubagentRuntime handle, so an already-returned run can keep
|
||||
// starting children after an engine HMR unload removes ctx.workflowEngine.
|
||||
// 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(
|
||||
runCtx,
|
||||
subagents,
|
||||
id,
|
||||
meta,
|
||||
request.parent,
|
||||
init,
|
||||
subagentProvider,
|
||||
this.config.disposeGraceMs,
|
||||
{
|
||||
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
|
||||
log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
|
||||
agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
|
||||
agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
|
||||
},
|
||||
request.signal,
|
||||
)
|
||||
|
||||
this.emitWorkflowEvent('workflow/start', info)
|
||||
// `workflow/end` fires as the (never-rejecting) result settles, with the
|
||||
// outcome DATA only — the value stays with the run's holder.
|
||||
void workerRun.result.then((settled) => {
|
||||
this.emitWorkflowEvent('workflow/end', info, {
|
||||
stopReason: settled.stopReason,
|
||||
...settled.error !== undefined ? { error: settled.error } : {},
|
||||
agentsStarted: settled.agentsStarted,
|
||||
})
|
||||
})
|
||||
|
||||
return workerRun
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkerThreadWorkflowEngine
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workflow-worker-thread`.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-worker-thread'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workflow-worker-thread-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this process-boundary implementation exposes no same-process event relation;
|
||||
* worker protocol and built-worker tests cover it.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Meta validation checks caller-provided DATA against the {@link WorkflowMeta}
|
||||
* contract and rejects every violation by name. Meta arrives as schema-checked
|
||||
* JSON data, never evaluated script text; evaluating it on the host could run getters outside the
|
||||
* worker timeout that exists to isolate model-written code.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/meta
|
||||
*/
|
||||
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
|
||||
|
||||
/** Collect shape violations for a meta value (plain JSON data by the seam contract). */
|
||||
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
|
||||
const violations: string[] = []
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
return { violations: ['meta must be an object'] }
|
||||
}
|
||||
const record = meta as Record<string, unknown>
|
||||
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
|
||||
}
|
||||
if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
|
||||
if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
|
||||
if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
|
||||
const phases: WorkflowPhase[] = []
|
||||
if (record.phases !== undefined) {
|
||||
if (!Array.isArray(record.phases)) {
|
||||
violations.push('meta.phases must be an array')
|
||||
} else {
|
||||
record.phases.forEach((phase, index) => {
|
||||
if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
|
||||
violations.push(`meta.phases[${index}] must be an object`)
|
||||
return
|
||||
}
|
||||
const entry = phase as Record<string, unknown>
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!['title', 'detail', 'provider', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
|
||||
}
|
||||
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
|
||||
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
|
||||
if (entry.provider !== undefined && typeof entry.provider !== 'string') violations.push(`meta.phases[${index}].provider must be a string`)
|
||||
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
|
||||
if (violations.length === 0) {
|
||||
phases.push({
|
||||
title: entry.title as string,
|
||||
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
|
||||
...entry.provider !== undefined ? { provider: entry.provider as string } : {},
|
||||
...entry.model !== undefined ? { model: entry.model as string } : {},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return { violations }
|
||||
return {
|
||||
violations,
|
||||
meta: {
|
||||
name: record.name as string,
|
||||
description: record.description as string,
|
||||
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
|
||||
...record.phases !== undefined ? { phases } : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a caller-provided meta value against the {@link WorkflowMeta}
|
||||
* contract. Throws `META_INVALID` naming every violation (unknown fields,
|
||||
* missing/mistyped `name`/`description`, malformed `phases`); the returned
|
||||
* meta is a NORMALIZED copy built from the validated fields, so the engine
|
||||
* never aliases the caller's object.
|
||||
* @param value - the meta data from the start request (plain JSON by the seam contract).
|
||||
* @returns the validated, normalized meta block.
|
||||
*/
|
||||
export function validateMeta(value: unknown): WorkflowMeta {
|
||||
const { meta, violations } = validateMetaShape(value)
|
||||
if (meta === undefined) {
|
||||
throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID')
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The host⇄worker wire protocol: one string-valued enum of message tags per direction, a
|
||||
* payload map giving each tag its parameters (the single source of truth), and the message
|
||||
* unions derived from them. Payloads are plain JSON by construction for structured clone. Both
|
||||
* directions are closed engine protocols whose receivers use `assertNever`; generic typed senders
|
||||
* make tag/payload mismatches compile-time errors rather than silently skipped messages.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/protocol
|
||||
*/
|
||||
|
||||
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult } from '@deepseek-ai/dsh-workflow'
|
||||
import type { ChildResult, ChildStartRequest } from './types.ts'
|
||||
|
||||
/** Message tags the worker sends the host (the wire values are the tag strings). */
|
||||
export enum WorkerToHostType {
|
||||
/** The startup handshake: the session is listening and awaits {@link HostToWorkerType.Go}. */
|
||||
Ready = 'ready',
|
||||
/** Observer narration: a `phase(title)` call. */
|
||||
Phase = 'phase',
|
||||
/** Observer narration: a `log(message)` call. */
|
||||
Log = 'log',
|
||||
/** Observer lifecycle: one `agent()` call started a child. */
|
||||
AgentStart = 'agent-start',
|
||||
/** Observer lifecycle: one `agent()` call settled. */
|
||||
AgentEnd = 'agent-end',
|
||||
/** Child RPC: start a child on the host (answered by ChildStarted or ChildStartError). */
|
||||
ChildStart = 'child-start',
|
||||
/** Child RPC: dispose a started child (answered by ChildDisposed). */
|
||||
ChildDispose = 'child-dispose',
|
||||
/** The run's single terminal result. */
|
||||
Result = 'result',
|
||||
}
|
||||
|
||||
/** The payload each worker→host tag carries. */
|
||||
export interface WorkerToHostPayloads {
|
||||
/** Ready carries nothing. */
|
||||
[WorkerToHostType.Ready]: Record<never, never>
|
||||
/** The phase title, verbatim. */
|
||||
[WorkerToHostType.Phase]: { title: string }
|
||||
/** The logged message, verbatim. */
|
||||
[WorkerToHostType.Log]: { message: string }
|
||||
/** The call's sequence number, label, phase, and child id. */
|
||||
[WorkerToHostType.AgentStart]: { info: WorkflowAgentInfo }
|
||||
/** The call identity plus its outcome. */
|
||||
[WorkerToHostType.AgentEnd]: { info: WorkflowAgentEndInfo }
|
||||
/** The RPC correlation id and the prompt plus validated options. */
|
||||
[WorkerToHostType.ChildStart]: { callId: number; request: ChildStartRequest }
|
||||
/** The RPC correlation id of the child to dispose. */
|
||||
[WorkerToHostType.ChildDispose]: { callId: number }
|
||||
/** The run's terminal outcome. */
|
||||
[WorkerToHostType.Result]: { result: WorkflowResult }
|
||||
}
|
||||
|
||||
/** Message tags the host sends the worker (the wire values are the tag strings). */
|
||||
export enum HostToWorkerType {
|
||||
/** Releases the startup gate: run the script body. */
|
||||
Go = 'go',
|
||||
/** Cancel the run: hooks start throwing and the script dies at its next await. */
|
||||
Cancel = 'cancel',
|
||||
/** Child RPC reply: the provider fulfilled with a published run (exactly one start reply per ChildStart). */
|
||||
ChildStarted = 'child-started',
|
||||
/** Child RPC reply: the provider's asynchronous start failed. */
|
||||
ChildStartError = 'child-start-error',
|
||||
/** Child RPC: a started child's result RESOLVED (its JSON projection). */
|
||||
ChildSettled = 'child-settled',
|
||||
/** Child RPC: a started child's result REJECTED (an infrastructure fault, rendered). */
|
||||
ChildFailed = 'child-failed',
|
||||
/** Child RPC reply: a requested disposal completed. */
|
||||
ChildDisposed = 'child-disposed',
|
||||
}
|
||||
|
||||
/** The payload each host→worker tag carries. */
|
||||
export interface HostToWorkerPayloads {
|
||||
/** Go carries nothing. */
|
||||
[HostToWorkerType.Go]: Record<never, never>
|
||||
/** The cancel reason, canonical for the whole run. */
|
||||
[HostToWorkerType.Cancel]: { reason: string }
|
||||
/** The RPC correlation id and the child agent's id (minted by the subagent seam). */
|
||||
[HostToWorkerType.ChildStarted]: { callId: number; childId: string }
|
||||
/** The RPC correlation id and the rendered start failure. */
|
||||
[HostToWorkerType.ChildStartError]: { callId: number; rendered: string }
|
||||
/** The RPC correlation id and the child's terminal result projection. */
|
||||
[HostToWorkerType.ChildSettled]: { callId: number; result: ChildResult }
|
||||
/** The RPC correlation id and the rendered infrastructure fault. */
|
||||
[HostToWorkerType.ChildFailed]: { callId: number; rendered: string }
|
||||
/** The RPC correlation id of the completed disposal. */
|
||||
[HostToWorkerType.ChildDisposed]: { callId: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* One worker→host message of tag `T`; unparameterized, the closed union over
|
||||
* every tag (a discriminated union — `switch` on `type` narrows).
|
||||
*/
|
||||
export type WorkerToHostMessage<T extends WorkerToHostType = WorkerToHostType> =
|
||||
{ [K in T]: { type: K } & WorkerToHostPayloads[K] }[T]
|
||||
|
||||
/**
|
||||
* One host→worker message of tag `T`; unparameterized, the closed union over
|
||||
* every tag (a discriminated union — `switch` on `type` narrows).
|
||||
*/
|
||||
export type HostToWorkerMessage<T extends HostToWorkerType = HostToWorkerType> =
|
||||
{ [K in T]: { type: K } & HostToWorkerPayloads[K] }[T]
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Materializes values leaving the script vm into plain JSON before they cross the worker
|
||||
* boundary, and renders thrown script values without rejecting the run. The walk rejects
|
||||
* values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may
|
||||
* run, and the vm is not a security boundary. The worker provides host-loop isolation and
|
||||
* forced termination, not hostile-value containment. See
|
||||
* .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/realm
|
||||
*/
|
||||
|
||||
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
|
||||
export class MaterializeError extends Error {
|
||||
constructor(public readonly path: string, public readonly reason: string) {
|
||||
super(`${path}: ${reason}`)
|
||||
this.name = 'MaterializeError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a thrown value to failure text without ever throwing: prefer the
|
||||
* `stack` (host or realm — a realm error's `stack` is a plain string read),
|
||||
* fall back to `message`, then `String()`. Reading those properties MAY run
|
||||
* script code (a getter, `toString`) — accepted under the module's trust
|
||||
* premise; if that code itself throws, a fixed label is returned instead.
|
||||
* @param error - any value thrown in the host or worker realm.
|
||||
* @returns human-readable text for the failure report; prefers the stack.
|
||||
*/
|
||||
export function renderThrown(error: unknown): string {
|
||||
try {
|
||||
const stack = (error as { stack?: unknown } | null | undefined)?.stack
|
||||
if (typeof stack === 'string' && stack.length > 0) return stack
|
||||
const message = (error as { message?: unknown } | null | undefined)?.message
|
||||
if (typeof message === 'string' && message.length > 0) return message
|
||||
return String(error)
|
||||
} catch {
|
||||
// A throwing accessor/toString on the thrown value — rendering must be
|
||||
// total (drive()'s never-reject contract), so fall back to a fixed label.
|
||||
return '[unrenderable thrown value]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object's prototype chain represents a plain data object: `null`, or a prototype
|
||||
* whose own prototype is `null` (the realm's `Object.prototype` — which we
|
||||
* cannot compare by identity across realms). A `Date`/`Map`/class instance
|
||||
* has a longer chain and is rejected.
|
||||
*/
|
||||
function hasPlainPrototype(value: object): boolean {
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
if (proto === null) return true
|
||||
return Object.getPrototypeOf(proto) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is
|
||||
* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail
|
||||
* with the offending path. Property accessors run normally, and a throwing read is wrapped
|
||||
* with its rendered failure.
|
||||
*
|
||||
* @param value - the realm value to materialize.
|
||||
* @param root - the path label for the root value (error messages).
|
||||
* @returns the host-realm copy (plain objects/arrays/scalars only).
|
||||
* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic
|
||||
* prototypes, or property reads that throw.
|
||||
*/
|
||||
export function materializeFromRealm(value: unknown, root = 'value'): unknown {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
return materialize(value, root, new Set())
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MaterializeError) throw error
|
||||
// A property read ran script code that threw; total-ize it so callers can
|
||||
// keep the narrow MaterializeError contract.
|
||||
throw new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function materialize(value: unknown, path: string, seen: Set<object>): unknown {
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return value
|
||||
case 'number': {
|
||||
if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
|
||||
return value
|
||||
}
|
||||
case 'bigint':
|
||||
throw new MaterializeError(path, 'bigints are not JSON data')
|
||||
case 'function':
|
||||
throw new MaterializeError(path, 'functions are not plain JSON data')
|
||||
case 'symbol':
|
||||
throw new MaterializeError(path, 'symbols are not plain JSON data')
|
||||
case 'undefined':
|
||||
throw new MaterializeError(path, 'undefined is not JSON data')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
if (value === null) return null
|
||||
const objectValue: object = value
|
||||
if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
|
||||
seen.add(objectValue)
|
||||
try {
|
||||
if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
|
||||
return materializeObject(objectValue, path, seen)
|
||||
} finally {
|
||||
seen.delete(objectValue)
|
||||
}
|
||||
}
|
||||
|
||||
function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
|
||||
const out: unknown[] = []
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!(index in value)) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
|
||||
out.push(materialize(value[index], `${path}[${index}]`, seen))
|
||||
}
|
||||
// Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
|
||||
// silently dropped by JSON — reject them instead.
|
||||
for (const key of Object.keys(value)) {
|
||||
const index = Number(key)
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length) {
|
||||
throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
|
||||
}
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
|
||||
if (!hasPlainPrototype(value)) {
|
||||
throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data')
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
// Object.keys = own enumerable string keys, matching JSON.stringify's
|
||||
// property selection exactly (non-enumerable props never reach JSON output).
|
||||
for (const key of Object.keys(value)) {
|
||||
// defineProperty, never assignment: a "__proto__" key must become an OWN
|
||||
// data property of the copy, not a prototype mutation.
|
||||
Object.defineProperty(out, key, {
|
||||
value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen),
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result serialization; it
|
||||
* never touches Cordis. Script values leaving the realm are materialized as plain JSON before
|
||||
* messaging. Values entering the trusted model-written realm are passed directly; `args` alone is
|
||||
* cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model.
|
||||
*
|
||||
* Fatal workflow errors—bad hook arguments, unsupported schemas/options, caps, start failures, and
|
||||
* cancellation—propagate through combinators. Only child failures and ordinary stage errors become
|
||||
* per-item nulls. Every returned promise has a rejection consumer so dropped script promises cannot
|
||||
* kill the worker. A cancelled script that never settles emits nothing; the host force-settles the
|
||||
* run within grace and terminates the thread.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/runtime
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools'
|
||||
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowMeta,
|
||||
WorkflowResult,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
|
||||
import type { ChildHandle, ChildPort, WorkerLimits } from './types.ts'
|
||||
|
||||
/** The observers the execution reports progress through (the session posts them to the host). */
|
||||
export interface ExecutionObserver {
|
||||
phase(title: string): void
|
||||
log(message: string): void
|
||||
agentStart(info: WorkflowAgentInfo): void
|
||||
agentEnd(info: WorkflowAgentEndInfo): void
|
||||
}
|
||||
|
||||
/** The `agent()` options the script may pass; everything else rejects loud. */
|
||||
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'provider', 'model'])
|
||||
/** Deferred Claude Code options we name explicitly in the rejection message. */
|
||||
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
|
||||
|
||||
/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
|
||||
function outputText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** A short display label derived from the prompt when the script passes none. */
|
||||
function defaultLabel(prompt: string): string {
|
||||
const newline = prompt.indexOf('\n')
|
||||
const line = newline === -1 ? prompt : prompt.slice(0, newline)
|
||||
return line.length <= 48 ? line : `${line.slice(0, 47)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* One live script execution inside the worker. Constructed per run by the
|
||||
* session; `drive()` is called exactly once and NEVER rejects — every failure
|
||||
* becomes a {@link WorkflowResult} with a non-`completed` stop reason. The
|
||||
* host owns cancellation and cleanup of any dropped child work.
|
||||
*/
|
||||
export class WorkflowExecution {
|
||||
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
|
||||
private started = 0
|
||||
private activeSlots = 0
|
||||
private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
|
||||
private cancelReason: string | undefined
|
||||
private cancelError: WorkflowError | undefined
|
||||
private currentPhase: string | undefined
|
||||
private readonly context: vm.Context
|
||||
private readonly compiled: vm.Script
|
||||
|
||||
constructor(
|
||||
meta: WorkflowMeta,
|
||||
body: string,
|
||||
args: unknown,
|
||||
private readonly limits: WorkerLimits,
|
||||
private readonly observer: ExecutionObserver,
|
||||
private readonly children: ChildPort,
|
||||
) {
|
||||
// Compile FIRST: a body syntax error must throw out of the constructor
|
||||
// before any realm state exists. The host pre-parses the identical
|
||||
// wrapper, so under one Node version this throw is unreachable in
|
||||
// production — the session still maps it to an error result defensively.
|
||||
// lineOffset compensates for the wrapper line, so stack traces carry the
|
||||
// script's own line numbers.
|
||||
try {
|
||||
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
||||
filename: `workflow:${meta.name}`,
|
||||
lineOffset: -1,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
|
||||
}
|
||||
|
||||
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
|
||||
|
||||
const globals: Record<string, unknown> = {
|
||||
agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
|
||||
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
|
||||
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
|
||||
phase: (title: unknown) => { this.phase(title) },
|
||||
log: (message: unknown) => { this.log(message) },
|
||||
// workerData already performed the real cross-thread structured clone.
|
||||
args,
|
||||
}
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
// Data properties on the contextified global; frozen shape not required —
|
||||
// a script overwriting its own hooks only sabotages itself.
|
||||
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run has been cancelled. A METHOD, not an inline property
|
||||
* read: `cancel()` mutates `cancelReason` concurrently (the session's
|
||||
* message handler), and an inline read after an `await` gets narrowed by
|
||||
* control flow into an always-false comparison.
|
||||
*/
|
||||
private isCancelled(): boolean {
|
||||
return this.cancelReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook entry guard: after {@link cancel}, EVERY hook throws
|
||||
* `CANCELLED` at its next call — cancellation is the next HOOK boundary,
|
||||
* not just the next `agent()`, so a script that caught one cancelled
|
||||
* rejection cannot keep emitting progress through `phase`/`log` or enter a
|
||||
* combinator.
|
||||
*/
|
||||
private throwIfCancelled(): void {
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: waiting `agent()` slots reject and every future hook call
|
||||
* throws `CANCELLED` — the script dies at its next await. A script that
|
||||
* never settles anyway (parked on a promise no hook owns) is the HOST's
|
||||
* problem: its grace timer force-settles the run and terminates the
|
||||
* worker. Idempotent; the first reason wins.
|
||||
* @param reason - human-readable cause carried on the CANCELLED error. The
|
||||
* host independently aborts the required signal shared by every child.
|
||||
*/
|
||||
cancel(reason: string): void {
|
||||
if (this.cancelReason !== undefined) return
|
||||
this.cancelReason = reason
|
||||
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
|
||||
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the script to settlement. Resolves — never rejects — with the run's
|
||||
* {@link WorkflowResult}: the materialized return value on `completed`, the
|
||||
* failure message on `error`, and `cancelled` when the script died of
|
||||
* cancellation. This method only chooses the result; the session publishes
|
||||
* it and the host owns terminal child cancellation.
|
||||
* @returns the settled outcome — this promise NEVER rejects (the seam's
|
||||
* `result`-never-rejects contract); every failure maps to a variant.
|
||||
*/
|
||||
async drive(): Promise<WorkflowResult> {
|
||||
try {
|
||||
// Cancelled before the body ever ran (an already-aborted start signal,
|
||||
// relayed by the host before its `go`): the script must not execute at
|
||||
// all, let alone report `completed`.
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
|
||||
const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
|
||||
// Cancelled while the body ran: a script that settled without touching
|
||||
// another hook (or without any) must still report `cancelled` — the
|
||||
// holder asked for cancellation and `completed` would be a lie.
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
const value = raw === undefined ? null : this.materializeResult(raw)
|
||||
return { value, stopReason: 'completed', agentsStarted: this.started }
|
||||
} catch (error: unknown) {
|
||||
// Any failure after cancel() reports `cancelled` with the canonical
|
||||
// reason — the reject path mirrors the resolve path's post-settle check.
|
||||
if (this.isCancelled()) {
|
||||
return { value: null, stopReason: 'cancelled', error: this.cancelledError().message, agentsStarted: this.started }
|
||||
}
|
||||
// renderThrown is total (thrown values of any realm), so this arm
|
||||
// cannot throw — drive() resolving is the `result` never-rejects contract
|
||||
// contract.
|
||||
return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a no-op rejection consumer WITHOUT changing what the caller
|
||||
* receives: if the script drops the promise (no await), cancellation cannot
|
||||
* become an unhandled rejection (which would kill the worker thread); if
|
||||
* the script does await it, it still observes the rejection.
|
||||
*/
|
||||
private contain<T>(promise: Promise<T>): Promise<T> {
|
||||
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
|
||||
return promise
|
||||
}
|
||||
|
||||
private cancelledError(): WorkflowError {
|
||||
// cancel() arms cancelError before any caller can observe isCancelled()
|
||||
// === true; the fallback guards the type, not a reachable path.
|
||||
/* v8 ignore next */
|
||||
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
|
||||
}
|
||||
|
||||
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
|
||||
private materializeResult(raw: unknown): unknown {
|
||||
try {
|
||||
return materializeFromRealm(raw, 'workflow result')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(
|
||||
`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
|
||||
'RESULT_UNSERIALIZABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
|
||||
* (see {@link cancel}); the callers guard their own entry and post-acquire
|
||||
* windows, so no cancelled-precheck is duplicated here.
|
||||
*/
|
||||
private acquireSlot(): Promise<void> {
|
||||
if (this.activeSlots < this.limits.maxConcurrentAgents) {
|
||||
this.activeSlots += 1
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.slotWaiters.push({
|
||||
resolve: () => {
|
||||
this.activeSlots += 1
|
||||
resolve()
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private releaseSlot(): void {
|
||||
this.activeSlots -= 1
|
||||
const next = this.slotWaiters.shift()
|
||||
if (next) next.resolve()
|
||||
}
|
||||
|
||||
/** The `agent(prompt, opts)` hook. */
|
||||
private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
|
||||
this.throwIfCancelled()
|
||||
if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
|
||||
throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const opts = this.readAgentOptions(rawOpts)
|
||||
if (this.started >= this.limits.maxTotalAgents) {
|
||||
throw new WorkflowError(
|
||||
`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`,
|
||||
'AGENT_CAP',
|
||||
)
|
||||
}
|
||||
this.started += 1
|
||||
const seq = this.started
|
||||
const label = opts.label ?? defaultLabel(rawPrompt)
|
||||
const phase = opts.phase ?? this.currentPhase
|
||||
|
||||
await this.acquireSlot()
|
||||
try {
|
||||
// Re-check after the acquire: the await yields at least one microtask
|
||||
// tick even when a slot is free, and a queued waiter resumes a tick
|
||||
// after its release — a cancel() landing in either window must not
|
||||
// reach the host (which would refuse anyway, but the refusal reads as
|
||||
// a start failure rather than the cancellation it is).
|
||||
this.throwIfCancelled()
|
||||
let run: ChildHandle
|
||||
try {
|
||||
run = await this.children.startAgent({
|
||||
prompt: rawPrompt,
|
||||
...opts.schema !== undefined ? { schema: opts.schema } : {},
|
||||
...opts.provider !== undefined ? { provider: opts.provider } : {},
|
||||
...opts.model !== undefined ? { model: opts.model } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// The host refuses starts once the run is cancelled — a refusal that
|
||||
// races our own cancel state must read as the cancellation it is,
|
||||
// not as a broken contract.
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
throw new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, 'AGENT_START', { cause: error })
|
||||
}
|
||||
// The start round-trip yields to the event loop, so a cancel CAN land
|
||||
// between the host starting the child and this continuation running —
|
||||
// wind the fresh child down instead of leaving it live behind a dead
|
||||
// script.
|
||||
if (this.isCancelled()) {
|
||||
await run.dispose()
|
||||
throw this.cancelledError()
|
||||
}
|
||||
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) }
|
||||
this.observer.agentStart(info)
|
||||
try {
|
||||
let result
|
||||
try {
|
||||
result = await run.result
|
||||
} catch (error: unknown) {
|
||||
// A rejected child result is an INFRASTRUCTURE fault relayed by the
|
||||
// host — distinct from a child that failed and resolved. Pair the
|
||||
// lifecycle before propagating, and propagate FATAL: an ordinary
|
||||
// throw would dissolve to a per-item null inside the combinators,
|
||||
// and a broken provider must not read as a failed child.
|
||||
if (this.isCancelled()) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
||||
throw this.cancelledError()
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
throw new WorkflowError(`child agent run failed: ${renderThrown(error)}`, 'AGENT_RESULT', { cause: error })
|
||||
}
|
||||
if (result.stopReason === 'completed') {
|
||||
if (opts.schema !== undefined) {
|
||||
// The provider honored outputSchema (capability-gated at start), so
|
||||
// a completed run without a structured value is a child failure.
|
||||
if (result.structured === undefined) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return result.structured
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return outputText(result.output)
|
||||
}
|
||||
// A cancelled RUN kills the script; a child that failed for its own
|
||||
// reasons resolves null (scripts .filter(Boolean) per the CC contract).
|
||||
if (this.isCancelled()) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
||||
throw this.cancelledError()
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
} finally {
|
||||
this.releaseSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize + validate the `agent()` options bag from the realm. */
|
||||
private readAgentOptions(rawOpts: unknown): {
|
||||
label?: string
|
||||
phase?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
schema?: ObjectJsonSchema
|
||||
} {
|
||||
if (rawOpts === undefined) return {}
|
||||
let opts: unknown
|
||||
try {
|
||||
opts = materializeFromRealm(rawOpts, 'agent() options')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
|
||||
}
|
||||
if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
|
||||
throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const record = opts as Record<string, unknown>
|
||||
for (const key of Object.keys(record)) {
|
||||
if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
|
||||
if (DEFERRED_AGENT_OPTIONS.has(key)) {
|
||||
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
for (const key of ['label', 'phase', 'provider', 'model'] as const) {
|
||||
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
||||
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
}
|
||||
let schema: ObjectJsonSchema | undefined
|
||||
if (record.schema !== undefined) {
|
||||
try {
|
||||
assertObjectJsonSchema(record.schema)
|
||||
schema = record.schema
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */
|
||||
if (!(error instanceof JsonSchemaError)) throw error
|
||||
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
||||
}
|
||||
}
|
||||
return {
|
||||
...record.label !== undefined ? { label: record.label as string } : {},
|
||||
...record.phase !== undefined ? { phase: record.phase as string } : {},
|
||||
...record.provider !== undefined ? { provider: record.provider as string } : {},
|
||||
...record.model !== undefined ? { model: record.model as string } : {},
|
||||
...schema !== undefined ? { schema } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
|
||||
private async parallel(rawThunks: unknown): Promise<unknown[]> {
|
||||
this.throwIfCancelled()
|
||||
if (!Array.isArray(rawThunks)) {
|
||||
throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawThunks.length, 'parallel()')
|
||||
const thunks = rawThunks.map((thunk, index) => {
|
||||
if (typeof thunk !== 'function') {
|
||||
throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return thunk as () => unknown
|
||||
})
|
||||
return Promise.all(thunks.map(async (thunk) => {
|
||||
try {
|
||||
return await thunk()
|
||||
} catch (error: unknown) {
|
||||
// Hook failures are WorkflowErrors built OUTSIDE the script's realm;
|
||||
// fatality is recognized by `instanceof` against this realm's class —
|
||||
// a script-built object can never pass it, so fatality cannot be
|
||||
// forged (nor accidentally dissolved).
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
|
||||
private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
|
||||
this.throwIfCancelled()
|
||||
if (!Array.isArray(rawItems)) {
|
||||
throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawItems.length, 'pipeline()')
|
||||
if (rawStages.length === 0) {
|
||||
throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const stages = rawStages.map((stage, index) => {
|
||||
if (typeof stage !== 'function') {
|
||||
throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return stage as (previous: unknown, item: unknown, index: number) => unknown
|
||||
})
|
||||
return Promise.all(rawItems.map(async (item: unknown, index) => {
|
||||
let value: unknown = item
|
||||
try {
|
||||
for (const stage of stages) {
|
||||
value = await stage(value, item, index)
|
||||
}
|
||||
return value
|
||||
} catch (error: unknown) {
|
||||
// An ordinary stage throw drops the ITEM to null and skips its
|
||||
// remaining stages; a fatal WorkflowError (see parallel()) kills the
|
||||
// whole script.
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private assertItemCap(length: number, hook: string): void {
|
||||
if (length > this.limits.maxItemsPerCall) {
|
||||
throw new WorkflowError(
|
||||
`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
|
||||
'ITEM_CAP',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
|
||||
private phase(title: unknown): void {
|
||||
this.throwIfCancelled()
|
||||
if (typeof title !== 'string' || title.length === 0) {
|
||||
throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.currentPhase = title
|
||||
this.observer.phase(title)
|
||||
}
|
||||
|
||||
/** The `log(message)` hook: narration to observers. */
|
||||
private log(message: unknown): void {
|
||||
this.throwIfCancelled()
|
||||
if (typeof message !== 'string') {
|
||||
throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.observer.log(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The worker-side half of the engine: {@link runWorkerSession} wires one MessagePort to one
|
||||
* {@link WorkflowExecution} — hook progress and child starts go out as messages, run control
|
||||
* and child lifecycle come back in — and posts the run's terminal result exactly once. Keeping it
|
||||
* separate from `worker.ts` lets unit tests drive the session over a MessageChannel, because main
|
||||
* process coverage cannot observe code inside a real Worker.
|
||||
*
|
||||
* The session announces ready and waits for `go`, so cancellation racing startup can prevent even
|
||||
* the script's synchronous prefix. A cancel in place of `go` releases the gate into a cancelled
|
||||
* drive without executing the body.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/session
|
||||
*/
|
||||
|
||||
import type { MessagePort } from 'node:worker_threads'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
|
||||
import type { HostToWorkerMessage, WorkerToHostPayloads } from './protocol.ts'
|
||||
import { renderThrown } from './realm.ts'
|
||||
import { WorkflowExecution } from './runtime.ts'
|
||||
import type { ExecutionObserver } from './runtime.ts'
|
||||
import type {
|
||||
ChildHandle,
|
||||
ChildPort,
|
||||
ChildResult,
|
||||
ChildStartRequest,
|
||||
WorkerInit,
|
||||
} from './types.ts'
|
||||
|
||||
/** The book-keeping for one in-flight child RPC (keyed by callId). */
|
||||
interface PendingChild {
|
||||
started: PromiseWithResolvers<string>
|
||||
settled: PromiseWithResolvers<ChildResult>
|
||||
disposed: PromiseWithResolvers<void>
|
||||
}
|
||||
|
||||
/** The typed post half of the port: each tag pairs with ITS payload from the map (a mismatch is a compile error at the call site). */
|
||||
type Post = <T extends WorkerToHostType>(type: T, payload: WorkerToHostPayloads[T]) => void
|
||||
|
||||
/**
|
||||
* The worker-side handle for one started child agent ({@link ChildHandle}):
|
||||
* every member is an RPC to the host keyed by this call's `callId`, resolved
|
||||
* by the session's message handler through the bridge's pending entry.
|
||||
*/
|
||||
class RpcChildHandle implements ChildHandle {
|
||||
readonly result: Promise<ChildResult>
|
||||
|
||||
constructor(
|
||||
private readonly post: Post,
|
||||
private readonly callId: number,
|
||||
private readonly entry: PendingChild,
|
||||
readonly id: string,
|
||||
) {
|
||||
this.result = entry.settled.promise
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.post(WorkerToHostType.ChildDispose, { callId: this.callId })
|
||||
return this.entry.disposed.promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker-side child-RPC bridge ({@link ChildPort}): allocates callIds,
|
||||
* posts the start/dispose RPCs, and owns the per-call pending
|
||||
* book-keeping the session's message handler settles via the `onChild*`
|
||||
* entry points.
|
||||
*/
|
||||
class ChildRpcBridge implements ChildPort {
|
||||
private nextCallId = 0
|
||||
private readonly pending = new Map<number, PendingChild>()
|
||||
|
||||
constructor(private readonly post: Post) {}
|
||||
|
||||
async startAgent(request: ChildStartRequest): Promise<ChildHandle> {
|
||||
this.nextCallId += 1
|
||||
const callId = this.nextCallId
|
||||
const entry: PendingChild = {
|
||||
started: Promise.withResolvers<string>(),
|
||||
settled: Promise.withResolvers<ChildResult>(),
|
||||
disposed: Promise.withResolvers<void>(),
|
||||
}
|
||||
// Containment: when asynchronous provider start 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 */ })
|
||||
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 established a published child; releases the `startAgent` await. */
|
||||
onChildStarted(callId: number, childId: string): void {
|
||||
this.pending.get(callId)?.started.resolve(childId)
|
||||
}
|
||||
|
||||
/** Asynchronous provider start failed; reject and retire the pending RPC. */
|
||||
onChildStartError(callId: number, rendered: string): void {
|
||||
const entry = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
entry?.started.reject(new Error(rendered))
|
||||
}
|
||||
|
||||
/** The child's terminal result arrived. */
|
||||
onChildSettled(callId: number, result: ChildResult): void {
|
||||
this.pending.get(callId)?.settled.resolve(result)
|
||||
}
|
||||
|
||||
/** The child's `result` rejected host-side (an infrastructure fault, relayed as fatal). */
|
||||
onChildFailed(callId: number, rendered: string): void {
|
||||
this.pending.get(callId)?.settled.reject(new Error(rendered))
|
||||
}
|
||||
|
||||
/** The host acked the dispose; the call's book-keeping is complete. */
|
||||
onChildDisposed(callId: number): void {
|
||||
const entry = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
entry?.disposed.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the nullable `parentPort` the bootstrap reads from
|
||||
* `node:worker_threads`.
|
||||
* @param port - `parentPort` as imported (null on the main thread).
|
||||
* @returns the port, non-null.
|
||||
*/
|
||||
export function requireParentPort(port: MessagePort | null): MessagePort {
|
||||
if (port === null) throw new Error('the workflow worker entry must be loaded inside a worker thread (no parentPort)')
|
||||
return port
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one workflow script to settlement against `port`, posting the terminal result message
|
||||
* exactly once; resolves after that post (stray children may still be winding down through the
|
||||
* port — the host owns their teardown and ultimately terminates the thread). It never rejects:
|
||||
* constructor failure becomes an error result. Host pre-parse makes syntax failure here a likely
|
||||
* Node-version skew, but the session still reports it instead of dying silently.
|
||||
* @param port - the channel to the host (the real `parentPort`, or one side
|
||||
* of an in-process `MessageChannel` in tests).
|
||||
* @param init - the run payload the host provided as `workerData`.
|
||||
*/
|
||||
export async function runWorkerSession(port: MessagePort, init: WorkerInit): Promise<void> {
|
||||
const post: Post = (type, payload) => {
|
||||
port.postMessage({ type, ...payload })
|
||||
}
|
||||
const children = new ChildRpcBridge(post)
|
||||
|
||||
const observer: ExecutionObserver = {
|
||||
phase: (title) => { post(WorkerToHostType.Phase, { title }) },
|
||||
log: (message) => { post(WorkerToHostType.Log, { message }) },
|
||||
agentStart: (info) => { post(WorkerToHostType.AgentStart, { info }) },
|
||||
agentEnd: (info) => { post(WorkerToHostType.AgentEnd, { info }) },
|
||||
}
|
||||
|
||||
let execution: WorkflowExecution
|
||||
try {
|
||||
execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children)
|
||||
} catch (error: unknown) {
|
||||
post(WorkerToHostType.Result, { result: { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: 0 } })
|
||||
return
|
||||
}
|
||||
|
||||
const gate = Promise.withResolvers<void>()
|
||||
port.on('message', (message: HostToWorkerMessage) => {
|
||||
switch (message.type) {
|
||||
case HostToWorkerType.Go:
|
||||
gate.resolve()
|
||||
break
|
||||
case HostToWorkerType.Cancel:
|
||||
execution.cancel(message.reason)
|
||||
// A cancel doubles as the gate release: drive() checks the cancelled
|
||||
// state before running the body, so the script never executes.
|
||||
gate.resolve()
|
||||
break
|
||||
case HostToWorkerType.ChildStarted:
|
||||
children.onChildStarted(message.callId, message.childId)
|
||||
break
|
||||
case HostToWorkerType.ChildStartError:
|
||||
children.onChildStartError(message.callId, message.rendered)
|
||||
break
|
||||
case HostToWorkerType.ChildSettled:
|
||||
children.onChildSettled(message.callId, message.result)
|
||||
break
|
||||
case HostToWorkerType.ChildFailed:
|
||||
children.onChildFailed(message.callId, message.rendered)
|
||||
break
|
||||
case HostToWorkerType.ChildDisposed:
|
||||
children.onChildDisposed(message.callId)
|
||||
break
|
||||
/* v8 ignore next 2 -- closed engine-owned union; the arm only makes adding a message type a compile error */
|
||||
default:
|
||||
assertNever(message, 'host-to-worker message')
|
||||
}
|
||||
})
|
||||
|
||||
post(WorkerToHostType.Ready, {})
|
||||
await gate.promise
|
||||
const result = await execution.drive()
|
||||
post(WorkerToHostType.Result, { result })
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init payload and
|
||||
* the child-port interfaces the worker-side runtime consumes. Host/worker messages are defined in
|
||||
* `./protocol.ts`; transported child requests and results are plain JSON for structured clone.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow'
|
||||
|
||||
/**
|
||||
* The per-run limits the worker-side runtime enforces. The host keeps the
|
||||
* knobs only it can act on (`provider`, `disposeGraceMs`).
|
||||
*/
|
||||
export interface WorkerLimits {
|
||||
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
|
||||
maxConcurrentAgents: number
|
||||
/** Total `agent()` calls per run (the runaway-loop backstop). */
|
||||
maxTotalAgents: number
|
||||
/** Items accepted by one `parallel()`/`pipeline()` call. */
|
||||
maxItemsPerCall: number
|
||||
/** vm timeout for the script's initial synchronous slice (inside the worker). */
|
||||
syncTimeoutMs: number
|
||||
}
|
||||
|
||||
/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */
|
||||
export interface WorkerInit {
|
||||
/** The validated meta block (plain data off the start request, validated host-side). */
|
||||
meta: WorkflowMeta
|
||||
/** The plain-JS script body, exactly as the start request carried it. */
|
||||
body: string
|
||||
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
|
||||
args?: unknown
|
||||
/** The worker-enforced limits. */
|
||||
limits: WorkerLimits
|
||||
}
|
||||
|
||||
/** What the worker asks the host to start for one `agent()` call (options already validated worker-side). */
|
||||
export interface ChildStartRequest {
|
||||
/** The child's prompt text. */
|
||||
prompt: string
|
||||
/** The structured-output schema, if the call passed one (already subset-checked). */
|
||||
schema?: ObjectJsonSchema
|
||||
/** The per-child provider override, if the call passed one. */
|
||||
provider?: string
|
||||
/** The per-child model override, if the call passed one. */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON projection of a child's `SubagentResult` crossing the port. The
|
||||
* seam's `stopReason` union is merge-extensible, so it degrades to `string`
|
||||
* on the wire — the runtime only ever branches on `'completed'`.
|
||||
*/
|
||||
export interface ChildResult {
|
||||
/** The child's final assistant output blocks. */
|
||||
output: ContentBlock[]
|
||||
/** The structured value, present iff the request carried a schema AND the provider honored it. */
|
||||
structured?: unknown
|
||||
/** Why the child run ended (`'completed'` is the only value the runtime branches on). */
|
||||
stopReason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker-side handle for one started child — the RPC mirror of the
|
||||
* subagent seam's run handle, reduced to what the runtime consumes.
|
||||
*/
|
||||
export interface ChildHandle {
|
||||
/** The child agent's id (minted host-side by the subagent seam). */
|
||||
readonly id: string
|
||||
/**
|
||||
* Resolves with the child's terminal {@link ChildResult}; REJECTS only when
|
||||
* the host reports an infrastructure fault (`child-failed`) — a child that
|
||||
* failed for its own reasons resolves with a non-`completed` stop reason.
|
||||
*/
|
||||
readonly result: Promise<ChildResult>
|
||||
/** Ask the host to dispose the child; resolves on the host's ack. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker-side port the runtime starts child agents through — the seam
|
||||
* that lets the execution core stay ignorant of the thread boundary.
|
||||
*/
|
||||
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 published child handle; rejects when synchronous start or the
|
||||
* provider's asynchronous start fails.
|
||||
*/
|
||||
startAgent(request: ChildStartRequest): Promise<ChildHandle>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Single-statement worker entry that boots `runWorkerSession` on real `parentPort`. Logic remains in
|
||||
* the session module for in-process MessageChannel coverage; importing this entry on the main thread
|
||||
* exercises `requireParentPort`'s failure path.
|
||||
* @module @deepseek-ai/dsh-workflow-worker-thread/worker
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { requireParentPort, runWorkerSession } from './session.ts'
|
||||
import type { WorkerInit } from './types.ts'
|
||||
|
||||
// workerData is `any` at the node:worker_threads boundary; the engine is the
|
||||
// only spawner and always provides a WorkerInit.
|
||||
void runWorkerSession(requireParentPort(parentPort), workerData as WorkerInit)
|
||||
Reference in New Issue
Block a user