docs: trim generated prose
This commit is contained in:
@@ -1,25 +1,9 @@
|
||||
/**
|
||||
* The model-facing `workflow` tool: run a JavaScript orchestration script that
|
||||
* fans out subagents, and return the script's final value. Pure schema +
|
||||
* lifecycle shaping — script parsing, execution, caps, and cancellation live
|
||||
* behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine
|
||||
* swaps in without touching what the model sees.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute`
|
||||
* starts a run and awaits `run.result` inside a `try/finally` that always
|
||||
* disposes the run, so the script and its children are torn down on every
|
||||
* path. A non-`completed` stop reason maps to an `isError` tool result (by
|
||||
* throwing) rather than returning partial output as success. Background
|
||||
* collection is deferred to the cross-tool background redesign.
|
||||
*
|
||||
* Render intent (decided up front, per the render-intent RFC): a `generic`
|
||||
* card whose title carries the workflow's `meta.name`, read directly from the
|
||||
* call's `meta` parameter — presentation is a pure function of `args`.
|
||||
*
|
||||
* Usage policy ships with the tool as a `tool:<toolName>` system-prompt
|
||||
* section (explicit-ask-only guidance) — tool guidance lives in tool plugins,
|
||||
* never in the deployment persona.
|
||||
*
|
||||
* The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
|
||||
* subagents, and return the script's final value. Pure schema + lifecycle shaping — script
|
||||
* parsing, execution, caps, and cancellation live behind `ctx.workflows`
|
||||
* (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model
|
||||
* sees.
|
||||
* @module @deepseek-ai/dsh-tool-workflow
|
||||
*/
|
||||
|
||||
@@ -184,10 +168,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the script is in flight, cancel the whole run. The
|
||||
// engine also receives `signal` directly, but an explicit bridge keeps
|
||||
// the tool's contract local (and covers an engine that ignores it).
|
||||
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
|
||||
// script is in flight, cancel the whole run.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before
|
||||
|
||||
@@ -224,13 +224,10 @@ describe('dsh-tool-workflow', () => {
|
||||
|
||||
describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => {
|
||||
it('an abort releases the tool even when the script parks on a promise no hook owns', async () => {
|
||||
// Regression for the review-found turn wedge: the tool awaits
|
||||
// run.result BEFORE its disposing finally, the registry and the loop
|
||||
// await the tool — so if cancellation could not settle result (a script
|
||||
// parked on `await new Promise(() => {})`), an aborted turn stayed
|
||||
// wedged forever. The seam now guarantees result settles within the
|
||||
// grace of cancel(); this drives that guarantee through the real
|
||||
// registry + real tool + real engine.
|
||||
// Regression for the review-found turn wedge: the tool awaits run.result before its
|
||||
// disposing finally, the registry and the loop await the tool — so if cancellation could
|
||||
// not settle result (a script parked on `await new Promise(() => {})`), an aborted turn
|
||||
// stayed wedged forever.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
|
||||
@@ -1,40 +1,7 @@
|
||||
/**
|
||||
* The host half of one worker-engine run: spawn the Worker, bridge its child
|
||||
* RPC onto `ctx.subagents`, fan its observer messages into the engine's
|
||||
* events, and own cancellation, the settle-within-grace guarantee, and child
|
||||
* cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always
|
||||
* ends with `worker.terminate()`, so no thread outlives its run.
|
||||
*
|
||||
* The run's `result` promise settles exactly once, from whichever of these
|
||||
* lands first: the worker's `result` message (a host-side cancellation in
|
||||
* flight overrides a non-cancelled report — the seam-visible result had not
|
||||
* settled when cancellation was requested), an unexpected worker death
|
||||
* (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or
|
||||
* `'cancelled'` when a cancel was in flight), or the post-cancel grace timer
|
||||
* (a script that never settles is force-settled `cancelled` and its worker
|
||||
* terminated — the real kill an in-process engine could not perform).
|
||||
*
|
||||
* Children live in a host-side registry (callId → run) as soon as the provider
|
||||
* accepts them, so cancellation reaches even a pre-publication attempt. The
|
||||
* host observes `result` immediately but acknowledges the child to the worker
|
||||
* only after `started` fulfills; readiness failure is a start error and the
|
||||
* host disposes the attempt because the worker never received a handle. The
|
||||
* worker drives disposal by RPC on the graceful path, `dispose()` host-drives
|
||||
* every registered child's disposal immediately (a wedged worker can relay no
|
||||
* dispose RPC, and child teardown must overlap the grace, not start after it),
|
||||
* and the registry lets the host abort and dispose every survivor when the
|
||||
* worker dies or is terminated mid-flight. The three
|
||||
* paths share ONE disposal per child (memoized by callId; the seam's
|
||||
* dispose() is idempotent anyway, the memo keeps the bookkeeping and the
|
||||
* containment warn single). Lifecycle pairing is host-guaranteed the same
|
||||
* way: every forwarded `agent-start` lives in a ledger, and a start the
|
||||
* dead or terminated worker never paired is closed by a synthesized
|
||||
* `agent-end` (outcome `'cancelled'`) before the run settles. On a
|
||||
* termination path `agentsStarted` reports the
|
||||
* HOST-observed count (accepted `child-start` messages) — `agent()` calls
|
||||
* still queued worker-side for a concurrency slot are unknowable then; the
|
||||
* worker's own count rides the result message on every graceful path.
|
||||
*
|
||||
* The host half of one worker-engine run: spawn the Worker, bridge its child RPC onto
|
||||
* `ctx.subagents`, fan its observer messages into the engine's events, and own cancellation,
|
||||
* the settle-within-grace guarantee, and child cleanup.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/host
|
||||
*/
|
||||
|
||||
@@ -54,25 +21,7 @@ import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts'
|
||||
|
||||
/**
|
||||
* Resolve the worker entry and spawn options for the current runtime shape.
|
||||
* Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the
|
||||
* entry is the TypeScript sibling and the worker needs the tsx loader
|
||||
* registered explicitly: a worker thread inherits no transform pipeline from
|
||||
* vitest (vite transforms in-process, not via a node loader), and passing
|
||||
* execArgv explicitly also shields the worker from any loader flags the
|
||||
* parent was started with. Built (`lib/index.js`), the entry is the sibling
|
||||
* bundle the package tsdown config emits and no loader is needed (execArgv
|
||||
* pinned empty — hermetic, like the environment).
|
||||
*
|
||||
* Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm
|
||||
* escape reaches `process`, and the harness's ambient credentials
|
||||
* (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as
|
||||
* `dsh-code-runtime-worker`, stronger than the scrubbed env the
|
||||
* defensive-patterns rule requires for spawned commands (a shell needs PATH;
|
||||
* this worker needs nothing). Sole exception: the unbuilt shape forwards
|
||||
* `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths
|
||||
* map depends on outside the repo cwd, not a secret). This closes the
|
||||
* AMBIENT channel only — an escapee still holds process-wide privileges
|
||||
* like fs access (the README's trust premise stands).
|
||||
* @param init - the run payload, passed as `workerData`.
|
||||
* @returns the entry URL and the Worker options to spawn it with.
|
||||
*/
|
||||
@@ -81,13 +30,8 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
|
||||
if (!import.meta.url.endsWith('.ts')) {
|
||||
return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } }
|
||||
}
|
||||
// Lazy tsx resolution: only the unbuilt shape needs it, so the built
|
||||
// bundle never requires tsx to be installed. TSX_TSCONFIG_PATH is the one
|
||||
// variable forwarded through the scrub: tsx finds a tsconfig by searching
|
||||
// UP from the worker's cwd, and a parent running with its cwd outside the
|
||||
// repo (the ACP snapshot harness pins the tsconfig through this exact
|
||||
// variable) would otherwise lose the dsh-* paths map and resolve workspace
|
||||
// imports to unbuilt lib/ bundles. Loader plumbing, not a secret.
|
||||
// Lazy tsx resolution: only the unbuilt shape needs it, so the built bundle never requires
|
||||
// tsx to be installed.
|
||||
return {
|
||||
entry: new URL('./worker.ts', import.meta.url),
|
||||
options: {
|
||||
@@ -161,34 +105,19 @@ export class WorkerRun implements WorkflowRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: the worker is told (its hooks start throwing and the
|
||||
* script dies at its next await), every host-side child is cancelled NOW on
|
||||
* BOTH seam channels — the shared request signal aborts and each registered
|
||||
* child's explicit `cancel()` is called (the seam leaves a provider free to
|
||||
* honor either, and a worker wedged in a synchronous spin could not relay
|
||||
* its own per-child cancel RPCs until far too late) — and the grace timer
|
||||
* arms: a run still unsettled `disposeGraceMs` later force-settles
|
||||
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
|
||||
* wins.
|
||||
* Cancel the worker and host-owned children, then arm forced settlement.
|
||||
* @param reason - human-readable cause (default `'workflow cancelled'`).
|
||||
*/
|
||||
cancel(reason?: string): void {
|
||||
// A settled run has nothing left to cancel: without this 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.
|
||||
// Do not arm a grace timer after settlement.
|
||||
if (this.settled || this.cancelReason !== undefined) return
|
||||
this.cancelReason = reason ?? 'workflow cancelled'
|
||||
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
|
||||
this.controller.abort(this.cancelReason)
|
||||
// The explicit channel is driven host-side, not left to the worker: a
|
||||
// provider honoring only run.cancel() must not wait on a wedged worker's
|
||||
// ChildCancel relay (those later RPCs land as idempotent no-ops).
|
||||
// Host-side cancellation still reaches children when the worker is wedged.
|
||||
for (const run of this.children.values()) run.cancel(this.cancelReason)
|
||||
this.graceTimer = setTimeout(() => {
|
||||
// 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.
|
||||
// Pair stranded child starts before terminal workflow events.
|
||||
this.endStrandedAgents()
|
||||
this.settleResult(this.cancelledResult(this.hostStarted))
|
||||
void this.worker.terminate()
|
||||
@@ -198,18 +127,8 @@ export class WorkerRun implements WorkflowRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Cancel + bounded settle + termination.
|
||||
*
|
||||
* @returns resolves when the run's resources are released or abandoned.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
@@ -313,12 +232,7 @@ export class WorkerRun implements WorkflowRun {
|
||||
this.children.set(callId, run)
|
||||
const childId = run.id
|
||||
|
||||
// Observe settlement IMMEDIATELY, before readiness. A provider may reject
|
||||
// result and started in the same turn; delaying this handler would make the
|
||||
// result transiently unhandled. Buffer a forwarding closure so the worker
|
||||
// still sees ChildStarted before ChildSettled/ChildFailed. Snapshot a
|
||||
// resolved result now: a provider mutating its resolved object while
|
||||
// publication is pending must not change what crosses the worker boundary.
|
||||
// Observe settlement IMMEDIATELY, before readiness.
|
||||
const forwardResult = run.result.then<() => void, () => void>(
|
||||
(result) => {
|
||||
try {
|
||||
@@ -339,12 +253,7 @@ export class WorkerRun implements WorkflowRun {
|
||||
},
|
||||
)
|
||||
|
||||
// The provider owns the publication boundary. Only acknowledge the child
|
||||
// after it is real, then flush any result that settled unusually early. A
|
||||
// readiness rejection is a START failure, not AGENT_RESULT: the worker
|
||||
// never receives a handle, so the host must also dispose the registered
|
||||
// attempt. A concurrent host disposal may already have removed it; the
|
||||
// identity guard preserves the one-disposal memo in that race.
|
||||
// The provider owns the publication boundary.
|
||||
void run.started.then(
|
||||
() => {
|
||||
this.post(HostToWorkerType.ChildStarted, { callId, childId })
|
||||
@@ -370,13 +279,9 @@ export class WorkerRun implements WorkflowRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Start (or join) one registered child's disposal; the registry entry leaves when it
|
||||
* settles.
|
||||
*
|
||||
* @param callId - the child's registry key.
|
||||
* @param run - the registered child (the caller looked it up).
|
||||
* @returns resolves when the disposal settled either way; never rejects.
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
/**
|
||||
* The `node:worker_threads` workflow engine: the {@link WorkflowService}
|
||||
* implementation. Runs each script in its OWN worker thread (one run = one
|
||||
* worker, no pooling — a run is heavyweight, so thread spin-up is noise): the
|
||||
* body executes in a vm context INSIDE the worker with the workflow hooks
|
||||
* injected, and `agent()` calls bridge back to `ctx.subagents` over the
|
||||
* message port — child agents are I/O-bound LLM loops and stay on the host
|
||||
* event loop; the thread isolates the SCRIPT, the only part that can spin
|
||||
* synchronously.
|
||||
*
|
||||
* TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the
|
||||
* model's existing bash access — so this engine defends against BUGGY
|
||||
* scripts, never hostile ones. A worker thread is NOT a security boundary:
|
||||
* the vm context inside it is escapable by construction, and an escapee
|
||||
* holds the same process privileges as the host (Node's permission model is
|
||||
* process-wide); genuine sandboxing (isolated-vm, a separate process) is an
|
||||
* engine swap behind the seam. What the thread buys, concretely:
|
||||
*
|
||||
* - `start()` never blocks the host: the script's initial synchronous slice
|
||||
* (and any later synchronous spin) occupies the WORKER's event loop, not
|
||||
* the harness's.
|
||||
* - Termination is REAL: a script that outlives its post-cancel grace is
|
||||
* `worker.terminate()`d — nothing of the script survives `dispose()`,
|
||||
* where an in-process engine could only abandon the spin on its own loop.
|
||||
* - The value boundary is serialization by construction: everything crossing
|
||||
* the thread is structured-clone data (and plain JSON before that, by the
|
||||
* materialization walk in ./realm.ts).
|
||||
*
|
||||
* Engine-specific limitations: worker startup (~tens of ms) is paid per run;
|
||||
* on a termination path `agentsStarted` reports the host-observed child
|
||||
* count (calls still queued worker-side for a slot are unknowable — see
|
||||
* ./host.ts); and a worker that dies unexpectedly (an OOM, a script reaching
|
||||
* `process.exit` through the documented vm escape) settles the run
|
||||
* `stopReason: 'error'` with the exit diagnostics.
|
||||
*
|
||||
* Plugin export shape: a default-exported {@link WorkflowService} subclass
|
||||
* (the class-based service form, like `dsh-bash-local`).
|
||||
*
|
||||
* The `node:worker_threads` workflow engine: the {@link WorkflowService} implementation.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* Meta validation: check the caller-provided {@link WorkflowMeta} DATA against
|
||||
* the shape contract and reject everything else loud, every violation named.
|
||||
* Meta arrives as plain JSON through the seam (the model-facing tool carries
|
||||
* it as a schema-validated object parameter) — the engine never evaluates
|
||||
* script text to obtain it, so no script-controlled code can run on the host
|
||||
* here (an evaluated meta literal could smuggle getters that spin the host
|
||||
* outside any vm timeout, the exact escape the worker thread exists to
|
||||
* prevent).
|
||||
*
|
||||
* Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape
|
||||
* contract and reject everything else loud, every violation named.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/meta
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
/**
|
||||
* 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. Everything in a
|
||||
* payload is plain JSON data by construction (the runtime materializes
|
||||
* script values before they reach a message; the host projects seam results
|
||||
* down to their JSON fields), so the structured-clone hop never meets a
|
||||
* value it cannot carry.
|
||||
*
|
||||
* Both directions are CLOSED (engine-owned): each side switches on `type`
|
||||
* and ends with `assertNever` — an unknown message is a protocol bug, never
|
||||
* something to skip silently. Senders go through a generic
|
||||
* `post(type, payload)` whose payload parameter is looked up from the map,
|
||||
* so a tag/payload mismatch is a compile error at the call site.
|
||||
*
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/protocol
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
/**
|
||||
* The engine's value boundary: copy script-realm values into plain JSON data
|
||||
* — loud about everything JSON cannot carry — and render thrown script
|
||||
* values to failure text. The script runs in a vm context INSIDE the worker
|
||||
* thread, so "host" here means the worker-side JavaScript around that
|
||||
* context; everything that later crosses the thread boundary is JSON by this
|
||||
* walk, which is what makes the postMessage hop total.
|
||||
*
|
||||
* TRUST PREMISE (everything in this module hangs on it): workflow scripts are
|
||||
* MODEL-WRITTEN, the same trust level as the model's existing bash access, so
|
||||
* this boundary guards against BUGGY scripts, not hostile ones. It rejects
|
||||
* loud what JSON would silently mangle — functions, symbols, bigints,
|
||||
* non-finite numbers, nested `undefined`, cycles, sparse arrays, exotic
|
||||
* prototypes — because accepted-then-ignored is this repo's banned failure
|
||||
* mode. It does NOT defend against adversarial values: the walk reads
|
||||
* properties ordinarily (a getter runs, and whatever it returns is what
|
||||
* crosses), {@link renderThrown} reads `stack`/`message`/`String()` directly,
|
||||
* and a proxy is walked through its traps. A hostile script gains nothing
|
||||
* worth defending here — the vm context inside the worker is escapable by
|
||||
* construction, so hostile-value containment would be cost without a threat
|
||||
* model (what the worker thread DOES buy is that a spin occupies the
|
||||
* worker's loop, not the host's, and termination is real).
|
||||
*
|
||||
* The host→realm direction needs no machinery at all: hooks hand the script
|
||||
* plain values of the worker realm, prototypes included — the script is
|
||||
* trusted. One consequence is documented in the engine README: an error
|
||||
* thrown by a hook is built OUTSIDE the script's vm context, so an in-script
|
||||
* `instanceof Error` check is false; read `name`/`code`/`message` instead.
|
||||
*
|
||||
* The engine's value boundary: copy script-realm values into plain JSON data — loud about
|
||||
* everything JSON cannot carry — and render thrown script values to failure text.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/realm
|
||||
*/
|
||||
|
||||
@@ -75,13 +49,7 @@ function hasPlainPrototype(value: object): boolean {
|
||||
|
||||
/**
|
||||
* Copy `value` (typically from the vm realm) into plain host JSON data.
|
||||
* Throws {@link MaterializeError} naming the offending path for anything JSON
|
||||
* cannot carry losslessly. Properties are read ordinarily — a getter runs and
|
||||
* its RESULT is materialized; a read that throws surfaces as a
|
||||
* {@link MaterializeError} carrying the rendered failure. `undefined` is
|
||||
* accepted only at the ROOT (a script with no `return` value) — the caller
|
||||
* decides what it means; an `undefined` nested INSIDE a container is a
|
||||
* violation.
|
||||
*
|
||||
* @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).
|
||||
|
||||
@@ -1,39 +1,8 @@
|
||||
/**
|
||||
* Per-run execution state for the engine's THREAD side: the script's vm
|
||||
* context and its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/
|
||||
* `log`/`args`), the concurrency semaphore and caps, cancellation, and the
|
||||
* drive loop that turns a script settlement into a {@link WorkflowResult}.
|
||||
* Children are started by RPC to the host through a {@link ChildPort}, so
|
||||
* this module never touches a cordis context — it runs inside the worker
|
||||
* thread.
|
||||
*
|
||||
* Value boundary (the trust premise lives in ./realm.ts): values ENTERING the
|
||||
* worker-side host code from the script (hook options, schemas, the return
|
||||
* value) are materialized by `materializeFromRealm` — a plain walk that
|
||||
* rejects loud everything JSON cannot carry, which also makes every value
|
||||
* safe for the later postMessage hop. Values ENTERING the realm (`args`,
|
||||
* `agent()` results, hook promises and their failures, combinator arrays) are
|
||||
* handed over DIRECTLY as worker-realm values: the script is model-written
|
||||
* and trusted, so outer prototypes are not a leak. `args` is cloned once at
|
||||
* start so a script scribbling on it cannot mutate the session's init object
|
||||
* (a benign-bug guard; the postMessage clone already isolated the caller).
|
||||
*
|
||||
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
||||
* unsupported options/schemas, tripped caps, synchronous start refusal,
|
||||
* pre-publication readiness failure, ready-child result rejection, and
|
||||
* cancellation) ALWAYS propagate through
|
||||
* `parallel`/`pipeline` — recognized by `instanceof` against this realm's
|
||||
* class, which a script inside the vm context cannot forge — and the per-item
|
||||
* `null` is reserved for child-run failures and ordinary in-stage script
|
||||
* errors. Every hook-returned promise gets a no-op rejection consumer, so a
|
||||
* dropped promise cannot surface an unhandled rejection (which would kill the
|
||||
* worker and read as an engine fault).
|
||||
*
|
||||
* There is deliberately NO worker-side abandon channel: a script that never
|
||||
* settles after a cancel simply never posts a result, and the HOST enforces
|
||||
* the settles-within-grace guarantee by force-settling `cancelled` and
|
||||
* terminating the worker — the real kill an in-process engine could not have.
|
||||
*
|
||||
* Per-run execution state for the engine's worker side: the script's vm context and its
|
||||
* injected hooks (`agent`/`parallel`/`pipeline`/`phase`/ `log`/`args`), the concurrency
|
||||
* semaphore and caps, cancellation, and the drive loop that turns a script settlement into a
|
||||
* {@link WorkflowResult}.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/runtime
|
||||
*/
|
||||
|
||||
@@ -105,12 +74,8 @@ export class WorkflowExecution {
|
||||
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.
|
||||
// Compile FIRST: a body syntax error must throw out of the constructor before any realm
|
||||
// state exists.
|
||||
try {
|
||||
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
||||
filename: `workflow:${meta.name}`,
|
||||
@@ -163,12 +128,10 @@ export class WorkflowExecution {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: in-flight children get a cancel RPC (the shared abort
|
||||
* fanout), 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.
|
||||
* Cancel the run: in-flight children get a cancel RPC (the shared abort fanout), waiting
|
||||
* `agent()` slots reject, and every future hook call throws `CANCELLED` — the script dies at
|
||||
* its next await.
|
||||
*
|
||||
* @param reason - human-readable cause, carried on the CANCELLED error and
|
||||
* into child cancel RPCs. Required: every caller (the session's cancel
|
||||
* message, drive()'s settle-reap) has a concrete reason.
|
||||
@@ -215,10 +178,8 @@ export class WorkflowExecution {
|
||||
// contract.
|
||||
return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
|
||||
} finally {
|
||||
// Reap strays: a script that fired agent() calls without awaiting them
|
||||
// leaves live children behind after settlement — cancel them all. (The
|
||||
// per-call wrappers dispose each child; the contain() consumer keeps
|
||||
// their rejections from going unhandled.)
|
||||
// Reap strays: a script that fired agent() calls without awaiting them leaves live
|
||||
// children behind after settlement — cancel them all.
|
||||
if (this.cancelReason === undefined) this.cancel('workflow settled')
|
||||
}
|
||||
}
|
||||
@@ -303,11 +264,7 @@ export class WorkflowExecution {
|
||||
|
||||
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).
|
||||
// Recheck cancellation after semaphore acquisition because acquire always yields.
|
||||
this.throwIfCancelled()
|
||||
let run: ChildHandle
|
||||
try {
|
||||
@@ -344,11 +301,8 @@ export class WorkflowExecution {
|
||||
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.
|
||||
// A rejected child result is an INFRASTRUCTURE fault relayed by the host — distinct
|
||||
// from a child that failed and resolved.
|
||||
if (this.isCancelled()) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
||||
throw this.cancelledError()
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
/**
|
||||
* 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. Deliberately separated
|
||||
* from the thread bootstrap (./worker.ts): the whole session is drivable
|
||||
* in-process over a `MessageChannel`, which is where its unit coverage lives
|
||||
* (code inside a real Worker is invisible to the main process's coverage).
|
||||
*
|
||||
* Startup handshake: the session posts `ready` and runs the script only
|
||||
* after the host's `go` — without it, a cancellation racing the worker's
|
||||
* boot could arrive AFTER the script's initial synchronous slice already
|
||||
* ran, and a run cancelled before start must not execute the body at all.
|
||||
* A `cancel` arriving instead of `go` still releases the gate: `drive()`
|
||||
* sees the cancelled state and settles without running the body.
|
||||
*
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/session
|
||||
*/
|
||||
|
||||
@@ -141,12 +129,10 @@ export function requireParentPort(port: MessagePort | null): MessagePort {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). Never rejects: a constructor failure
|
||||
* (unparseable body — host pre-parse makes this a Node-version-skew signal)
|
||||
* is reported as an `error` result rather than dying without a result.
|
||||
* 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).
|
||||
*
|
||||
* @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`.
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init
|
||||
* payload and the child-port interfaces the worker-side runtime consumes.
|
||||
* The host⇄worker MESSAGE protocol lives in ./protocol.ts; everything here
|
||||
* that a message transports (`ChildStartRequest`, `ChildResult`) is plain
|
||||
* JSON data by construction, so the structured-clone hop never meets a value
|
||||
* it cannot carry. Types only, per the package convention.
|
||||
*
|
||||
* Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init payload and
|
||||
* the child-port interfaces the worker-side runtime consumes.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/types
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
/**
|
||||
* The worker-thread entry the engine spawns: bootstrap ./session.ts on the
|
||||
* real `parentPort`. Deliberately a single statement — every piece of logic
|
||||
* lives in `runWorkerSession`, which the unit suite drives in-process over a
|
||||
* `MessageChannel` (code inside a real Worker is invisible to main-process
|
||||
* coverage); loading this module on the main thread throws via
|
||||
* `requireParentPort`, which is how the suite covers the file itself.
|
||||
*
|
||||
* The worker-thread entry the engine spawns: bootstrap ./session.ts on the real `parentPort`.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/worker
|
||||
*/
|
||||
|
||||
|
||||
@@ -21,21 +21,9 @@ function fakeParent(): Agent {
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
/**
|
||||
* `vi.waitFor` with a contention-proof default timeout: the 1s default
|
||||
* flaked repeatedly on the CI coverage lane, where worker-thread cold start
|
||||
* (CPU-bound — a fresh thread compiles the runtime) competes with three
|
||||
* sibling vitest workers for CPU. The 10s default is for exactly those
|
||||
* races — waiting for a worker to start, run its first script line, or
|
||||
* deliver an async child-registration message to the host. It is NOT for a
|
||||
* wait that asserts the HOST reacted PROMPTLY to something that already
|
||||
* happened (a settled result, an observed worker death): those keep an
|
||||
* explicit tight override below, or the generous default would silently
|
||||
* accept a multi-second regression in host-side reap latency as passing
|
||||
* (proven by injecting a 6s delay into one such reap and watching the
|
||||
* un-overridden version of this helper still pass in ~6s).
|
||||
* @param assertion - retried until it stops throwing or the timeout elapses.
|
||||
* @param timeout - override for a wait that must stay deliberately tight.
|
||||
* @returns resolves when the assertion passes.
|
||||
* `vi.waitFor` with a contention-proof default timeout: the 1s default flaked repeatedly on
|
||||
* the CI coverage lane, where worker-thread cold start (CPU-bound — a fresh thread compiles
|
||||
* the runtime) competes with three sibling vitest workers for CPU.
|
||||
*/
|
||||
function waitFor(assertion: () => void, timeout = 10_000): Promise<void> {
|
||||
return vi.waitFor(assertion, { timeout, interval: 50 })
|
||||
@@ -534,11 +522,9 @@ describe('dsh-workflow-workerthread', () => {
|
||||
|
||||
it('a child-start racing the host cancel is refused: no child starts after cancellation', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
// Cancel from INSIDE the log listener: the worker has already posted
|
||||
// its child-start (queued right behind the log message), so the host
|
||||
// processes it with cancelReason set — the refusal arm no real-world
|
||||
// timing can hit reliably. (The closure runs only after `handle` below
|
||||
// is initialized — the listener fires on the worker's first message.)
|
||||
// Cancel from inside the log listener: the worker has already posted its child-start
|
||||
// (queued right behind the log message), so the host processes it with cancelReason set —
|
||||
// the refusal arm no real-world timing can hit reliably.
|
||||
ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') })
|
||||
const handle = ctx.workflows.start({ ...scripted("log('mark')\nreturn await agent('late')"), parent })
|
||||
const result = await handle.result
|
||||
@@ -553,12 +539,9 @@ describe('dsh-workflow-workerthread', () => {
|
||||
ctx.on('workflow/log', (_info, message) => { narration.push(message) })
|
||||
ctx.on('workflow/phase', (_info, title) => { narration.push(`phase:${title}`) })
|
||||
const handle = ctx.workflows.start({
|
||||
// The sync spin keeps the worker's loop busy so the cancel message
|
||||
// cannot be processed before the script settles `completed` — the
|
||||
// worker posts a completed result that must LOSE to the in-flight
|
||||
// host cancellation. The trailing narration exercises host-side
|
||||
// suppression: posted pre-cancel-processing worker-side, arriving
|
||||
// post-cancel host-side.
|
||||
// The sync spin keeps the worker's loop busy so the cancel message cannot be processed
|
||||
// before the script settles `completed` — the worker posts a completed result that must
|
||||
// LOSE to the in-flight host cancellation.
|
||||
...scripted(`
|
||||
log('started')
|
||||
const end = Date.now() + 1000
|
||||
@@ -694,11 +677,8 @@ describe('dsh-workflow-workerthread', () => {
|
||||
})
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// BEFORE dispose(): the settlement itself must have aborted the signal —
|
||||
// without it this child would stay live until dispose's terminate. This
|
||||
// is a HOST-PROMPTNESS claim, not a cold-start race — a tight explicit
|
||||
// bound (unlike the file default) so a multi-second reap regression
|
||||
// cannot pass by outlasting the wait.
|
||||
// before dispose(): the settlement itself must have aborted the signal — without it this
|
||||
// child would stay live until dispose's terminate.
|
||||
await waitFor(() => { expect(aborted).toEqual(['workflow settled']) }, 1000)
|
||||
await handle.dispose()
|
||||
})
|
||||
@@ -730,13 +710,10 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// reach this child, the assertion below would time out first.
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'cancel-only', maxConcurrentAgents: 2, disposeGraceMs: 30_000 })
|
||||
const handle = ctx.workflows.start({
|
||||
// The stray child's start RPC reaches the host, then the script wedges
|
||||
// its own worker in a synchronous spin: the worker cannot process the
|
||||
// Cancel message, so it can relay NO ChildCancel RPC — only the host's
|
||||
// own children loop can deliver the explicit cancel in time. The
|
||||
// microtask yields let the agent() continuation POST its child-start
|
||||
// before the spin seizes the worker's loop (the posted message needs
|
||||
// no further worker-loop turns to reach the host).
|
||||
// The stray child's start RPC reaches the host, then the script wedges its own worker
|
||||
// in a synchronous spin: the worker cannot process the Cancel message, so it can relay
|
||||
// NO ChildCancel RPC — only the host's own children loop can deliver the explicit
|
||||
// cancel in time.
|
||||
...scripted(`
|
||||
agent('wedged child')
|
||||
for (let i = 0; i < 20; i++) await null
|
||||
@@ -762,11 +739,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
config: { provider: 'stub', maxConcurrentAgents: 8, disposeGraceMs: 400 },
|
||||
})
|
||||
const handle = ctx.workflows.start({
|
||||
// Same shape as the wedged-cancel test above: the child's start RPC
|
||||
// reaches the host, then the script seizes its worker's loop, so the
|
||||
// worker can relay NO dispose RPC — the host's own dispose() drive is
|
||||
// the only thing that can start (and finish) this child's disposal
|
||||
// before the grace runs out.
|
||||
// A wedged worker leaves host disposal as the only path to child quiescence.
|
||||
...scripted(`
|
||||
agent('wedged child')
|
||||
for (let i = 0; i < 20; i++) await null
|
||||
@@ -967,10 +940,9 @@ describe('dsh-workflow-workerthread', () => {
|
||||
})
|
||||
ctx.on('workflow/end', () => { order.push('run-end') })
|
||||
const handle = ctx.workflows.start({
|
||||
// Same choreography as the force-settle pairing test, but the worker
|
||||
// DIES (the documented vm escape) instead of being terminated: the
|
||||
// exit path must close slow's pair from the ledger too. The escaped
|
||||
// setTimeout lets the already-posted messages flush before the kill.
|
||||
// Same choreography as the force-settle pairing test, but the worker DIES (the
|
||||
// documented vm escape) instead of being terminated: the exit path must close slow's
|
||||
// pair from the ledger too.
|
||||
...scripted(`
|
||||
const p = agent('slow')
|
||||
await agent('fast')
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
/**
|
||||
* The workflow capability seam (`ctx.workflows`): an abstract service defining
|
||||
* WHAT a workflow engine does — execute a model-written orchestration script
|
||||
* that fans out subagents — without saying HOW. Implementations subclass
|
||||
* {@link WorkflowService} and register as the `workflows` service (one
|
||||
* implementation per context, cordis' standard duplicate-service behavior);
|
||||
* the implementation is `@deepseek-ai/dsh-workflow-workerthread`, which runs each
|
||||
* script in its own worker thread. Hardened engines (an isolated-vm or
|
||||
* separate-process sandbox) swap in without touching the model-facing tool
|
||||
* that consumes them (`@deepseek-ai/dsh-tool-workflow`).
|
||||
*
|
||||
* The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they
|
||||
* carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun}
|
||||
* — a listener must not gain `cancel`/`dispose`; control stays with the
|
||||
* `start()` caller holding the run. Every emit is per-listener contained (a
|
||||
* throwing subscriber is logged, never propagated) and every listener gets its
|
||||
* own payload clone (mutating it corrupts nothing), so one bad observer can
|
||||
* neither strand a live run, starve later listeners, nor poison another
|
||||
* listener's view.
|
||||
*
|
||||
* The workflow capability seam (`ctx.workflows`): an abstract service defining what a workflow
|
||||
* engine does — execute a model-written orchestration script that fans out subagents — without
|
||||
* saying how.
|
||||
* @module @deepseek-ai/dsh-workflow
|
||||
*/
|
||||
|
||||
@@ -119,28 +103,9 @@ export type WorkflowEventName =
|
||||
| 'workflow/end'
|
||||
|
||||
/**
|
||||
* The workflow-seam error codes. Every one of these is FATAL when it reaches
|
||||
* a script (see {@link WorkflowError.fatal}): the combinators re-throw it
|
||||
* instead of dissolving it into an ordinary per-item `null`.
|
||||
*
|
||||
* - `SCRIPT_PARSE` — the script (or its meta statement) does not parse.
|
||||
* - `META_INVALID` — the meta block evaluated but fails the shape contract.
|
||||
* - `INVALID_ARGUMENT` — a hook was called with malformed arguments.
|
||||
* - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support
|
||||
* (deferred: `effort`/`isolation`/`agentType`) or does not know.
|
||||
* - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output
|
||||
* subset (see dsh-tools).
|
||||
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
|
||||
* - `AGENT_START` — synchronous subagent start or the provider's asynchronous
|
||||
* publication/readiness boundary failed before cancellation took precedence.
|
||||
* - `AGENT_RESULT` — a run whose readiness FULFILLED had its `result` REJECT: an
|
||||
* infrastructure fault at the subagent seam, even if the rejection settled
|
||||
* before readiness. This is distinct from a child that failed and resolved
|
||||
* (which is the per-item `null`, never an error).
|
||||
* - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary
|
||||
* is not plain JSON data.
|
||||
* - `CANCELLED` — the run was cancelled; pending and future hooks reject
|
||||
* with this (the script-kill mechanism).
|
||||
* The workflow-seam error codes. Every one of these is FATAL when it reaches a script (see
|
||||
* {@link WorkflowError.fatal}): the combinators re-throw it instead of dissolving it into an
|
||||
* ordinary per-item `null`.
|
||||
*/
|
||||
export type WorkflowErrorCode =
|
||||
| 'SCRIPT_PARSE'
|
||||
@@ -185,31 +150,9 @@ export function isFatalWorkflowError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract workflow execution service. Subclass, implement {@link start}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.workflows` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link start} throws synchronously for a request that cannot begin (an
|
||||
* unparseable script, an invalid meta block). Once it returns a
|
||||
* {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with
|
||||
* `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled,
|
||||
* `result` SETTLES within the implementation's bounded grace even if the
|
||||
* script itself never settles (a consumer awaiting `result` must never be
|
||||
* wedged past a cancellation).
|
||||
* - The `workflow/*` events fire through {@link emitWorkflowEvent} (data
|
||||
* snapshots, per-listener containment); `workflow/end` fires exactly once
|
||||
* per started run, after `result` is settled or as it settles.
|
||||
* - `dispose()` reaches quiescence within a bounded grace: it cancels, waits
|
||||
* for the script to settle AND its started children to finish disposing,
|
||||
* and abandons whatever is left rather than hanging its caller (the engine
|
||||
* documents what abandonment leaves behind).
|
||||
* - Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to
|
||||
* the `start()` caller and does not track its live runs — disposing the
|
||||
* engine's own fiber mid-run deliberately leaves those runs to their
|
||||
* holders' teardown, so an engine reload cannot yank a run out from under
|
||||
* the consumer awaiting it.
|
||||
* Abstract workflow execution service. Subclass, implement {@link start}, and load the
|
||||
* subclass as a plugin — it registers as `ctx.workflows` (one implementation per context;
|
||||
* loading a second throws, cordis' standard duplicate-service behavior).
|
||||
*/
|
||||
export abstract class WorkflowService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
@@ -225,25 +168,13 @@ export abstract class WorkflowService extends Service {
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
|
||||
/**
|
||||
* Emit one `workflow/*` lifecycle event with PER-LISTENER containment and
|
||||
* PER-LISTENER payload snapshots: each subscriber is dispatched individually
|
||||
* with its OWN structural clone of the payload (the payloads are plain JSON
|
||||
* data by the seam contract), so a listener mutating what it received can
|
||||
* corrupt neither the engine's live state nor any other listener's or later
|
||||
* event's view; a thrown listener is logged (never propagated — the logging
|
||||
* itself is total, even for a thrown value whose own string coercion
|
||||
* throws), so one bad subscriber can neither fail the engine mid-run,
|
||||
* surface as an unhandled rejection on a detached settle hook, nor starve
|
||||
* the listeners registered after it (cordis `emit` halts on the first throw
|
||||
* — same guarantee as the subagent seam's lifecycle emits).
|
||||
* Emit isolated payload snapshots and contain each lifecycle listener independently.
|
||||
* @param name - the `workflow/*` event to dispatch.
|
||||
* @param args - the event's payload, matching its declared signature.
|
||||
*/
|
||||
protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) {
|
||||
try {
|
||||
// The declared workflow/* signatures are all void-returning emits; the
|
||||
// dispatch callback applies the payload tuple.
|
||||
;(callback as (...payload: unknown[]) => void)(...structuredClone(args))
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`workflow: ${name} listener threw: ${renderListenerError(error)}`)
|
||||
@@ -253,19 +184,15 @@ export abstract class WorkflowService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Total renderer for a listener-thrown value: the containment catch must never
|
||||
* itself throw, and `String(error)` does when the value's own `toString` /
|
||||
* `Symbol.toPrimitive` throws. Local rather than an engine package's renderer
|
||||
* — the seam sits below every engine and cannot import one.
|
||||
* Render a thrown value without weakening listener containment.
|
||||
* @param error - any thrown value.
|
||||
* @returns `String(error)`, or a fixed label when even coercion throws.
|
||||
* @returns string form or a fixed fallback when coercion throws.
|
||||
*/
|
||||
function renderListenerError(error: unknown): string {
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
// Only a throwing toString/Symbol.toPrimitive lands here; the fixed label
|
||||
// keeps the containment guarantee total.
|
||||
// String coercion itself is untrusted.
|
||||
return '[unrenderable thrown value]'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,16 +106,7 @@ export interface WorkflowResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle the consumer holds while a script executes. The consumer awaits
|
||||
* `result`, may `cancel` mid-flight, and MUST `dispose` on every path.
|
||||
* `result` does NOT reject — a script failure resolves with `stopReason:
|
||||
* 'error'` — and once the run is cancelled it SETTLES within the engine's
|
||||
* bounded grace even if the script itself never settles (the engine
|
||||
* force-settles `cancelled`; what becomes of the script is engine-documented
|
||||
* — the worker-thread engine terminates its worker), so a consumer awaiting
|
||||
* `result` is never wedged past a cancellation. `dispose()` = cancel + that
|
||||
* bounded settle + child quiescence; it never hangs on a stuck script and is
|
||||
* safe to call on every path (idempotent).
|
||||
* The handle the consumer holds while a script executes.
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
readonly id: WorkflowRunId
|
||||
|
||||
Reference in New Issue
Block a user