Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 13 additions & 1 deletion src/lib/agent-observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/lib/agent-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
108 changes: 108 additions & 0 deletions src/lib/db-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

__dirname is not available in ES modules, which this project uses (as indicated by "type": "module" in package.json). Using it will cause a runtime error in a pure ESM environment. Use import.meta.dir instead, which is the idiomatic equivalent in Bun and ESM.

Suggested change
const source = readFileSync(join(__dirname, 'db-backup.ts'), 'utf-8');
const source = readFileSync(join(import.meta.dir, '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);
});
});
95 changes: 95 additions & 0 deletions src/lib/db-backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Comment on lines +284 to +288

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current concatenation r.sch || '.' || r.tbl is not robust against schema or table names that contain special characters or dots (e.g., a schema named my.schema). While unlikely in the current database structure, it is best practice to use quote_ident when constructing qualified names for functions like pg_get_serial_sequence to ensure they are correctly parsed by PostgreSQL.

Suggested change
'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
);
EXECUTE format(
'SELECT setval(pg_get_serial_sequence(quote_ident(%L) || ''.'' || quote_ident(%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'}`);
}
}
18 changes: 18 additions & 0 deletions src/lib/db-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ export async function runMigrations(sql: Sql): Promise<MigrationRecord[]> {
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) {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/executor-registry-pure.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading