From 86f97885cc01986911c7d733917c4e65c85ffef8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 19:35:10 -0600 Subject: [PATCH] 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) --- src/resources/extensions/gsd/auto.ts | 5 +++++ .../extensions/gsd/tests/auto-loop.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 81939a4ae..2fc826095 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1061,6 +1061,11 @@ export async function startAuto( verboseMode: boolean, options?: { step?: boolean }, ): Promise { + 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). diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index af3fda5ca..ce6c5d0f2 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -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"),