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:
commit
b0697f24f6
3 changed files with 140 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue