Gateway Module — `dispatch/`
1. Purpose
The dispatch module owns the full lifecycle of CyberShinobi agent dispatches: creation, milestone progress, completion, and the three background detectors that reconcile agent liveness against reality. It is the largest gateway module (~2,275 TS lines) and the only module that mounts two Express routers — /api/dispatch (lifecycle CRUD) and /api/timeline (SSE event stream). It also hosts a process-local timeline event bus that fans repository/detector writes out to SSE subscribers, and three independent setInterval-driven services: the stall detector, the filesystem-heartbeat daemon, and the dispatch garbage collector. Dispatch rows are the canonical post-dispatch record; agent output cache entries link to them by dispatch_id.
2. File Inventory
| File | Lines | Responsibility |
|---|---|---|
routes.ts | 441 | /api/dispatch router — 6 endpoints; FCF vault-filing + output-cache backfill on complete |
repository.ts | 461 | SQLite prepared statements; parseRow; milestone 3-tier fuzzy match; timeline emission |
fs-heartbeat.ts | 426 | Filesystem-heartbeat daemon — subagent JSONL mtime tracking; silent_but_live flip |
stall-detector.ts | 267 | Dual-signal stall sweep; synthesises result_summary; Telegram stall notify |
dispatch-gc.ts | 221 | Zombie garbage collector — auto-closes rows stale >1h |
timeline-routes.ts | 189 | /api/timeline router — global + dispatch-scoped SSE streams |
types.ts | 142 | Zod input schemas, DispatchRecord, DispatchRepository interface |
index.ts | 66 | createDispatchLayer factory — composes repo + 2 routers + 3 detectors |
eventBus.ts | 62 | Process-local TimelineEventBus singleton (Node EventEmitter) |
| Total | 2,275 |
3. Public API Surface
REST Endpoints — /api/dispatch (mount: index.ts:734)
| Method | Path | Auth | Body Schema | Response | Side Effects |
|---|---|---|---|---|---|
| POST | /api/dispatch/ | Bearer | ReportDispatchInputSchema | {dispatch_id, previous_dispatch_id} | INSERT agent_dispatch; snapshots MAX(id) pre-insert for N+1 check |
| PUT | /api/dispatch/:id/progress | Bearer | ReportProgressInputSchema (id from path) | {acknowledged, milestones_remaining} | UPDATE milestones; dispatched/silent_but_live → active; emits milestone. 404 if no dispatch, 409 if all milestones complete |
| PUT | /api/dispatch/:id/complete | Bearer | ReportCompleteInputSchema (id from path) | {acknowledged, total_duration_seconds, output_missing?, output_backfilled?, vault_path?} | UPDATE → completed; probes cache for output; if absent + output_content supplied, vault-files + writes output cache entry; fires Telegram completion notify via setImmediate |
| GET | /api/dispatch/active | Bearer | — | {data: DispatchRecord[]} | Reads dispatched/active/silent_but_live |
| GET | /api/dispatch/recent?limit= | Bearer | — | {data: DispatchRecord[]} | Reads completed/failed |
| GET | /api/dispatch/agent/:name?limit= | Bearer | — | {data: DispatchRecord[]} | Per-agent history |
| GET | /api/dispatch/:id | Bearer | — | {data: DispatchRecord} | Single dispatch; 404 if absent |
REST Endpoints — /api/timeline (mount: index.ts:735, SSE)
| Method | Path | Auth | Body | Response | Side Effects |
|---|---|---|---|---|---|
| GET | /api/timeline/stream?mission= | Bearer — see §9 | — | text/event-stream | Subscribes to timelineBus; forwards ALL events (optional mission filter); 15s keep-alive ping |
| GET | /api/timeline/stream/:dispatch_id | Bearer — see §9 | — | text/event-stream | Dispatch-scoped filter via shouldForward; 404 if dispatch absent; 15s ping |
MCP Tools
The dispatch write-path is fronted by MCP tools registered in mcp/index.ts (documented in the mcp module spec). Dispatch-relevant tools: report_dispatch/report_dispatch_raw, report_progress/report_progress_raw, report_complete/report_complete_raw (raw twins canonical per Raw Twin Discipline), plus the read-only list_dispatches. No MCP tool targets /api/timeline.
4. Internal API
Exported from index.ts via createDispatchLayer(db, psychicCacheRepo?, notificationService?) → DispatchLayer { repo, router, timelineRouter, stallDetector, fsHeartbeatDetector, dispatchGc }.
DispatchRepository (types.ts) methods: create, getMaxId, findById, findActive, findByMission, findByAgent, findRecent, updateProgress, complete, markStalled.
eventBus.ts exports the timelineBus singleton with emitTimelineEvent(event) and onTimelineEvent(listener) → unsubscribe. Consumed cross-module by psychic-cache/repository.ts (cache_write events) and activity/repository.ts (activity events).
fs-heartbeat.ts exposes sweepOnce(): SweepResult for verification probes. dispatch-gc.ts exposes sweep() for opportunistic invocation.
5. Background Services
Three setInterval services, started/stopped from index.ts:928-946 alongside the shared DB handle.
| Service | Trigger / Interval | Side Effects |
|---|---|---|
Stall detector (stall-detector.ts) | 60s (createStallDetector(db, 60_000, notif)) | Selects dispatched/active/silent_but_live rows where last_heartbeat < now-5min AND fs_last_activity < now-5min (D1222: NULL fs_last_activity is a non-vote, not auto-stall — closes false-positive class on VS Code dispatches the VPS daemon can't see). Marks stalled, synthesises result_summary, emits dispatch_stalled, fires Telegram stall notify. D1057: uses strftime ISO predicate (lexicographic T-separator comparison). |
FS-heartbeat daemon (fs-heartbeat.ts) | 60s | Resolves each in-flight dispatch's subagent JSONL (~/.claude/projects/*/*/subagents/agent-*.jsonl) by scanning .meta.json sidecars (agentType match) + regex Dispatch ID: N on first line; caches path on jsonl_path. Ticks fs_last_activity from JSONL mtime. Flips active→silent_but_live when MCP silent but fs ticking; recovers silent_but_live→active when both fresh. Emits dispatch_silent_but_live. Per-row (not transactional) — I/O held outside write lock. |
Dispatch GC (dispatch-gc.ts) | 1h (3_600_000) + opportunistic sweep on every psychic-cache write (callback wired at index.ts:426/431) | Selects stalled OR dispatched/active with last_heartbeat < now-1h; closes as completed, confidence='low', domain_affinity=0, [GC] Auto-closed summary; emits dispatch_gc_closed; fires Telegram completion notify. D1310: restored strftime predicate (silent-no-op fix, D1057 sibling). |
Timeline emission discipline (all three + repository): events emitted via setImmediate after the DB write commits, so SSE subscribers can safely re-read the row.
6. Data Contracts
Zod schemas in types.ts:
ReportDispatchInputSchema:agent_name(min 1),task_description(min 1),mission_namespace?,milestones(string[], min 1),retry_of?(positive int),plan_slug?,channel?(vscode|hermes, defaultvscode),session_id?.ReportProgressInputSchema:dispatch_id(positive int),milestone_name(min 1),notes?.ReportCompleteInputSchema:dispatch_id(positive int),result_summary(min 1),confidence(high|medium|low),domain_affinity(boolean),output_content?.
DispatchRecord (readonly interface) carries 22 fields incl. status (dispatched|active|completed|failed|stalled|silent_but_live), milestones[], retry_of, plan_slug, fs_last_activity, jsonl_path, completion_notified_at, hang_notified_at, channel, session_id. TimelineEvent (eventBus.ts): kind (7 kinds), dispatch_id (nullable), agent_name, mission_namespace, created_at, detail?.
7. Dependencies
- Gateway modules consumed:
shared/zod-error.js(formatZodError);psychic-cache/types.js(PsychicCacheRepository— optional, for output-missing probe + backfill);notifications/notification-service.js(NotificationService— optional, Telegram).eventBusis consumed BY psychic-cache and activity (reverse dependency). - External libraries:
better-sqlite3,express,zod, Nodefs/os/path/events. - Environment variables:
VAULT_ROOT(falls back to the hard-coded Google Drive SageLibrary path);HOME(subagent JSONL root resolution).
8. Test Coverage
| Layer | File | Notes |
|---|---|---|
| Routes | test/dispatch-routes.test.ts | Endpoint contract, 400/404/409 paths, N+1 echo |
| Repository | test/dispatch-repository.test.ts | parseRow legacy coercion, milestone 3-tier match, status transitions |
| Detector | test/stall-detector.test.ts | Dual-signal candidate set, NULL non-vote, summary synthesis |
| Detector | test/fs-heartbeat.test.ts | JSONL discovery, tick/flip/recover paths, sweepOnce |
| Detector | test/dispatch-gc.test.ts | Zombie selection, close semantics |
| SSE | test/timeline-routes.test.ts | shouldForward filter logic |
9. Known Limitations
dispatch_gc_closedevents are NOT forwarded to SSE subscribers.eventBus.tsdeclares the kind anddispatch-gc.tsemits it, but the dispatch-scopedshouldForwardintimeline-routes.tsonly whitelistsmilestone,dispatch_complete,dispatch_stalled,dispatch_silent_but_live—dispatch_gc_closedandcache_write/activity(for the scoped route) fall through toreturn false. GC closures are invisible on the dispatch-scoped timeline stream. [DIVERGENCE — MEDIUM] Confirmed by source read at HEAD 5c9a304.- Timeline SSE and post-AK-415 Bearer gate collide.
/api/timeline/stream*areGETunder a now-Bearer-required prefix, but expose no?token=query-param handshake (unlike/api/sessions/events, which does). A browserEventSourcecannot set anAuthorizationheader, so these streams are unreachable from an unauthenticated EventSource — the dashboard must proxy with a Bearer token or the stream 401s. [DIVERGENCE — worth gray-fox/genji review] - FS-heartbeat daemon runs on the gateway (VPS) host; it cannot stat operator-Mac subagent JSONLs for VS Code channel dispatches (D1220 cross-host evidence) — hence the D1222 NULL non-vote fix.
silent_but_liveis effectively a Hermes-channel-only signal until Path A wiring lands. markStalled(ids)on the repository interface is largely vestigial — the stall detector writesstalleddirectly via its own prepared statement;markStalledFnin the repo emits events but the primary sweep path does not call it.
10. Change History
| Date | Dispatch | Summary |
|---|---|---|
| 2026-07-04 | 2296 | Module spec authored (Phase 4 §5.2), smoke-clone-2, HEAD 5c9a304 |
| — | 16 | N+1 monotonicity echo (previous_dispatch_id, getMaxId); plan_slug |
| — | 40 | fs-heartbeat daemon, silent_but_live state, dual-signal composition |
| — | 52 | Output-missing probe pivot to dispatch_id metadata key; server-side backfill |
| — | 70 | Timeline SSE (timelineBus, timeline-routes), milestone status_changed_at |
| — | 77 | retry_of retry-chain link |
| — | 371 | Telegram completion + stall notifications |
| — | 392 | Dispatch GC, channel attribution |
| — | 399 | session_id cross-channel awareness |
| — | 657 | Global /api/timeline/stream route |
| — | 1057 / 1222 / 1310 | strftime predicate restores; NULL fs_last_activity non-vote |