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

@@ -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>