diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 486080919..6d53b1f3c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "4.260522.6", + "version": "4.260522.8", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index f4af64ddb..cebc619eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "4.260522.6", + "version": "4.260522.8", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 795e451dc..6f6cf897c 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "4.260522.6", + "version": "4.260522.8", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 5f9e196c4..88509143b 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "4.260522.6", + "version": "4.260522.8", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/src/lib/agent-observability.test.ts b/src/lib/agent-observability.test.ts index c1a4795c2..80e51c881 100644 --- a/src/lib/agent-observability.test.ts +++ b/src/lib/agent-observability.test.ts @@ -92,10 +92,11 @@ describe('assessHealth (pure)', () => { expect(assessHealth(row).flags).not.toContain('stale_executor'); }); - test('missing_session fires when current executor has no session linkage', () => { + test('missing_session fires when current executor has no session linkage or claude_session_id anchor', () => { const row = baseRow(); row.sessionId = null; row.sessionLinkSource = null; + row.claudeSessionId = null; expect(assessHealth(row).flags).toContain('missing_session'); }); @@ -107,6 +108,17 @@ describe('assessHealth (pure)', () => { expect(assessHealth(row).flags).not.toContain('missing_session'); }); + test('missing_session does NOT fire for resume-ready executor anchors with claude_session_id', () => { + const row = baseRow(); + row.executorState = 'terminated'; + row.sessionId = null; + row.sessionStatus = null; + row.sessionExecutorId = null; + row.sessionLinkSource = null; + row.claudeSessionId = 'resume-anchor-session'; + expect(assessHealth(row).flags).not.toContain('missing_session'); + }); + test('missing_attribution fires when no name or role', () => { const row = baseRow(); row.customName = null; diff --git a/src/lib/agent-observability.ts b/src/lib/agent-observability.ts index 501061bca..815b6549c 100644 --- a/src/lib/agent-observability.ts +++ b/src/lib/agent-observability.ts @@ -154,8 +154,12 @@ export function assessHealth(row: AgentObservabilityRow, now: number = Date.now( } } - // missing_session: agent has a current executor but no session linkage. - if (row.currentExecutorId && !row.sessionId) { + // missing_session: agent has a current executor but no recoverable + // session anchor. A linked `sessions` row is ideal for analytics, but + // resume readiness is anchored by executors.claude_session_id; recovered + // / terminated executors created by `genie recover-orphans` intentionally + // may not have a sessions row. + if (row.currentExecutorId && !row.sessionId && !row.claudeSessionId) { flags.push('missing_session'); } diff --git a/src/lib/db-backup.test.ts b/src/lib/db-backup.test.ts index 4bf744c82..cf8d3e059 100644 --- a/src/lib/db-backup.test.ts +++ b/src/lib/db-backup.test.ts @@ -126,3 +126,111 @@ describe('snapshot file format', () => { expect(decompressed.toString('utf-8')).toBe(sql); }); }); + +// ============================================================================ +// rebalanceIdentitySequences — post-restore sequence rebalance +// +// Reproduces the 2026-05-22 khal-os incident: pg_dump --clean --if-exists +// recreates IDENTITY-bearing tables (resetting the implicit sequence to its +// initial value), then COPYs in the rows with their original explicit ids. +// Without an explicit setval() the next INSERT collides with an existing PK. +// ============================================================================ + +describe('buildIdentityRebalanceSql', () => { + test('emits a PL/pgSQL DO block that walks attidentity columns', async () => { + const { buildIdentityRebalanceSql } = await import('./db-backup.js'); + const sql = buildIdentityRebalanceSql(); + expect(sql.trimStart()).toMatch(/^DO \$\$/); + expect(sql).toContain("attidentity IN ('a', 'd')"); + expect(sql).toContain("n.nspname = 'public'"); + expect(sql).toContain("c.relkind = 'r'"); + expect(sql).toContain('setval(pg_get_serial_sequence'); + expect(sql).toContain('GREATEST(COALESCE'); + }); + + test('uses format(%L, %I) — safe against schema/table identifiers', async () => { + const { buildIdentityRebalanceSql } = await import('./db-backup.js'); + const sql = buildIdentityRebalanceSql(); + expect(sql).toContain('%L'); + expect(sql).toContain('%I'); + }); +}); + +describe('rebalanceIdentitySequences', () => { + test('runner is invoked with the rebalance SQL block', async () => { + const { rebalanceIdentitySequences, buildIdentityRebalanceSql } = await import('./db-backup.js'); + let captured = ''; + rebalanceIdentitySequences({ + runner: (sql) => { + captured = sql; + return { status: 0 }; + }, + }); + expect(captured).toBe(buildIdentityRebalanceSql()); + }); + + test('non-zero exit status throws with stderr context', async () => { + const { rebalanceIdentitySequences } = await import('./db-backup.js'); + expect(() => + rebalanceIdentitySequences({ + runner: () => ({ status: 3, stderr: Buffer.from('ERROR: permission denied\n') }), + }), + ).toThrow(/Identity sequence rebalance failed \(exit 3\): ERROR: permission denied/); + }); + + test('handles string stderr (legacy spawnSync encoding paths)', async () => { + const { rebalanceIdentitySequences } = await import('./db-backup.js'); + expect(() => + rebalanceIdentitySequences({ + runner: () => ({ status: 1, stderr: 'syntax error at end of input' }), + }), + ).toThrow(/syntax error at end of input/); + }); + + test('null status (timeout) is treated as failure', async () => { + const { rebalanceIdentitySequences } = await import('./db-backup.js'); + expect(() => + rebalanceIdentitySequences({ + runner: () => ({ status: null, stderr: Buffer.from('killed') }), + }), + ).toThrow(/exit null/); + }); + + test('missing stderr falls back to "unknown error" sentinel', async () => { + const { rebalanceIdentitySequences } = await import('./db-backup.js'); + expect(() => + rebalanceIdentitySequences({ + runner: () => ({ status: 2 }), + }), + ).toThrow(/unknown error/); + }); + + test('zero exit returns void without throwing', async () => { + const { rebalanceIdentitySequences } = await import('./db-backup.js'); + expect(() => + rebalanceIdentitySequences({ + runner: () => ({ status: 0 }), + }), + ).not.toThrow(); + }); +}); + +// ============================================================================ +// restore() wiring — rebalance must run AFTER the psql replay so the +// freshly-loaded rows are visible to MAX(col). +// ============================================================================ + +describe('restore() rebalance wiring (source-shape lock)', () => { + test('restore() calls rebalanceIdentitySequences after the psql pipe', () => { + const source = readFileSync(join(__dirname, 'db-backup.ts'), 'utf-8'); + const restoreFn = source.slice( + source.indexOf('export function restore('), + source.indexOf('export interface RebalanceIdentitySequencesOptions'), + ); + const psqlIdx = restoreFn.indexOf("spawnSync('psql', ['-v', 'ON_ERROR_STOP=1']"); + const rebalanceIdx = restoreFn.indexOf('rebalanceIdentitySequences('); + expect(psqlIdx).toBeGreaterThan(-1); + expect(rebalanceIdx).toBeGreaterThan(-1); + expect(psqlIdx).toBeLessThan(rebalanceIdx); + }); +}); diff --git a/src/lib/db-backup.ts b/src/lib/db-backup.ts index 523c5db4f..e12dff826 100644 --- a/src/lib/db-backup.ts +++ b/src/lib/db-backup.ts @@ -220,4 +220,99 @@ export function restore(snapshotFile?: string, cwd?: string): void { if (restoreResult.status !== 0) { throw new Error(`psql restore failed (exit ${restoreResult.status}): ${restoreResult.stderr?.toString().trim()}`); } + + // Rebalance IDENTITY sequences after restore. + // + // `pg_dump --clean --if-exists` drops every IDENTITY-bearing table and + // recreates it via DDL — which resets the implicit identity sequence to + // its initial value (1). The subsequent COPY restores rows with their + // original explicit ids (1..N), but pg_dump does NOT emit a setval() to + // advance the sequence past N. The next INSERT requesting a sequence + // value then collides with an existing row's PK. + // + // We observed this on 2026-05-22 (khal-os): `_genie_migrations` had 63 + // rows but `_genie_migrations_id_seq` was at 48; the first attempt to + // apply pending migration 064 hit `duplicate key value violates unique + // constraint "_genie_migrations_pkey"`. Every PG-touching CLI command + // (`genie ls`, `genie task list`, `genie events`) was blocked. + // + // The fix is a single PL/pgSQL block that walks every IDENTITY column + // in the `public` schema and calls setval() with MAX(col). Idempotent; + // safe on a freshly-installed DB (no IDENTITY columns yet) and on a + // restored DB (advances the sequence to a sane value). + rebalanceIdentitySequences(env); +} + +/** + * Walk every IDENTITY column in `public` schema and advance its sequence + * to `max(col)`. Called from `restore()` to undo the sequence-desync that + * `pg_dump --clean --if-exists` causes (see comment in restore()). + * + * Exported for direct test coverage and operator-driven recovery — a host + * that already drifted via a pre-fix restore can call this to unblock the + * migration runner without re-restoring. + */ +export interface RebalanceIdentitySequencesOptions { + /** Test seam — replaces the `spawnSync('psql', ['-c', sql])` invocation. */ + runner?: (sql: string) => { status: number | null; stderr?: Buffer | string }; +} + +/** + * Build the PL/pgSQL block that walks every IDENTITY column in public and + * setvals its backing sequence to MAX(col). Exposed so tests can lock the + * shape without spawning psql. + * + * For empty tables (MAX(col) = NULL), we setval(1, false) so the next + * nextval() yields 1 — the canonical identity-start behavior. For non-empty + * tables we setval(max, true) so the next nextval() yields max+1. + */ +export function buildIdentityRebalanceSql(): string { + return `DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE a.attidentity IN ('a', 'd') + AND n.nspname = 'public' + AND c.relkind = 'r' + LOOP + EXECUTE format( + 'SELECT setval(pg_get_serial_sequence(%L, %L), GREATEST(COALESCE((SELECT MAX(%I) FROM %I.%I), 0), 1), COALESCE((SELECT MAX(%I) FROM %I.%I), 0) > 0)', + r.sch || '.' || r.tbl, r.col, + r.col, r.sch, r.tbl, + r.col, r.sch, r.tbl + ); + END LOOP; +END +$$;`; +} + +export function rebalanceIdentitySequences( + envOrOpts: NodeJS.ProcessEnv | RebalanceIdentitySequencesOptions = pgEnv(), + opts: RebalanceIdentitySequencesOptions = {}, +): void { + // Accept either an env block (legacy positional) or an options bag, so + // restore() can keep passing its env and tests can inject a fake runner. + const isOpts = envOrOpts !== null && typeof envOrOpts === 'object' && 'runner' in envOrOpts; + const env: NodeJS.ProcessEnv = isOpts ? pgEnv() : (envOrOpts as NodeJS.ProcessEnv); + const runner = + (isOpts ? (envOrOpts as RebalanceIdentitySequencesOptions).runner : opts.runner) ?? + ((sql: string) => + spawnSync('psql', ['-v', 'ON_ERROR_STOP=1', '-c', sql], { + env, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30_000, + })); + + const sqlBlock = buildIdentityRebalanceSql(); + const result = runner(sqlBlock); + + if (result.status !== 0) { + const stderr = typeof result.stderr === 'string' ? result.stderr : (result.stderr?.toString() ?? ''); + throw new Error(`Identity sequence rebalance failed (exit ${result.status}): ${stderr.trim() || 'unknown error'}`); + } } diff --git a/src/lib/db-migrations.ts b/src/lib/db-migrations.ts index 838167db2..c3f5e2f39 100644 --- a/src/lib/db-migrations.ts +++ b/src/lib/db-migrations.ts @@ -143,6 +143,24 @@ export async function runMigrations(sql: Sql): Promise { const appliedSet = new Set(applied.map((r) => r.name)); const pending = files.filter((f) => !appliedSet.has(f.name)); + if (pending.length === 0) return []; + + // Defensive: ensure _genie_migrations_id_seq is sane before INSERTing. + // Hosts that restored from a pg_dump may have the sequence stuck at its + // initial value while the table holds rows with high explicit ids; the + // next IDENTITY-generated id would collide with an existing PK. We + // observed this on khal-os 2026-05-22 where the sequence was at 48 with + // 63 rows applied, blocking every PG-touching CLI command with + // `duplicate key value violates unique constraint "_genie_migrations_pkey"`. + // The canonical fix is `rebalanceIdentitySequences` (run by `restore()` + // post-pg_dump cutover), but a defensive `setval()` here unblocks any + // host that already drifted — without forcing them to re-restore. + await sql.unsafe(`SELECT setval( + '_genie_migrations_id_seq', + GREATEST(COALESCE((SELECT MAX(id) FROM _genie_migrations), 0), 1), + COALESCE((SELECT MAX(id) FROM _genie_migrations), 0) > 0 + )`); + const results: MigrationRecord[] = []; for (const migration of pending) { diff --git a/src/lib/executor-registry-pure.test.ts b/src/lib/executor-registry-pure.test.ts new file mode 100644 index 000000000..39609b54c --- /dev/null +++ b/src/lib/executor-registry-pure.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { initialEndedAtForState } from './executor-registry.js'; + +describe('executor-registry pure helpers', () => { + test('initialEndedAtForState stamps terminal executor states at creation time', () => { + const before = new Date().toISOString(); + const endedAt = initialEndedAtForState('terminated'); + const after = new Date().toISOString(); + + expect(endedAt).not.toBeNull(); + expect(endedAt! >= before).toBe(true); + expect(endedAt! <= after).toBe(true); + }); + + test('initialEndedAtForState leaves live executor states open', () => { + expect(initialEndedAtForState('spawning')).toBeNull(); + expect(initialEndedAtForState('running')).toBeNull(); + expect(initialEndedAtForState('idle')).toBeNull(); + }); +}); diff --git a/src/lib/executor-registry.ts b/src/lib/executor-registry.ts index 6f321c284..edc5ade82 100644 --- a/src/lib/executor-registry.ts +++ b/src/lib/executor-registry.ts @@ -45,6 +45,12 @@ export interface CreateExecutorOpts { // CRUD // ============================================================================ +const TERMINAL_EXECUTOR_STATES = new Set(['terminated', 'done', 'error']); + +export function initialEndedAtForState(state: ExecutorState): string | null { + return TERMINAL_EXECUTOR_STATES.has(state) ? new Date().toISOString() : null; +} + /** * Create an executor record and return it. * Does NOT set agent.current_executor_id — caller should use @@ -58,22 +64,24 @@ export async function createExecutor( ): Promise { const sql = await getConnection(); const id = opts.id ?? randomUUID(); + const state = opts.state ?? 'spawning'; const now = new Date().toISOString(); + const endedAt = initialEndedAtForState(state); const rows = await sql` INSERT INTO executors ( id, agent_id, provider, transport, pid, tmux_session, tmux_pane_id, tmux_window, tmux_window_id, claude_session_id, state, metadata, worktree, repo_path, pane_color, - started_at + started_at, ended_at ) VALUES ( ${id}, ${agentId}, ${provider}, ${transport}, ${opts.pid ?? null}, ${opts.tmuxSession ?? null}, ${opts.tmuxPaneId ?? null}, ${opts.tmuxWindow ?? null}, ${opts.tmuxWindowId ?? null}, - ${opts.claudeSessionId ?? null}, ${opts.state ?? 'spawning'}, + ${opts.claudeSessionId ?? null}, ${state}, ${sql.json(opts.metadata ?? {})}, ${opts.worktree ?? null}, ${opts.repoPath ?? null}, ${opts.paneColor ?? null}, - ${now} + ${now}, ${endedAt} ) RETURNING * `; @@ -92,7 +100,9 @@ export async function createAndLinkExecutor( ): Promise { const sql = await getConnection(); const id = opts.id ?? randomUUID(); + const state = opts.state ?? 'spawning'; const now = new Date().toISOString(); + const endedAt = initialEndedAtForState(state); return sql.begin(async (tx: Sql) => { const rows = await tx` @@ -100,15 +110,15 @@ export async function createAndLinkExecutor( id, agent_id, provider, transport, pid, tmux_session, tmux_pane_id, tmux_window, tmux_window_id, claude_session_id, state, metadata, worktree, repo_path, pane_color, - started_at + started_at, ended_at ) VALUES ( ${id}, ${agentId}, ${provider}, ${transport}, ${opts.pid ?? null}, ${opts.tmuxSession ?? null}, ${opts.tmuxPaneId ?? null}, ${opts.tmuxWindow ?? null}, ${opts.tmuxWindowId ?? null}, - ${opts.claudeSessionId ?? null}, ${opts.state ?? 'spawning'}, + ${opts.claudeSessionId ?? null}, ${state}, ${tx.json((opts.metadata ?? {}) as import('postgres').JSONValue)}, ${opts.worktree ?? null}, ${opts.repoPath ?? null}, ${opts.paneColor ?? null}, - ${now} + ${now}, ${endedAt} ) RETURNING * `;