fix: keep headless alive for provider auto-resume

This commit is contained in:
Mikael Hugo 2026-04-29 20:16:23 +02:00
parent db41f92812
commit 120d7deda8
3 changed files with 79 additions and 8 deletions

View file

@ -117,16 +117,30 @@ export function isTerminalNotification(
return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix));
}
export function isPauseNotification(event: Record<string, unknown>): boolean {
if (event.type !== "extension_ui_request" || event.method !== "notify")
return false;
const message = String(event.message ?? "").toLowerCase();
return (
message.startsWith("auto-mode paused") ||
message.startsWith("step-mode paused")
);
}
export function isAutoResumeScheduledNotification(
event: Record<string, unknown>,
): boolean {
if (event.type !== "extension_ui_request" || event.method !== "notify")
return false;
return /auto-resuming in \d+s/i.test(String(event.message ?? ""));
}
export function isBlockedNotification(event: Record<string, unknown>): boolean {
if (event.type !== "extension_ui_request" || event.method !== "notify")
return false;
const message = String(event.message ?? "").toLowerCase();
// Blocked notifications come through stopAuto as "Auto-mode stopped (Blocked: ...)"
return (
message.includes("blocked:") ||
message.startsWith("auto-mode paused") ||
message.startsWith("step-mode paused")
);
return message.includes("blocked:") || isPauseNotification(event);
}
export function isMilestoneReadyNotification(

View file

@ -30,12 +30,14 @@ import {
EXIT_SUCCESS,
FIRE_AND_FORGET_METHODS,
IDLE_TIMEOUT_MS,
MULTI_TURN_DEADLOCK_BACKSTOP_MS,
isAutoResumeScheduledNotification,
isBlockedNotification,
isInteractiveHeadlessTool,
isMilestoneReadyNotification,
isPauseNotification,
isQuickCommand,
isTerminalNotification,
MULTI_TURN_DEADLOCK_BACKSTOP_MS,
mapStatusToExitCode,
NEW_MILESTONE_IDLE_TIMEOUT_MS,
shouldArmHeadlessIdleTimeout,
@ -554,6 +556,7 @@ async function runHeadlessOnce(
let completed = false;
let exitCode = 0;
let milestoneReady = false; // tracks "Milestone X ready." for auto-chaining
let providerAutoResumePending = false;
const recentEvents: TrackedEvent[] = [];
let lastVisibleProgressAt = Date.now();
const interactiveToolCallIds = new Set<string>();
@ -1100,8 +1103,15 @@ async function runHeadlessOnce(
// Handle extension_ui_request
if (eventObj.type === "extension_ui_request" && clientStarted) {
const waitForProviderAutoResume =
providerAutoResumePending && isPauseNotification(eventObj);
if (isAutoResumeScheduledNotification(eventObj)) {
providerAutoResumePending = true;
}
// Check for terminal notification before auto-responding
if (isBlockedNotification(eventObj)) {
if (isBlockedNotification(eventObj) && !waitForProviderAutoResume) {
blocked = true;
}
@ -1110,13 +1120,20 @@ async function runHeadlessOnce(
milestoneReady = true;
}
if (isTerminalNotification(eventObj)) {
if (isTerminalNotification(eventObj) && !waitForProviderAutoResume) {
completed = true;
}
// Structured trace: handle unit start/end notify messages
if (eventObj.method === "notify") {
const message = String(eventObj.message ?? "");
if (
message.includes("Auto-mode resumed") ||
message.includes("Step-mode resumed") ||
(message.includes("[unit]") && message.includes("starting"))
) {
providerAutoResumePending = false;
}
if (traceActive) {
if (message.includes("[unit]") && message.includes("starting")) {
handleUnitStart(message);

View file

@ -194,8 +194,10 @@ import {
EXIT_CANCELLED,
EXIT_ERROR,
EXIT_SUCCESS,
isAutoResumeScheduledNotification,
isBlockedNotification,
isInteractiveHeadlessTool,
isPauseNotification,
isTerminalNotification,
mapStatusToExitCode,
shouldArmHeadlessIdleTimeout,
@ -271,6 +273,44 @@ test("isBlockedNotification: auto pause exits as blocked", () => {
);
});
test("isAutoResumeScheduledNotification detects provider auto-resume notices", () => {
assert.equal(
isAutoResumeScheduledNotification({
type: "extension_ui_request",
method: "notify",
message: "Rate limited: rate limit exceeded. Auto-resuming in 60s...",
}),
true,
);
assert.equal(
isAutoResumeScheduledNotification({
type: "extension_ui_request",
method: "notify",
message: "Auto-mode paused (Escape). Type to interact.",
}),
false,
);
});
test("isPauseNotification detects pause banners separately from auto-resume notices", () => {
assert.equal(
isPauseNotification({
type: "extension_ui_request",
method: "notify",
message: "Auto-mode paused (Escape). Type to interact.",
}),
true,
);
assert.equal(
isPauseNotification({
type: "extension_ui_request",
method: "notify",
message: "Rate limited: rate limit exceeded. Auto-resuming in 60s...",
}),
false,
);
});
test("shouldArmHeadlessIdleTimeout: arms after tool calls when no interactive tool is in flight", () => {
assert.equal(shouldArmHeadlessIdleTimeout(1, 0), true);
assert.equal(shouldArmHeadlessIdleTimeout(3, 0), true);