fix: apply the review items for #690 at merge

Boot prune keeps the inboxes of sessions on the reboot-restore offer (their records are already pruned and a rebuild reuses the id), with a case in test/agent-inbox-server-wiring.test.ts.
docs/wiki: post, inbox and ack in the codeman agent command block of Driving-Codeman-From-An-Agent, and "Keep agent inbox across restarts" in Settings-Reference.
codeman agent inbox prints message text through terminalSafeText (ANSI stripped, other C0, DEL and C1 controls shown as \xHH); --json stays as stored.
Shutdown flushes the inbox snapshot again after app.close(), for a post that lands after the first flush.
POST /api/cleanup-state drops the inboxes of the records it removes.
InboxPostSchema derives its fence from MAX_MESSAGE_BYTES instead of the literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 14:15:49 +02:00
1 parent a1a33cf050
commit 9ba24a02df
11 files changed
+150 -10

No files matched your search

+34 -2
View File
@@ -4,7 +4,9 @@
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
* keep it, because a detached session comes back.
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
* every session state.json still holds. A failed restore leaves all mail alone.
* every session state.json still holds plus every session on the reboot-restore
* offer (its record is already pruned, and a rebuild reuses the id). A failed
* restore leaves all mail alone.
*
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
* like tab-layout-restore-gate.test.ts.
@@ -12,6 +14,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { agentInbox } from '../src/web/agent-inbox.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import type { SessionState } from '../src/types.js';
type CleanupServer = {
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
@@ -48,9 +53,23 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
return server;
}
/** A reboot-restore offer entry; the prune reads only its `sessionId`. */
function offerEntry(sessionId: string): RebootRestoreEntry {
return {
sessionId,
workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState,
};
}
describe('agent inbox server wiring', () => {
beforeEach(() => agentInbox.resetForTests());
afterEach(() => agentInbox.resetForTests());
afterEach(() => {
agentInbox.resetForTests();
rebootRestoreRegistry.reset();
});
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
const server = bareServer();
@@ -77,4 +96,17 @@ describe('agent inbox server wiring', () => {
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => {
// After a host reboot the dead session is in neither `this.sessions` nor state.json
// (cleanupStaleSessions already pruned its record), only in the offer, and the
// rebuild reuses its id, so its mail must still be there when it comes back.
const server = bareServer();
rebootRestoreRegistry.set([offerEntry('on-offer')]);
for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']);
expect(agentInbox.pendingCount('on-offer')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
});
+30
View File
@@ -40,6 +40,7 @@ import {
registerAgentCommands,
resolveAgentContext,
stripAnsi,
terminalSafeText,
waitExitCode,
type AgentContext,
type AgentDeps,
@@ -707,6 +708,35 @@ describe('agent inbox verbs', () => {
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox prints message text with escape sequences and control characters neutralized', async () => {
const hostile = {
...MSG,
text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend',
};
const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
// out[0] is the `── <id> from …` header (styled by the palette), out[1] the text.
expect(deps.out).toHaveLength(2);
expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/);
expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend');
});
it('terminalSafeText keeps newline and tab and escapes every other control', () => {
expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc');
expect(terminalSafeText('a\r\nb')).toBe('a\nb');
expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f');
expect(terminalSafeText('ünïcödé ✓')).toBe('ünïcödé ✓');
});
it('inbox --json hands the text out as stored', async () => {
const raw = { ...MSG, text: 'red \x1b[31mtext' };
const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true);
await agentInboxRead(deps, {});
expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe(
'red \x1b[31mtext'
);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
+24
View File
@@ -11,6 +11,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { agentInbox } from '../../src/web/agent-inbox.js';
// ── Mocks ──────────────────────────────────────────────────────────
@@ -343,6 +344,29 @@ describe('system-routes', () => {
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
});
it('drops the agent inbox of every record it removes, and only those', async () => {
agentInbox.resetForTests();
agentInbox.post('stale-detached', null, 'mail for a detached session');
agentInbox.post('still-kept', null, 'mail for a session that stays');
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) =>
action(new Set(['stale-detached']))
);
vi.mocked(harness.ctx.store.cleanupSessionsByIds).mockReturnValueOnce({
count: 1,
cleaned: [{ id: 'stale-detached', name: 'w9-gone' }],
});
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(1);
expect(agentInbox.pendingCount('stale-detached')).toBe(0);
expect(agentInbox.pendingCount('still-kept')).toBe(1);
} finally {
agentInbox.resetForTests();
}
});
});
// ========== GET /api/subagents ==========