fix(headless): await auto loop in headless mode

This commit is contained in:
Mikael Hugo 2026-04-29 15:37:17 +02:00
parent df614a3e47
commit c5df4b46a6
4 changed files with 61 additions and 47 deletions

View file

@ -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<typeof startUnitSpan> | 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<string, ReturnType<typeof startToolSpan>>();
// 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);

View file

@ -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<boolean> {
const launchAuto = async (
verboseMode: boolean,
options?: {
step?: boolean;
milestoneLock?: string | null;
},
): Promise<void> => {
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;
}

View file

@ -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);
});
});

View file

@ -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("),