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
18 changes: 18 additions & 0 deletions src/hooks/dispatch-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ async function readStdin(): Promise<string> {
}

async function dispatchAction(): Promise<void> {
// Mac-CPU fix C — short-circuit DB boot for hook dispatch forks.
//
// The long-lived `genie serve` daemon owns migrations + seed (it ran
// them at startup). Each `genie hook dispatch` fork (hundreds/min on a
// busy Mac dev machine) is short-lived; making each fork re-run
// `runMigrations` + `needsSeed` (which loops all 92 ~/.claude/teams
// entries on every call) is the second-largest contributor to the
// .18 100%-CPU regression on Mac.
//
// Setting this env BEFORE `dispatch(stdin)` ensures the first
// `getConnection()` inside any handler skips migrations + seed.
// Handlers that need DB still get a working connection — they just
// skip the boot-time setup that the daemon already handled.
//
// Set unconditionally for the dispatch entrypoint; daemon and CLI
// code paths never enter this function.
process.env.GENIE_SKIP_DB_BOOT = '1';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent GENIE_SKIP_DB_BOOT from leaking to child CLI runs

Setting process.env.GENIE_SKIP_DB_BOOT = '1' here changes the entire hook process environment, and hook handlers like auto-spawn forward process.env into spawnSync('genie', ...) child commands. That means non-hook subcommands (for example genie spawn) can inherit this flag and skip runMigrations/runSeed in runPostConnectSetup, which can break those commands on fresh installs or immediately after schema changes before bootstrapping has completed.

Useful? React with 👍 / 👎.


const stdin = await readStdin();
if (!stdin.trim()) {
process.exit(0);
Expand Down
28 changes: 28 additions & 0 deletions src/lib/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,34 @@ describe('retention cleanup', () => {
});
});

describe('GENIE_SKIP_DB_BOOT (Mac CPU fix C — hook-dispatch coldstart)', () => {
test('runPostConnectSetup honors GENIE_SKIP_DB_BOOT alongside isTestMode', () => {
const source = readFileSync(join(__dirname, 'db.ts'), 'utf-8');
// The skipBoot guard must combine isTestMode + the env var so the
// hook-dispatch entrypoint can short-circuit migrations + seed
expect(source).toContain("process.env.GENIE_SKIP_DB_BOOT === '1'");
expect(source).toMatch(/skipBoot\s*=\s*isTestMode\s*\|\|\s*process\.env\.GENIE_SKIP_DB_BOOT/);

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 regular expression used here is too broad. It matches the assignment prefix but does not verify the actual condition value (=== '1'). A change that incorrectly checks for a different value (e.g., === '0') would still pass this test. Consider tightening the regex to include the full comparison logic.

Suggested change
expect(source).toMatch(/skipBoot\s*=\s*isTestMode\s*\|\|\s*process\.env\.GENIE_SKIP_DB_BOOT/);
expect(source).toMatch(/skipBoot\s*=\s*isTestMode\s*\|\|\s*process\.env\.GENIE_SKIP_DB_BOOT\s*===\s*'1'/);

// Both migrations and seed gated by skipBoot
expect(source).toMatch(/if \(!skipBoot\) await runMigrations/);
expect(source).toMatch(/if \(!skipBoot && needsSeed\(\)\) await runSeed/);
});

test('hook dispatch entrypoint sets GENIE_SKIP_DB_BOOT before invoking dispatch()', () => {
const dispatchSource = readFileSync(join(__dirname, '..', 'hooks', 'dispatch-command.ts'), 'utf-8');
// Env must be set inside dispatchAction (not at module load — that would
// affect the entire genie binary including the daemon path)
const dispatchActionIdx = dispatchSource.indexOf('async function dispatchAction');
expect(dispatchActionIdx).toBeGreaterThan(-1);
const fnBody = dispatchSource.slice(dispatchActionIdx, dispatchSource.indexOf('}', dispatchActionIdx + 100));
expect(fnBody).toContain("process.env.GENIE_SKIP_DB_BOOT = '1'");
// Must be set BEFORE dispatch(stdin) so the first getConnection() inside
// any handler skips migrations + seed
const envSetIdx = dispatchSource.indexOf("GENIE_SKIP_DB_BOOT = '1'");
const dispatchCallIdx = dispatchSource.indexOf('await dispatch(stdin)');
expect(envSetIdx).toBeLessThan(dispatchCallIdx);
});
});

describe('pool error recovery', () => {
test('migration failure resets both sqlClient and activePort', () => {
const source = readFileSync(join(__dirname, 'db.ts'), 'utf-8');
Expand Down
20 changes: 17 additions & 3 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,13 +663,27 @@ async function healthCheckCachedClient() {
}
}

/** Run post-connect setup (migrations, seed, retention). Skipped in test mode. */
/**
* Run post-connect setup (migrations, seed, retention).
*
* Skipped when:
* - `isTestMode` (test DB has its own setup path)
* - `GENIE_SKIP_DB_BOOT=1` (set by `genie hook dispatch` — Mac CPU fix C)
*
* The `genie serve` daemon owns migrations + seed at startup; short-lived
* forks (especially hook dispatch, hundreds/minute on a busy Mac) must not
* re-run them. Doing so means every PreToolUse / UserPromptSubmit /
* PostToolUse cold-start re-issues the migration check + loops all 92
* `~/.claude/teams` entries via `needsSeed()`. That was the second-largest
* contributor to the .18 100%-CPU Mac regression.
*/
async function runPostConnectSetup(client: postgres.Sql, isTestMode: boolean, timings: { t0: number; t1: number }) {
const _t2 = Date.now();
if (!isTestMode) await runMigrations(client);
const skipBoot = isTestMode || process.env.GENIE_SKIP_DB_BOOT === '1';
if (!skipBoot) await runMigrations(client);
const _t3 = Date.now();

if (!isTestMode && needsSeed()) await runSeed(client);
if (!skipBoot && needsSeed()) await runSeed(client);
const _t4 = Date.now();

// Retention is no longer run from getConnection — it now lives on a
Expand Down
Loading