fix(sync-gbrain): refuse on an active autopilot before the code stage registers a source

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Evgenii LopatinandClaude Sonnet 5.5 authored and garrytan committed 2026-10-24 20:24:47 +00:00
1 parent 00100f1598
commit 4da504ace6
2 files changed
+62 -7

No files matched your search

+13 -7
View File
@@ -1225,6 +1225,18 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
return skipStageForLocalStatus("code", localStatus, t0);
}
// #1734: refuse on an active autopilot BEFORE any source-metadata change
// (Step 0b rename, Step 1 `sources add`), so a refused stage leaves the
// brain's source list untouched. The check right before the destructive walk
// below stays: autopilot can start while the steps in between run.
const refusedAutopilot = (signal: string | null): StageResult => ({
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
});
const apEarly = detectAutopilot(gbrainEnv);
if (apEarly.active) return refusedAutopilot(apEarly.signal);
// Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form).
// Earlier /sync-gbrain versions registered `gstack-code-<slug>` (no path
// suffix). On a multi-worktree repo, those collapsed onto a single id
@@ -1308,13 +1320,7 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
// --allow-reclone. Both surface a visible reason and fail the stage so the
// verdict shows ERR rather than silently skipping protection.
const apBeforeWalk = detectAutopilot(gbrainEnv);
if (apBeforeWalk.active) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
};
}
if (apBeforeWalk.active) return refusedAutopilot(apBeforeWalk.signal);
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
if (!reclone.allow) {
return {
+49
View File
@@ -1139,3 +1139,52 @@ esac
expect(r.stderr.length).toBeLessThan(64 * 1024);
});
});
// #1734: a refused code stage must leave the brain's source list untouched.
describe("code stage autopilot refusal registers nothing (#1734)", () => {
it.skipIf(process.platform === "win32")("refuses before `gbrain sources add` when an autopilot process is visible", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
const repo = mkdtempSync(join(tmpdir(), "gstack-ap-refuse-repo-"));
const bindir = mkdtempSync(join(tmpdir(), "gstack-ap-refuse-bin-"));
const commandLog = join(home, "gbrain-commands.log");
mkdirSync(gstackHome, { recursive: true });
mkdirSync(join(home, ".gbrain"), { recursive: true });
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" }));
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 });
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG"
case "$1 $2" in
"--version ") echo 'gbrain 0.59.0.0' ;;
"sources list") echo '{"sources":[]}' ;;
"sources add"|"sources attach"|"sync --strategy") ;;
*) echo "unexpected gbrain command: $*" >&2; exit 1 ;;
esac
`);
chmodSync(join(bindir, "gbrain"), 0o755);
writeFileSync(join(bindir, "pgrep"), "#!/bin/sh\necho 4242\nexit 0\n");
chmodSync(join(bindir, "pgrep"), 0o755);
const r = spawnSync("bun", [SCRIPT, "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: {
...process.env,
HOME: home,
GSTACK_HOME: gstackHome,
GBRAIN_HOME: "",
GSTACK_TEST_GBRAIN_LOG: commandLog,
PATH: `${bindir}:${process.env.PATH || ""}`,
},
});
const commands = existsSync(commandLog) ? readFileSync(commandLog, "utf-8") : "";
const state = JSON.parse(readFileSync(join(gstackHome, ".gbrain-sync-state.json"), "utf-8"));
const code = state.last_stages.find((s: { name: string }) => s.name === "code");
for (const d of [repo, bindir, home]) rmSync(d, { recursive: true, force: true });
expect(r.status).toBe(1);
expect(code.ok).toBe(false);
expect(code.summary).toContain("refused: gbrain autopilot active (process:gbrain autopilot)");
expect(commands).not.toMatch(/^sources (add|rename|remove) /m);
expect(commands).not.toMatch(/^sync /m);
});
});