Skip to content

fix(agents): the turn cap is a runaway guard derived from the wall clock, not a budget a good run hits — coding gets 270 turns in 45 minutes, and the write-up says why it stopped - #1020

Merged
justinhelmer merged 1 commit into
mainfrom
fix/turn-cap-is-a-runaway-guard
Sep 14, 2026
Merged

fix(agents): the turn cap is a runaway guard derived from the wall clock, not a budget a good run hits — coding gets 270 turns in 45 minutes, and the write-up says why it stopped#1020
justinhelmer merged 1 commit into
mainfrom
fix/turn-cap-is-a-runaway-guard

Conversation

@justinhelmer

Copy link
Copy Markdown
Contributor

A coding run that had finished all three of its asks was cut off with "Hit the 60-turn budget before finishing" while twenty minutes of its wall clock remained. Turns are no longer a budget a working run can hit: the wall clock is the budget, the turn cap is a runaway guard derived from it (six turns a minute — coding gets 270 in 45 minutes), and when the guard does fire the write-up says it stopped because the pace looked like a loop.

What & why

The run-loop spec already said "wall clock is the real budget; turns are a backstop", but the registry hand-tuned each preset's maxTurns to a number a busy run reaches long before its minutes: at 20–40 s per turn (a model think plus a tool call), 60 turns is 20–40 minutes of work inside a 45-minute budget. The owner's report of 2026-09-14 was a coding run that had done everything it was asked and was still told it hit a budget — "these make people not want to use it".

This PR makes the turn cap what the spec claimed it was. One rule in src/agents/registry.ts: RUNAWAY_TURNS_PER_MINUTE = 6 and runawayTurnCap(maxMinutes) = maxMinutes × 6; every loop-running preset takes maxTurns from it through loopBudget(minutes), so the cap follows the wall clock and is never set by hand again. A turn every ten seconds sustained for the whole budget is a retry loop, not work — so a run that reaches the cap first was looping, and the forced write-up now says so instead of "budget".

Preset Wall clock maxTurns before maxTurns after
coding 45 min 60 270
review 25 min 30 150
research 8 min 12 48
general 5 min 8 30
explore 120 min 150 720
conductor 120 min 40 720
ship 120 min 1 1 (structural: its def never runs the loop)

Everything downstream keeps its vocabulary: the loop's two conditions stay (the guard still ends the loop; the × 2 iteration cap still bounds bookkeeping turns), the run_note kind stays turn_budget_exhausted (the friction analyzer and the model proxy share it), the proxy still refuses model calls past the grant's maxTurns — it now says "past the run's 270-turn guard". The friction proposer stops suggesting "raise maxTurns" and points at the retry loop instead.

Part of the orchestration program (#821); amends the proposed record 0032 where it described maxTurns as a budget (the trace's mechanism is unchanged: the proxy still refuses past the cap).

Tour

1. The rule — six turns a minute, and the cap every preset derives from it

RUNAWAY_TURNS_PER_MINUTE names the pace that marks a run as looping; runawayTurnCap multiplies it by the wall clock; loopBudget hands a preset both numbers as one fact so the two can never drift apart.

Look for: the comment's pacing argument — 20–40 s a turn is the observation the whole change rests on.

/** The pace that marks a run as looping rather than working: a model turn
* every ten seconds, sustained for the whole wall clock. A busy run takes
* 20–40 s a turn (a model think plus a tool call), so a run that averages six
* a minute from start to end is re-issuing calls, not making progress — and
* its turn cap ends it before the wall clock would, with a write-up that
* says so (docs/reference/specs/run-loop.md item 1). */
export const RUNAWAY_TURNS_PER_MINUTE = 6;
/** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
* Every preset that runs the loop derives its `maxTurns` from this, so the
* cap is never a number a good run reaches — the minutes are the budget. */
export function runawayTurnCap(maxMinutes: number): number {
return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
}
/** A loop-running preset's budget as one fact: the wall clock, and the runaway
* guard derived from it. */
function loopBudget(maxMinutes: number): Pick<AgentDef, "maxMinutes" | "maxTurns"> {
return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
}

2. maxTurns on the definition — a guard, not a budget

The field's doc now tells a reader adding a preset what the number is and where it comes from.

/** The runaway guard, not a budget: `runawayTurnCap(maxMinutes)` for every
* preset that runs the loop (`loopBudget`). The wall clock below is the
* budget; a run that reaches this cap first was pacing like a loop, and its
* write-up says so. The proxy refuses model calls past it too. */
maxTurns: number;

3. The presets take their cap from the rule

Every loop-running preset spreads loopBudget(minutes) in place of a hand-set maxTurns / maxMinutes pair; the per-preset tuning comments ("scoping is capped at ~5 calls…", "12 bound at ~4 min in practice") go with them. Coding is the one that mattered:

name: "coding",
description: "Implements changes and ships PRs (git + gh in a workspace).",
system: CODING_SYSTEM,
residentSystem: CODING_SYSTEM_RESIDENT,
toolset: "full",
maxTokens: 64000,
...loopBudget(45),
// Coding steps run long: a single model turn can take 5-6 minutes and
// installs/tests add more — a 5m cache entry would expire between
// requests, so the 2× write buys reads for the whole run.
cacheTtl: "1h",

4. Ship keeps its structural 1

The ship def never runs the loop — agent:ship forks into the pipeline whose rounds run the coding and review defs — so its maxTurns stays a placeholder, with the comment that already said so.

// Never sent to a model: `agent:ship` forks inside dispatch() into the
// pipeline orchestrator (src/core/shipPipeline.ts), whose child rounds run
// on the coding/review defs above — runAgent is never called with THIS def.
system:
"You are Switchboard's ship pipeline. This prompt is never sent to a model — the pipeline orchestrates coding and review child runs on their own definitions.",
// Full toolset and the coding machine class, so repo and PR resolution
// gate a ship thread like a coding one. `maxMinutes` is the pipeline's
// wall clock (docs/reference/specs/agent-ship.md item 8): the ship preset's
// declared budget, which a deployment's `ship.maxMinutes` knob replaces
// (`shipPresetFor`) and a boundary or a `budget:` directive clips like any
// preset's; every child round runs its own agent's budget clipped to what
// remains of it. Turns and tokens are placeholders: no model call is ever
// made with this def.
toolset: "full",
machine: "repo-resident",
identity: "write",
maxTurns: 1,
maxTokens: 16000,
maxMinutes: 120,
},

5. The runner's finale says why it stopped

Both loop conditions are unchanged. When the loop ends without the deadline having passed, the guard fired: the note, the finale instruction to the model and the user-facing label all name the turns counted and the minutes elapsed and say the pace looked like a loop. Time exhaustion keeps its wording.

Look for: elapsedMs is computed from the deadline, not a start stamp, so a resumed run reports the time spent across generations.

switchboard/src/runner.ts

Lines 739 to 763 in dc41327

// The wall clock ran out, or the turn guard caught a run pacing like a loop:
// one final tool-less call so the work so far is written up instead of
// discarded. The guard's write-up says why it stopped — the turns and the
// minutes — never "budget": a budget is a number a good run may reach, and
// this cap is not one.
const wasTimeout = now() >= deadline;
const elapsedMs = opts.agent.maxMinutes * 60_000 - (deadline - now());
const pace = `${turn} model turn${turn === 1 ? "" : "s"} in ${elapsedMinutes(elapsedMs)}`;
note(
wasTimeout ? "time_budget_exhausted" : "turn_budget_exhausted",
wasTimeout
? "time budget exhausted — writing up findings so far"
: `turn guard fired: ${pace}, a pace that looks like a loop — writing up findings so far`,
);
const writeUp =
"Write your final answer now from what you have learned so far: report your findings/results to date, then state plainly which parts of the task you did not get to and what a follow-up (in this thread, to reuse this workspace) should focus on.";
const text = await runFinale(
complete,
opts,
messages,
system,
wasTimeout
? `You have reached the time budget and can make no more tool calls. ${writeUp}`
: `You have hit the run's turn guard — ${pace}, a pace that looks like a loop — and can make no more tool calls. ${writeUp}`,
);

6. The two labels, and the minutes helper

The ⚠️ Hit the N-minute budget… sibling is byte-identical; the guard's label is ⚠️ Stopped after N model turns in M minutes — that pace looks like a loop; findings so far:, and its empty-write-up fallback says the same reason.

switchboard/src/runner.ts

Lines 764 to 780 in dc41327

