LISAOS // DOCS
GATEWAY // DISPATCH

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

FileLinesResponsibility
routes.ts441/api/dispatch router — 6 endpoints; FCF vault-filing + output-cache backfill on complete
repository.ts461SQLite prepared statements; parseRow; milestone 3-tier fuzzy match; timeline emission
fs-heartbeat.ts426Filesystem-heartbeat daemon — subagent JSONL mtime tracking; silent_but_live flip
stall-detector.ts267Dual-signal stall sweep; synthesises result_summary; Telegram stall notify
dispatch-gc.ts221Zombie garbage collector — auto-closes rows stale >1h
timeline-routes.ts189/api/timeline router — global + dispatch-scoped SSE streams
types.ts142Zod input schemas, DispatchRecord, DispatchRepository interface
index.ts66createDispatchLayer factory — composes repo + 2 routers + 3 detectors
eventBus.ts62Process-local TimelineEventBus singleton (Node EventEmitter)
Total2,275

3. Public API Surface

REST Endpoints — /api/dispatch (mount: index.ts:734)

MethodPathAuthBody SchemaResponseSide Effects
POST/api/dispatch/BearerReportDispatchInputSchema{dispatch_id, previous_dispatch_id}INSERT agent_dispatch; snapshots MAX(id) pre-insert for N+1 check
PUT/api/dispatch/:id/progressBearerReportProgressInputSchema (id from path){acknowledged, milestones_remaining}UPDATE milestones; dispatched/silent_but_liveactive; emits milestone. 404 if no dispatch, 409 if all milestones complete
PUT/api/dispatch/:id/completeBearerReportCompleteInputSchema (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/activeBearer{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/:idBearer{data: DispatchRecord}Single dispatch; 404 if absent

REST Endpoints — /api/timeline (mount: index.ts:735, SSE)

MethodPathAuthBodyResponseSide Effects
GET/api/timeline/stream?mission=Bearer — see §9text/event-streamSubscribes to timelineBus; forwards ALL events (optional mission filter); 15s keep-alive ping
GET/api/timeline/stream/:dispatch_idBearer — see §9text/event-streamDispatch-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.

ServiceTrigger / IntervalSide 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)60sResolves 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 activesilent_but_live when MCP silent but fs ticking; recovers silent_but_liveactive 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, default vscode), 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). eventBus is consumed BY psychic-cache and activity (reverse dependency).
  • External libraries: better-sqlite3, express, zod, Node fs/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

LayerFileNotes
Routestest/dispatch-routes.test.tsEndpoint contract, 400/404/409 paths, N+1 echo
Repositorytest/dispatch-repository.test.tsparseRow legacy coercion, milestone 3-tier match, status transitions
Detectortest/stall-detector.test.tsDual-signal candidate set, NULL non-vote, summary synthesis
Detectortest/fs-heartbeat.test.tsJSONL discovery, tick/flip/recover paths, sweepOnce
Detectortest/dispatch-gc.test.tsZombie selection, close semantics
SSEtest/timeline-routes.test.tsshouldForward filter logic

9. Known Limitations

  • dispatch_gc_closed events are NOT forwarded to SSE subscribers. eventBus.ts declares the kind and dispatch-gc.ts emits it, but the dispatch-scoped shouldForward in timeline-routes.ts only whitelists milestone, dispatch_complete, dispatch_stalled, dispatch_silent_but_livedispatch_gc_closed and cache_write/activity (for the scoped route) fall through to return 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* are GET under a now-Bearer-required prefix, but expose no ?token= query-param handshake (unlike /api/sessions/events, which does). A browser EventSource cannot set an Authorization header, 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_live is effectively a Hermes-channel-only signal until Path A wiring lands.
  • markStalled(ids) on the repository interface is largely vestigial — the stall detector writes stalled directly via its own prepared statement; markStalledFn in the repo emits events but the primary sweep path does not call it.

10. Change History

DateDispatchSummary
2026-07-042296Module spec authored (Phase 4 §5.2), smoke-clone-2, HEAD 5c9a304
16N+1 monotonicity echo (previous_dispatch_id, getMaxId); plan_slug
40fs-heartbeat daemon, silent_but_live state, dual-signal composition
52Output-missing probe pivot to dispatch_id metadata key; server-side backfill
70Timeline SSE (timelineBus, timeline-routes), milestone status_changed_at
77retry_of retry-chain link
371Telegram completion + stall notifications
392Dispatch GC, channel attribution
399session_id cross-channel awareness
657Global /api/timeline/stream route
1057 / 1222 / 1310strftime predicate restores; NULL fs_last_activity non-vote