v1.91.216.0 fix(sync-gbrain): refuse an active autopilot before touching the source list (#3360 part 2, port of #3362) (#3386)

* 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>

* docs(changelog): sync-gbrain autopilot refusal before source registration (#3360, port of #3362)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(sync-gbrain): the early autopilot refusal check matches #3377's pid-naming wording (#3360)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Evgenii Lopatin <e75533@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 16:35:45 +00:00
1 parent 5ac0f56934
commit 64d82101ca
6 files changed
+76 -10

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.91.216.0] - 2026-10-25
**/sync-gbrain refuses an active autopilot before it touches the brain's source list (#3360, part 2; ported from #3362 by @exGeni).** The code stage checked for a running `gbrain autopilot` only right before its destructive walk. By then it had already cleaned up legacy sources, renamed a source and run `gbrain sources add`, so a refused run still registered a source it never synced.
### Fixed
- **`bin/gstack-gbrain-sync.ts`**: the code stage runs the autopilot check before Step 0a, so a refusal leaves the source list untouched. The check before the walk stays, because autopilot can start while the steps in between run. Dry-run, read-only and deny policies and the local-engine skip still return earlier and are unchanged. Which processes count as an autopilot (the machine-wide probe, #3360 part 1) is unchanged.
### Tests
- `test/gstack-gbrain-sync.test.ts` runs the real script against a fake `gbrain` and a `pgrep` that reports an autopilot: the stage is refused and the fake saw no `sources add`, `rename`, `remove` or `sync`. On main the same run issues `sources add`.
## [1.91.215.0] - 2026-10-25
**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr <n> --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way.
+1 -1
View File
@@ -1 +1 @@
1.91.215.0
1.91.216.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.215.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.216.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+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 {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.215",
"version": "1.91.216",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+50
View File
@@ -1139,3 +1139,53 @@ 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 pid 4242 ");
expect(code.summary).toContain("Stop autopilot, then re-run /sync-gbrain.");
expect(commands).not.toMatch(/^sources (add|rename|remove) /m);
expect(commands).not.toMatch(/^sync /m);
});
});