# Main-Loop Offload Architecture

CTM is a **single-threaded** Node.js HTTP/WS server. better-sqlite3 is synchronous, and the DB
lives on Dropbox/CloudStorage (cold pages are slow). **Any synchronous span > ~50 ms on the main
event loop is a visible typing freeze.** This doc is the standing convention for keeping
non-essential work off the loop, plus an index of what's already offloaded/cached.

## The rule: READ → DECIDE → WRITE

Every heavy job/handler is decomposed so only trivial glue touches the main loop:

1. **READ (off-thread, fixed op)** — dispatch the `fs`/parse/`SELECT` work to a *registered* op on
   the **read-pool** (`lib/read-pool-client.js`, reads) or **db-owner worker**
   (`lib/db-owner-worker-client.js`, writes). Payload = small primitives/arrays; return small.
2. **DECIDE (trivial main)** — apply the small result to the in-memory `sessions` Map / fire small
   single-row writes.
3. **WRITE (db-owner)** — batched writes go through the single serial db-owner worker, never a 2nd writer.

**The single most important rule:** for periodic *safety-net* jobs, the timeout/failure fallback is
**"skip this tick" (return null), NOT "run the heavy work on main"** — running the fallback on main
*is* the freeze. Only correctness-critical one-shots keep a true main-thread fallback.

### `pool:'io'` ≠ off-thread
The scheduler's `pool` option is **only a concurrency limiter** — a job's `run()` body still runs on
the main loop. To get a job's work off the loop it must *dispatch* to the read-pool / db-owner.

### Observability
`lib/perf-tracker.js` drops the op tag at the first `await`, so a heavy sync chunk *after* an await
logs as `(unknown)` in `[perf-lag]`. Wrap such chunks with `runSync('name', fn)` /
`runSyncProbed('name', fn, {context})`. Scheduler jobs are auto-labeled `scheduler:<name>`. Check
`GET /api/diagnostics/event-loop` (`busyPct`, `maxBlockMs`, `unknownSharePct`, `topOps`).

### Caching the unavoidable
When a per-session/per-request DB read or `fs.stat` can't be moved off-thread, **memoize it with a
short TTL** keyed on a cheap freshness signal — but never cache *volatile* fields (attention /
waiting-for-input / live-status / pending-permission) that gate an approval card.

## What MUST stay on main
The `sessions` Map (node-pty handles), WebSocket send + `broadcastSessionList` fan-out,
`sessionEvents` EventEmitter. All writes route through the single serial db-owner worker.

## Index of offloads / caches (as of 2026-06-07)

| Area | Mechanism | Where |
|---|---|---|
| model-sync (claude JSONL scan + Codex state-DB) | read-pool op `resolveAgentModels` | `lib/coding-agent-models.js`, `workers/read-pool-worker.js` |
| session-metadata-sync stat loop | read-pool op `statFiles` | `lib/session-jobs.js` |
| prompt-index compute | read-pool op `computeUserPromptIndex` + **persisted** `prompt-index-cache.json` (warm cold-boot) | `lib/session-prompt-index.js`, `lib/prompt-index-disk-cache.js` |
| conversation blob parse | read-pool op `parseConversationBlob` (SWR; cold-load also off-thread, `CTM_CONV_BLOB_COLD_OFFLOAD`) | server.js `_parseSessionConversationMessages` |
| recent-sessions list / getAllSessionsData | read-pool op + **persisted** `recent-sessions-cache.json` | server.js |
| broadcast per-session file-info + agent-sessions | 2s TTL cache (`CTM_BROADCAST_PAYLOAD_CACHE`) — volatile fields stay fresh | server.js `_cachedActiveSessionFileInfo` / `_activeAgentSessionsForPayload` |
| `safeRealpath` (worktree/binding) | memoized (TTL) | `lib/session-workspace-binding.js` |
| worktree git fan-out (`listRichWorktrees`) | coalescing TTL cache + read-pool | server.js `_cachedRichWorktrees` |
| `getStarredSessionIds` (apiRecentSessions) | 30s cache, bust on star (`CTM_STARRED_CACHE_TTL_MS`) | server.js `_getCachedStarredSessionIds` |
| prompt source-ids (`_messageIndexPromptSourceIds`) | 10s per-session cache (`CTM_PROMPT_SOURCE_IDS_TTL_MS`) | server.js |
| session-relink readdir | 1.5s per-dir cache (`CTM_RELINK_READDIR_TTL_MS`) | `lib/session-jobs.js` |
| conversation import / message-backfill / cursor-import / integrity-recover / scrollback / image save | db-owner worker ops | `workers/db-owner-worker.js` |

## Regression guards
- `tests/restart-loop-responsive.test.js` — boots + hard-restarts a seeded instance, asserts
  `/api/health` p95 < 120 ms during restore (runs in `npm test`).
- `tests/event-loop-soak.js` — opt-in keystroke-echo + event-loop-budget gate against a dev port:
  `CTM_TEST_PORT=$DEV_CTM_PORT node tests/event-loop-soak.js` (asserts echo p95 + no op dominates).

**Before adding a new per-session/per-request/per-broadcast/per-tick code path**, confirm it does no
unguarded synchronous `fs`/DB/large-`JSON` work on main — offload it (READ→DECIDE→WRITE) or cache it.
