diff --git a/packages/coding-agent/src/core/subagent-runner.ts b/packages/coding-agent/src/core/subagent-runner.ts index d462379f7..5c5ceaa54 100644 --- a/packages/coding-agent/src/core/subagent-runner.ts +++ b/packages/coding-agent/src/core/subagent-runner.ts @@ -183,6 +183,12 @@ export async function runSubagent( runLifecycle: false, }); + // Debug: confirm tool count after bindExtensions so operators can verify + // extension tools (e.g. checkpoint) are present before the model is called. + process.stderr.write( + `[subagent:${name}] tool count after bindExtensions: ${session.getActiveToolNames().length} (${session.getActiveToolNames().join(", ")})\n`, + ); + // Collect incremental text output from events so the timeout case // can still return partial output. let partialOutput = ""; diff --git a/src/resources/extensions/sf/auto/loop.js b/src/resources/extensions/sf/auto/loop.js index f7b022852..e7968234b 100644 --- a/src/resources/extensions/sf/auto/loop.js +++ b/src/resources/extensions/sf/auto/loop.js @@ -1140,8 +1140,10 @@ export async function autoLoop(ctx, pi, s, deps) { preDispatchResult.action, ); if (preDispatchResult.action === "break") { - finishTurn("stopped", "manual-attention", "pre-dispatch-break"); - break; + // Instead of breaking, treat as idle: sleep and continue polling for new work + finishTurn("idle", "manual-attention", "pre-dispatch-break"); + await delay(3000); // Sleep 3s before next poll + continue; } if (preDispatchResult.action === "continue") { finishTurn("skipped"); @@ -1156,8 +1158,10 @@ export async function autoLoop(ctx, pi, s, deps) { ); deps.uokObserver?.onPhaseResult("dispatch", dispatchResult.action); if (dispatchResult.action === "break") { - finishTurn("stopped", "manual-attention", "dispatch-break"); - break; + // Instead of breaking, treat as idle: sleep and continue polling for new work + finishTurn("idle", "manual-attention", "dispatch-break"); + await delay(3000); // Sleep 3s before next poll + continue; } if (dispatchResult.action === "continue") { finishTurn("skipped"); diff --git a/src/resources/extensions/sf/auto/phases-unit.js b/src/resources/extensions/sf/auto/phases-unit.js index 6227605c4..2a2b10f35 100644 --- a/src/resources/extensions/sf/auto/phases-unit.js +++ b/src/resources/extensions/sf/auto/phases-unit.js @@ -1557,7 +1557,19 @@ export async function runUnitPhase(ic, iterData, loopState, sidecarItem) { u.startedAt === s.currentUnit?.startedAt, ); if (lastUnit && lastUnit.toolCalls === 0) { - if ( + // Swarm bypass: the ledger entry only reflects the parent session, which + // never receives the subagent's tool calls. Use the real count surfaced by + // runUnitViaSwarm (swarmToolCallCount) to avoid a false-positive retry. + const swarmRealToolCalls = unitResult.swarmToolCallCount ?? 0; + const isSwarmWithWork = unitResult._via === "swarm" && swarmRealToolCalls > 0; + if (isSwarmWithWork) { + debugLog("runUnitPhase", { + phase: "zero-tool-calls-swarm-bypass", + unitType, + unitId, + swarmToolCallCount: swarmRealToolCalls, + }); + } else if ( USER_DRIVEN_DEEP_UNITS.has(unitType) && isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined) ) { diff --git a/src/resources/extensions/sf/auto/run-unit.js b/src/resources/extensions/sf/auto/run-unit.js index a67ac6e21..b7c0ba197 100644 --- a/src/resources/extensions/sf/auto/run-unit.js +++ b/src/resources/extensions/sf/auto/run-unit.js @@ -491,6 +491,11 @@ async function runUnitViaSwarm(ctx, _pi, s, unitType, unitId, prompt, options) { requestDispatchedAt, _via: "swarm", _swarmResult: swarmResult, + // Surface real tool-call count from the subagent session so the zero-tool-call + // guard in phases-unit.js can distinguish a genuine no-op from the expected + // case where the parent-session ledger shows 0 (swarm subagents run in an + // isolated session whose messages are never written to the parent session). + swarmToolCallCount: collectedToolCalls.length, }; } diff --git a/src/resources/extensions/sf/tests/run-unit-via-swarm.test.mjs b/src/resources/extensions/sf/tests/run-unit-via-swarm.test.mjs index ffbae15d0..bd84f2397 100644 --- a/src/resources/extensions/sf/tests/run-unit-via-swarm.test.mjs +++ b/src/resources/extensions/sf/tests/run-unit-via-swarm.test.mjs @@ -695,6 +695,68 @@ describe("deriveWorkMode (via envelope.workMode in dispatch calls)", () => { } }); +// ─── Round 8: swarmToolCallCount for zero-tool-call guard bypass ───────────── + +describe("runUnit — Round 8: swarmToolCallCount in UnitResult", () => { + test("swarmToolCallCount is 0 when no tool calls emitted (default mock)", async () => { + // When the swarm worker emits no tool-call events, swarmToolCallCount must be + // 0 so phases-unit.js still applies the zero-tool-call guard for real no-ops. + process.env.SF_AUTONOMOUS_VIA_SWARM = "1"; + + const ctx = makeCtx("/proj"); + const pi = makePi(); + const s = makeS("/proj"); + + const result = await runUnit(ctx, pi, s, "execute-task", "r8-notc", "build", {}); + + expect(result.status).toBe("completed"); + expect(result._via).toBe("swarm"); + expect(result.swarmToolCallCount).toBe(0); + }); + + test("swarmToolCallCount equals the number of tool calls emitted", async () => { + // When the swarm worker emits 3 tool-call events, swarmToolCallCount must be 3 + // so phases-unit.js can bypass the zero-tool-call guard that fires because the + // parent-session ledger entry has 0 (subagent tool calls don't appear there). + process.env.SF_AUTONOMOUS_VIA_SWARM = "1"; + + mockWithToolCallEvents([ + { name: "Bash", arguments: { command: "npm test" } }, + { name: "Read", arguments: { file_path: "/foo.ts" } }, + { name: "checkpoint", arguments: { outcome: "complete", summary: "done" } }, + ]); + + const ctx = makeCtx("/proj"); + const pi = makePi(); + const s = makeS("/proj"); + + const result = await runUnit(ctx, pi, s, "execute-task", "r8-tc3", "build", {}); + + expect(result.status).toBe("completed"); + expect(result._via).toBe("swarm"); + expect(result.swarmToolCallCount).toBe(3); + }); + + test("swarmToolCallCount is 1 when only checkpoint is emitted", async () => { + // checkpoint counts as a tool call in collectedToolCalls even though it's + // protocol (not work). The bypass check is > 0, and the no-op guard separately + // handles the checkpoint-only case via isNoOpExecutorTranscript. + process.env.SF_AUTONOMOUS_VIA_SWARM = "1"; + + mockWithToolCallEvents([ + { name: "checkpoint", arguments: { outcome: "continue", summary: "partial" } }, + ]); + + const ctx = makeCtx("/proj"); + const pi = makePi(); + const s = makeS("/proj"); + + const result = await runUnit(ctx, pi, s, "execute-task", "r8-chkonly", "build", {}); + + expect(result.swarmToolCallCount).toBe(1); + }); +}); + // ─── Round 6: onEvent threading + real tool calls ──────────────────────────── /**