Skip to content

<objective> Build the single shared ToolDispatcher with its six-stage pipeline, queue wait, timeout enforcement, and structural routing proof.

Purpose: Deliver D-88 through D-98 except the workspace handlers, plus C-2 unsubscribe and the TOOL-03/TOOL-04 unit surface. Output: Dispatcher module, events unsubscribe, and three test files. </objective>

<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>

<context> @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-CONTEXT.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-PATTERNS.md </context>

<tasks>

<task type="auto"> <name>Task 1: EventBus unsubscribe (C-2, D-91, D-97, D-105)</name> <files>src/core/events.ts, test/core/events.test.ts</files> <read_first> - src/core/events.ts lines 21-36 (subscribe with no removal; the publisher already iterates a copy so removal during delivery is safe) - src/core/ports.ts lines 45-46 (BridgePort.subscribe returning () => void; the established precedent) - src/lifecycle/controller.ts lines 486-490 and 545-548 (storing the unsubscribe function and calling it on release) - test/core/events.test.ts (existing bus assertions to keep passing) </read_first> <action>Apply the C-2 recommended resolution in src/core/events.ts per D-91 and D-97: change EventBus.subscribe to return an unsubscribe function () => void in the BridgePort style, so a queued call that adds a listener removes it when it settles and listeners cannot accumulate. Existing callers ignore the return value and still compile. Extend test/core/events.test.ts with an unsubscribe case (subscribe, unsubscribe, publish, listener not called). (Implements C-2; planner decision recorded: change subscribe rather than adding a dispatcher-only hook.)</action> <acceptance_criteria> - subscribe returns an unsubscribe function and the new test proves removal - All existing event-bus tests still pass </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/core/events.test.js</automated> </verify> <done>Unsubscribe added with existing bus behavior intact.</done> </task>

<task type="tdd"> <name>Task 2: ToolDispatcher six-stage pipeline (D-88, D-89, D-90, D-95, D-96, D-97, D-98, D-109, D-110)</name> <files>src/tools/dispatch.ts, test/tools/dispatch.test.ts</files> <read_first> - src/lifecycle/controller.ts lines 58-70 (LifecycleDeps dependency-object style to copy), line 74 (public events bus), line 104 (snapshot), line 115 (mutationGate), line 142 (create), lines 267-271 (explicit state check in probe) - src/lifecycle/mutation-gate.ts lines 23-41 (gateMutation and runGatedMutation; the wrapper adds no decision logic per D-63) - src/core/policy.ts lines 129 and 160-167 (decideMutation and checkExecPolicy) - src/contract/validate.ts line 263 (validate("tools", raw), the single schema gate) - src/tools/workspace.ts (from plan 04-04; handler signatures to call) - src/runtime/openshell.ts (from plan 04-03; buildExecArgv to call) - test/lifecycle/mutation-gate.test.ts lines 38-65 (CORE-05 title style) - test/support/fake-execution.ts lines 11-16 (call-log pattern for order assertions) </read_first> <behavior> - Test (TOOL-03): a recorded call order across fake stages (schema, state, lock, audit admission, execution, result audit) for each tool, proving schema runs before state and no later stage runs after a refusal - Test (TOOL-03): a schema failure has zero side effects; SHELL_NOT_ALLOWED precedes the state check; AUDIT_WRITE_FAILED at admission runs no execution (S-21) - Test (TOOL-03): exec refused unless active in both modes; write in degraded returns a warning in advisory mode and REALM_NOT_ACTIVE in required mode; status returns controller.snapshot() with displayStatus (D-27); retry by state (error calls create(boundRoot.realPath) per C-1; connecting, active, degraded return the snapshot; unbound returns REALM_UNBOUND; stopping returns REALM_STOPPING) - Test (TOOL-03): the dispatcher and the controller hold the same LockTable instance (D-96) - Test titles cite TOOL-03 </behavior> <action>Write the failing tests first per behavior, then implement ToolDispatcher in src/tools/dispatch.ts per D-88 as the only tool entry point with constructor dependencies sessionId, boundRoot (a PinnedRoot), policy, controller, locks (the same LockTable instance the controller uses, D-96), stateDir, runOpenShell, and clock, reading no process.env. Implement the D-89 stage order exactly: schema via validate("tools", raw) with SCHEMA_INVALID, then shell-form checkExecPolicy so SHELL_NOT_ALLOWED precedes the state check, then state (writes via controller.mutationGate(), exec only in active in both modes per D-109, read/list/status/retry ungated), then lock (write canonicalizes in write mode, runs the credential check, and pre-checks locks.isHeldBy with the authoritative acquire left inside lockedWrite), then audit admission (AUDIT_WRITE_FAILED refuses with no execution per S-21), then execution under the D-91 timeout, then result audit (an append failure after execution returns AUDIT_WRITE_FAILED without rollback per OQ-P4-07). Emit the D-90 admission and result lines with the phase marker from plan 04-04, write entries carrying byte count and sha256 never content, exec entries carrying redacted argv or script length and sha256. Status returns controller.snapshot() and displayStatus; retry follows D-95 with create(boundRoot.realPath) per C-1 and D-110. Subscribe to controller.events with the C-2 unsubscribe on teardown (D-97); add no controller method. Define TOOL_CALL_TIMEOUT_MS = 30000 as a constant in dispatch.ts following the OPENSHELL_CREATE_DEADLINE_MS numeric-separator style, never a policy key (D-91, D-101). (Implements D-88 dispatcher half, D-89, D-90, D-95, D-96, D-97, D-98 dispatcher half, D-109, D-110; OQ-P4-08 default for advisory writes after the wait; OQ-P4-09 default for the constant; OQ-P4-11 default: retry exposed here, surfaced in Phase 5.)</action> <acceptance_criteria> - test/tools/dispatch.test.ts asserts the recorded stage order for each tool, zero-side-effect schema failure, SHELL_NOT_ALLOWED before state, admission-failure with no execution, exec-active-only, advisory write warning, and retry by state - A refusal at any stage runs no later stage and no execution </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/tools/dispatch.test.js</automated> </verify> <done>Dispatcher pipeline green with ordered stages and mode-correct gates.</done> </task>

<task type="tdd"> <name>Task 3: Queue wait and timeout enforcement (D-91, TOOL-04)</name> <files>src/tools/dispatch.ts, test/tools/queue-timeout.test.ts</files> <read_first> - src/tools/dispatch.ts (from Task 2 of this plan; the queue and timeout seams to drive) - src/lifecycle/controller.ts line 400 (connectBridge: the withTimeout bounded-wait precedent) - src/core/timeout.ts line 6 (withTimeout races but does not cancel; the runner owns the kill) - test/lifecycle/startup-budget.test.ts (scripted delays) and test/core/timeout.test.ts (liveTimers) - test/support/fake-openshell.ts (from plan 04-05; the hang step the kill test uses) </read_first> <behavior> - Test (TOOL-04): a runner that never settles yields TOOL_TIMEOUT within the bound and the runner receives its kill - Test (TOOL-04): a call in connecting returns REALM_NOT_ACTIVE after toolQueueMs (default 2000) for exec, and proceeds at once when active is reached during the wait - Test (TOOL-04): the timer is cleared on settle; degraded does not wait and returns at once - Test titles cite TOOL-04 </behavior> <action>Write the failing tests first per behavior, then implement the D-91 queue and timeout in src/tools/dispatch.ts: when the state is connecting and the call needs active (exec, and write before the gate), wait on controller.events for a transition out of connecting bounded by policy.toolQueueMs (default 2000), clearing the timer on either outcome; a transition to active proceeds at once; on expiry exec returns REALM_NOT_ACTIVE (retryable) and write follows the mode gate (required returns REALM_NOT_ACTIVE, advisory proceeds with a warning per OQ-P4-08). Only connecting waits; degraded and error return at once (OQ-P4-08). Race every awaited stage through withTimeout with TOOL_TIMEOUT; exec passes timeoutMs to the OpenShell runner, which kills the process at expiry (F-P3-05), so a timed-out call never keeps running. File tools stay synchronous within their size bounds (maxBytes at most 1 MiB, MAX_LIST_ENTRIES 1000) with no timer wrapped around them (D-91). Retryable codes come from the frozen catalogue; no code is added (D-101). (Implements D-91 and TOOL-04; OQ-P4-09 default: the constant, not a policy key.)</action> <acceptance_criteria> - test/tools/queue-timeout.test.ts asserts TOOL_TIMEOUT with kill, connecting expiry and early-proceed, timer cleared on settle, and no wait in degraded - No file-tool path wraps a timer </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/tools/queue-timeout.test.js</automated> </verify> <done>Queue wait and timeout green with kill and timer hygiene.</done> </task>