if (wasTimeout) {
return text
? `⚠️ _Hit the ${opts.agent.maxMinutes}-minute budget before finishing — findings so far:_\n\n${text}`
: `Stopped at the ${opts.agent.maxMinutes}-minute budget without finishing. Partial work may exist in the workspace — narrow the task and try again.`;
}
return text
? `⚠️ _Stopped after ${pace} — that pace looks like a loop; findings so far:_\n\n${text}`
: `Stopped after ${pace} — that pace looks like a loop — without finishing. Partial work may exist in the workspace — look for a retry loop in the run's events before trying again.`;
}
/** The run's elapsed time as the write-up says it: whole minutes, or "under a
* minute" — the guard's label is about pace, not a stopwatch reading. */
function elapsedMinutes(ms: number): string {
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "under a minute";
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}

7. The proxy names the guard

The turn_budget_exhausted kind and the 403 stay; the note and the response body read "past the run's N-turn guard" instead of "N-turn budget".

if (!turn.ok) {
const used = `${turn.turns} turn${turn.turns === 1 ? "" : "s"} used`;
grant.publish({
type: "run_note",
kind: "turn_budget_exhausted",
summary: `model proxy refused a call past the run's ${turn.maxTurns}-turn guard (${used})`,
at: deps.clock(),
});
log(`[model-proxy] 403 turn_budget_exhausted run=${grant.runId} turns=${turn.turns}/${turn.maxTurns}`);
return refusalResponse(
shape,
403,
"turn_budget_exhausted",
`the run is past its ${turn.maxTurns}-turn guard (${used})`,
);
}

8. The friction proposer stops saying "raise maxTurns"

budget_hit:turns now says the run outpaced the runaway guard, quotes the rate from the constant, and sends the reader to the retry loop or to batching. The time-budget suggestion is unchanged.

case "budget_hit":
return p.signature === "turns"
? `Runs outpace the runaway guard: the turn cap is \`RUNAWAY_TURNS_PER_MINUTE\` (${RUNAWAY_TURNS_PER_MINUTE} turns a minute) over the affected agent's wall clock, a pace a working run does not sustain — so a run that reaches it is looping, not working. Read the evidence rows for a retry loop (the same call re-issued turn after turn) and fix its cause where the agent reads before acting (the target repo's AGENTS.md, the resident command table), or have the prompt batch tool calls (several commands per \`bash\` call). The cap is derived from \`maxMinutes\` (\`src/agents/registry.ts\`), not a knob to turn.`
: `Runs exhaust the TIME budget and are cut off mid-work. Raise \`maxMinutes\` for the affected agent (\`src/agents/registry.ts\`), or split the task shape that triggers it — a run that is forced to write up findings is a run whose work was wasted.`;

9. Tests: the rule is pinned, not the numbers

Every preset but ship must satisfy maxTurns === runawayTurnCap(maxMinutes); the derived values are asserted once so a wall-clock change shows up in the diff. The three per-preset tests drop their turn counts and keep the minutes.

describe("the turn cap is a runaway guard derived from the wall clock (docs/reference/specs/run-loop.md item 1)", () => {
it("the rule: six turns a minute over the wall clock", () => {
expect(RUNAWAY_TURNS_PER_MINUTE).toBe(6);
expect(runawayTurnCap(45)).toBe(270);
expect(runawayTurnCap(25)).toBe(150);
expect(runawayTurnCap(5)).toBe(30);
});
it("every loop-running preset's maxTurns is runawayTurnCap(maxMinutes); ship keeps its structural 1", () => {
for (const [name, def] of Object.entries(AGENTS)) {
if (name === "ship") continue;
expect(def.maxTurns, name).toBe(runawayTurnCap(def.maxMinutes));
expect(def.maxTurns, name).toBe(def.maxMinutes * RUNAWAY_TURNS_PER_MINUTE);
}
expect(AGENTS.ship.maxTurns).toBe(1);
});
it("the derived caps: coding 270 in 45, review 150 in 25, research 48 in 8, general 30 in 5, explore and conductor 720 in 120", () => {
expect(AGENTS.coding.maxTurns).toBe(270);
expect(AGENTS.review.maxTurns).toBe(150);
expect(AGENTS.research.maxTurns).toBe(48);
expect(AGENTS.general.maxTurns).toBe(30);
expect(AGENTS.explore.maxTurns).toBe(720);
expect(AGENTS.conductor.maxTurns).toBe(720);
});
});

10. Tests: the write-up's wording, with a clock that advances

