fix(coding-agents): admit one codebase survey per bank, held by a heartbeat lease (#4303)

* fix(coding-agents): admit one codebase survey per bank

* fix(coding-agents): hold the survey lease with a heartbeat supervisor

The PID-owned survey lock could wedge a bank forever: a recycled PID answers
kill(pid, 0) as alive, and the lock had no expiry. Replace it with a lease the
holder refreshes every 5s, reclaimable after 30s of silence. The survey agent
cannot heartbeat, so it now runs under a tiny detached Node supervisor
(dist/survey-supervisor.js) that holds the lease for exactly the agent's
lifetime, releases on exit, and kills the agent if the lease was taken over.

Only rename/utimes/stat are used, so it behaves the same on macOS, Linux and
Windows; no flock, O_EXCL or PID probes.

* fix(coding-agents): hand the survey spec to its supervisor via env, not argv

Reproducing #4255 end to end (6 concurrent SessionStart hooks on a cold
bank) showed the supervisor SIGKILLed within milliseconds of starting,
so no survey ran at all. A harmless stand-in launched from the same
point with the spec in argv was killed 5/5; the same launch with the
spec in the environment survived 5/5. The spec carries the whole agent
command line (prompt, inline MCP config, tool deny-list), which endpoint
security (SentinelOne here) kills on sight when a hook-launched node
process carries it.

The supervisor now reads HINDSIGHT_SURVEY_SPEC and deletes it before
starting the agent, so the agent and its MCP server never inherit it.

---------

