fix(sync-gbrain): autopilot refusal names the matched pid and command and how to proceed (#3360 part 1) (#3377)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 06:51:16 +00:00
1 parent db87940e24
commit 688f0729f5
6 files changed
+103 -16

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.91.199.0] - 2026-10-25
**When /sync-gbrain refuses because a gbrain autopilot is running, it now says which process it found and what to do.** The guard against syncing while `gbrain autopilot` runs is machine-wide on purpose: two syncs sharing one brain or working tree can race and delete files (#1734). But the refusal only said `(process:gbrain autopilot)`, so someone whose autopilot served a different brain could not tell what was blocking them (#3360, part 1). Garry decided on 2026-10-24 to keep the check machine-wide and improve the message.
### Changed
- **`lib/gbrain-guards.ts`**: the live-process probe still runs `pgrep -f "gbrain autopilot"` across the whole machine, then reads each match's command line with `ps`. The refusal names every match as `pid <n> \`<command>\``, says the check counts any process on the machine whose command line contains "gbrain autopilot" (an autopilot serving another brain or repo included), and says to stop that autopilot or its `gbrain autopilot --install` service, or end a process that only mentions it. Commands are cut at 200 characters and the user and password part of a URL is masked. A pid that `ps` cannot describe still refuses, as `command unavailable`. The header comment now gives the machine-wide reason in place of the stale gbrain #1226 note.
### Tests
- `test/gbrain-guards.test.ts` pins the full refusal for one matched process, lists two matches, keeps an empty match inactive, and runs the real probe against a live process whose command line carries "gbrain autopilot" and a database URL with a password: the refusal names its pid and masks the password. All four fail on main. The existing probe, lock and stale-lock tests are unchanged.
### Gate integrity
- No gate edit: the guard refuses in exactly the cases it did before, and only its message changed. No threshold, budget, timeout, trial count or case kind changed.
## [1.91.198.0] - 2026-10-25
**A second-opinion reviewer that says "not ready" is no longer reported as clean.** When the outside reviewer (Codex or Claude Code) ended its reply with `Verdict: not ready` but listed only advisory P2 or P3 findings, `lib/outside-review-result.ts` printed `VERDICT: clean` and exited 0, so /ship and /codex carried on as if the reviewer had approved. The not-ready check only ran when no severity parsed at all, so a parsed P2 or P3 switched it off (#3329). Ported from #3364 by @mvanhorn.
+1 -1
View File
@@ -1 +1 @@
1.91.198.0
1.91.199.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.198.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.199.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,
+40 -13
View File
@@ -17,9 +17,12 @@
* subcommand `--help` is generic — so capability detection is best-effort and
* defaults to "unsupported". When we can't protect a user-managed source's
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
* known lock paths under both the configured home and ~/.gbrain are secondary.
* - The live `gbrain autopilot` process is the PRIMARY signal and it is
* deliberately machine-wide (#3360, Garry 2026-10-24): any autopilot on the
* machine refuses, whichever brain or repo it serves, because two syncs that
* share one brain or working tree can race. The refusal names the matched
* pid and command so the user can tell whose it is. Known lock paths under
* both the configured home and ~/.gbrain are secondary.
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
* never blocks a normal sync (that would brick the tool).
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
@@ -69,11 +72,17 @@ export interface AutopilotStatus {
signal: string | null;
}
/** A running process whose command line matches `gbrain autopilot`. */
export interface AutopilotProcess {
pid: number;
command: string;
}
export interface AutopilotProbe {
/** Override the lock-path list (tests). */
lockPaths?: string[];
/** Override the live-process check (tests). */
processRunning?: () => boolean;
/** Override the live-process check (tests): a bare boolean, or the matched processes. */
processRunning?: () => boolean | AutopilotProcess[];
}
/**
@@ -109,10 +118,17 @@ export function detectAutopilot(
// Stale lock (holder pid is dead): ignore this signal, keep checking. Pure
// decision function — we do NOT delete the file here; the caller may clean it.
}
// Primary signal: a live `gbrain autopilot` process.
const running = (probe.processRunning ?? defaultProcessRunning)();
if (running) return { active: true, signal: "process:gbrain autopilot" };
return { active: false, signal: null };
// Primary signal: a live `gbrain autopilot` process anywhere on the machine.
const running = (probe.processRunning ?? autopilotProcesses)();
if (running === true) return { active: true, signal: "process:gbrain autopilot" };
if (!Array.isArray(running) || running.length === 0) return { active: false, signal: null };
const listed = running.map(p => `pid ${p.pid} \`${p.command}\``).join(", ");
return {
active: true,
signal: `process:gbrain autopilot ${listed}; this check counts any process on this machine whose command line contains ` +
`"gbrain autopilot", even an autopilot serving another brain or repo. Stop that autopilot (or its service, if ` +
`\`gbrain autopilot --install\` set one up), or end the process that only mentions it`,
};
}
/** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */
@@ -143,11 +159,22 @@ function isPidAlive(pid: number): boolean {
}
}
function defaultProcessRunning(): boolean {
function autopilotProcesses(): AutopilotProcess[] {
// No reliable pgrep on Windows; rely on the lock-file signal there.
if (process.platform === "win32") return false;
const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
return r.status === 0 && (r.stdout || "").trim().length > 0;
if (process.platform === "win32") return [];
const found = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
const pids = found.status === 0 ? (found.stdout || "").split(/\s+/).filter(p => /^\d+$/.test(p)) : [];
if (pids.length === 0) return [];
const ps = spawnSync("ps", ["-o", "pid=,args=", "-p", pids.join(",")], { encoding: "utf-8", timeout: 3_000 });
const commands = new Map<string, string>();
for (const line of (ps.stdout || "").split("\n")) {
const m = line.match(/^\s*(\d+)\s+(.*\S)/);
if (m) commands.set(m[1], m[2]);
}
return pids.map(pid => ({
pid: Number(pid),
command: (commands.get(pid) ?? "command unavailable").replace(/\/\/[^\s/@]+@/g, "//***@").slice(0, 200),
}));
}
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.198",
"version": "1.91.199",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+47
View File
@@ -1,4 +1,5 @@
import { describe, test, expect, afterEach } from "bun:test";
import { spawn } from "child_process";
import * as fs from "fs";
import * as os from "os";
import { join } from "path";
@@ -78,6 +79,52 @@ describe("detectAutopilot", () => {
expect(r.signal).toBe("process:gbrain autopilot");
});
// #3360: the probe stays machine-wide; the refusal names whose autopilot it is.
test("a matched autopilot process is named by pid and command, with how to proceed", () => {
const r = detectAutopilot(process.env, {
lockPaths: [],
processRunning: () => [{ pid: 4242, command: "gbrain autopilot --repo /src/other-brain" }],
});
expect(r.active).toBe(true);
expect(r.signal).toBe(
"process:gbrain autopilot pid 4242 `gbrain autopilot --repo /src/other-brain`; this check counts any process on this machine " +
"whose command line contains \"gbrain autopilot\", even an autopilot serving another brain or repo. Stop that autopilot " +
"(or its service, if `gbrain autopilot --install` set one up), or end the process that only mentions it",
);
});
test("every matched autopilot process is listed", () => {
const r = detectAutopilot(process.env, {
lockPaths: [],
processRunning: () => [
{ pid: 4242, command: "gbrain autopilot --repo /a" },
{ pid: 4343, command: "gbrain autopilot --repo /b" },
],
});
expect(r.signal).toContain("pid 4242 `gbrain autopilot --repo /a`, pid 4343 `gbrain autopilot --repo /b`;");
});
test("no matched process is inactive", () => {
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => [] });
expect(r).toEqual({ active: false, signal: null });
});
test.skipIf(process.platform === "win32")("the real probe names a live process anywhere on the machine, credentials masked", async () => {
const url = ["postgres://u", "hunter2@h/db"].join(":");
const child = spawn(process.execPath, ["-e", "console.log('ready'); setTimeout(() => {}, 30000)", `gbrain autopilot --repo /tmp/gstack-3360 --db ${url}`], { stdio: ["ignore", "pipe", "ignore"] });
try {
await new Promise(resolve => child.stdout!.once("data", resolve));
const r = detectAutopilot(process.env, { lockPaths: [] });
expect(r.active).toBe(true);
expect(r.signal).toContain(`pid ${child.pid} \``);
expect(r.signal).toContain("gbrain autopilot --repo /tmp/gstack-3360 --db postgres://***@h/db");
expect(r.signal).not.toContain("hunter2");
expect(r.signal).toContain("Stop that autopilot");
} finally {
child.kill("SIGKILL");
}
});
test("a lock with no parseable pid stays conservative (active, no pid in signal)", () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
const lock = join(tmp, "autopilot.lock");