The finale test drives a 40 s-per-call clock so the label counts real minutes, asserts the exact prefix, that the word "budget" is absent, and that the finale instruction names the guard and the pace; a second test pins the empty-write-up fallback.

it("forces a write-up that says the turn guard fired — the turns, the minutes, and that the pace looks like a loop — when turns run out", async () => {
// Always asks for tools; maxTurns=2 → 2 tool turns, then a final tool-less call.
// The clock advances 40 s per tool call, so the label counts real minutes.
let t = 0;
const advancing: Executor = {
...fakeExecutor,
exec: async () => {
t += 40_000;
return "ok";
},
};
const provider = scripted([bashUse("t1"), bashUse("t2"), text("partial findings")]);
const answer = await runAgent({
provider,
model: "m",
agent: agent({ maxTurns: 2, maxMinutes: 10 }),
messages: [{ role: "user", content: [{ type: "text", text: "go" }] }],
toolContext: { executor: advancing },
now: () => t,
});
expect(answer).toContain(
"⚠️ _Stopped after 2 model turns in 1 minute — that pace looks like a loop; findings so far:_",
);
expect(answer).not.toContain("budget");
expect(answer).toContain("partial findings");
// The model is told why it was stopped, not that it ran out of budget.
const finale = provider.requests[provider.requests.length - 1];
const instruction = finale.messages[finale.messages.length - 1];
expect(JSON.stringify(instruction.content)).toMatch(/turn guard.*2 model turns/);
// The forced final call must not offer tools.
expect(finale.tools).toBeUndefined();
});

11. Tests: the proposer and the proxy

The proposer's suggestion must quote the rate, name the runaway guard, mention batching and the retry loop, and never say "raise maxTurns"; the proxy's note and body carry the new wording.

it("budget_hit:turns names the runaway guard's pace and asks for batching or the retry loop — never to raise maxTurns", () => {
const [p] = proposeImprovements(
[
{
key: "budget_hit:turns",
kind: "budget_hit",
signature: "turns",
runIds: ["a", "b"],
occurrences: 2,
durationMs: 0,
severity: "high",
examples: [{ runId: "a", finishedAt: T0, summary: "budget hit (turns): 270 turns used", severity: "high" }],
},
],
{ top: 1, runsAnalyzed: 2 },
);
const fix = p.body.slice(p.body.indexOf("## Suggested fix"));
expect(fix).toContain(`${RUNAWAY_TURNS_PER_MINUTE} turns a minute`);
expect(fix).toMatch(/runaway guard/);
expect(fix).toMatch(/batch/i);
expect(fix).toMatch(/retry loop/i);
expect(fix).not.toMatch(/raise `maxTurns`/i);
});

describe("the turn guard — a refusal is a typed run event", () => {
it("the call past maxTurns is 403 turn_budget_exhausted, publishes a `turn_budget_exhausted` note on the run's stream naming the guard and the counts, forwards nothing and opens no span", async () => {
const h = harness();
const token = h.bearers.mint(h.grant("run-1", { maxTurns: 1 }));
expect((await handleModelProxyRequest(request({ headers: bearer(token) }).req, h.deps)).status).toBe(200);
const refused = await handleModelProxyRequest(request({ headers: bearer(token) }).req, h.deps);
expect(refused.status).toBe(403);
expect(errorType(refused)).toBe("turn_budget_exhausted");
expect(h.fetchFake).toHaveBeenCalledTimes(1);
expect(h.starts.filter((s) => s.name === "model.turn")).toHaveLength(1);
expect(h.published).toEqual([
{
type: "run_note",
kind: "turn_budget_exhausted",
summary: "model proxy refused a call past the run's 1-turn guard (1 turn used)",
at: h.clock.now,
},
]);
expect(refused.body).toContain("past its 1-turn guard (1 turn used)");
});
});

12. The run-loop spec: item 1 is the rule, item 3 the wording

Item 1 states the rule, the number, the derived caps and why; item 3 quotes both labels and says the note kinds are unchanged; item 5 lists wall clocks only. New proof rows bind the registry rule test and the two runner tests.