Co-authored-by: r266-tech <r266-tech@users.noreply.github.com>
This commit is contained in:
Nicolò Boschi
2026-09-11 11:38:17 +02:00
committed by GitHub
parent 59e25d74f8
commit cabfbb9fe0
8 changed files with 799 additions and 76 deletions
@@ -14,7 +14,7 @@ describe("buildSessionStartContext", () => {
it("cold git repo + autoSeed on -> seeds + surveys, note in systemMessage (user-visible) + roster in additionalContext (model)", async () => {
const client = { listDocumentIds: async () => new Set<string>(), listPages: listPagesOk };
const startSeed = vi.fn();
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const out = await buildSessionStartContext({
cwd: "/repo/dir",
bankId: "bank-1",
@@ -58,7 +58,7 @@ describe("buildSessionStartContext", () => {
harness: "codex",
hasGit: () => true,
startSeed,
startSurvey: vi.fn(),
startSurvey: vi.fn().mockResolvedValue(true),
});
expect(startSeed).toHaveBeenCalledWith("/repo/dir", { limit: 300, harness: "codex" });
});
@@ -66,7 +66,7 @@ describe("buildSessionStartContext", () => {
it("cold git repo + codebaseSurvey:false -> starts the seed but NOT the survey", async () => {
const client = { listDocumentIds: async () => new Set<string>(), listPages: listPagesOk };
const startSeed = vi.fn();
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const out = await buildSessionStartContext({
cwd: "/repo/dir",
bankId: "bank-1",
@@ -152,7 +152,7 @@ describe("buildSessionStartContext", () => {
it("warm bank (non-empty doc set) -> deepen engine fires, but no survey/note", async () => {
const startSeed = vi.fn();
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const client = { listDocumentIds: async () => new Set(["git:abc"]), listPages: listPagesOk };
const out = await buildSessionStartContext({
cwd: "/repo/dir",
@@ -325,7 +325,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
];
it(">= threshold since the latest reachable baseline -> re-surveys + records a new baseline", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
await buildSessionStartContext({
cwd: "/repo",
@@ -358,7 +358,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
client: warmClient(["survey-baseline:oldsha"], retain),
hasGit: () => true,
startSeed: vi.fn(),
startSurvey: vi.fn(),
startSurvey: vi.fn().mockResolvedValue(true),
headSha: () => "newsha",
commitsSince: () => 25,
});
@@ -374,7 +374,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("< threshold -> no re-survey, no new baseline", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
await buildSessionStartContext({
cwd: "/repo",
@@ -392,7 +392,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("no baseline yet (upgrade from a pre-feature bank) -> records HEAD as baseline, does NOT survey", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
await buildSessionStartContext({
cwd: "/repo",
@@ -410,7 +410,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("all markers unreachable (rebase/gc) -> re-baselines to HEAD, does NOT survey", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
await buildSessionStartContext({
cwd: "/repo",
@@ -428,7 +428,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("takes the MIN reachable count (newest survey), ignoring older + dead-branch markers", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const counts: Record<string, number | null> = { old1: 50, old2: 10, dead: null };
await buildSessionStartContext({
cwd: "/repo",
@@ -445,7 +445,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("surveyRefreshCommits=0 disables re-survey even far past threshold", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
await buildSessionStartContext({
cwd: "/repo",
bankId: "bank-1",
@@ -461,7 +461,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
});
it("cold seed records the survey baseline", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
await buildSessionStartContext({
cwd: "/repo",
@@ -481,7 +481,7 @@ describe("buildSessionStartContext — periodic re-survey (bank-stored commit co
describe("buildSessionStartContext — crashed-survey retry (baseline without findings)", () => {
it("re-fires the survey when a baseline exists but NO findings docs ever arrived", async () => {
const startSurvey = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(true);
const retain = vi.fn();
const client = {
listDocumentIds: async (tag: string) =>
@@ -508,3 +508,33 @@ describe("buildSessionStartContext — crashed-survey retry (baseline without fi
expect(out).toBeTruthy();
});
});
describe("survey launch admission", () => {
it.each([false, true])(
"does not advance a %s warm/cold baseline when launch is skipped or fails",
async (warm) => {
const retain = vi.fn();
const startSurvey = vi.fn().mockResolvedValue(false);
await buildSessionStartContext({
cwd: "/repo",
bankId: "bank-1",
cfg: resolveConfig({ surveyRefreshCommits: 20 }),
client: {
listDocumentIds: async (tag) =>
tag === "source:survey-baseline"
? new Set(["survey-baseline:oldsha"])
: new Set(warm ? ["git:old"] : []),
listPages: listPagesOk,
retain,
},
hasGit: () => true,
startSeed: vi.fn(),
startSurvey,
headSha: () => "newsha",
commitsSince: () => 25,
});
expect(startSurvey).toHaveBeenCalledOnce();
expect(retain).not.toHaveBeenCalled();
}
);
});
@@ -147,10 +147,7 @@ export async function buildSessionStartContext(args: {
stateDir?: string;
hasGit?: (dir: string) => boolean;
startSeed?: (repoDir: string, opts?: { limit?: number; harness?: string }) => void;
startSurvey?: (
repoDir: string,
opts?: { harness?: SurveyHarness; model?: string; budgetUsd?: number }
) => void;
startSurvey?: typeof startCodebaseSurvey;
headSha?: (dir: string) => string | null;
commitsSince?: (dir: string, sinceSha: string) => number | null;
}): Promise<SessionStartOutput> {
@@ -232,13 +229,13 @@ export async function buildSessionStartContext(args: {
if (docIds.size === 0) {
if (cfg.codebaseSurvey !== false) {
// Run the survey under the current harness's own CLI (falls back to any available agent).
startSurvey(cwd, {
const started = await startSurvey(cwd, {
harness: harness as SurveyHarness,
model: cfg.surveyModel,
budgetUsd: cfg.surveyBudgetUsd,
});
const sha = resolveHeadSha(cwd);
if (sha) recordSurveyBaseline(sha); // baseline for the commit-count re-survey below
if (started && sha) recordSurveyBaseline(sha);
}
diag(harness, "seed_started", { bank: bankId });
} else if (cfg.codebaseSurvey !== false && cfg.surveyRefreshCommits > 0) {
@@ -268,17 +265,19 @@ export async function buildSessionStartContext(args: {
const findingsAbsent =
counts.length > 0 && !SURVEY_DOC_IDS.some((id) => uploads.has(id));
if ((sinceLast !== null && sinceLast >= cfg.surveyRefreshCommits) || findingsAbsent) {
startSurvey(cwd, {
const started = await startSurvey(cwd, {
harness: harness as SurveyHarness,
model: cfg.surveyModel,
budgetUsd: cfg.surveyBudgetUsd,
});
recordSurveyBaseline(sha);
diag(harness, "survey_refresh", {
bank: bankId,
commits: sinceLast,
retry: findingsAbsent,
});
if (started) {
recordSurveyBaseline(sha);
diag(harness, "survey_refresh", {
bank: bankId,
commits: sinceLast,
retry: findingsAbsent,
});
}
} else if (sinceLast === null) {
recordSurveyBaseline(sha); // first baseline, or reset after a rebase — no survey
}
@@ -0,0 +1,189 @@
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
acquireLease,
heartbeatLease,
LEASE_STALE_MS,
releaseLease,
superviseSurvey,
type SurveyLease,
} from "./survey-lease";
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "hindsight-lease-test-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(root, { recursive: true, force: true });
});
/** Backdate the lease's heartbeat, as if its holder died `ms` ago. */
function age(lease: SurveyLease, ms: number) {
const then = new Date(Date.now() - ms);
utimesSync(join(lease.directory, lease.owner), then, then);
}
describe("acquireLease", () => {
it("admits exactly one holder per key, and leaves no staging behind", () => {
const first = acquireLease(root, "k");
expect(first).toBeDefined();
expect(acquireLease(root, "k")).toBeUndefined();
expect(acquireLease(root, "other")).toBeDefined();
expect(readdirSync(root).sort()).toEqual(["survey-k.lock", "survey-other.lock"]);
expect(readdirSync(first!.directory)).toEqual([first!.owner]);
});
it("is free again once released", () => {
releaseLease(acquireLease(root, "k")!);
expect(readdirSync(root)).toEqual([]);
expect(acquireLease(root, "k")).toBeDefined();
});
it("keeps a fresh lease however old the process that took it is — liveness is the heartbeat", () => {
const lease = acquireLease(root, "k")!;
age(lease, LEASE_STALE_MS - 5_000);
expect(acquireLease(root, "k")).toBeUndefined();
});
it("reclaims a lease whose heartbeat stopped, exactly once", () => {
const dead = acquireLease(root, "k")!;
age(dead, LEASE_STALE_MS + 5_000);
const next = acquireLease(root, "k");
expect(next).toBeDefined();
expect(next!.owner).not.toBe(dead.owner);
expect(acquireLease(root, "k")).toBeUndefined(); // the new holder is fresh
});
it("treats a heartbeat from the future (clock stepped back) as stale", () => {
const lease = acquireLease(root, "k")!;
age(lease, -(LEASE_STALE_MS + 60_000));
expect(acquireLease(root, "k")).toBeDefined();
});
it("honors a custom stale window", () => {
const lease = acquireLease(root, "k")!;
age(lease, 3_000);
expect(acquireLease(root, "k", 10_000)).toBeUndefined();
expect(acquireLease(root, "k", 1_000)).toBeDefined();
});
it("reclaims an unowned directory left by an interrupted release", () => {
mkdirSync(join(root, "survey-k.lock"));
expect(acquireLease(root, "k")).toBeDefined();
});
it("never touches a lock directory it does not recognise", () => {
const directory = join(root, "survey-k.lock");
mkdirSync(join(directory, "a"), { recursive: true });
mkdirSync(join(directory, "b"));
expect(acquireLease(root, "k")).toBeUndefined();
expect(readdirSync(directory).sort()).toEqual(["a", "b"]);
});
it("fails closed when the root cannot be created", () => {
expect(acquireLease(join(root, "\0bad"), "k")).toBeUndefined();
});
});
describe("releaseLease / heartbeatLease", () => {
it("an old generation can neither release nor refresh its successor", () => {
const old = acquireLease(root, "k")!;
age(old, LEASE_STALE_MS + 5_000);
const current = acquireLease(root, "k")!;
releaseLease(old);
expect(heartbeatLease(old)).toBe(false);
expect(readdirSync(current.directory)).toEqual([current.owner]);
expect(heartbeatLease(current)).toBe(true);
});
it("a heartbeat keeps an aged lease from being reclaimed", () => {
const lease = acquireLease(root, "k")!;
age(lease, LEASE_STALE_MS + 5_000);
expect(heartbeatLease(lease)).toBe(true);
expect(acquireLease(root, "k")).toBeUndefined();
});
});
describe("superviseSurvey", () => {
function fakeAgent() {
const child = Object.assign(new EventEmitter(), { kill: vi.fn() });
const spawn = vi.fn().mockReturnValue(child);
return { child, spawn };
}
it("runs the agent and releases the lease when it exits", async () => {
const lease = acquireLease(root, "k")!;
const { child, spawn } = fakeAgent();
const run = superviseSurvey({ lease, bin: "/bin/agent", args: ["-p", "x"] }, spawn);
expect(spawn).toHaveBeenCalledWith("/bin/agent", ["-p", "x"], {
stdio: "ignore",
windowsHide: true,
});
child.emit("exit", 0);
await run.done;
expect(readdirSync(root)).toEqual([]);
});
it("heartbeats while the agent runs", async () => {
vi.useFakeTimers();
const lease = acquireLease(root, "k")!;
const { child, spawn } = fakeAgent();
const run = superviseSurvey({ lease, bin: "a", args: [], heartbeatMs: 1_000 }, spawn);
age(lease, LEASE_STALE_MS + 5_000);
vi.advanceTimersByTime(1_000);
expect(acquireLease(root, "k")).toBeUndefined();
child.emit("exit", 0);
await run.done;
});
it("kills the agent, and keeps its hands off the lease, once the lease is taken over", async () => {
vi.useFakeTimers();
const lease = acquireLease(root, "k")!;
const { child, spawn } = fakeAgent();
const run = superviseSurvey({ lease, bin: "a", args: [], heartbeatMs: 1_000 }, spawn);
age(lease, LEASE_STALE_MS + 5_000); // the machine slept past the stale window
const successor = acquireLease(root, "k")!;
vi.advanceTimersByTime(1_000);
await run.done;
expect(child.kill).toHaveBeenCalled();
child.emit("exit", null);
expect(readdirSync(successor.directory)).toEqual([successor.owner]);
});
it("does not start the agent if the lease was lost before it could", async () => {
const lease = acquireLease(root, "k")!;
releaseLease(lease);
const { spawn } = fakeAgent();
await superviseSurvey({ lease, bin: "a", args: [] }, spawn).done;
expect(spawn).not.toHaveBeenCalled();
});
it("releases the lease when the agent cannot be spawned", async () => {
const lease = acquireLease(root, "k")!;
const { child, spawn } = fakeAgent();
const run = superviseSurvey({ lease, bin: "missing", args: [] }, spawn);
child.emit("error", new Error("spawn missing ENOENT"));
await run.done;
expect(readdirSync(root)).toEqual([]);
const throwing = vi.fn(() => {
throw new Error("EMFILE");
});
await superviseSurvey({ lease: acquireLease(root, "k")!, bin: "a", args: [] }, throwing).done;
expect(readdirSync(root)).toEqual([]);
});
it("stop() kills the agent and releases", async () => {
const lease = acquireLease(root, "k")!;
const { child, spawn } = fakeAgent();
const run = superviseSurvey({ lease, bin: "a", args: [] }, spawn);
run.stop();
await run.done;
expect(child.kill).toHaveBeenCalled();
expect(readdirSync(root)).toEqual([]);
});
});
@@ -0,0 +1,200 @@
/**
* Single-flight admission for the codebase survey (#4255): at most one survey per destination
* (API + credential + bank) runs at a time, however many SessionStart hooks race the cold check.
*
* The lease is a lock DIRECTORY holding exactly one owner file named by a random token, and it is
* kept alive by a HEARTBEAT: the holder rewrites the owner file's mtime every few seconds, and a
* lease whose mtime is older than the stale threshold belongs to a dead holder and may be taken.
* Only filesystem primitives that behave the same on macOS, Linux and Windows are used — no
* `flock`/`fcntl` (advisory, fd-bound, unreliable over NFS), no `O_EXCL` (broken on NFS), and no
* PID liveness probes (PIDs are recycled, `kill(pid, 0)` answers for whatever process now has the
* number, and it cannot be probed across users at all).
*
* A heartbeat needs a holder that runs OUR code, and the survey agent (`claude -p`, `codex exec`,
* …) does not — while the hook that won the lease exits immediately. So the survey runs under a
* tiny detached supervisor (`survey-supervisor.ts` → `superviseSurvey`) that holds the lease for
* exactly the agent's lifetime: it heartbeats, releases when the agent exits, and kills the agent
* if it finds the lease was taken from it (a machine that slept past the stale window), so a
* reclaimed lease never leaves two surveys running.
*/
import { spawn as realSpawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readdirSync,
renameSync,
rmdirSync,
rmSync,
statSync,
unlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
/** How often the supervisor refreshes its lease. */
export const LEASE_HEARTBEAT_MS = 5_000;
/** A lease not refreshed for this long belongs to a dead holder. Six missed heartbeats: generous
* enough for a loaded machine, short enough that a crashed survey is retried on the next session. */
export const LEASE_STALE_MS = 30_000;
export interface SurveyLease {
/** The lock directory: `<root>/survey-<key>.lock`. */
directory: string;
/** The owner file inside it — a random token, so a generation can only ever touch its own. */
owner: string;
}
/** Environment variable carrying the JSON `SurveySupervisorSpec` from the hook to the supervisor.
*
* NOT argv: the spec holds the whole agent command line (survey prompt, inline MCP config,
* `--disallowedTools Bash Write …`), and a node process launched by the hook with that on its own
* command line was SIGKILLed by endpoint security within milliseconds of starting — 5/5 runs on a
* SentinelOne-managed Mac, vs 0/5 for the identical launch with the spec in the environment (#4255
* repro). The supervisor deletes it before starting the agent, so it goes no further. */
export const SURVEY_SPEC_ENV = "HINDSIGHT_SURVEY_SPEC";
/** What the hook hands the detached supervisor (via `SURVEY_SPEC_ENV`). */
export interface SurveySupervisorSpec {
lease: SurveyLease;
bin: string;
args: string[];
heartbeatMs?: number;
}
function tryRename(from: string, to: string): boolean {
try {
renameSync(from, to);
return true;
} catch {
return false; // ENOTEMPTY / EEXIST / EPERM: someone else holds it
}
}
/** Remove only this generation's owner file. A replacement lease is nonempty, so the rmdir that
* follows cannot take a newer owner with it even if one moved in between the two calls. */
export function releaseLease(lease: SurveyLease): void {
try {
unlinkSync(join(lease.directory, lease.owner));
} catch {
return; // already released or taken over: never touch an unknown generation
}
try {
rmdirSync(lease.directory);
} catch {
/* a new owner is already there */
}
}
/** Refresh the lease. `false` only when the owner file is gone — the lease was reclaimed from us.
* Any other failure (a transient EBUSY/EPERM on Windows) is retried on the next beat; if it
* persists the lease goes stale and the ENOENT arrives then. */
export function heartbeatLease(lease: SurveyLease): boolean {
try {
const now = new Date();
utimesSync(join(lease.directory, lease.owner), now, now);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
/** Clear the way for a new owner when the current lease is abandoned. Returns whether it may be
* retried; the retry's own rename is what decides between racing reclaimers. */
function reclaimIfStale(directory: string, staleMs: number): boolean {
const owners = readdirSync(directory);
if (owners.length === 0) {
// A release interrupted between its unlink and its rmdir: unowned, and on Windows a rename
// cannot replace even an empty directory.
try {
rmdirSync(directory);
} catch {
/* a new owner moved in; the retried rename will lose */
}
return true;
}
if (owners.length !== 1) return false; // not a layout we wrote: leave it alone
const age = Date.now() - statSync(join(directory, owners[0])).mtimeMs;
// A future mtime (the clock stepped back) is no proof of a live holder either: a live one
// rewrites it with the current clock within one heartbeat.
if (Math.abs(age) <= staleMs) return false;
releaseLease({ directory, owner: owners[0] });
return true;
}
/**
* Take the lease for `key` under `root`, or `undefined` when a live holder has it. Never throws:
* anything that prevents proving admission counts as "held" — skipping a survey is cheap,
* launching a duplicate one is not.
*
* The owner file is written into a private staging directory which is then RENAMED into place: a
* rename onto a nonempty directory fails on every platform, so exactly one contender wins, and
* unlike `mkdir` there is no instant where the lock exists without its owner.
*/
export function acquireLease(
root: string,
key: string,
staleMs: number = LEASE_STALE_MS
): SurveyLease | undefined {
let staging: string | undefined;
try {
mkdirSync(root, { recursive: true, mode: 0o700 });
staging = mkdtempSync(join(root, "claim-"));
const owner = randomUUID();
writeFileSync(join(staging, owner), "", { flag: "wx", mode: 0o600 });
const directory = join(root, `survey-${key}.lock`);
if (!tryRename(staging, directory)) {
if (!reclaimIfStale(directory, staleMs)) return undefined;
if (!tryRename(staging, directory)) return undefined; // another reclaimer won
}
return { directory, owner };
} catch {
return undefined;
} finally {
if (staging) rmSync(staging, { recursive: true, force: true });
}
}
/**
* Run the survey agent while holding `spec.lease` (the body of the detached supervisor). Resolves
* once the agent is gone; the lease is released when the agent exits and left alone when it was
* lost. `stop()` ends the survey early (the supervisor's signal handlers).
*/
export function superviseSurvey(
spec: SurveySupervisorSpec,
spawnFn: typeof realSpawn = realSpawn
): { done: Promise<void>; stop(): void } {
let stop = () => {};
const done = new Promise<void>((resolve) => {
// The hook took the lease moments ago; if it is already gone, someone else is surveying.
if (!heartbeatLease(spec.lease)) return resolve();
let child: ChildProcess;
try {
child = spawnFn(spec.bin, spec.args, { stdio: "ignore", windowsHide: true });
} catch {
releaseLease(spec.lease);
return resolve();
}
let finished = false;
const finish = (release: boolean) => {
if (finished) return;
finished = true;
clearInterval(timer);
if (release) releaseLease(spec.lease);
resolve();
};
const timer = setInterval(() => {
if (heartbeatLease(spec.lease)) return;
child.kill(); // the lease now belongs to another survey
finish(false);
}, spec.heartbeatMs ?? LEASE_HEARTBEAT_MS);
child.on("error", () => finish(true));
child.once("exit", () => finish(true));
stop = () => {
child.kill();
finish(true);
};
});
return { done, stop: () => stop() };
}
@@ -1,11 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveClaudeBin,
startCodebaseSurvey,
startCodebaseSurvey as startSurvey,
SURVEY_AGENT,
SURVEY_AGENT_CONFIG,
SURVEY_PROMPT,
} from "./survey";
import { releaseLease, SURVEY_SPEC_ENV, type SurveySupervisorSpec } from "./survey-lease";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, readdirSync, writeFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { pathToFileURL, fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveHostConfig } from "./host-client";
import { resolveConfig } from "./config";
vi.mock("./host-client", () => ({ resolveHostConfig: vi.fn() }));
let lockDir: string;
beforeEach(() => {
lockDir = mkdtempSync(join(tmpdir(), "hindsight-survey-test-"));
vi.mocked(resolveHostConfig).mockReturnValue({
cfg: resolveConfig({ apiUrl: "https://api.example.test", apiToken: "test-token" }),
bankId: "bank-1",
});
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(lockDir, { recursive: true, force: true });
});
function startCodebaseSurvey(repoDir: string, opts: Parameters<typeof startSurvey>[1] = {}) {
return startSurvey(repoDir, {
lease: { dir: lockDir },
supervisorPath: "/x/survey-supervisor.js",
...opts,
});
}
describe("resolveClaudeBin", () => {
const ORIGINAL_ENV = process.env.HINDSIGHT_CLAUDE_BIN;
@@ -15,17 +48,17 @@ describe("resolveClaudeBin", () => {
else process.env.HINDSIGHT_CLAUDE_BIN = ORIGINAL_ENV;
});
it("an explicit argument wins over everything", () => {
it("an explicit argument wins over everything", async () => {
process.env.HINDSIGHT_CLAUDE_BIN = "/env/claude";
expect(resolveClaudeBin("/explicit/claude")).toBe("/explicit/claude");
});
it("HINDSIGHT_CLAUDE_BIN env var wins when no explicit arg is given", () => {
it("HINDSIGHT_CLAUDE_BIN env var wins when no explicit arg is given", async () => {
process.env.HINDSIGHT_CLAUDE_BIN = "/env/claude";
expect(resolveClaudeBin()).toBe("/env/claude");
});
it("falls back to the bare 'claude' PATH lookup when nothing else resolves", () => {
it("falls back to the bare 'claude' PATH lookup when nothing else resolves", async () => {
delete process.env.HINDSIGHT_CLAUDE_BIN;
const bin = resolveClaudeBin();
expect(typeof bin).toBe("string");
@@ -35,14 +68,33 @@ describe("resolveClaudeBin", () => {
describe("startCodebaseSurvey", () => {
function fakeSpawn() {
return vi.fn().mockReturnValue({ on: vi.fn(), unref: vi.fn() });
return vi.fn().mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
pid: process.pid,
unref: vi.fn(),
kill: vi.fn(),
});
vi.spyOn(child, "on");
queueMicrotask(() => child.emit("spawn"));
return child;
});
}
const yes = () => true;
/** The survey agent is launched through the lease supervisor: decode the agent's bin + argv
* from the supervisor's spec; the options are the real spawn's (the agent inherits them). */
function launched(spawn: ReturnType<typeof fakeSpawn>, i = 0) {
const [node, argv, options] = spawn.mock.calls[i];
expect(node).toBe("node");
// Only the script path on the command line: the payload rides in the environment (#4255).
expect(argv).toEqual(["/x/survey-supervisor.js"]);
const spec = JSON.parse(options.env[SURVEY_SPEC_ENV]) as SurveySupervisorSpec;
return [spec.bin, spec.args, options, spec] as const;
}
// ── claude recipe (the default / self-contained inline-MCP one) ────────────────────────────────
it("claude: spawns the resolved binary with the expected argv, sandbox, and options", () => {
it("claude: spawns the resolved binary with the expected argv, sandbox, and options", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", {
await startCodebaseSurvey("/repo", {
model: "sonnet",
mcpServerPath: "/x/mcp-server.js",
claudeBin: "/bin/claude",
@@ -51,7 +103,7 @@ describe("startCodebaseSurvey", () => {
});
expect(spawn).toHaveBeenCalledTimes(1);
const [bin, argv, options] = spawn.mock.calls[0];
const [bin, argv, options] = launched(spawn);
expect(bin).toBe("/bin/claude");
expect(argv).toContain("-p");
@@ -91,24 +143,24 @@ describe("startCodebaseSurvey", () => {
expect(child.unref).toHaveBeenCalled();
});
it("claude: defaults model to 'haiku' and --max-budget-usd to 2", () => {
it("claude: defaults model to 'haiku' and --max-budget-usd to 2", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes });
const argv = spawn.mock.calls[0][1];
await startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes });
const argv = launched(spawn)[1];
expect(argv[argv.indexOf("--model") + 1]).toBe("haiku");
expect(argv[argv.indexOf("--max-budget-usd") + 1]).toBe("2");
});
// ── codex recipe (read-only sandbox + inline -c MCP) ───────────────────────────────────────────
it("codex: spawns `codex exec --sandbox read-only` with inline MCP overrides + the prompt", () => {
it("codex: spawns `codex exec --sandbox read-only` with inline MCP overrides + the prompt", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", {
await startCodebaseSurvey("/repo", {
harness: "codex",
mcpServerPath: "/x/mcp-server.js",
spawn,
exists: (b) => b === "codex",
});
const [bin, argv, options] = spawn.mock.calls[0];
const [bin, argv, options] = launched(spawn);
expect(bin).toBe("codex");
expect(argv.slice(0, 3)).toEqual(["exec", "--sandbox", "read-only"]);
expect(argv).toContain(SURVEY_PROMPT);
@@ -125,28 +177,28 @@ describe("startCodebaseSurvey", () => {
});
// ── Antigravity recipe (plan read-only mode + global MCP config) ───────────────────────────────
it("antigravity: spawns `agy -p` in plan mode", () => {
it("antigravity: spawns `agy -p` in plan mode", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", {
await startCodebaseSurvey("/repo", {
harness: "antigravity-cli",
spawn,
exists: (b) => b === "agy",
});
const [bin, argv, options] = spawn.mock.calls[0];
const [bin, argv, options] = launched(spawn);
expect(bin).toBe("agy");
expect(argv).toEqual(["-p", SURVEY_PROMPT, "--mode=plan"]);
expect(options.env.HINDSIGHT_DISABLE_HOOKS).toBe("1");
});
// ── opencode recipe (our own read-only agent; tools from the loaded plugin) ────────────────────
it("opencode: spawns `opencode run` under OUR survey agent, never the built-in plan agent", () => {
it("opencode: spawns `opencode run` under OUR survey agent, never the built-in plan agent", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", {
await startCodebaseSurvey("/repo", {
harness: "opencode",
spawn,
exists: (b) => b === "opencode",
});
const [bin, argv, options] = spawn.mock.calls[0];
const [bin, argv, options] = launched(spawn);
expect(bin).toBe("opencode");
expect(argv).toEqual(["run", "--agent", SURVEY_AGENT, SURVEY_PROMPT]);
// `plan` appends a read-only system-reminder that talks models out of the ingest call the
@@ -157,7 +209,7 @@ describe("startCodebaseSurvey", () => {
// The recipe above is only safe because the agent it names is read-only. opencode drops denied
// tools from the model's tool list entirely, so this ruleset IS the sandbox.
it("the survey agent denies everything except reading and the one ingest tool", () => {
it("the survey agent denies everything except reading and the one ingest tool", async () => {
expect(SURVEY_AGENT_CONFIG.permission["*"]).toBe("deny");
expect(SURVEY_AGENT_CONFIG.permission.hindsight_ingest_document).toBe("allow");
const allowed = Object.entries(SURVEY_AGENT_CONFIG.permission)
@@ -171,45 +223,49 @@ describe("startCodebaseSurvey", () => {
});
// ── agent selection + fallback ─────────────────────────────────────────────────────────────────
it("honors the HINDSIGHT_CODEX_BIN override for the codex binary", () => {
it("honors the HINDSIGHT_CODEX_BIN override for the codex binary", async () => {
const spawn = fakeSpawn();
process.env.HINDSIGHT_CODEX_BIN = "/opt/codex";
try {
startCodebaseSurvey("/repo", { harness: "codex", spawn, exists: (b) => b === "/opt/codex" });
await startCodebaseSurvey("/repo", {
harness: "codex",
spawn,
exists: (b) => b === "/opt/codex",
});
} finally {
delete process.env.HINDSIGHT_CODEX_BIN;
}
expect(spawn.mock.calls[0][0]).toBe("/opt/codex");
expect(launched(spawn)[0]).toBe("/opt/codex");
});
it("falls back to another available agent when the preferred harness's CLI is missing", () => {
it("falls back to another available agent when the preferred harness's CLI is missing", async () => {
const spawn = fakeSpawn();
// Prefer Antigravity, but only codex is installed → survey runs under codex.
startCodebaseSurvey("/repo", {
await startCodebaseSurvey("/repo", {
harness: "antigravity-cli",
mcpServerPath: "/x/mcp-server.js",
spawn,
exists: (b) => b === "codex",
});
const [bin, argv] = spawn.mock.calls[0];
const [bin, argv] = launched(spawn);
expect(bin).toBe("codex");
expect(argv[0]).toBe("exec");
});
it("no capable agent found → no spawn (fail open; the git-log seed still ran)", () => {
it("no capable agent found → no spawn (fail open; the git-log seed still ran)", async () => {
const spawn = fakeSpawn();
startCodebaseSurvey("/repo", { harness: "antigravity-cli", spawn, exists: () => false });
await startCodebaseSurvey("/repo", { harness: "antigravity-cli", spawn, exists: () => false });
expect(spawn).not.toHaveBeenCalled();
});
// ── fail-safe ──────────────────────────────────────────────────────────────────────────────────
it("fail-safe: a spawn that throws synchronously does not throw out of startCodebaseSurvey", () => {
it("fail-safe: a spawn that throws synchronously does not throw out of startCodebaseSurvey", async () => {
const spawn = vi.fn().mockImplementation(() => {
throw new Error("spawn EMFILE");
});
expect(() =>
await expect(
startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes })
).not.toThrow();
).resolves.toBe(false);
});
it("fail-safe: an async 'error' event on the child does not crash the caller", async () => {
@@ -217,7 +273,186 @@ describe("startCodebaseSurvey", () => {
const child = new EventEmitter() as InstanceType<typeof EventEmitter> & { unref: () => void };
child.unref = vi.fn();
const spawn = vi.fn().mockReturnValue(child);
startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes });
const launched = startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes });
expect(() => child.emit("error", new Error("ENOENT"))).not.toThrow();
await expect(launched).resolves.toBe(false);
await expect(startCodebaseSurvey("/repo", { spawn: fakeSpawn(), exists: yes })).resolves.toBe(
true
);
});
it("launches the agent under the detached lease supervisor, which owns the lease", async () => {
const spawn = fakeSpawn();
await expect(
startCodebaseSurvey("/repo", { claudeBin: "/bin/claude", spawn, exists: yes })
).resolves.toBe(true);
const [bin, , options, spec] = launched(spawn);
expect(bin).toBe("/bin/claude");
expect(options).toMatchObject({ cwd: "/repo", detached: true, stdio: "ignore" });
expect(readdirSync(spec.lease.directory)).toEqual([spec.lease.owner]);
expect(spawn.mock.results[0].value.unref).toHaveBeenCalled();
});
it("admits one concurrent survey and permits retry once the lease is released", async () => {
const spawn = fakeSpawn();
const results = await Promise.all(
Array.from({ length: 6 }, () => startCodebaseSurvey("/repo", { spawn, exists: yes }))
);
expect(spawn).toHaveBeenCalledTimes(1);
expect(results.filter(Boolean)).toHaveLength(1);
releaseLease(launched(spawn)[3].lease); // what the supervisor does when the agent exits
await expect(startCodebaseSurvey("/repo", { spawn, exists: yes })).resolves.toBe(true);
expect(spawn).toHaveBeenCalledTimes(2);
});
it("releases the lease when the supervisor cannot be spawned", async () => {
const child = Object.assign(new EventEmitter(), { unref: vi.fn() });
const failing = vi.fn().mockReturnValue(child);
const launch = startCodebaseSurvey("/repo", { spawn: failing, exists: yes });
child.emit("error", Object.assign(new Error("spawn node ENOENT"), { code: "ENOENT" }));
await expect(launch).resolves.toBe(false);
await expect(startCodebaseSurvey("/repo", { spawn: fakeSpawn(), exists: yes })).resolves.toBe(
true
);
});
it("keys admission by the resolved API, credential and bank, not repository or asking harness", async () => {
const spawn = fakeSpawn();
await expect(startCodebaseSurvey("/repo-a", { spawn, exists: yes })).resolves.toBe(true);
// A second directory / fallback harness can write the same destination.
await expect(
startCodebaseSurvey("/repo-b", { harness: "codex", spawn, exists: yes })
).resolves.toBe(false);
for (const [apiUrl, apiToken, bankId] of [
["https://other.example.test", "test-token", "bank-1"],
["https://api.example.test", "other-token", "bank-1"],
["https://api.example.test", "test-token", "bank-2"],
]) {
vi.mocked(resolveHostConfig).mockReturnValue({
cfg: resolveConfig({ apiUrl, apiToken }),
bankId,
});
await expect(startCodebaseSurvey("/repo-a", { spawn, exists: yes })).resolves.toBe(true);
}
expect(spawn).toHaveBeenCalledTimes(4);
});
it("a disabled plugin launches nothing", async () => {
vi.mocked(resolveHostConfig).mockReturnValue({
cfg: { ...resolveConfig({}), disabled: true },
bankId: "",
});
const spawn = fakeSpawn();
await expect(startCodebaseSurvey("/repo", { spawn, exists: yes })).resolves.toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
// Real processes end to end: bundled hook + bundled supervisor + a stand-in agent.
it("single-flights independent hooks, survives their exit, and recovers a dead supervisor", async () => {
const build = (entry: string, outfile: string) =>
buildSync({
entryPoints: [fileURLToPath(new URL(entry, import.meta.url))],
bundle: true,
platform: "node",
format: "esm",
outfile,
banner: {
js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
},
});
const bundle = join(lockDir, "survey.mjs");
const supervisor = join(lockDir, "survey-supervisor.mjs");
build("./survey.ts", bundle);
build("../survey-supervisor.ts", supervisor);
const config = join(lockDir, "config.json");
writeFileSync(
config,
JSON.stringify({ bankId: "bank-1", apiUrl: "https://api.example.test", apiToken: "t" })
);
// The stand-in agent records its pid and its supervisor's, then idles like a long survey.
const agent = `const fs = require('node:fs'); if (process.env.${SURVEY_SPEC_ENV}) fs.writeFileSync(${JSON.stringify(lockDir)} + '/leaked-spec', ''); fs.writeFileSync(${JSON.stringify(lockDir)} + '/pid-' + process.pid + '-' + process.ppid, ''); setInterval(() => {}, 1000);`;
const staleMs = 2_000;
const hook = `
import { startCodebaseSurvey } from ${JSON.stringify(pathToFileURL(bundle).href)};
import { spawn } from 'node:child_process';
const started = await startCodebaseSurvey(${JSON.stringify(lockDir)}, {
exists: () => true,
mcpServerPath: '/unused-mcp-server.js',
supervisorPath: ${JSON.stringify(supervisor)},
lease: { dir: ${JSON.stringify(join(lockDir, "locks"))}, staleMs: ${staleMs}, heartbeatMs: 200 },
// Swap the real agent for the stand-in; the supervisor itself runs for real.
spawn: (_node, argv, options) => {
const spec = { ...JSON.parse(options.env.${SURVEY_SPEC_ENV}), bin: process.execPath, args: ['-e', ${JSON.stringify(agent)}] };
return spawn(process.execPath, argv, { ...options, env: { ...options.env, ${SURVEY_SPEC_ENV}: JSON.stringify(spec) } });
},
});
console.log(JSON.stringify(started));
`;
const runHook = async () => {
const { stdout } = await promisify(execFile)(
process.execPath,
["--input-type=module", "-e", hook],
{
env: {
PATH: process.env.PATH,
HOME: lockDir,
HINDSIGHT_CONFIG: config,
SystemRoot: process.env.SystemRoot,
},
}
);
return JSON.parse(stdout.trim()) as boolean;
};
const race = async () =>
(await Promise.all(Array.from({ length: 6 }, runHook))).filter(Boolean).length;
const agents = () =>
readdirSync(lockDir)
.filter((name) => name.startsWith("pid-"))
.map((name) => {
const [, pid, ppid] = name.split("-").map(Number);
return { pid, ppid };
});
const alive = (pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
try {
expect(await race()).toBe(1);
await vi.waitFor(() => expect(agents()).toHaveLength(1));
// The supervisor strips the spec before starting the agent.
expect(readdirSync(lockDir)).not.toContain("leaked-spec");
// Every hook has exited; past the stale window the heartbeat still holds the lease.
await sleep(staleMs * 2);
expect(await runHook()).toBe(false);
// The agent finishes: the supervisor releases, and exactly one new survey is admitted.
const first = agents()[0];
process.kill(first.pid, "SIGTERM");
await vi.waitFor(() => expect(alive(first.ppid)).toBe(false));
expect(await race()).toBe(1);
await vi.waitFor(() => expect(agents().filter((a) => alive(a.pid))).toHaveLength(1));
// The supervisor dies without releasing: its lease goes stale, and one reclaimer wins.
const second = agents().find((a) => alive(a.pid))!;
process.kill(second.ppid, "SIGKILL");
expect(await runHook()).toBe(false); // not stale yet
await sleep(staleMs * 2);
expect(await race()).toBe(1);
} finally {
for (const { pid, ppid } of agents()) {
for (const p of [pid, ppid]) {
try {
process.kill(p, "SIGKILL");
} catch {
/* already exited */
}
}
}
}
}, 30_000);
});
@@ -37,11 +37,20 @@
* missing binary or a spawn failure must silently no-op, never crash the caller.
*/
import { spawn as realSpawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { binOnPath } from "./util";
import { resolveHostConfig } from "./host-client";
import {
acquireLease,
LEASE_STALE_MS,
releaseLease,
SURVEY_SPEC_ENV,
type SurveySupervisorSpec,
} from "./survey-lease";
/** Deterministic doc ids of the survey's findings (its fixed titles slugified by
* hindsight_ingest_document). Their presence in the bank = the survey actually FINISHED —
@@ -282,9 +291,12 @@ function buildSurveyPlan(
* Spawn a DETACHED headless agent to survey `repoDir` and ingest structural findings via the
* `hindsight_ingest_document` tool. Runs the survey under the current harness's own CLI when
* available, else falls back to any available agent (claude → codex → antigravity → opencode — claude and
* codex first because their inline-MCP recipes are self-contained). Fire-and-forget; never throws.
* codex first because their inline-MCP recipes are self-contained). The agent runs under the
* detached lease supervisor (survey-supervisor.ts), so only one survey per destination runs at a
* time (#4255). Resolves once launched, not when the survey finishes; false means no launch.
* Never throws.
*/
export function startCodebaseSurvey(
export async function startCodebaseSurvey(
repoDir: string,
opts: {
harness?: SurveyHarness;
@@ -294,8 +306,10 @@ export function startCodebaseSurvey(
claudeBin?: string;
spawn?: typeof realSpawn;
exists?: (bin: string) => boolean; // seam for tests
supervisorPath?: string;
lease?: { dir?: string; staleMs?: number; heartbeatMs?: number }; // seam for tests
} = {}
): void {
): Promise<boolean> {
try {
const spawnFn = opts.spawn ?? realSpawn;
const exists = opts.exists ?? binOnPath;
@@ -324,22 +338,62 @@ export function startCodebaseSurvey(
budgetUsd: opts.budgetUsd,
mcpServerPath,
});
const child = spawnFn(plan.bin, plan.args, {
cwd: repoDir,
detached: true,
stdio: "ignore",
windowsHide: true,
env: plan.env,
// Use the SAME resolver as the selected agent's MCP/plugin, including fallback harnesses
// and bank overrides. Hash credentials too: equal bank ids on one API can belong to
// different tenants, but neither tokens nor bank names should appear in scratch paths.
const { cfg, bankId } = resolveHostConfig(harness, repoDir);
if (cfg.disabled) return false;
const key = createHash("sha256")
.update(JSON.stringify([cfg.apiUrl.replace(/\/+$/, ""), cfg.apiToken ?? "", bankId]))
.digest("hex");
const lease = acquireLease(
opts.lease?.dir ?? join(tmpdir(), "hindsight-coding-agent", "surveys"),
key,
opts.lease?.staleMs ?? LEASE_STALE_MS
);
if (!lease) return false;
const spec: SurveySupervisorSpec = {
lease,
bin: plan.bin,
args: plan.args,
...(opts.lease?.heartbeatMs ? { heartbeatMs: opts.lease.heartbeatMs } : {}),
};
const supervisorPath =
opts.supervisorPath ??
join(dirname(fileURLToPath(import.meta.url)), "survey-supervisor.js");
return await new Promise<boolean>((resolve) => {
try {
// The agent inherits the supervisor's cwd and env. The spec rides in the environment,
// never argv — see SURVEY_SPEC_ENV for the endpoint-security kill that argv triggers.
const child = spawnFn("node", [supervisorPath], {
cwd: repoDir,
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...plan.env, [SURVEY_SPEC_ENV]: JSON.stringify(spec) },
});
let spawned = false;
// spawn() failures (node not found, EACCES, sandboxes) arrive as an async 'error' event;
// unhandled, it would crash the caller.
child.on("error", () => {
if (spawned) return; // the supervisor owns the lease now
releaseLease(lease);
resolve(false);
});
child.once("spawn", () => {
spawned = true;
child.unref();
resolve(true);
});
} catch {
releaseLease(lease);
resolve(false);
}
});
// spawn() failures (binary not found, EACCES, sandboxed environments) often arrive
// ASYNCHRONOUSLY as an 'error' event on the child, not as a synchronous throw — an unhandled
// 'error' event would crash the caller. Swallow it: fire-and-forget best-effort.
child.on("error", () => {});
child.unref();
return; // one survey agent is enough
}
// No capable agent found — fail open (the git-log seed already ran; the survey is a bonus).
} catch {
/* best-effort: a failed spawn must not break the caller */
}
return false;
}
@@ -0,0 +1,14 @@
#!/usr/bin/env node
/**
* Hold the codebase-survey lease for the lifetime of the survey agent. Spawned DETACHED by
* core/survey.ts, which hands over the `SurveySupervisorSpec` in the `SURVEY_SPEC_ENV` variable
* (not argv — see there), because the hook that won the lease exits immediately and the agent
* itself cannot heartbeat. See core/survey-lease.ts.
*/
import { SURVEY_SPEC_ENV, superviseSurvey, type SurveySupervisorSpec } from "./core/survey-lease";
const spec = JSON.parse(process.env[SURVEY_SPEC_ENV] ?? "") as SurveySupervisorSpec;
delete process.env[SURVEY_SPEC_ENV]; // the agent (and its MCP server) inherit this environment
const run = superviseSurvey(spec);
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.once(signal, run.stop);
void run.done; // the agent and the heartbeat keep this process alive until it resolves
@@ -57,6 +57,8 @@ export default defineConfig({
"droid-stop-hook": "src/droid-stop-hook.ts",
// Spawned DETACHED to start the local daemon — a cold start outlives every hook timeout.
"daemon-start": "src/daemon-start.ts",
// Spawned DETACHED to hold the codebase-survey lease for the survey agent's lifetime.
"survey-supervisor": "src/survey-supervisor.ts",
"mcp-server": "src/mcp-server.ts",
"hindsight-seed": "src/hindsight-seed.ts",
},