From c5df4b46a62b7867e377425ace342b6e5b906d0d Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 29 Apr 2026 15:37:17 +0200 Subject: [PATCH] fix(headless): await auto loop in headless mode --- src/headless.ts | 40 ------------------- .../extensions/sf/commands/handlers/auto.ts | 23 +++++++++-- .../tests/headless-unit-notification.test.ts | 35 ++++++++++++++++ .../sf/tests/start-auto-detached.test.ts | 10 +++-- 4 files changed, 61 insertions(+), 47 deletions(-) create mode 100644 src/resources/extensions/sf/tests/headless-unit-notification.test.ts diff --git a/src/headless.ts b/src/headless.ts index e52602996..d38a8e07e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -585,9 +585,6 @@ async function runHeadlessOnce( let traceActive = false; // true once maybeStartTrace succeeded // Current unit span — tool spans are children of this let activeUnitSpan: ReturnType | null = null; - let activeHeadlessUnit: - | { unitType: string; unitId: string; startedMessage: string } - | null = null; // Map from tool call ID to its tool span (for matching start/end) const toolSpanByCallId = new Map>(); // Tracks pending tool_execution_start for which we haven't seen toolName yet @@ -677,27 +674,6 @@ async function runHeadlessOnce( activeUnitSpan = null; } - function trackHeadlessUnitNotification(message: string): void { - const parsed = parseHeadlessUnitNotification(message); - if (!parsed) return; - - if (parsed.kind === "start") { - activeHeadlessUnit = { - unitType: parsed.unitType, - unitId: parsed.unitId, - startedMessage: message, - }; - return; - } - - if ( - activeHeadlessUnit && - activeHeadlessUnit.unitType === parsed.unitType && - activeHeadlessUnit.unitId === parsed.unitId - ) { - activeHeadlessUnit = null; - } - } /** * Handle tool_execution_start: create a tool span under the active unit (or root if no unit active). @@ -1105,7 +1081,6 @@ async function runHeadlessOnce( // Structured trace: handle unit start/end notify messages if (eventObj.method === "notify") { const message = String(eventObj.message ?? ""); - trackHeadlessUnitNotification(message); if (traceActive) { if (message.includes("[unit]") && message.includes("starting")) { handleUnitStart(message); @@ -1371,21 +1346,6 @@ async function runHeadlessOnce( } } - if ( - isAutoMode && - exitCode === EXIT_SUCCESS && - !blocked && - activeHeadlessUnit - ) { - process.stderr.write( - `[headless] Error: Auto-mode ended while ${activeHeadlessUnit.unitType} ${activeHeadlessUnit.unitId} was still in progress.\n`, - ); - process.stderr.write( - "[headless] Treating this as incomplete instead of complete; resume with `sf headless auto` after inspecting the worktree.\n", - ); - exitCode = EXIT_ERROR; - } - // Cleanup if (timeoutTimer) clearTimeout(timeoutTimer); if (idleTimer) clearTimeout(idleTimer); diff --git a/src/resources/extensions/sf/commands/handlers/auto.ts b/src/resources/extensions/sf/commands/handlers/auto.ts index a41d081fc..4683f07ff 100644 --- a/src/resources/extensions/sf/commands/handlers/auto.ts +++ b/src/resources/extensions/sf/commands/handlers/auto.ts @@ -8,6 +8,7 @@ import { isAutoActive, isAutoPaused, pauseAuto, + startAuto, startAutoDetached, stopAuto, stopAutoRemote, @@ -63,6 +64,20 @@ export async function handleAutoCommand( ctx: ExtensionCommandContext, pi: ExtensionAPI, ): Promise { + const launchAuto = async ( + verboseMode: boolean, + options?: { + step?: boolean; + milestoneLock?: string | null; + }, + ): Promise => { + if (process.env.SF_HEADLESS === "1") { + await startAuto(ctx, pi, projectRoot(), verboseMode, options); + return; + } + startAutoDetached(ctx, pi, projectRoot(), verboseMode, options); + }; + if (trimmed === "next" || trimmed.startsWith("next ")) { if (trimmed.includes("--dry-run")) { const { handleDryRun } = await import("../../commands-maintenance.js"); @@ -87,7 +102,7 @@ export async function handleAutoCommand( } } - startAutoDetached(ctx, pi, projectRoot(), verboseMode, { + await launchAuto(verboseMode, { step: true, milestoneLock: milestoneId, }); @@ -134,11 +149,11 @@ export async function handleAutoCommand( ); await showHeadlessMilestoneCreation(ctx, pi, projectRoot(), seedContent); } else if (milestoneId) { - startAutoDetached(ctx, pi, projectRoot(), verboseMode, { + await launchAuto(verboseMode, { milestoneLock: milestoneId, }); } else { - startAutoDetached(ctx, pi, projectRoot(), verboseMode); + await launchAuto(verboseMode); } return true; } @@ -192,7 +207,7 @@ export async function handleAutoCommand( if (trimmed === "") { if (!(await guardRemoteSession(ctx, pi))) return true; - startAutoDetached(ctx, pi, projectRoot(), false, { step: true }); + await launchAuto(false, { step: true }); return true; } diff --git a/src/resources/extensions/sf/tests/headless-unit-notification.test.ts b/src/resources/extensions/sf/tests/headless-unit-notification.test.ts new file mode 100644 index 000000000..dd5e466c7 --- /dev/null +++ b/src/resources/extensions/sf/tests/headless-unit-notification.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { parseHeadlessUnitNotification } from "../../../../headless.ts"; + +describe("headless unit notification parsing", () => { + test("parses hyphenated unit start notifications", () => { + assert.deepEqual( + parseHeadlessUnitNotification("[unit] execute-task M003/S01/T03 starting"), + { + kind: "start", + unitType: "execute-task", + unitId: "M003/S01/T03", + }, + ); + }); + + test("parses hyphenated unit end notifications", () => { + assert.deepEqual( + parseHeadlessUnitNotification( + "[unit] execute-task M003/S01/T03 ended -> success", + ), + { + kind: "end", + unitType: "execute-task", + unitId: "M003/S01/T03", + verdict: "success", + }, + ); + }); + + test("ignores non-unit notifications", () => { + assert.equal(parseHeadlessUnitNotification("Auto-mode stopped."), null); + }); +}); diff --git a/src/resources/extensions/sf/tests/start-auto-detached.test.ts b/src/resources/extensions/sf/tests/start-auto-detached.test.ts index 9e2d2ad53..f0334cfa5 100644 --- a/src/resources/extensions/sf/tests/start-auto-detached.test.ts +++ b/src/resources/extensions/sf/tests/start-auto-detached.test.ts @@ -9,14 +9,18 @@ function readGsdFile(relativePath: string): string { return readFileSync(resolve(sfDir, relativePath), "utf-8"); } -test("command entrypoints use startAutoDetached instead of awaiting startAuto (#3733)", () => { +test("interactive command entrypoints use startAutoDetached instead of awaiting startAuto (#3733)", () => { const autoHandlerSrc = readGsdFile("commands/handlers/auto.ts"); const workflowHandlerSrc = readGsdFile("commands/handlers/workflow.ts"); const guidedFlowSrc = readGsdFile("guided-flow.ts"); assert.ok( - !autoHandlerSrc.includes("await startAuto("), - "auto command handler should not await startAuto from the active agent turn", + autoHandlerSrc.includes('process.env.SF_HEADLESS === "1"'), + "auto command handler should only await startAuto in headless mode", + ); + assert.ok( + autoHandlerSrc.includes("await startAuto("), + "headless auto command handler should await startAuto so the process lifetime matches the auto loop", ); assert.ok( !workflowHandlerSrc.includes("await startAuto("),