/** * DshDispatchAdapter — dsh dispatch seam for the graph engine and loop mode. * * When rolebox runs as a dsh (DeepSeek Harness) cordis plugin, graph node * dispatch and loop worker rounds must go through dsh services instead of the * opencode SDK client. This adapter implements BOTH dispatch surfaces rolebox * consumes, backed by the same dsh services: * * - {@link NodeDispatchPort} (`src/graph/engine/engine-advance.ts`) — the * seam every graph engine touches to launch nodes. `executeNode` routes a * graph node to the dsh subagent seam via `SubagentRuntime.start` * (`ctx.subagents`, contract §4.3); results are collected through the dsh * session service (`ctx.sessions`, §4.1) and the run's `result` promise; * cancellation maps to the run's `dispose()` (the dsh abort/task surface); * failures map to the engine's escalate semantics by translating the dsh * `SubagentResult.stopReason` into the engine's `DispatchTaskStatus` * vocabulary (`completed → completed`, `error/refusal → error`, `aborted → * cancelled`, `max-tokens → timeout`) that `mapDispatchStatusToSignal` * (`engine-recovery.ts`) already turns into `answer` / `escalate` signals. * - {@link IDispatchAdapter} (`src/loop/dispatch-adapter.ts`) — the seam * the loop coordinator uses to drive worker rounds. `dispatchRound` / * `getRoundResult` / `cancelRound` share the SAME run registry as the * graph port, so graph and loop dispatches observe one consistent view. * * ── Per-role agent mapping ──────────────────────────────────────────────── * A graph node's `agent` (or a loop round's `agent`) IS the rolebox agent id * registered by {@link DshAgentRegistrar} (`agent-registrar.ts`) — the * registrar registers one `SubagentProvider` per `AgentDefinition` keyed by * `definition.id`. The adapter therefore resolves `node.agent` directly as the * provider name for `SubagentRuntime.start`; when the agent is not registered * the start rejects with a descriptive error (the engine contains it and * escalates the node). Per-role tool allowlists / model overrides are applied * by the registrar's provider at spawn time (capabilities.toolFilter / * agentOptions merge) — not duplicated here. * * ── Graceful degradations (documented) ─────────────────────────────────── * - Budget accounting: dsh has no token/cost budget tracker. The * `getSessionUsage` member of `NodeDispatchPort` is therefore omitted — * the engine's `captureNodeUsage` (engine-recovery.ts) guards on absence * and leaves per-node `tokensConsumed` at its default zero. Graph-level * budget ceilings are likewise not enforced on the dsh path. * - Per-run hard timeout: dsh's `SubagentStartRequest` has no * `timeout_ms` field (the dsh vocabulary is `agentOptions.maxTokens`). * When a node declares `budget.timeout_ms`, the adapter enforces it with * an AbortController timer (abort signal + dispose) that settles the run * as `timeout`; the engine's stale-node watcher backstops hangs otherwise. * - `injectNote` (loop progress markers): dsh has no `prompt` on the * SessionStore (`DshSessionAdapter.prompt` returns null — prompting is * driven by the dsh agent loop), so it is a no-op. * - Result sidecars: the dsh run's output ContentBlocks are materialized to * `{directory}/.rolebox/state/results/{taskId}.txt` (the same layout the * opencode sidecar uses) so `graph_status(node, include_output)` and the * loop `loop_output` tool read node results through the shared * `GraphToolSet.resultText` path. When the sidecar write fails the * MaterializedResultRef carries a `fetchError` and readers degrade. * * This module does NOT import from any host SDK — neither the opencode * plugin/SDK nor any dsh package. The dsh surface is consumed structurally * (duck-typed) against the shapes verified in `docs/dsh-plugin-contract.md` * §4.1/§4.3, so a fake dsh service double can drive it in tests. * * @module */ import type { NodeDispatchPort } from "../../../graph/engine/engine-advance.ts"; import type { DispatchParentContext, TaskTerminatedCallback } from "../../../graph/engine/dispatch-bridge.ts"; import type { DispatchTask } from "../../../dispatch/types.ts"; import type { NodeRuntimeState } from "../../../types.engine-v2.ts"; import type { IDispatchAdapter } from "../../../loop/dispatch-adapter.ts"; import type { ISessionClient } from "../../ports/session-client.ts"; import type { DshSubagentRun, DshSubagentRuntime, DshSubagentStartRequest } from "./agent-registrar.ts"; export type { DshSubagentResult } from "./agent-registrar.ts"; /** * The dsh `SubagentRuntime` surface the dispatch path consumes. A superset of * {@link DshSubagentRuntime} (the registrar's catalog seam) — adds `start`, * which routes to the registered provider for the given name. Consumed * structurally so the real `ctx.subagents` service and a test double both * satisfy it. */ export interface DshSubagentDispatchRuntime extends DshSubagentRuntime { /** Start a subagent run through the named provider (§4.3 `start`). */ start(name: string, request: DshSubagentStartRequest): Promise; } /** * Nested-graph liveness seam. The dsh graph toolset is consumed structurally * (the adapter never imports the graph subsystem) so a run whose agent launched * a nested graph can be settled from THAT graph's outcome instead of the * agent's turn completion. * * A dispatched subagent that calls `graph_run` ends its turn immediately * (graph_run is non-blocking), so the run's `result` resolves `completed` * while the nested graph is still executing. Without this seam the outer node * would report success and the nested graph's eventual failure would be lost. */ export interface DshNestedGraphLiveness { /** * Whether the session still owns a graph that has not reached a terminal * phase (including a quiescent-blocked HITL gate). */ hasExecuting(sessionId: string): boolean; /** * Subscribe to graph-terminal events for the graphs the session's agents * launched. Returns an unsubscribe function. */ subscribeTerminal(observer: (info: { graphId: string; sessionId?: string; failed: boolean; }) => void): () => void; } /** * dsh `SubagentResult` — the terminal outcome a `SubagentRun.result` promise * resolves with. The single structural declaration lives in * `agent-registrar.ts` (`DshSubagentResult`) so `DshSubagentRun.result` is * typed and the dispatch adapter reads it without a cast ; this module * re-exports it for existing consumers. */ /** * Thrown when a dsh dispatch cannot resolve a live parent `Agent` for the * spawn's parent session. * * dsh declares `SubagentStartRequest.parent` REQUIRED (`readonly parent: * Agent` — `dsh-subagent/lib/types/types.d.ts:101`) and the in-process driver * dereferences it (`parent.ctx` / `parent.session` / `parent.options`) while * composing the child. Rolebox therefore fails loud here instead of forwarding * `parent: undefined` and letting a real provider raise an opaque `TypeError` * at spawn. */ export declare class DshParentUnresolvedError extends Error { /** The parent/origin session id that did not resolve to a live agent. */ readonly sessionId: string; constructor(sessionId: string); } /** Options for constructing a {@link DshDispatchAdapter}. */ export interface DshDispatchAdapterOptions { /** * The dsh subagent seam (`ctx.subagents`, a `SubagentRuntime`). Injected so * the adapter stays SDK-free; tests inject a fake double. */ subagents: DshSubagentDispatchRuntime; /** * Optional dsh session client (`DshSessionAdapter` over `ctx.sessions`). * Used to read origin-session summaries for loop rounds * (`readOriginSummary` / `getLastMessageId`). Absent → those methods return * empty/undefined (documented degradation). */ sessionClient?: ISessionClient; /** * Resolve the live parent `Agent` for a spawn from the session it runs under * (`parentSessionId`). dsh declares `SubagentStartRequest.parent` REQUIRED * (`readonly parent: Agent` — `dsh-subagent/lib/types/types.d.ts:101`) and * the in-process driver dereferences it (`parent.ctx` / `parent.session` / * `parent.options`) while composing the child, so a parent-less start is a * hard failure — never a graceful degradation. * * The adapter resolves the parent BEFORE building the start request and * fails loud with {@link DshParentUnresolvedError} when this returns * `undefined`/`null` (or when no resolver is wired at all). Wire it from the * probed live-agent registry: `(sid) => registry?.get(sid)`. */ parentResolver?: (sessionId: string) => unknown; /** * Optional nested-graph liveness seam (see {@link DshNestedGraphLiveness}). * When wired, a run that settles `completed` while its session still owns an * executing nested graph is held `running` until that graph reaches a * terminal state; a failed nested graph then settles the task as `error` * (the engine's escalate path) instead of a silent success. Absent → the * pre-existing behavior (settle from `stopReason` alone). */ graphLiveness?: DshNestedGraphLiveness; /** * Optional workspace directory for result sidecars * (`{directory}/.rolebox/state/results/`). Defaults to `process.cwd()`. */ directory?: string; /** Optional logger name override. */ loggerName?: string; } /** * dsh-backed dispatch seam implementing {@link NodeDispatchPort} (graph * engine) AND {@link IDispatchAdapter} (loop coordinator). * * The two surfaces share one internal run registry: * - `executeNode` / `dispatchRound` start a dsh subagent run and register it. * - the run's `result` promise settles the task: stopReason → DispatchTask * status (completed/error/cancelled/timeout), output materialized to a * sidecar on completion. * - `onTaskTerminated` / `registerTerminatedListener` bridge run settlement * into the engine's dispatch→signal seam (immediate-fire guard mirrors * `DispatchManager.onTaskTerminated`). * - `cancelTask` / `cancelRound` map to the dsh abort surface: abort the * run's signal + `run.dispose()`, settle as `cancelled`. * * `getSessionUsage` is intentionally absent (dsh has no budget accounting — * the engine's `captureNodeUsage` guards on absence). */ export declare class DshDispatchAdapter implements NodeDispatchPort, IDispatchAdapter { private readonly opts; private readonly tasks; private readonly log; private readonly directory; /** * Runs whose `result` settled `completed` while their session still owned an * executing nested graph. Keyed by task id → the run's materialized output * text, settled when the nested graph reaches a terminal state. */ private readonly pendingNestedSettle; /** Lazily-created nested-graph terminal subscription (one per adapter). */ private nestedUnsub?; /** * Dispatch-parent index: child session id (a `SubagentRun.id`) → the REAL * live parent session recorded at spawn (`liveParentSessionId`). Lets the * adapter walk a nested graph's invoking session chain up to the outermost * live session (see {@link resolveSessionChain}) so a blocked * `needs_approval` gate can be surfaced to the user's orchestrator session. * Entries are never evicted (the run registry itself is lifetime-long), so a * chain resolved after the child agent's session has ended still resolves. */ private readonly childToParent; constructor(opts: DshDispatchAdapterOptions); /** * Start a dsh subagent run for an agent, register it, and wire settlement. * * `agent` is the rolebox agent id — it IS the provider name registered by * {@link DshAgentRegistrar} (per-role agent mapping). When the provider is * missing the start rejects with a descriptive error; graph dispatch * failures are contained by the engine and escalate the node. * * @param agent Rolebox agent id (== dsh provider name). * @param prompt The prompt text for the subagent. * @param description Human-readable label (run `label`). * @param parentSessionId Context session carried onto the task record AND * the start request's `sessionId` (the registrar's * per-session active-role key). Unchanged by the * parent-resolution split. * @param liveParentSessionId REAL platform session used to resolve the live * parent `Agent` via `parentResolver`. Equals * `parentSessionId` on the loop path; on the graph * path it is the graph's live parent session * (`parentContext.parentSessionId`) rather than the * graph-scoped budget key. * @param timeoutMs Optional per-run hard timeout — enforced with an * AbortController timer (dsh has no native * `timeout_ms` on the start request). */ private startRun; /** * Wire the run's `result` promise into the registry: on settle, translate * the dsh stopReason into a DispatchTask status, materialize output, and * fire termination listeners. Fire-and-forget — the returned promise never * rejects (both handlers contain their work). */ private wireRunSettlement; /** Translate a resolved dsh SubagentResult into a task status + output. */ private settleFromResult; /** * Record the run's terminating signal on the task before it settles, with * parity to the completion evaluator (`completion-evaluator.ts` sets * `task.terminatingSignal` on the in-process / opencode / pi paths). The dsh * adapter settles its own tasks, so without this the engine's * `mapDispatchStatusToSignal` (`engine-recovery.ts`) finds the field unset * and the ledger empty for EVERY completed dsh node — logging * "no terminatingSignal recorded for completed task" and inferring the * `answer` it could have read directly. * * Infallible by construction: settlement is fire-and-forget and must never * reject (see {@link wireRunSettlement}), so a throwing ledger falls back to * the same synthetic answer the completion evaluator uses. */ private recordTerminatingSignal; /** * Hold a `completed` run open while its session still owns an executing * nested graph. Returns `true` when settlement was deferred (the caller must * NOT settle the task), `false` when the task can settle normally. * * The nested graph is correlated by the run's session id: in dsh a * `SubagentRun.id` IS a `SessionId`, and the graph tool's invoking-session is * the child agent's session — so `task.sessionId` identifies the graphs the * dispatched agent launched. */ private deferUntilNestedGraphsSettle; /** * Settle every deferred task whose session has no executing graph left after * a nested graph reached a terminal state. A failed nested graph (escalated / * timed-out node) settles the task `error` so the engine escalates the node * instead of reporting a fabricated success. */ private onNestedGraphTerminal; /** Settle a run as `error` from a rejected result promise (defensive). */ private settleError; /** * Force a terminal status onto a still-running task (cancellation and the * per-run timeout timer). No-op for an unknown or already-terminal task. */ private forceSettle; /** * Terminal-settle funnel: fire the termination listeners and resolve the * registry's `done` promise (so `getRoundResult` never hangs on a run whose * result promise the provider left unsettled after abort). */ private finishTerminal; /** Fire every one-time termination listener for a settled task, then clear. */ private fireTerminated; /** * Materialize the run's output text to a sidecar file * (`{directory}/.rolebox/state/results/{taskId}.txt`, the shared layout). * Best-effort — a failed write degrades to a `fetchError` ref. */ private materialize; /** Execute a graph node by starting a dsh subagent run for `node.agent`. */ executeNode(node: NodeRuntimeState, parentContext: DispatchParentContext | undefined, description?: string): Promise; /** * Cancel a running dsh subagent run (the dsh abort surface): dispose the * run and settle the task as `cancelled`. Returns `true` when the * cancellation was issued (task was running), `false` for unknown or * already-terminal tasks. */ cancelTask(taskId: string): Promise; /** Look up a dispatched task's current record (status + result ref). */ getTask(taskId: string): DispatchTask | undefined; /** * Walk a dispatched child session up to the outermost live session by * following the dispatch-parent index recorded at each * {@link startRun} (`childToParent`). * * In dsh a `SubagentRun.id` IS a `SessionId`; each run records the REAL live * parent session that owned its spawn. Starting from a nested graph's * invoking session (the child session that called `graph_run`), this returns * `[sessionId, parent, ..., outermost]` — the chain a blocked approval must * travel to reach the user's orchestrator session. A session with no tracked * dispatcher is its own outermost (`[sessionId]`), so a single-level graph * (whose invoking session IS the orchestrator) yields a length-1 chain and * the caller skips propagation. * * Cycle-safe: a seen-set stops a malformed parent loop, and the walk is * bounded by the registry size. Consumed (structurally) by the graph * toolset's `resolveSessionChain` seam. */ resolveSessionChain(sessionId: string): string[]; /** * Register a one-time termination listener (fire-once semantics mirroring * `DispatchManager.onTaskTerminated`). When the task is already terminal the * callback fires async via microtask (the listen-after-terminate race). */ onTaskTerminated(taskId: string, callback: TaskTerminatedCallback): void; /** Remove a previously-registered termination listener. */ removeTaskTerminatedListener(taskId: string, callback: TaskTerminatedCallback): void; /** Submit a loop round to a worker agent via the dsh subagent seam. */ dispatchRound(input: { originSessionId: string; agent: string; prompt: string; description?: string; timeoutMs?: number; }): Promise<{ workerTaskId: string; workerSessionId: string; }>; /** Retrieve the result of a completed worker round (awaits settlement). */ getRoundResult(workerTaskId: string): Promise<{ text: string; hadError: boolean; errorReason?: string; }>; /** Cancel a running worker round (dsh abort surface). */ cancelRound(workerTaskId: string): Promise; /** * Read the latest assistant output from the origin session, up to * `SUMMARY_INPUT_CHAR_CAP` characters (mirrors `DispatchAdapter`). When no * session client is wired (or dsh cannot derive messages) returns "". */ readOriginSummary(originSessionId: string, sinceMessageId?: string): Promise; /** Return the ID of the most recent message in a session, or undefined. */ getLastMessageId(originSessionId: string): Promise; /** * Inject a silent progress note — no-op on dsh (documented degradation): * dsh has no `prompt` on the SessionStore (`DshSessionAdapter.prompt` * returns null; prompting is driven by the dsh agent loop). */ injectNote(_sessionId: string, _text: string): Promise; /** Register a one-time terminated listener (returns the callback). */ registerTerminatedListener(taskId: string, callback: (taskId: string, status: string) => void): (taskId: string, status: string) => void; /** Remove a previously-registered terminated listener. */ removeTerminatedListener(taskId: string, callback: (taskId: string, status: string) => void): void; /** Read-only query: current lifecycle status of a dispatched task. */ getTaskStatus(taskId: string): Promise; } //# sourceMappingURL=dispatch.d.ts.map