From 7ce9922cde694e4d2a354df6c2fd64dca3606d3c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 30 Apr 2026 12:54:42 +0200 Subject: [PATCH 1/7] fix(host-sweep): clear orphan processing_ack on kill to prevent claim-stuck loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host kills a container (absolute-ceiling, claim-stuck, or crashed), resetStuckProcessingRows reset messages_in but left orphan rows in processing_ack. The next sweep tick spawned a fresh container and, on the same tick, ran enforceRunningContainerSla against outbound.db that still contained the previous container's claim with a hours-old status_changed timestamp — instant kill-claim, before the agent-runner could open outbound.db to run its own clearStaleProcessingAcks(). Loop until tries hit MAX_TRIES. Add deleteOrphanProcessingClaims() in session-db and call it at the end of resetStuckProcessingRows. Safe to write outbound.db here because the host only enters this path after killContainer (or when no container is running). Tests in host-sweep.test.ts cover the helper plus the regression: orphan claim from a 2h-old kill is now removed atomically with the messages_in reset, so the next sweep tick sees an empty claims list and the freshly respawned container survives long enough to start its agent-runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/session-db.ts | 13 ++++ src/host-sweep.test.ts | 150 ++++++++++++++++++++++++++++++++++++++++- src/host-sweep.ts | 21 ++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 48e9297..ca15276 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -180,6 +180,19 @@ export function getProcessingClaims(outDb: Database.Database): ProcessingClaim[] .all() as ProcessingClaim[]; } +/** + * Delete orphan 'processing' rows. Called by the host after killing a + * container so the leftover claim doesn't trip claim-stuck on the next sweep + * tick (which would kill the freshly respawned container before its + * agent-runner can run its own startup cleanup). + * + * Safe because the host only writes to outbound.db when no container is + * running (we just killed it). Returns the number of rows deleted. + */ +export function deleteOrphanProcessingClaims(outDb: Database.Database): number { + return outDb.prepare("DELETE FROM processing_ack WHERE status = 'processing'").run().changes; +} + export interface ContainerState { current_tool: string | null; tool_declared_timeout_ms: number | null; diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index eefcc8a..bd2e233 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -3,9 +3,17 @@ * ACTION-ITEMS item 9. Lives on the pure helper `decideStuckAction` so we * don't have to mock the filesystem or the container runner. */ +import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; -import { ABSOLUTE_CEILING_MS, CLAIM_STUCK_MS, decideStuckAction } from './host-sweep.js'; +import { deleteOrphanProcessingClaims, getProcessingClaims } from './db/session-db.js'; +import { + ABSOLUTE_CEILING_MS, + CLAIM_STUCK_MS, + _resetStuckProcessingRowsForTesting, + decideStuckAction, +} from './host-sweep.js'; +import type { Session } from './types.js'; const BASE = Date.parse('2026-04-20T12:00:00.000Z'); @@ -144,3 +152,143 @@ describe('decideStuckAction', () => { expect(res.action).toBe('ok'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Orphan claim cleanup (regression test for the SIGKILL → claim-stuck loop) +// +// Repro of the production bug seen 2026-04-30: container A claimed message M +// (writes processing_ack row with status='processing'). Host kills A by +// absolute-ceiling. Old behavior: messages_in.M was reset to pending but +// processing_ack.M survived. On the next sweep tick, wakeContainer spawned B, +// the same-tick SLA check saw M's stale claim age (hours), and SIGKILL'd B +// before agent-runner could run clearStaleProcessingAcks(). Loop. The fix +// deletes processing_ack 'processing' rows when the host kills/cleans the +// container, breaking the loop atomically. +// ───────────────────────────────────────────────────────────────────────────── + +function makeSessionDbs(): { inDb: Database.Database; outDb: Database.Database } { + const inDb = new Database(':memory:'); + inDb.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + series_id TEXT, + tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + `); + const outDb = new Database(':memory:'); + outDb.exec(` + CREATE TABLE processing_ack ( + message_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + status_changed TEXT NOT NULL + ); + `); + return { inDb, outDb }; +} + +function fakeSession(): Session { + return { + id: 'sess-test', + agent_group_id: 'ag-test', + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: new Date().toISOString(), + }; +} + +describe('deleteOrphanProcessingClaims', () => { + it('removes only processing rows, leaves completed/failed alone', () => { + const { outDb } = makeSessionDbs(); + const ts = new Date().toISOString(); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-proc', 'processing', ?)").run(ts); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-done', 'completed', ?)").run(ts); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-fail', 'failed', ?)").run(ts); + + const removed = deleteOrphanProcessingClaims(outDb); + + expect(removed).toBe(1); + const remaining = outDb.prepare('SELECT message_id, status FROM processing_ack ORDER BY message_id').all(); + expect(remaining).toEqual([ + { message_id: 'm-done', status: 'completed' }, + { message_id: 'm-fail', status: 'failed' }, + ]); + }); + + it('returns 0 when nothing to clear', () => { + const { outDb } = makeSessionDbs(); + expect(deleteOrphanProcessingClaims(outDb)).toBe(0); + }); +}); + +describe('resetStuckProcessingRows — orphan claim cleanup', () => { + it('deletes orphan processing_ack rows so next sweep tick does not see them', () => { + const { inDb, outDb } = makeSessionDbs(); + const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2h ago + + // messages_in.status stays 'pending' during processing — only the + // container's processing_ack moves to 'processing'. See + // src/db/schema.ts header comment on processing_ack. + inDb + .prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES ('m-1', 1, 'chat', ?, 'pending', '{}')", + ) + .run(claimedAt); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-1', 'processing', ?)").run(claimedAt); + + // Sanity: the orphan claim is what would trip claim-stuck. + expect(getProcessingClaims(outDb)).toHaveLength(1); + + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'absolute-ceiling'); + + // Regression assertion: orphan claim is gone — next sweep tick will see + // an empty claims list and not kill the freshly respawned container. + expect(getProcessingClaims(outDb)).toEqual([]); + + // And the message itself was rescheduled with backoff (existing behavior). + const row = inDb.prepare('SELECT status, tries, process_after FROM messages_in WHERE id = ?').get('m-1') as { + status: string; + tries: number; + process_after: string | null; + }; + expect(row.status).toBe('pending'); + expect(row.tries).toBe(1); + expect(row.process_after).not.toBeNull(); + }); + + it('still clears orphan claims even when the inbound message has already been retried (skip path)', () => { + // Edge case: the inbound row was already rescheduled (process_after in + // future), so the per-message retry loop skips it. The orphan in + // processing_ack must still be removed — otherwise the bug remains. + const { inDb, outDb } = makeSessionDbs(); + const claimedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + + inDb + .prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, tries, content) VALUES ('m-2', 2, 'chat', ?, 'pending', ?, 1, '{}')", + ) + .run(claimedAt, future); + outDb.prepare("INSERT INTO processing_ack VALUES ('m-2', 'processing', ?)").run(claimedAt); + + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'claim-stuck'); + + expect(getProcessingClaims(outDb)).toEqual([]); + const row = inDb.prepare('SELECT tries FROM messages_in WHERE id = ?').get('m-2') as { tries: number }; + expect(row.tries).toBe(1); // not bumped, the skip path held + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 69a4d61..30cdc64 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -33,6 +33,7 @@ import { getActiveSessions } from './db/sessions.js'; import { getAgentGroup } from './db/agent-groups.js'; import { countDueMessages, + deleteOrphanProcessingClaims, getContainerState, getMessageForRetry, getProcessingClaims, @@ -249,6 +250,15 @@ function enforceRunningContainerSla( resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck'); } +export function _resetStuckProcessingRowsForTesting( + inDb: Database.Database, + outDb: Database.Database, + session: Session, + reason: string, +): void { + resetStuckProcessingRows(inDb, outDb, session, reason); +} + function resetStuckProcessingRows( inDb: Database.Database, outDb: Database.Database, @@ -285,4 +295,15 @@ function resetStuckProcessingRows( }); } } + + // Drop the orphan 'processing' rows. Without this, the next sweep tick + // would re-read them, see the old status_changed timestamp, conclude the + // freshly respawned container is stuck, and SIGKILL it before its + // agent-runner has a chance to run clearStaleProcessingAcks() on startup. + // We're safe to write outbound.db here because we just killed the container + // that owned it (or it crashed and left no writer behind). + const cleared = deleteOrphanProcessingClaims(outDb); + if (cleared > 0) { + log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); + } } From ccfdf2dd7576603ba9832a5eeed430ee43e2e35a Mon Sep 17 00:00:00 2001 From: Claw <728255-_ky@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 15:06:01 -0400 Subject: [PATCH 2/7] fix(agent-runner): open inbound.db fresh per messages_in read Cached singleton can return stale rows on virtiofs/NFS mounts, causing follow-up messages to silently never be polled. Add openInboundDb() with mmap_size=0 and switch the three messages_in readers to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 28 +++++++- container/agent-runner/src/db/messages-in.ts | 75 ++++++++++++-------- 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3f0e73b..3ca44a8 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -28,11 +28,37 @@ let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; -/** Inbound DB — container opens read-only (host is the sole writer). */ +/** + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * + * Use this (not getInboundDb) for readers that need to see host-written rows + * promptly — e.g. messages_in polling. Caller must .close() the returned + * connection (try/finally). + * + * Needed for mounts where host writes don't reliably invalidate + * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple + * Container), NFS. + * + * Cost is microseconds per query, so safe for universal use. + */ +export function openInboundDb(): Database { + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** + * Inbound DB — long-lived singleton, OK for tables the host writes once + * at spawn and never again (destinations, session_routing). For + * messages_in polling — where the host writes continuously and a stale + * view causes the pollHandle hang — use `openInboundDb()` instead. + */ export function getInboundDb(): Database { if (!_inbound) { _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); _inbound.exec('PRAGMA busy_timeout = 5000'); + _inbound.exec('PRAGMA mmap_size = 0'); } return _inbound; } diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 4ecf818..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -8,7 +8,7 @@ * processing_ack. The host reads processing_ack to sync message lifecycle. */ import { getConfig } from '../config.js'; -import { getInboundDb, getOutboundDb } from './connection.js'; +import { openInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { id: string; @@ -50,31 +50,35 @@ function getMaxMessagesPerPrompt(): number { * trigger=1 separately (see src/db/session-db.ts). */ export function getPendingMessages(): MessageInRow[] { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const pending = inbound - .prepare( - `SELECT * FROM messages_in - WHERE status = 'pending' - AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) - ORDER BY seq DESC - LIMIT ?`, - ) - .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + try { + const pending = inbound + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + ORDER BY seq DESC + LIMIT ?`, + ) + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; - if (pending.length === 0) return []; + if (pending.length === 0) return []; - // Filter out messages already acknowledged in outbound.db - const ackedIds = new Set( - (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( - (r) => r.message_id, - ), - ); + // Filter out messages already acknowledged in outbound.db + const ackedIds = new Set( + (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( + (r) => r.message_id, + ), + ); - // Reverse: we fetched DESC to take the most recent N, but the agent - // should see them in chronological order (oldest first). - return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + // Reverse: we fetched DESC to take the most recent N, but the agent + // should see them in chronological order (oldest first). + return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + } finally { + inbound.close(); + } } /** Mark messages as processing — writes to processing_ack in outbound.db. */ @@ -112,7 +116,12 @@ export function markFailed(id: string): void { /** Get a message by ID (read from inbound.db). */ export function getMessageIn(id: string): MessageInRow | undefined { - return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + const inbound = openInboundDb(); + try { + return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + } finally { + inbound.close(); + } } /** @@ -120,19 +129,23 @@ export function getMessageIn(id: string): MessageInRow | undefined { * Reads from inbound.db, checks processing_ack to skip already-handled responses. */ export function findQuestionResponse(questionId: string): MessageInRow | undefined { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; - if (!response) return undefined; + if (!response) return undefined; - // Check it hasn't been acked already - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; + // Check it hasn't been acked already + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; - return response; + return response; + } finally { + inbound.close(); + } } From a590fbd830e79af7822a99976e8c29ac9ebaa5cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:19 +0000 Subject: [PATCH 3/7] chore: bump version to 2.0.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 146af58..e8c1dc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.23", + "version": "2.0.24", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 663d9a409190bd1e79fa505fae04644dcdab2429 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:23 +0000 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20update=20token=20count=20to=20139k?= =?UTF-8?q?=20tokens=20=C2=B7=2070%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d6afa67..8f04fa8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 139k tokens, 69% of context window + + 139k tokens, 70% of context window @@ -10,7 +10,7 @@ - + From a71d2a4e2c7d7bc477ebb5fc25c8e37415cf37d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 16:03:16 +0000 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20update=20token=20count=20to=20140k?= =?UTF-8?q?=20tokens=20=C2=B7=2070%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 8f04fa8..d0bd6da 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 139k tokens, 70% of context window + + 140k tokens, 70% of context window @@ -15,8 +15,8 @@ tokens - - 139k + + 140k From 897b77029659220a247aa8ca16a89001126edb98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 16:03:18 +0000 Subject: [PATCH 6/7] chore: bump version to 2.0.25 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e8c1dc9..d3ccf04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.24", + "version": "2.0.25", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 1b08b58fcd80a2019b5cf012904798901b087fc8 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 1 May 2026 17:03:02 +0000 Subject: [PATCH 7/7] setup: drop redundant agent ping; harden auth detection and OAuth paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify: remove the CLI ping; cli-agent step earlier in setup already proved the round-trip works, and the test agent gets cleaned up before verify runs — so the ping was guaranteed to fail on installs that wired a messaging app instead of staying CLI-only. Status now collapses to service-running ∧ credentials ∧ ≥1 wired group. - agent-ping: catch Claude Code's "Please run /login" / "Not logged in" / "Invalid API key" banners so a successfully-spawned agent that has no credentials no longer reports as 'ok'. - auth paste: validate the full sk-ant-oat…AA shape; when the cleaned input is under 90 chars, surface a truncation-specific hint pointing at terminal wrap as the likely cause. Strip internal whitespace at both validate and assignment so multi-line pastes that survive clack also go through cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 25 +++++++++--------- setup/lib/agent-ping.test.ts | 9 +++++++ setup/lib/agent-ping.ts | 5 +++- setup/verify.test.ts | 51 ++++++++++++++---------------------- setup/verify.ts | 38 +++++++-------------------- 5 files changed, 54 insertions(+), 74 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index f977571..c468abc 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -491,14 +491,6 @@ async function main(): Promise { 6, ), ); - } else { - const agentPing = res.terminal?.fields.AGENT_PING; - if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { - notes.push( - "• Your assistant didn't reply to a test message. " + - 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', - ); - } } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { notes.push( @@ -518,7 +510,6 @@ async function main(): Promise { unresolved_count: notes.length, service_running: res.terminal?.fields.SERVICE === 'running', has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', - agent_responds: res.terminal?.fields.AGENT_PING === 'ok', }); await offerClaudeAssist({ stepName: 'verify', @@ -777,15 +768,25 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { message: `Paste your ${label}`, clearOnError: true, validate: (v) => { - if (!v || !v.trim()) return 'Required'; - if (!v.trim().startsWith(prefix)) { + // Strip any internal whitespace so a line-wrapped paste that did + // survive into clack can still validate. The mid-token-newline + // case where clack only sees the first line is caught by the + // shape check below. + const cleaned = (v ?? '').replace(/\s+/g, ''); + if (!cleaned) return 'Required'; + if (!cleaned.startsWith(prefix)) { return `Should start with ${prefix}…`; } + if (method === 'oauth' && !/^sk-ant-oat[A-Za-z0-9_-]{80,500}AA$/.test(cleaned)) { + return cleaned.length < 90 + ? 'Token looks truncated — line breaks in the paste can cut it off. Widen your terminal so the token fits on one line, then paste again.' + : "Token shape doesn't look right (expected sk-ant-oat…AA)."; + } return undefined; }, }), ); - const token = (answer as string).trim(); + const token = (answer as string).replace(/\s+/g, ''); const res = await runQuietChild( 'auth', diff --git a/setup/lib/agent-ping.test.ts b/setup/lib/agent-ping.test.ts index 5f2be2c..3578ec1 100644 --- a/setup/lib/agent-ping.test.ts +++ b/setup/lib/agent-ping.test.ts @@ -20,6 +20,15 @@ describe('classifyPingResult', () => { expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error'); }); + it('detects Claude Code login banners printed as a chat reply', () => { + expect( + classifyPingResult(0, 'Invalid API key · Please run /login'), + ).toBe('auth_error'); + expect( + classifyPingResult(0, 'Not logged in · Please run /login'), + ).toBe('auth_error'); + }); + it('preserves socket errors', () => { expect(classifyPingResult(2, '')).toBe('socket_error'); }); diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts index 49c5fe2..5682c2f 100644 --- a/setup/lib/agent-ping.ts +++ b/setup/lib/agent-ping.ts @@ -20,7 +20,10 @@ export function classifyPingResult(exitCode: number | null, stdout: string, stde if ( /Invalid bearer token/i.test(output) || /authentication[_ ]error/i.test(output) || - /Failed to authenticate/i.test(output) + /Failed to authenticate/i.test(output) || + /Please run \/login/i.test(output) || + /Not logged in/i.test(output) || + /Invalid API key/i.test(output) ) { return 'auth_error'; } diff --git a/setup/verify.test.ts b/setup/verify.test.ts index 1e09acd..444b2cd 100644 --- a/setup/verify.test.ts +++ b/setup/verify.test.ts @@ -5,45 +5,14 @@ import { determineVerifyStatus } from './verify.js'; const healthyBase = { service: 'running' as const, credentials: 'configured', - anyChannelConfigured: false, registeredGroups: 1, - agentPing: 'ok' as const, }; describe('determineVerifyStatus', () => { - it('accepts a working CLI-only install', () => { + it('accepts a healthy install with at least one wired agent group', () => { expect(determineVerifyStatus(healthyBase)).toBe('success'); }); - it('accepts a messaging-channel install when CLI ping is skipped', () => { - expect( - determineVerifyStatus({ - ...healthyBase, - anyChannelConfigured: true, - agentPing: 'skipped', - }), - ).toBe('success'); - }); - - it('fails when neither CLI nor messaging channels are usable', () => { - expect( - determineVerifyStatus({ - ...healthyBase, - agentPing: 'skipped', - }), - ).toBe('failed'); - }); - - it('fails when the CLI agent does not respond', () => { - expect( - determineVerifyStatus({ - ...healthyBase, - anyChannelConfigured: true, - agentPing: 'no_reply', - }), - ).toBe('failed'); - }); - it('fails when no agent groups are registered', () => { expect( determineVerifyStatus({ @@ -52,4 +21,22 @@ describe('determineVerifyStatus', () => { }), ).toBe('failed'); }); + + it('fails when the service is not running', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + service: 'stopped', + }), + ).toBe('failed'); + }); + + it('fails when credentials are missing', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + credentials: 'missing', + }), + ).toBe('failed'); + }); }); diff --git a/setup/verify.ts b/setup/verify.ts index 30a5408..de1160c 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -14,7 +14,6 @@ import Database from 'better-sqlite3'; import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; -import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getPlatform, @@ -33,11 +32,12 @@ export async function run(_args: string[]): Promise { // 1. Check service status + detect checkout mismatch. // - // Why the mismatch matters: the host binds `/cli.sock` relative - // to the project root it was started from. If the running service is from - // a sibling checkout (common for developers with multiple clones), this - // repo's `data/cli.sock` won't exist — AGENT_PING would return a - // misleading `socket_error`. Surface the mismatch directly instead. + // Why the mismatch matters: the host reads `/data/v2.db` and + // binds `/cli.sock` relative to the project root it was started + // from. If the running service is from a sibling checkout (common for + // developers with multiple clones), nothing in this checkout is actually + // wired up. Surface the mismatch directly so the user knows to point the + // service at the right folder. let service: | 'not_found' | 'stopped' @@ -186,7 +186,6 @@ export async function run(_args: string[]): Promise { if (has('IMESSAGE_ENABLED')) channelAuth.imessage = 'configured'; const configuredChannels = Object.keys(channelAuth); - const anyChannelConfigured = configuredChannels.length > 0; // 5. Check registered groups in v2 central DB (agent_groups + messaging_group_agents) let registeredGroups = 0; @@ -218,23 +217,12 @@ export async function run(_args: string[]): Promise { mountAllowlist = 'configured'; } - // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if - // everything upstream looks healthy, since a broken socket would just hang. - let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped'; - if (service === 'running' && registeredGroups > 0) { - log.info('Pinging CLI agent'); - agentPing = await pingCliAgent(); - log.info('Agent ping result', { agentPing }); - } - - // Determine overall status. A CLI-only install is valid when the local - // agent round-trip succeeds; messaging app credentials are optional. + // Determine overall status. The cli-agent step earlier in setup already + // proved the agent round-trip works; verify is a static health check. const status = determineVerifyStatus({ service, credentials, - anyChannelConfigured, registeredGroups, - agentPing, }); log.info('Verification complete', { status, channelAuth }); @@ -247,7 +235,6 @@ export async function run(_args: string[]): Promise { CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, MOUNT_ALLOWLIST: mountAllowlist, - AGENT_PING: agentPing, STATUS: status, LOG: 'logs/setup.log', }); @@ -258,18 +245,11 @@ export async function run(_args: string[]): Promise { export function determineVerifyStatus(input: { service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout'; credentials: string; - anyChannelConfigured: boolean; registeredGroups: number; - agentPing: PingResult | 'skipped'; }): 'success' | 'failed' { - const cliAgentResponds = input.agentPing === 'ok'; - const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds; - return input.service === 'running' && input.credentials !== 'missing' && - hasUsableChannel && - input.registeredGroups > 0 && - (cliAgentResponds || input.agentPing === 'skipped') + input.registeredGroups > 0 ? 'success' : 'failed'; }