<task type="auto"> <name>Task 4: Dispatcher routing proof in openshell-routing (D-99, D-104)</name> <files>test/runtime/openshell-routing.test.ts</files> <read_first> - test/runtime/openshell-routing.test.ts lines 21 and 34-60 (route names and fixture style) - test/tools/no-bypass.test.ts (from plan 04-04; the src/tools half of D-99 this extends) - src/tools/dispatch.ts (from Task 2 of this plan; the single entry point under test) </read_first> <action>Extend test/runtime/openshell-routing.test.ts per D-99 for the dispatcher half: assert the session-execution workload start goes only through the OpenShell argv builder, the libpod client key set still contains no exec method, AgentOS still starts only through the supervisor, and every tool call routes through ToolDispatcher with no path that skips a stage. Test titles cite TOOL-03. (Implements the routing half of D-99 alongside plan 04-04.)</action> <acceptance_criteria> - The routing test asserts single-entry dispatch plus the three Phase 3 no-bypass assertions - No live process, socket, or container is touched </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-routing.test.js</automated> </verify> <done>Dispatcher routing proof green with no bypass path.</done> </task>

</tasks>

<threat_model>

Trust Boundaries ​

BoundaryDescription
tool caller -> dispatcherUntrusted params cross into the pipeline; schema runs first with zero side effects on failure
queue timer -> stateA connecting state crosses into execution only through an observed transition or a bounded expiry

STRIDE Threat Register ​

Threat IDCategoryComponentSeverityDispositionMitigation Plan
T-04-09T (Tampering)src/tools/dispatch.tshighmitigateThe six-stage order is fixed with refusal-before-side-effects at every stage; the recorded call-order test proves schema runs first and no later stage runs after a refusal, so no call can skip validation, gating, or audit (D-89)
T-04-10D (Denial of Service)src/tools/dispatch.tshighmitigateEvery awaited stage races withTimeout against TOOL_TIMEOUT, exec passes timeoutMs to the killing runner, and the connecting wait is bounded by toolQueueMs with the timer cleared on settle and the listener unsubscribed, so no queued or hung call leaks a timer, a listener, or a process (D-91, C-2)
T-04-11R (Repudiation)src/tools/dispatch.tsmediummitigateAdmission is written before execution and the result after; a result-audit failure returns AUDIT_WRITE_FAILED without rollback and the caller treats the outcome as unknown (OQ-P4-07, D-89)
</threat_model>

<verification> npx tsc -p tsconfig.json && node --test dist/test/core/events.test.js dist/test/tools/dispatch.test.js dist/test/tools/queue-timeout.test.js dist/test/runtime/openshell-routing.test.js; grep the tool test files for TOOL-03 and TOOL-04 in test titles. </verification>

<success_criteria> Six-stage pipeline, queue wait, timeout with kill, and routing proof all green with one shared lock table. </success_criteria>

Artifacts this phase produces (contribution of this plan) ​

  • src/tools/dispatch.ts (new): exports ToolDispatcher, the only tool entry point; TOOL_CALL_TIMEOUT_MS = 30000 constant (D-88 to D-98 except workspace handlers).
  • EventBus.subscribe in src/core/events.ts (changed signature): returns an unsubscribe function (C-2).
  • test/tools/dispatch.test.ts (new): TOOL-03 stage-order, refusal, gate, and retry cases.
  • test/tools/queue-timeout.test.ts (new): TOOL-04 timeout, kill, queue, and timer cases.
  • test/runtime/openshell-routing.test.ts (extended): TOOL-03 dispatcher routing assertions.
  • test/core/events.test.ts (extended): unsubscribe case.

<output> Create .planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-06-SUMMARY.md when done </output>

AGPL-3.0-only. Copyright (C) 2026 Spectrum Web Co/swcstudiospace.