fix(auto): guard startAuto() against concurrent invocation (#2923)

checkAutoStartAfterDiscuss() fire-and-forgets startAuto() when a
milestone is ready. The headless runner then chains `/gsd auto`,
calling startAuto() a second time. Two concurrent auto-loops on the
same AutoSession singleton corrupt shared state (counters, dispatch
maps), causing planning/execution to never run after research.

Add an early `s.active` check at the top of startAuto() so the second
call no-ops. Add source-scanning test to enforce the guard exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-27 19:35:10 -06:00
parent eb2cfa580c
commit 86f97885cc
2 changed files with 23 additions and 0 deletions

View file

@ -1061,6 +1061,11 @@ export async function startAuto(
verboseMode: boolean,
options?: { step?: boolean },
): Promise<void> {
if (s.active) {
debugLog("startAuto", { phase: "already-active", skipping: true });
return;
}
const requestedStepMode = options?.step ?? false;
// Escape stale worktree cwd from a previous milestone (#608).

View file

@ -1195,6 +1195,24 @@ test("startAuto calls selfHealRuntimeRecords before autoLoop (#1727)", { skip: "
);
});
test("startAuto guards against concurrent invocation (#2923)", () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto.ts"),
"utf-8",
);
const fnIdx = src.indexOf("export async function startAuto");
assert.ok(fnIdx > -1, "startAuto must exist in auto.ts");
// The guard must appear before any other logic in the function body
const fnBody = src.slice(fnIdx, fnIdx + 500);
const activeGuard = fnBody.indexOf("if (s.active)");
assert.ok(activeGuard > -1, "startAuto must check s.active to prevent concurrent auto-loops");
const returnIdx = fnBody.indexOf("return;", activeGuard);
assert.ok(
returnIdx > -1 && returnIdx < activeGuard + 120,
"s.active guard must early-return to prevent a second concurrent loop",
);
});
test("agent_end handler calls resolveAgentEnd (not handleAgentEnd)", () => {
const hooksSrc = readFileSync(
resolve(import.meta.dirname, "..", "bootstrap", "register-hooks.ts"),