diff --git a/src/resources/extensions/gsd/auto-timers.ts b/src/resources/extensions/gsd/auto-timers.ts index 2b14365d0..b2faa0c44 100644 --- a/src/resources/extensions/gsd/auto-timers.ts +++ b/src/resources/extensions/gsd/auto-timers.ts @@ -122,6 +122,10 @@ export function startUnitSupervision(sctx: SupervisionContext): void { phase: "wrapup-warning-sent", wrapupWarningSent: true, }); + // Only trigger a new turn if no tools are currently in flight. + // Triggering during active tool calls causes tool results to be skipped + // with "Skipped due to queued user message", leading to provider errors (#3512). + const softTrigger = getInFlightToolCount() === 0; pi.sendMessage( { customType: "gsd-auto-wrapup", @@ -136,7 +140,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void { "4. leave precise resume notes if anything remains unfinished", ].join("\n"), }, - { triggerTurn: true }, + { triggerTurn: softTrigger }, ); }, softTimeoutMs); @@ -293,6 +297,8 @@ export function startUnitSupervision(sctx: SupervisionContext): void { ); } + // Only trigger a new turn if no tools are currently in flight (#3512). + const contextTrigger = getInFlightToolCount() === 0; pi.sendMessage( { customType: "gsd-auto-wrapup", @@ -308,7 +314,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void { "Do NOT start new sub-tasks or investigations.", ].join("\n"), }, - { triggerTurn: true }, + { triggerTurn: contextTrigger }, ); if (s.continueHereHandle) { diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4fa51648e..a7cceab81 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -606,6 +606,18 @@ export async function stopAuto( debugLog("stop-cleanup-locks", { error: e instanceof Error ? e.message : String(e) }); } + // ── Step 1b: Flush queued follow-up messages (#3512) ── + // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger + // extra LLM turns after stop. Flush them the same way run-unit.ts does. + try { + const cmdCtxAny = s.cmdCtx as Record | null; + if (typeof cmdCtxAny?.clearQueue === "function") { + (cmdCtxAny.clearQueue as () => unknown)(); + } + } catch (e) { + debugLog("stop-cleanup-queue", { error: e instanceof Error ? e.message : String(e) }); + } + // ── Step 2: Skill state ── try { clearSkillSnapshot(); @@ -834,6 +846,19 @@ export async function pauseAuto( ): Promise { if (!s.active) return; clearUnitTimeout(); + + // Flush queued follow-up messages (#3512). + // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger + // extra LLM turns after pause. Flush them the same way run-unit.ts does. + try { + const cmdCtxAny = s.cmdCtx as Record | null; + if (typeof cmdCtxAny?.clearQueue === "function") { + (cmdCtxAny.clearQueue as () => unknown)(); + } + } catch (e) { + debugLog("pause-cleanup-queue", { error: e instanceof Error ? e.message : String(e) }); + } + // Unblock any pending unit promise so the auto-loop is not orphaned. // Pass errorContext so runUnitPhase can distinguish user-initiated pause // from provider-error pause and avoid hard-stopping (#2762). diff --git a/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts b/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts new file mode 100644 index 000000000..5ad5311b2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts @@ -0,0 +1,107 @@ +// GSD-2 — Regression tests for #3512: gsd-auto-wrapup mid-turn interruption +// Copyright (c) 2026 Jeremy McSpadden + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const autoTimersPath = join(import.meta.dirname, "..", "auto-timers.ts"); +const autoTimersSrc = readFileSync(autoTimersPath, "utf-8"); + +const autoPath = join(import.meta.dirname, "..", "auto.ts"); +const autoSrc = readFileSync(autoPath, "utf-8"); + +const runUnitPath = join(import.meta.dirname, "..", "auto", "run-unit.ts"); +const runUnitSrc = readFileSync(runUnitPath, "utf-8"); + +describe("#3512: gsd-auto-wrapup must not interrupt in-flight tool calls", () => { + test("soft timeout wrapup gates triggerTurn on getInFlightToolCount() === 0", () => { + // The soft timeout sendMessage must NOT use a hardcoded `triggerTurn: true`. + // It must check getInFlightToolCount() before deciding whether to trigger. + // Use the section marker comment to isolate the soft timeout block. + const startMarker = "── 1. Soft timeout warning"; + const endMarker = "── 2. Idle watchdog"; + const softTimeoutSection = autoTimersSrc.slice( + autoTimersSrc.indexOf(startMarker), + autoTimersSrc.indexOf(endMarker), + ); + assert.ok( + softTimeoutSection.length > 0, + "Could not locate soft timeout section", + ); + + // Must reference getInFlightToolCount to gate the trigger + assert.ok( + softTimeoutSection.includes("getInFlightToolCount"), + "Soft timeout wrapup must gate triggerTurn behind getInFlightToolCount() check", + ); + + // Must NOT have a hardcoded triggerTurn: true + assert.ok( + !softTimeoutSection.includes("triggerTurn: true"), + "Soft timeout wrapup must not use hardcoded triggerTurn: true", + ); + }); + + test("context-pressure wrapup gates triggerTurn on getInFlightToolCount() === 0", () => { + // The context budget sendMessage must NOT use a hardcoded `triggerTurn: true`. + // Use the section marker to isolate the context-pressure block. + const startMarker = "── 4. Context-pressure continue-here monitor"; + const contextSection = autoTimersSrc.slice( + autoTimersSrc.indexOf(startMarker), + ); + assert.ok( + contextSection.length > 0, + "Could not locate context budget section", + ); + + // Must reference getInFlightToolCount to gate the trigger + assert.ok( + contextSection.includes("getInFlightToolCount"), + "Context budget wrapup must gate triggerTurn behind getInFlightToolCount() check", + ); + + // Must NOT have a hardcoded triggerTurn: true + assert.ok( + !contextSection.includes("triggerTurn: true"), + "Context budget wrapup must not use hardcoded triggerTurn: true", + ); + }); +}); + +describe("#3512: pauseAuto and stopAuto must flush queued follow-up messages", () => { + test("stopAuto calls clearQueue()", () => { + // stopAuto must flush queued messages to prevent late async_job_result + // notifications from triggering extra LLM turns after stop. + const stopAutoSection = autoSrc.slice( + autoSrc.indexOf("export async function stopAuto("), + autoSrc.indexOf("export async function pauseAuto("), + ); + assert.ok(stopAutoSection, "Could not locate stopAuto function"); + assert.ok( + stopAutoSection.includes("clearQueue"), + "stopAuto must call clearQueue() to flush queued follow-up messages", + ); + }); + + test("pauseAuto calls clearQueue()", () => { + // pauseAuto must also flush queued messages — same issue as stopAuto. + const pauseAutoSection = autoSrc.slice( + autoSrc.indexOf("export async function pauseAuto("), + ); + assert.ok(pauseAutoSection, "Could not locate pauseAuto function"); + assert.ok( + pauseAutoSection.includes("clearQueue"), + "pauseAuto must call clearQueue() to flush queued follow-up messages", + ); + }); + + test("run-unit.ts still has its existing clearQueue() call (baseline)", () => { + // Verify the original clearQueue pattern in run-unit.ts hasn't been removed. + assert.ok( + runUnitSrc.includes("clearQueue"), + "run-unit.ts must retain its clearQueue() call after unit completion", + ); + }); +});