Merge pull request #3519 from jeremymcs/fix/auto-wrapup-inflight-interrupt

fix(gsd): prevent auto-wrapup from interrupting in-flight tool calls
This commit is contained in:
Jeremy McSpadden 2026-04-04 19:26:02 -05:00 committed by GitHub
commit b0697f24f6
3 changed files with 140 additions and 2 deletions

View file

@ -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) {

View file

@ -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<string, unknown> | 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<void> {
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<string, unknown> | 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).

View file

@ -0,0 +1,107 @@
// GSD-2 — Regression tests for #3512: gsd-auto-wrapup mid-turn interruption
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
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",
);
});
});