1. **Wall clock is the budget; the turn cap is a runaway guard derived from it.** Each agent defines `maxMinutes` (the hard deadline). Its `maxTurns` is not set by hand: every preset that runs the loop derives it as `runawayTurnCap(maxMinutes)` = `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`, with `RUNAWAY_TURNS_PER_MINUTE = 6` — a model turn every ten seconds, sustained for the whole budget, is a loop, not work (a busy run takes 20–40 s a turn), so the cap is never a number a working run reaches, and a run that reaches it first was pacing like a loop. Derived caps: coding 270 in 45 min, review 150 in 25, research 48 in 8, general 30 in 5, explore and conductor 720 in 120; `ship` keeps `maxTurns: 1` because its def never runs the loop. When the guard ends a run, the write-up says why (item 3). `update_status`-only turns don't count against the guard; an absolute iteration cap of `maxTurns × 2` bounds the loop regardless.
2. **Wrap-up warning**: once, as the deadline approaches (≤3 min or 25% left), the model is told how long remains and to consolidate rather than explore.
3. **Forced write-up**: when the wall clock runs out or the turn guard fires, the model gets one final tool-less call to report findings so far + what a follow-up should do, told which of the two ended the run. The answer is prefixed `⚠️ Hit the <N>-minute budget before finishing — findings so far:` at the deadline, and `⚠️ Stopped after <N> model turns in <M> minutes — that pace looks like a loop; findings so far:` when the guard fires — the turns counted and the minutes elapsed, never the word budget, because the cap is not one. The `run_note` kinds stay `time_budget_exhausted` and `turn_budget_exhausted` (the friction analyzer and the model proxy share them). An empty write-up still produces a user-facing message that says the same reason. **The finale call is bounded** (`FINALE_TIMEOUT_MS`, 3 min; `RunOptions.finaleTimeoutMs` for tests): a provider that hangs on the write-up yields the empty-write-up fallback message instead of a run that never ends — for every finale path (budget, dead sandbox, soft stop).

13. The model-proxy spec, item 5

The example note and the count read "past the run's 270-turn guard"; the kind stays.

5. **The turn guard is enforced here, as a typed run event.** The grant's `maxTurns` is the preset's runaway guard — `runawayTurnCap(maxMinutes)`, six turns a minute over the wall clock ([run-loop.md](run-loop.md) item 1; 270 for a coding run) — not a budget a working run reaches. A turn is counted before the call is forwarded (so concurrent calls cannot overrun) and the call past it is refused `403 turn_budget_exhausted`, forwarding nothing and opening no span; the refusal publishes one `run_note` of kind `turn_budget_exhausted` on the run's stream naming the guard and the counts (`model proxy refused a call past the run's 270-turn guard (270 turns used)`; the response body says `the run is past its 270-turn guard (270 turns used)`) — the same kind the native loop notes when its own guard ends the loop, so the run page and the friction analyzer read one vocabulary. A `403` rather than a `429`: a rate-limit status is one an SDK retries, and this is a refusal. An upstream failure spends the turn like a failed native turn does. A run that ended between the door and the turn — its bearer verified a moment ago, the run revoked since — is `403 revoked` with no note: the store answers `ended`, never a zero-turn budget.

14. Record 0032, amended

The proposed record described maxTurns as a budget in its facts table, the proxy paragraph and the presets row; each now says it is the derived guard. decisions:check passes (an accepted record's body is frozen; a proposed one may be amended).

| The bot alone holds the model key and calls the provider itself (the review abridge's `meat` binary runs on the bot host with the same key); there is no proxy, no run-scoped credential, no per-run meter beyond the `model.turn` span's token attrs; `maxTokens` is a per-call output cap, `maxTurns` is the runaway guard derived from the wall clock (six turns a minute over `maxMinutes`) and ends a run pacing like a loop with a tool-less write-up that says so; the costs page's LLM line is the Anthropic Admin cost report by workspace | `src/runner.ts:251-313,612-620,738-750`, `src/core/meatProcess.ts:85`, `src/core/costs.ts:21-22` |

15. Remaining changes

  • docs/explanation/agents-and-toolsets.md — the Budget column lists wall clocks; a paragraph explains the derived cap and the label a user sees
  • docs/reference/specs/agent-coding.md, agent-review.md, agent-general.md, agent-explore.md, agent-conductor.md, agent-ship.md, github-tools.md — Budgets lines quote the wall clock and the derived guard; proof rows follow the renamed registry tests
  • docs/reference/specs/self-improvement.md — the budget_hit:turns suggestion summary
  • docs/reference/specs/README.md, docs/reference/code-map.md — "turn budget" → "turn guard" in the model-proxy rows
  • docs/how-to/add-an-agent.md — the example preset uses loopBudget(10); a sentence says the cap is never set by hand
  • docs/how-to/operate-production.md — the probe's 403 turn_budget_exhausted line names the guard
  • src/core/descriptionTurn.ts — a comment that quoted "60 turns / 45 min" no longer does (the 8-turn clip stays: a bounded bookkeeping turn, not the budget)
  • src/runner.ts header and loop comments — "turn budget" → "turn guard"
  • src/core/dispatcher.test.ts — the run bearer test pins the general preset's grant to its derived guard (30) instead of 8

Decisions

  • Derived, not hand-tuned. Every earlier maxTurns carried a comment justifying its number, and every number was wrong for a busy run. One rule tied to the wall clock cannot drift from it, and a test pins the rule rather than the values.
  • Why 6 a minute. A model turn is a think plus a tool call; on the runs we have, that is 20–40 s. A run averaging 10 s a turn for its entire budget is not thinking between calls — it is re-issuing them. Anything looser and a stuck loop burns money the wall clock alone would stop late; anything tighter and a run of fast one-line greps (real, item 1 of run-friction) trips it. It is a constant, not config: the friction proposer and the specs quote it by name.
  • The kind is unchanged. turn_budget_exhausted is shared by the runner, the model proxy (docs/reference/specs/model-proxy.md item 5), the friction analyzer's budget_hit category and the ledger's records; renaming it would touch every consumer for a word. The wording a person reads changed; the type an analyzer reads did not.
  • Ship keeps 1. Its def is never handed to runAgent; the pipeline's rounds run the coding and review defs with their own derived caps, clipped to the pipeline's remaining wall clock.
  • Why not remove the cap. A stuck retry loop still spends money every ten seconds until the wall clock fires; the guard ends it in a fraction of the budget and names the cause. The right long-term budget is a per-run dollar cap enforced at the model proxy, which meters every turn already — deferred; this PR only stops the cap from ending good runs.

Validation

Criterion Proof Receipt
Every preset but ship has maxTurns === runawayTurnCap(maxMinutes); the constant is 6; the derived caps are 270 / 150 / 48 / 30 / 720 / 720; ship keeps 1 [unit] src/agents/registry.test.ts::the turn cap is a runaway guard derived from the wall clock (docs/reference/specs/run-loop.md item 1)::* (3) red first: 3 failed against the hand-set registry (RUNAWAY_TURNS_PER_MINUTE undefined, coding.maxTurns 60 ≠ 270); green after
The guard's write-up: ⚠️ _Stopped after 2 model turns in 1 minute — that pace looks like a loop; findings so far:_, no "budget" in the answer, the finale instruction names the guard and the pace, no tools offered; the empty write-up still says the reason [unit] src/runner.test.ts::forces a write-up that says the turn guard fired…turns run out, ::the turn guard's empty write-up still says why the run stopped red first: both failed on the old 2-turn budget label; green after
Time exhaustion's wording is unchanged [unit] src/runner.test.ts::labels the write-up with the minute budget…, ::emits time_budget_exhausted when the wall clock ran out unchanged tests, green
The run_note kinds are unchanged: turn_budget_exhausted after the guard, time_budget_exhausted after the deadline [unit] src/runner.test.ts::emits a run_note for the wrap-up warning and for turn-budget exhaustion; src/core/runFriction.test.ts::budget_hit: time- and turn-budget exhaustion notes are high-severity budget hits unchanged tests, green
The proxy refuses past maxTurns with the kind unchanged and the note / body naming the guard [unit] src/channels/modelProxy.test.ts::the turn guard — a refusal is a typed run event::* red first on the old summary text; green after
The friction proposer's budget_hit:turns fix quotes 6 turns a minute, names the runaway guard, batching and the retry loop, never "raise maxTurns" [unit] src/core/frictionProposals.test.ts::…::budget_hit:turns names the runaway guard's pace… red first (the old text said "Raise maxTurns"); green after
The description turn's 8-turn clip is untouched [unit] src/core/descriptionTurn.test.ts (unchanged, green)
Every spec proof resolves; every changed source path has a covering spec; no test verification removed npm run specs:check44 spec(s), 2733 proof reference(s) checked; npm run specs:coverage -- --changed --test-guardevery changed source path has a covering spec, test-guard ok local run; CI consistency leg
Record 0032 amended within the rules npm run decisions:check45 record(s) carry a valid status … accepted bodies unchanged against origin/main local run; CI consistency leg
The whole gate npm run verify — exit 0 locally at dc41327 (bot 373 files / 6817 tests passed, every workspace green) this PR's checks
PR title is the changelog line npm run check:pr-title — ok

No visual change.

🤖 Generated with Claude Code

…ock, not a budget a good run hits — coding gets 270 turns in 45 minutes, and the write-up says why it stopped

Every preset hand-set a `maxTurns` a busy run reached long before its
minutes: at 20–40 s a turn, 60 turns is 20–40 minutes inside a 45-minute
budget, and a coding run that had finished its work was cut off with
"Hit the 60-turn budget". One rule now derives the cap from the wall
clock — `RUNAWAY_TURNS_PER_MINUTE = 6`, `runawayTurnCap(maxMinutes)` —
so a turn every ten seconds sustained for the whole budget, a loop, is
what ends a run early, and the forced write-up says so: "Stopped after
N model turns in M minutes — that pace looks like a loop". Ship keeps
its structural 1. The `turn_budget_exhausted` kind, both loop
conditions and the proxy's refusal are unchanged; the proxy and the
friction proposer name the guard instead of a budget to raise. Specs,
the how-to pages and the proposed record 0032 follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM: Clean, coherent change: maxTurns becomes a wall-clock-derived runaway guard with honest write-up wording; code, tests, specs and docs all move together, test-guard shows only spec-licensed renames.

  • [nit] F1 src/config/profile.ts:68 — A budget-clipped run keeps the preset's full-clock maxTurns, so the 6-turns/min invariant holds only at preset defaults

Verdict: approve — a well-executed reframing of maxTurns from a hand-set backstop into a derived runaway guard, with code, tests, specs and docs moving in lockstep.

What I checked

  • Full 26-file diff read (matches GitHub's +219/−86); specs:coverage --test-guard run: every changed source path has a covering spec, and all guard lines are check: renames explicitly allowed by spec changes in the same diff — verification is intact and actually strengthened (the new registry tests pin the derivation rule for every preset, the runner tests pin the new label wording and empty-write-up fallback, the proxy test now asserts the response body too).
  • runawayTurnCap / loopBudget in src/agents/registry.ts are correct and every loop-running preset uses them; ship keeps its structural 1, descriptionTurn's ad-hoc 8/5 is not an AGENTS preset so the invariant test rightly doesn't cover it.
  • Runner finale (src/runner.ts:739-770): elapsedMs = maxMinutes*60_000 - (deadline - now()) is consistent with how the deadline is computed, including the resume path (elapsed across the run's whole budget) and clipped budgets (the runner reads the budgetedAgent copy, so minutes match the deadline basis). elapsedMinutes rounding matches the tests (2×40 s → "1 minute").
  • Model proxy note/body wording, the dispatcher-test grant update (8→30), and the friction proposal's "never raise the cap" fix are all consistent with the updated run-loop.md item 1 / model-proxy.md item 5. No spec contradictions — every touched spec was updated in the same diff.

Findings

  • F1 (nit) src/config/profile.ts:68budgetedAgent replaces maxMinutes with the effective (boundary- or budget:-clipped) minutes but keeps the preset's maxTurns, so a coding run clipped to 10 minutes still carries a 270-turn guard (~27 turns/min of its effective clock). Harmless — the wall clock ends the run first and the guard only fires later than the 6/min rule would suggest — and the spec phrases the invariant at the preset level, so this is an observation, not a contradiction. If you ever want the guard to track the effective clock, budgetedAgent is the one-line place.

No correctness bugs found; the change is safe to merge.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

@justinhelmer

Copy link
Copy Markdown
Contributor Author

Review round 1 at dc41327 — LGTM, one finding.

F1 (nit)budgetedAgent keeps the preset's maxTurns when a boundary or budget: clips the minutes, so a clipped run carries a guard looser than six a minute of its effective clock. Acknowledged and skipped under the current review level (minor): the wall clock still ends a clipped run first, and the spec states the rule at the preset level. If the guard should track the effective clock, budgetedAgent (src/config/profile.ts) is the one-line place — maxTurns: runawayTurnCap(minutes) — and the grant the proxy enforces would follow since provision copies the effective agent's cap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant