docs: describe the workflow engine as worker-thread first

The outer ring catches up with the engine swap (the package's own
README/JSDoc rode the port commit):

- Seam module doc and README name the worker-thread engine as THE
  implementation, with isolated-vm/separate-process sandboxing as the
  deferred hardening; the seam service doc states the holder-owned-runs
  contract (engine-fiber disposal deliberately leaves live runs to
  their holders).
- Seam contract precision: agentsStarted documents the termination-path
  degradation to the host-observed count; the events section scopes the
  agent-start/agent-end pair to calls that STARTED a child run;
  WorkflowRun wording drops the vm-era abandonment language.
- The dynamic-workflows RFC is rewritten in place to the shipped
  mechanism (implemented-RFC rule): why worker threads, the thread's
  concrete buys, the in-process node:vm first cut recorded under
  alternatives considered; the tool section describes the usage policy
  as the tool's own prompt section.
- gen-doc-graphs: six workflow/* DYNAMIC_EVENT_DISPATCHERS entries (the
  catalog no longer claims nothing dispatches them) and the seam-note
  wording; core-data-structures gains its workflow.md index row;
  packages/README + AGENTS.md layout line + example cordis.yml comments
  say worker-thread; catalogs regenerated.
This commit is contained in:
imccyu
2026-07-09 18:50:29 +08:00
parent b5f618bcfb
commit d5c65e2b4c
16 changed files with 72 additions and 49 deletions

View File

@@ -5,9 +5,9 @@ The workflow seam: a model-written JavaScript orchestration script that fans out
| Package | Role | ctx key |
|---|---|---|
| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` |
| `workflow-vm/` | In-process `node:vm` engine: parses the script, injects the hooks, drives `ctx.subagents` | (provides `ctx.workflows`) |
| `workflow-vm/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) |
| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) |
The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The seam split exists for engine hardening: `node:vm` is in-process and cannot kill a pathological synchronous spin — a worker-thread or isolated-vm engine swaps in behind the same interface if that ever matters.
The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters.
The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md).

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-workflow
The **workflow 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. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) is the first, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer.
The **workflow 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. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) (one worker thread per run) is the implementation, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer.
## Service: `WorkflowService` (abstract)
`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `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). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller.
`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `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). `dispose()` must reach quiescence within a bounded grace (cancel → wait for the script to settle and its children to finish disposing → abandon), never hanging its caller. Runs are HOLDER-owned: the engine does not track its live runs, so disposing the engine's fiber mid-run leaves each run to its holder's teardown.
The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment and PER-LISTENER payload snapshots (a throwing subscriber is logged, never propagated, and cannot starve later listeners; each subscriber gets its own clone of the payload, so mutating it corrupts neither the engine nor other listeners) — the same containment guarantee as the subagent seam's lifecycle emits.
@@ -22,7 +22,7 @@ All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta)
- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value.
- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration.
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call, correlated by `seq`.
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call that STARTED a child run (a call rejected at validation or caps, refused at start, or cancelled while queued for a slot emits no pair), correlated by `seq`.
## Non-goals (this cut)

View File

@@ -4,10 +4,10 @@
* 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);
* `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the
* first. Future engines (a worker-thread or isolated-vm sandbox) swap in
* without touching the model-facing tool that consumes them
* (`@deepseek-ai/dsh-tool-workflow`).
* the implementation is `@deepseek-ai/dsh-workflow-vm`, 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}
@@ -198,6 +198,11 @@ export function isFatalWorkflowError(error: unknown): boolean {
* 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.
*/
export abstract class WorkflowService extends Service {
constructor(ctx: Context) {

View File

@@ -89,7 +89,13 @@ export interface WorkflowResult {
stopReason: WorkflowStopReason
/** The failure message (present iff `stopReason` is not `completed`). */
error?: string
/** How many `agent()` calls the run accepted (whole lifetime, including calls still queued for a slot when the run was cancelled). */
/**
* How many `agent()` calls the run accepted over its whole lifetime. On a
* graceful settlement this is the script-side count (calls still queued for
* a concurrency slot included); on a termination path (grace force-settle,
* worker death) it degrades to the host-observed count — calls queued
* inside a terminated script are unknowable then.
*/
agentsStarted: number
}
@@ -98,18 +104,19 @@ export interface WorkflowResult {
* `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 abandons
* the script and reports `cancelled`), 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).
* 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).
*/
export interface WorkflowRun {
readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is abandoned at the grace). */
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */
cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
dispose(): Promise<void>