diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index 0eb6514baf..8308178ecb 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -623,6 +623,17 @@ export async function autoLoop( finishTurn("stopped", "execution", "unit-break"); break; } + if (unitPhaseResult.action === "retry") { + finishIncompleteIteration({ + status: "retry", + reason: unitPhaseResult.reason, + retry: true, + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("retry", "execution", unitPhaseResult.reason); + continue; + } // ── Verify first, then reconcile (only mark complete on pass) ── debugLog("autoLoop", { phase: "custom-engine-verify", iteration, unitId: iterData.unitId }); @@ -963,6 +974,21 @@ export async function autoLoop( finishTurn("stopped", "execution", "unit-break"); break; } + if (unitPhaseResult.action === "retry") { + dispatchSettled = settleDispatchFailed(dispatchId, unitPhaseResult.reason, { + markFailed: markDispatchFailed, + logWriteFailure: logDispatchLedgerWriteFailure, + }) || dispatchSettled; + finishIncompleteIteration({ + status: "retry", + reason: unitPhaseResult.reason, + retry: true, + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("retry", "execution", unitPhaseResult.reason); + continue; + } // ── Phase 5: Finalize ─────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 7ac5f619a0..34782b4676 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -2370,9 +2370,14 @@ export async function runUnitPhase( `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, "warning", ); - // Fall through to next iteration where dispatch will re-derive - // and re-dispatch this unit. - return { action: "next", data: { unitStartedAt: _resolveCurrentUnitStartedAtForTest(s.currentUnit), requestDispatchedAt: unitResult.requestDispatchedAt } }; + return { + action: "retry", + reason: "zero-tool-calls", + data: { + unitStartedAt: _resolveCurrentUnitStartedAtForTest(s.currentUnit), + requestDispatchedAt: unitResult.requestDispatchedAt, + }, + }; } } } diff --git a/src/resources/extensions/gsd/auto/types.ts b/src/resources/extensions/gsd/auto/types.ts index 402d0f6a05..da5348e952 100644 --- a/src/resources/extensions/gsd/auto/types.ts +++ b/src/resources/extensions/gsd/auto/types.ts @@ -77,6 +77,7 @@ export interface UnitResult { export type PhaseResult = | { action: "continue" } | { action: "break"; reason: string } + | { action: "retry"; reason: string; data?: T } | { action: "next"; data: T } export interface IterationContext { diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index a966ee2b0d..aecce03762 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -3123,7 +3123,7 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; const pi = makeMockPi(); - let iterationCount = 0; + let closeoutCount = 0; const notifications: string[] = []; ctx.ui.notify = (msg: string) => { notifications.push(msg); }; @@ -3158,6 +3158,7 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", }; }, closeoutUnit: async () => { + closeoutCount++; // Simulate snapshotUnitMetrics adding a 0-toolCalls entry to ledger mockLedger.units.push({ type: "execute-task", @@ -3168,15 +3169,9 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", tokens: { input: 100, output: 200, total: 300, cacheRead: 0, cacheWrite: 0 }, cost: 0.50, }); + if (closeoutCount >= 2) s.active = false; }, getLedger: () => mockLedger, - postUnitPostVerification: async () => { - deps.callLog.push("postUnitPostVerification"); - iterationCount++; - // Deactivate after 2nd iteration - s.active = iterationCount < 2; - return "continue" as const; - }, }); const loopPromise = autoLoop(ctx, pi, s, deps); @@ -3189,6 +3184,7 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", await new Promise((r) => setTimeout(r, 50)); mockLedger.units.length = 0; // clear previous entry (deps as any).closeoutUnit = async () => { + closeoutCount++; mockLedger.units.push({ type: "execute-task", id: "M001/S01/T01", @@ -3198,6 +3194,7 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", tokens: { input: 500, output: 800, total: 1300, cacheRead: 0, cacheWrite: 0 }, cost: 1.00, }); + if (closeoutCount >= 2) s.active = false; }; resolveAgentEnd(makeEvent()); @@ -3219,6 +3216,11 @@ test("autoLoop rejects execute-task with 0 tool calls as hallucinated (#1833)", deriveCount >= 2, `deriveState should be called at least 2 times for retry (got ${deriveCount})`, ); + assert.equal( + deps.callLog.filter((c) => c === "postUnitPreVerification").length, + 1, + "zero-tool retry should bypass finalize on the failed iteration", + ); }); test("autoLoop pauses user-driven deep question instead of flagging 0 tool calls", async () => { @@ -3310,7 +3312,7 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 ctx.sessionManager = { getSessionFile: () => "/tmp/session.json" }; const pi = makeMockPi(); - let iterationCount = 0; + let closeoutCount = 0; const notifications: string[] = []; ctx.ui.notify = (msg: string) => { notifications.push(msg); }; @@ -3344,6 +3346,7 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 }; }, closeoutUnit: async () => { + closeoutCount++; // complete-slice with 0 tool calls — context exhausted, no progress mockLedger.units.push({ type: "complete-slice", @@ -3354,15 +3357,9 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 tokens: { input: 50, output: 100, total: 150, cacheRead: 0, cacheWrite: 0 }, cost: 0.10, }); + if (closeoutCount >= 2) s.active = false; }, getLedger: () => mockLedger, - postUnitPostVerification: async () => { - deps.callLog.push("postUnitPostVerification"); - iterationCount++; - // Deactivate after 2nd iteration - s.active = iterationCount < 2; - return "continue" as const; - }, }); const loopPromise = autoLoop(ctx, pi, s, deps); @@ -3375,6 +3372,7 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 await new Promise((r) => setTimeout(r, 50)); mockLedger.units.length = 0; (deps as any).closeoutUnit = async () => { + closeoutCount++; mockLedger.units.push({ type: "complete-slice", id: "M001/S01", @@ -3384,6 +3382,7 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 tokens: { input: 200, output: 400, total: 600, cacheRead: 0, cacheWrite: 0 }, cost: 0.30, }); + if (closeoutCount >= 2) s.active = false; }; resolveAgentEnd(makeEvent()); @@ -3404,6 +3403,11 @@ test("autoLoop rejects complete-slice with 0 tool calls as context-exhausted (#2 deriveCount >= 2, `deriveState should be called at least 2 times for retry (got ${deriveCount})`, ); + assert.equal( + deps.callLog.filter((c) => c === "postUnitPreVerification").length, + 1, + "zero-tool retry should bypass finalize on the failed iteration", + ); }); // ─── Worktree health check (#1833) ────────────────────────────────────────