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
8 changes: 8 additions & 0 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ const ccHooks = [
// pulls the freshest cloud snapshot for HEAD if newer than local.
// See src/hooks/graph-pull-worker.ts.
{ entry: "dist/src/hooks/graph-pull-worker.js", out: "graph-pull-worker" },
// Detached provisioning worker for the code-graph tree-sitter parsers.
// Spawned by session-start-setup so a cold npm install + native compile
// can outlive the hook's ~120s async timeout. See src/hooks/graph-deps-worker.ts.
{ entry: "dist/src/hooks/graph-deps-worker.js", out: "graph-deps-worker" },
];

const ccShell = [
Expand Down Expand Up @@ -167,6 +171,10 @@ const codexHooks = [
// recently-used org skill (judging runs on the codex CLI). Same shared module CC uses.
{ entry: "dist/src/skillify/skillopt-worker.js", out: "skillopt-worker" },
{ entry: "dist/src/hooks/graph-pull-worker.js", out: "graph-pull-worker" },
// Detached provisioning worker for the code-graph tree-sitter parsers —
// codex parity with CC. Spawned by session-start-setup so a cold npm install
// + native compile can outlive the hook. See src/hooks/graph-deps-worker.ts.
{ entry: "dist/src/hooks/graph-deps-worker.js", out: "graph-deps-worker" },
// G3: code-graph auto-build parity for Codex (same shared hook as CC/Cursor).
// graph-on-stop is built separately via buildGraphOnStop() (code-split).
];
Expand Down
310 changes: 276 additions & 34 deletions src/cli/graph-deps.ts

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/hooks/codex/session-start-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { log as _log } from "../../utils/debug.js";
import { makeWikiLogger } from "../../utils/wiki-log.js";
import { autoUpdate } from "../shared/autoupdate.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { spawnDetachedNodeWorker } from "../../utils/spawn-detached.js";
const log = (msg: string) => _log("codex-session-setup", msg);

const { log: wikiLog } = makeWikiLogger(join(homedir(), ".codex", "hooks"));
Expand Down Expand Up @@ -48,6 +49,17 @@ async function main(): Promise<void> {
if (process.env.HIVEMIND_WIKI_WORKER === "1") return;

const input = await readStdin<CodexSessionStartInput>();

// Provision the code-graph tree-sitter parsers into the shared embed-deps
// dir so the graph-on-stop hook can auto-build the graph. Spawned as a
// DETACHED worker — NOT run inline — because a cold provision runs npm +
// a from-source native compile that can exceed this hook's ~120s async
// timeout; the worker outlives the hook and finishes in the background.
// Fired BEFORE the credentials early-return: provisioning is purely local
// and must not depend on login. Best-effort — the spawn helper swallows any
// failure, and ensureGraphDeps inside the worker serializes via its own lock.
spawnDetachedNodeWorker(join(__bundleDir, "graph-deps-worker.js"));

const creds = loadCredentials();
if (!creds?.token) { log("no credentials"); return; }

Expand Down
13 changes: 10 additions & 3 deletions src/hooks/codex/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,16 @@ async function main(): Promise<void> {
creds = await healDriftedOrgToken(creds, log);
}

// Spawn async setup (table creation, placeholder, version check) as detached process.
// Codex doesn't support async hooks, so we use the same pattern as the wiki worker.
if (creds?.token) {
// Spawn async setup (graph-deps provisioning, table creation, placeholder,
// version check) as a detached process. Codex doesn't support async hooks,
// so we use the same pattern as the wiki worker.
//
// Spawned UNCONDITIONALLY — not gated on creds. The setup worker runs
// ensureGraphDeps() (purely local code-graph provisioning) BEFORE its own
// credentials early-return, so it must fire even when logged out. The
// remote/credentialed work (autoupdate, table + placeholder) stays gated
// INSIDE the worker on its `if (!creds?.token) return`.
{
const setupScript = join(__bundleDir, "session-start-setup.js");
const child = spawn("node", [setupScript], {
detached: true,
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/graph-deps-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node

/**
* Detached background worker that provisions the code-graph tree-sitter
* parsers into the shared embed-deps dir (see src/cli/graph-deps.ts).
*
* Why a dedicated detached worker (NOT an inline call in the SessionStart
* setup hook): a cold provision runs `npm install` plus a from-source native
* compile, which on a slow / arm64 box can take MINUTES. The SessionStart
* setup hooks run under the harness's ~120s async timeout — a synchronous
* inline install would blow that cap and get the hook killed mid-install,
* leaving a half-written tree. Spawning this worker detached + unref'd lets
* the hook return immediately while the install runs to completion in the
* background; the mkdir lockdir inside ensureGraphDeps serializes concurrent
* workers, and the ready marker makes the next session's fast path a no-op.
*
* Best-effort + self-contained: ensureGraphDeps swallows every failure
* internally (logs to the debug channel, records a backoff attempt) and never
* throws to us. The worker writes nothing to stdout/stderr — it's detached and
* any output would go nowhere useful.
*
* The CLI paths (`hivemind graph init`, `installEmbeddings`) still call
* ensureGraphDeps() inline: those run in the foreground where blocking is fine
* and expected.
*/

import { ensureGraphDeps } from "../cli/graph-deps.js";
import { log as _log } from "../utils/debug.js";

const log = (msg: string) => _log("graph-deps-worker", msg);

try {
ensureGraphDeps({ logFn: log, warnFn: log });
} catch (e: any) {
// ensureGraphDeps is best-effort and shouldn't throw, but never let a
// stray error escape to a non-zero exit — the worker is fire-and-forget.
log(`fatal: ${e?.message ?? e}`);
}
process.exit(0);
12 changes: 12 additions & 0 deletions src/hooks/session-start-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { makeWikiLogger } from "../utils/wiki-log.js";
import { EmbedClient } from "../embeddings/client.js";
import { embeddingsDisabled, embeddingsStatus } from "../embeddings/disable.js";
import { autoUpdate } from "./shared/autoupdate.js";
import { spawnDetachedNodeWorker } from "../utils/spawn-detached.js";
const log = (msg: string) => _log("session-setup", msg);

const __bundleDir = dirname(fileURLToPath(import.meta.url));
Expand All @@ -32,6 +33,17 @@ async function main(): Promise<void> {
if (process.env.HIVEMIND_WIKI_WORKER === "1") return;

const input = await readStdin<SessionStartInput>();

// Provision the code-graph tree-sitter parsers into the shared embed-deps
// dir so the graph-on-stop hook can auto-build the graph. Spawned as a
// DETACHED worker — NOT run inline — because a cold provision runs npm +
// a from-source native compile that can exceed this hook's ~120s async
// timeout; the worker outlives the hook and finishes in the background.
// Fired BEFORE the credentials early-return: provisioning is purely local
// and must not depend on login. Best-effort — the spawn helper swallows any
// failure, and ensureGraphDeps inside the worker serializes via its own lock.
spawnDetachedNodeWorker(join(__bundleDir, "graph-deps-worker.js"));

const creds = loadCredentials();
if (!creds?.token) { log("no credentials"); return; }

Expand Down
3 changes: 3 additions & 0 deletions tests/claude-code/session-start-setup-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ vi.mock("../../src/embeddings/client.js", () => ({
async warmup() { return embedWarmupMock(); }
},
}));
// The hook spawns a detached graph-deps worker; stub the spawn boundary so
// this branch-coverage test never launches a real process.
vi.mock("../../src/utils/spawn-detached.js", () => ({ spawnDetachedNodeWorker: vi.fn() }));

async function runHook(): Promise<void> {
delete process.env.HIVEMIND_WIKI_WORKER;
Expand Down
47 changes: 47 additions & 0 deletions tests/claude-code/session-start-setup-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const ensureTableMock = vi.fn();
const ensureSessionsTableMock = vi.fn();
const autoUpdateMock = vi.fn();
const embedWarmupMock = vi.fn();
const spawnDetachedMock = vi.fn();

vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: any[]) => stdinMock(...a) }));
vi.mock("../../src/commands/auth.js", () => ({
Expand All @@ -38,6 +39,13 @@ vi.mock("../../src/deeplake-api.js", () => ({
vi.mock("../../src/hooks/shared/autoupdate.js", () => ({
autoUpdate: (...a: any[]) => autoUpdateMock(...a),
}));
// The hook no longer provisions graph-deps inline: a cold npm install +
// native compile can exceed the ~120s hook timeout, so it spawns a DETACHED
// worker via spawnDetachedNodeWorker. Mock that boundary and assert the hook
// spawns graph-deps-worker.js (and BEFORE the credentials gate).
vi.mock("../../src/utils/spawn-detached.js", () => ({
spawnDetachedNodeWorker: (...a: any[]) => spawnDetachedMock(...a),
}));
vi.mock("../../src/embeddings/client.js", () => ({
EmbedClient: class {
async warmup() { return embedWarmupMock(); }
Expand Down Expand Up @@ -108,6 +116,7 @@ beforeEach(() => {
ensureSessionsTableMock.mockReset().mockResolvedValue(undefined);
autoUpdateMock.mockReset().mockResolvedValue(undefined);
embedWarmupMock.mockReset().mockResolvedValue(true);
spawnDetachedMock.mockReset();
fetchMock.mockReset().mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.1" }),
Expand Down Expand Up @@ -147,6 +156,44 @@ describe("session-start-setup hook — guards", () => {
});
});

describe("session-start-setup hook — graph-deps provisioning (detached worker)", () => {
const spawnedGraphDeps = () =>
spawnDetachedMock.mock.calls.find(([p]) => typeof p === "string" && p.endsWith("graph-deps-worker.js"));

it("spawns the graph-deps worker detached once on the happy path (NOT inline)", async () => {
await runHook();
expect(spawnedGraphDeps()).toBeDefined();
});

it("spawns graph-deps even when there are NO credentials (local, login-independent)", async () => {
loadCredsMock.mockReturnValue(null);
await runHook();
// The worker spawn must fire BEFORE the credentials early-return.
expect(spawnedGraphDeps()).toBeDefined();
expect(debugLogMock).toHaveBeenCalledWith("no credentials");
});

it("spawns the graph-deps worker BEFORE loadCredentials (ordering)", async () => {
let graphAt = -1;
let credsAt = -1;
let counter = 0;
spawnDetachedMock.mockImplementation((p: string) => { if (p.endsWith("graph-deps-worker.js")) graphAt = counter++; });
loadCredsMock.mockImplementation(() => { credsAt = counter++; return { token: "tok", userName: "alice" }; });
await runHook();
expect(graphAt).toBeGreaterThanOrEqual(0);
expect(credsAt).toBeGreaterThanOrEqual(0);
expect(graphAt).toBeLessThan(credsAt);
});

it("the detached spawn does not block the rest of the hook (table setup still runs)", async () => {
// spawnDetachedNodeWorker is fire-and-forget: the hook proceeds to its
// credentialed table-setup work without awaiting the worker.
await runHook();
expect(spawnedGraphDeps()).toBeDefined();
expect(ensureTableMock).toHaveBeenCalled();
});
});

describe("session-start-setup hook — userName backfill", () => {
it("backfills userName via node:os when missing and saves creds", async () => {
loadCredsMock.mockReturnValue({ token: "tok", orgId: "o", orgName: "acme" });
Expand Down
7 changes: 5 additions & 2 deletions tests/codex/codex-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,15 @@ describe("codex integration: session-start-setup", () => {
});

it("exits cleanly with no credentials (HIVEMIND_TOKEN='')", () => {
// HIVEMIND_GRAPH_ON_STOP=0 makes the detached graph-deps worker (spawned
// by this hook) early-return instead of running a real npm install against
// the dev machine's ~/.hivemind/embed-deps during the integration run.
const raw = runHook("session-start-setup.js", {
session_id: "test-session-setup-002",
cwd: "/tmp/test-project",
hook_event_name: "SessionStart",
model: "gpt-5.2",
});
}, { HIVEMIND_GRAPH_ON_STOP: "0" });
expect(raw).toBe("");
});

Expand All @@ -349,7 +352,7 @@ describe("codex integration: session-start-setup", () => {
cwd: "/tmp/test-project",
hook_event_name: "SessionStart",
model: "gpt-5.2",
});
}, { HIVEMIND_GRAPH_ON_STOP: "0" });
expect(raw).toBe("");
});
});
Expand Down
28 changes: 25 additions & 3 deletions tests/codex/codex-session-start-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,17 @@ describe("codex session-start hook — guards", () => {
it("emits not-logged-in context when creds are missing (no token)", async () => {
loadCredsMock.mockReturnValue(null);
const out = await runHook();
expect(spawnMock).not.toHaveBeenCalled();
// The setup worker now spawns UNCONDITIONALLY (it provisions graph-deps
// before its own creds gate). But the graph-pull-worker stays creds-gated,
// so exactly one spawn (session-start-setup.js) fires here.
const setupCall = spawnMock.mock.calls.find(
([_cmd, args]) => Array.isArray(args) && args[0]?.includes?.("session-start-setup.js"),
);
expect(setupCall).toBeDefined();
const pullCall = spawnMock.mock.calls.find(
([_cmd, args]) => Array.isArray(args) && args[0]?.includes?.("graph-pull-worker"),
);
expect(pullCall).toBeUndefined();
// Codex hook now emits JSON, not plain text. Parse + assert on
// additionalContext (single-line status). See AGENT_CHANNELS.md → Codex
// for why we kept this minimal.
Expand Down Expand Up @@ -181,10 +191,22 @@ describe("codex session-start hook — spawn async setup", () => {
expect(debugLogMock).toHaveBeenCalledWith("spawned async setup process");
});

it("does not spawn when creds are missing", async () => {
it("spawns the setup worker even when creds are missing (graph-deps is login-independent)", async () => {
loadCredsMock.mockReturnValue({ token: "" });
const fake = makeFakeChild();
spawnMock.mockReturnValue(fake);
await runHook();
expect(spawnMock).not.toHaveBeenCalled();
// session-start-setup.js MUST spawn (it provisions graph-deps before its
// own creds gate). The graph-pull-worker MUST NOT (still creds-gated).
const setupCall = spawnMock.mock.calls.find(
([_cmd, args]) => Array.isArray(args) && args[0]?.includes?.("session-start-setup.js"),
);
expect(setupCall).toBeDefined();
expect(debugLogMock).toHaveBeenCalledWith("spawned async setup process");
const pullCall = spawnMock.mock.calls.find(
([_cmd, args]) => Array.isArray(args) && args[0]?.includes?.("graph-pull-worker"),
);
expect(pullCall).toBeUndefined();
});

it("logs 'triggered (background)' on the auto-mine path when creds are missing and worker actually fires", async () => {
Expand Down
45 changes: 45 additions & 0 deletions tests/codex/codex-session-start-setup-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ensureTableMock = vi.fn();
const ensureSessionsTableMock = vi.fn();
const queryMock = vi.fn();
const autoUpdateMock = vi.fn();
const spawnDetachedMock = vi.fn();

vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: any[]) => stdinMock(...a) }));
vi.mock("../../src/commands/auth.js", () => ({
Expand All @@ -45,6 +46,13 @@ vi.mock("../../src/deeplake-api.js", () => ({
vi.mock("../../src/hooks/shared/autoupdate.js", () => ({
autoUpdate: (...a: any[]) => autoUpdateMock(...a),
}));
// The hook no longer provisions graph-deps inline: a cold npm install +
// native compile can exceed the ~120s hook timeout, so it spawns a DETACHED
// worker via spawnDetachedNodeWorker. Mock that boundary and assert the hook
// spawns graph-deps-worker.js (and BEFORE the credentials gate).
vi.mock("../../src/utils/spawn-detached.js", () => ({
spawnDetachedNodeWorker: (...a: any[]) => spawnDetachedMock(...a),
}));

async function runHook(env: Record<string, string | undefined> = {}): Promise<void> {
delete process.env.HIVEMIND_WIKI_WORKER;
Expand Down Expand Up @@ -80,6 +88,7 @@ beforeEach(() => {
ensureSessionsTableMock.mockReset().mockResolvedValue(undefined);
queryMock.mockReset().mockResolvedValue([]); // placeholder SELECT → empty, INSERT will follow
autoUpdateMock.mockReset().mockResolvedValue(undefined);
spawnDetachedMock.mockReset();
});

afterEach(() => {
Expand All @@ -100,6 +109,42 @@ describe("codex session-start-setup hook — guards", () => {
});
});

describe("codex session-start-setup hook — graph-deps provisioning (detached worker)", () => {
const spawnedGraphDeps = () =>
spawnDetachedMock.mock.calls.find(([p]) => typeof p === "string" && p.endsWith("graph-deps-worker.js"));

it("spawns the graph-deps worker detached once on the happy path (NOT inline)", async () => {
await runHook();
expect(spawnedGraphDeps()).toBeDefined();
});

it("spawns graph-deps even when there are NO credentials (local, login-independent)", async () => {
loadCredsMock.mockReturnValue(null);
await runHook();
// The worker spawn must fire BEFORE the credentials early-return.
expect(spawnedGraphDeps()).toBeDefined();
expect(debugLogMock).toHaveBeenCalledWith("no credentials");
});

it("spawns the graph-deps worker BEFORE loadCredentials (ordering)", async () => {
let graphAt = -1;
let credsAt = -1;
let counter = 0;
spawnDetachedMock.mockImplementation((p: string) => { if (p.endsWith("graph-deps-worker.js")) graphAt = counter++; });
loadCredsMock.mockImplementation(() => { credsAt = counter++; return { token: "tok", userName: "alice" }; });
await runHook();
expect(graphAt).toBeGreaterThanOrEqual(0);
expect(credsAt).toBeGreaterThanOrEqual(0);
expect(graphAt).toBeLessThan(credsAt);
});

it("the detached spawn does not block the rest of the hook (table setup still runs)", async () => {
await runHook();
expect(spawnedGraphDeps()).toBeDefined();
expect(ensureTableMock).toHaveBeenCalled();
});
});

describe("codex session-start-setup hook — userName backfill", () => {
it("backfills userName when missing and saves creds", async () => {
loadCredsMock.mockReturnValue({ token: "tok", orgId: "o", orgName: "acme" });
Expand Down
Loading