diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index eea27701..f9dd62d8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -700,7 +700,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock. -⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. +⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. ### Voice dictation via Claude diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 45a4d24f..7dc35a47 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -803,9 +803,13 @@ export function registerSystemRoutes( const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); - // A detached session kept its agent inbox; removing its record is the delete, - // so the mail goes with it (as on the persisted-only DELETE /api/sessions/:id). - agentInbox.drop(s.id); + // A detached session kept its agent inbox. Its record going is the delete only + // when no tmux session stands behind it: a detached pane the multiplexer still + // lists is re-adopted under the same id at the next boot (restoreMuxSessions, + // with or without a state.json record), and its mail must come back with it. + // Keeping a pane that died while detached errs toward the 12 h TTL and the boot + // prune, the same trade the boot keep set makes. + if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id); } return { cleanedSessions: result.count }; }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 3ac9baca..f96ed393 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -367,6 +367,37 @@ describe('system-routes', () => { agentInbox.resetForTests(); } }); + + it('keeps the agent inbox of a removed record whose tmux session the multiplexer still lists', async () => { + // Detach keeps the tmux session and its mux entry; the next boot re-adopts it + // under the same id even without a state.json record, so its mail must survive. + agentInbox.resetForTests(); + agentInbox.post('stale-detached', null, 'mail for a session nothing can bring back'); + agentInbox.post('pane-still-alive', null, 'mail for a detached pane that boot re-adopts'); + harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn( + async (_activeIds: Set, action: (ids: ReadonlySet) => unknown) => + action(new Set(['stale-detached', 'pane-still-alive'])) + ); + vi.mocked(harness.ctx.store.cleanupSessionsByIds).mockReturnValueOnce({ + count: 2, + cleaned: [ + { id: 'stale-detached', name: 'w9-gone' }, + { id: 'pane-still-alive', name: 'w8-detached' }, + ], + }); + // The harness is rebuilt per test, so this mock does not leak into the next one. + vi.mocked(harness.ctx.mux.getSession).mockImplementation(((id: string) => + id === 'pane-still-alive' ? { sessionId: id } : null) as unknown as typeof harness.ctx.mux.getSession); + try { + const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).cleanedSessions).toBe(2); + expect(agentInbox.pendingCount('stale-detached')).toBe(0); + expect(agentInbox.pendingCount('pane-still-alive')).toBe(1); + } finally { + agentInbox.resetForTests(); + } + }); }); // ========== GET /api/subagents ==========