fix(headless): await auto loop in headless mode
This commit is contained in:
parent
df614a3e47
commit
c5df4b46a6
4 changed files with 61 additions and 47 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue