fix(autopilot): enforce the round cap, bound the plan, refuse dead plans - #8618
fix(autopilot): enforce the round cap, bound the plan, refuse dead plans#8618iamwhatever wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Three inert guarantees made real at the boundaries the tracker already owns, with the unsalvageable fourth item (persistence) withdrawn instead of converged — the right call. Watch
[DESIGN-REVIEWED] 5f1613d |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @iamwhatever overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks are done. Every candidate finding died on verification: the manual append+broadcast pair in First-Principles-Verdict: PASS Three advertised-but-unimplemented guarantees made real, one declared bounded addition, riders declared and gate-mandated; every item names its harm and sits at cause level. What this change shipsIntent: make the autopilot's unattended-run limits actually hold (round cap, whole-plan ceiling), refuse dead plans out loud, and stop stage capture blocking the gateway — a FIX carrying one declared addition (the plan ceiling, tracker item #1783).
Duplication checked and cleared: [FIRST-PRINCIPLES-REVIEWED] 5f1613d |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've now examined both candidates against the actual code. Let me verify one final point on candidate 1 — whether the The key facts I've established from the code: Candidate 1 (dead-plan early return skips the
So (a) a concrete reachable input producing the stranded-message/lost-note outcome is not established; (c) is a no-op on the reachable paths. Fails the 80+ bar. Candidate 2 (final stage hitting round cap shows "send guidance" instead of "complete"): Reaching the cap on the last stage produces the halt message rather than the completion summary. Whether that message is wrong on the final stage is a product judgment (the stage did spawn all 3 waves, so "struggling, ask for guidance" is defensible) — not a crash, data loss, or security hole. The candidate's own confidence is "low" and it names this as an unsettleable product judgment. Fails the bar. No new grounded finding surfaced while falsifying these. No findings. [OPUS-REVIEWED] 5f1613d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
a82219e to
b652e6f
Compare
|
Disposition: fixed —
Legitimate, and the consequence is worse than "invalid data": it is silent loss of
Fixed in
Regression coverage, all red before the fix: |
|
Disposition: fixed —
Legitimate, and it disabled this PR's own new guard on the path this PR's other half
Fixed in
Regression coverage, red before the fix: a |
|
Disposition: fixed —
Legitimate, and the consequence chain completes exactly as traced:
Fixed in One existing test moved with it, and deliberately: |
b652e6f to
5e9b43e
Compare
|
Disposition: fixed — Design Review Watch item 1: the two headline features don't compose; a resumed plan skips the budget load Legitimate, and the framing is the sharpest of the three reports on this defect:
Fixed in The "no restart test covers budget reload" gap is closed too: a |
|
Disposition: fixed — Design Review Watch item 2: Legitimate, and this one was found only by this lane. It defeated a guarantee the
Fixed in Two regression tests, both red before the fix:
The ARMED-vs-RUNNING distinction |
|
Disposition: rebutted — First Principles subtraction 1: drop Kept, on the ground that it is the requested shape of the acceptance item, not
So removing it would ship item (c) with a piece of its own definition missing, and On the merits of the premise, the review is right that there is a tension and I do
Two things separate the cases. Go All is an attended decision at I accept the cost accounting: the zero option is cheap because the halt is |
|
Disposition: fixed — First Principles subtraction 2: delete Taken. The consumer count is correct and, more to the point, the retention
Verified independently before acting: Deleted in Worth recording as the general form, since it is the sharper half of this PR's |
5e9b43e to
4bcbe46
Compare
|
Disposition: fixed —
Legitimate, and the round-2 validator on this same span is what left it open: I
Fixed in Coverage, red before the fix: This is the second round on this span. The invariant I should have written the first |
27fc8b7 to
3ac4206
Compare
|
/ai-review override gpt 3ac4206: The round cap is a stage-boundary halt by design; a per-spawn denial inside the spawn tool is a new control layer #1783 does not ask for, every spawned agent is already bounded by its own governance gate, and the boundary halt is user-visible and identical to what the Slack path has always had. |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
3ac4206 to
037702f
Compare
|
/ai-review override gpt 037702f: The round cap is a stage-boundary halt by design; a per-spawn denial inside the spawn tool is a new control layer #1783 does not ask for, every spawned agent is already bounded by its own governance gate, and the boundary halt is user-visible and identical to what the Slack path has always had. |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Round 9 — Opus advisory fixed; the remaining red is inherited from
|
Three of the P1-P3 findings from the autopilot hardening tracker, all the same shape: the orchestrator advertises a guarantee that nothing implements. `MAX_STAGE_ROUNDS` now enforces something on the dashboard. `record_round()` returns whether the stage has spent its round budget and `_stage_loop` discarded the return value, so the "max 3 rounds per stage" the orchestrator prompt promises enforced nothing on this path. The loop now halts after a stage's subagent wave when the stage has spent its rounds -- that is where rounds accumulate, since `_subagent_done` records one per completed wave against `tracker.current_stage` while the stage is still running. Placed after the result capture, so a stage that genuinely finished keeps its result on disk. Wiring the cap up exposed an off-by-one in the unit it counts. The loop entered a stage through `record_round`, called for its side effects rather than its count -- inert while nothing here read the cap, but the moment the cap IS read that tick spends a third of the budget before any subagent runs: a dashboard stage would be cut after two waves while the same stage driven from `_subagent_done` got three. Stricter than the prompt promises AND inconsistent between paths. The side effects move to `OrchestrationTracker.start_stage`, which registers the stage at zero rounds and restarts the stage clock; counting stays in `record_round`, so all three rounds belong to actual spawn waves. `MAX_STAGE_ESCALATIONS` is deliberately NOT checked here, and that is a reachability fact rather than a preference. Escalations are only recorded by `reset_after_guidance`, which zeroes the capped stage's rounds while KEEPING its key, so `current_stage` does not move, the loop's next entry starts at the stage after it, and an escalated stage is never re-entered. A check there would be dead code -- the very defect this change fixes. It stays enforced in `slack/gateway.py`, where the tracker is not driven by a stage loop. Whole-plan duration watchdog. `orchestrator.max_plan_duration_seconds` (default 2 h, `0` disables) is checked at each stage boundary, with one latched warning at 75%. Only a per-stage timeout existed and stage count multiplied it: ten stages at the 30-minute default is a five-hour unattended run. At the boundary rather than mid-turn, because the running stage has its own ceiling and cutting between stages leaves every finished stage captured on disk. AUTO-RUN only. The clock is wall-clock from the plan's first stage, and a stage-gated plan spends most of it parked at an approval prompt -- so enforcing the ceiling there cut a plan the user was actively stepping through, having counted their own review time between Go clicks against them. The budget bounds UNATTENDED runtime; when the user clicks each stage they are the ceiling. The budget load is a question about the tracker, not about the loop. It was gated on `tracker is None`, i.e. "did this loop create the object", so a tracker the loop did not build -- the one `slack/gateway.py` creates lazily when a subagent result lands on a slot the loop has not reached -- ran the whole plan on constructor defaults, with the new plan watchdog sitting at `0`, which means DISABLED. The tracker now answers `budgets_unset`, and `mark_budgets_loaded()` is recorded even when the load raised so one bad config read cannot become one per stage-loop entry. A failed load lands both budgets on `OrchestratorConfig`'s dataclass defaults rather than leaving the ceiling at 0. Stage-result capture no longer blocks the event loop. It was one synchronous call that walked the messages, redacted every segment, created the directory and wrote the file. Split at the boundary the repo already uses: `_collect_stage_result_parts` walks the messages on the loop, because `slot.messages` is live state the loop mutates, and hands an immutable tuple of raw strings to `_write_stage_result` on a worker, which redacts and writes. So redaction moves off the loop too and nothing mutable is reachable from that thread. `_capture_stage_result` is deleted: it was retained "for callers that are not on the event loop" and there were none -- one `# noqa: F401` re-export plus test files -- so the retention rationale was fiction. A plan whose stages are gone is now refused out loud. `mode` is persisted and the transcript keeps the plan turn's `[OPTION: Go | Go All | Cancel]` row, but the plan SHAPE (`_stage_titles`, and so `_plan_stage_count`) is in-memory only -- so a restored slot renders buttons over a plan that no longer exists. Pressing one ran zero stages and returned in total silence: the loop's range is empty and the completion message is gated on `start_idx < total`, so the user got no response at all and no way to tell a dead plan from a hung one. `_stage_loop` now posts `⚠️ This plan is no longer active …`, logs `auto_run_plan_expired`, closes the turn out and returns -- before the tracker is built, so before the config is read. The same gate covers a planning turn that parsed no stages. Persisting the plan instead was implemented, reviewed for five rounds, and withdrawn. It is not an omission but a boundary: Autopilot executes a plan the user is watching, it is not a task runner that owns work across process lifetimes. Resuming means restoring an execution ledger -- which stage ran, how many rounds it spent, which results are real -- and every restored fact is a way to re-run a completed stage's side effects or to skip a stage that never ran. The review record bears that out: nine of the eleven findings on this change came from that one item, the last of them a crash window between recording a stage result in memory and the next slot save that has no answer inside the persistence design. A plan is cheap to re-ask for; a mis-resumed plan is not. So the module owes the user honesty rather than continuity, which is what the refusal above is. The cap halts AUTO-RUN only, like the plan watchdog and for the same reason: it exists to stop an unattended plan from spinning. An attended stage that spent exactly its three allowed waves and then finished has done nothing wrong, and the user is about to be asked for Go anyway -- halting it read "Auto-run stopped" on a plan that was never in auto-run and skipped the Go row, stranding the step-through. The cap is a stage-boundary halt, deliberately, not a per-spawn denial. Rounds are recorded when a wave COMPLETES, so within one stage turn the model can issue a further wave before the boundary check runs. Closing that would mean a hard gate inside the spawn tool that reads the orchestration tracker -- a new control layer in the spawn path, which #1783 does not ask for and which every spawned agent's own governance gate already bounds. The boundary halt is the guarantee the prompt actually promises ("auto-run stops"), it is user-visible, and it is what the Slack path has always had. One user-visible rendering change rides along. `timeout_human` inlined minute/second formatting; extracting `_human_secs` so an hour-scale plan budget can render `2h` added an hours branch the EXISTING per-stage timeout text now goes through too, so a stage timeout configured at 3600s prints `1h` where it printed `60m`. Nothing else about that message changed and no caller parses the string. Tests: 41 across four new files (9 round cap, 21 plan watchdog, 7 off-loop capture, 4 refusal) plus two updated existing ones, each red before the fix it covers. `test_stage_round_cap_enforced.py` carries the off-by-one guard -- two waves per stage must run the plan through, which fails the moment stage entry starts spending a round again. `test_expired_plan_is_refused.py` pins that the refusal is audible, closes the turn out, costs no tracker and no config load, and is not gated on `auto_run`. `test_completion_result_read_off_loop.py` moves its intercept from `_capture_stage_result` to `_write_stage_result`, or the split would make it pass vacuously. `context_management.py` and `test_completion_result_read_off_loop.py` leave the black baseline: both became black-clean as a side effect of formatting the code this change touches, and the gate requires a file that has become clean to be pruned. Not split into its own commit because the readiness guard asserts a single commit on base. Related: #1783
037702f to
5f1613d
Compare
|
/ai-review override gpt 5f1613d: The round cap is a stage-boundary halt by design; a per-spawn denial inside the spawn tool is a new control layer #1783 does not ask for, every spawned agent is already bounded by its own governance gate, and the boundary halt is user-visible and identical to what the Slack path has always had. |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Round 10 — every review lane green on
|
Problem / Motivation
Three items from the autopilot hardening tracker (#1783), all the same shape: the
orchestrator advertises a guarantee that nothing implements.
MAX_STAGE_ROUNDSwas dead code on the dashboard.record_round()returnswhether the stage has spent its round budget and
_stage_loopdiscarded thereturn value. The "max 3 rounds per stage" the orchestrator prompt promises
enforced nothing here.
OrchestratorConfigcarried onlystage_timeout_seconds, which bounds ONE stage. Ten stages at the 30-minutedefault is a five-hour unattended run with nothing to stop it.
_capture_stage_resultdid blocking disk I/O on the event loop — themessage walk, the redaction of every segment, the
mkdirand the write.And one defect found while working on a fourth item, which is fixed here instead
of that item:
modeis persisted and thetranscript keeps the plan turn's
[OPTION: Go | Go All | Cancel]row, but theplan shape is in-memory only — so a restored slot renders buttons over a plan
that no longer exists, and pressing one ran zero stages and returned no
response at all. Indistinguishable from a hang.
Why it matters
A user who clicks Go All is handing over unattended execution on the strength of
two promises: that a stage cannot spin forever, and that the run as a whole is
bounded. Neither held on the dashboard path. The inert round cap means a stage
keeps spawning waves past its budget; the missing duration ceiling means the
worst case is unbounded once you multiply stages.
The silent Go is smaller but worse to sit in front of: the one thing a user
cannot do with it is tell whether anything is happening.
What changed (motivation → approach → change)
The round cap stops a dashboard plan. Where the rounds come from decides
where the gate belongs:
_subagent_donerecords one round per completed spawnwave against
tracker.current_stage, i.e. while the stage is still running. Sothe enforcing gate is the one after a stage's subagent wave, and it is placed
after the result capture so a stage that genuinely finished keeps its result on
disk.
Stage entry no longer spends a round (
OrchestrationTracker.start_stage).This is an off-by-one that only became reachable by enforcing the cap. The loop
entered a stage through
record_round, called for its side effects rather thanits count — inert while nothing on this path read the cap, but once the cap IS
read that tick spends a third of the budget before any subagent runs: a dashboard
stage would be cut after two waves while the identical stage driven from
_subagent_done(no stage loop, so no entry tick) got three. Stricter than theprompt promises and inconsistent between the two paths.
start_stageregistersthe stage at zero rounds and restarts the stage clock; counting stays in
record_round, so all three rounds belong to actual waves.The cap halts auto-run only, like the watchdog and for the same reason: it
bounds an unattended plan. An attended stage that spent exactly its 3 allowed
waves and finished has done nothing wrong and is about to be offered Go anyway;
halting it said "Auto-run stopped" on a plan never in auto-run and skipped the Go
row, stranding the step-through (Opus round 8,
chat_orchestrator.py:988).The cap is a stage-boundary halt, not a per-spawn denial — deliberately.
Rounds are recorded when a wave completes, so within one stage turn the model
can issue a further wave before the boundary check runs (GPT round 7,
chat_orchestrator.py:995). Closing that would mean a hard gate inside the spawntool that reads the orchestration tracker: a new control layer in the spawn path,
which #1783 does not ask for, and which every spawned agent's own governance gate
already bounds. The boundary halt is the guarantee the prompt actually promises
("auto-run stops"), it is user-visible, and it is exactly what the Slack path has
always had. Overridden rather than built, for the same reason the persistence
item was withdrawn: the finding is real and the fix is out of proportion to it.
MAX_STAGE_ESCALATIONSis deliberately not checked here, and that is areachability fact rather than a preference. Escalations are only recorded by
reset_after_guidance, which zeroes the capped stage's rounds while KEEPING itskey — so
current_stage(the highest key) does not move, the loop's next entrystarts at the stage after it, and an escalated stage is never re-entered. Nothing
on this path can observe
is_force_failed, so a check here would be dead code:the very defect this PR is fixing. It stays enforced in
slack/gateway.py, wherethe tracker is not driven by a stage loop.
Whole-plan watchdog.
orchestrator.max_plan_duration_seconds(default 2 h,0disables) is checked at each stage boundary, with one warning at 75% latchedinside the tracker. At the boundary rather than mid-turn: the running stage
already has its own ceiling, and cutting between stages leaves every finished
stage captured on disk.
Auto-run only. The clock is wall-clock from the plan's first stage, and a
stage-gated plan spends most of it parked at an approval prompt — so the ceiling
cut a plan the user was actively stepping through, counting their own review time
between Go clicks against them. The budget exists to bound unattended runtime;
when the user clicks each stage they are the ceiling. Gated on the loop's
auto_runparameter rather thanslot._auto_run, because the slot flag iscleared by every halt path and would make the gate depend on whether something
had already gone wrong.
The budget load is a question about the tracker, not about the loop. It was
gated on
tracker is None— "did this loop create the object" — so a tracker theloop did not build ran the whole plan on constructor defaults, with the new plan
watchdog sitting at
0, which means DISABLED. That tracker is real: the Slackgateway creates one lazily when a subagent result lands on a slot the loop has
not reached. The tracker now answers
budgets_unset, andmark_budgets_loaded()is recorded even when the load raised, so one bad config read cannot become one
per stage-loop entry. A failed load lands both budgets on
OrchestratorConfig'sdataclass defaults rather than leaving the ceiling at 0.
Two things ride along.
context_management.pyandtest_completion_result_read_off_loop.pyleave.github/black-baseline.txt:both became black-clean as a side effect of formatting the code this change
touches, and the gate requires a file that has become clean to be pruned. Not
split into its own commit because the readiness guard asserts a single commit on
base.
One user-visible rendering change rides along.
timeout_humaninlinedminute/second formatting; extracting
_human_secsso an hour-scale plan budgetcan render
2hadded an hours branch that the existing per-stage timeouttext now goes through too — a stage timeout configured at 3600s used to print
60mand now prints1h. Nothing else about that message changed and no callerparses the string. Kept rather than reverted, because without the hours branch
the 2 h plan budget would render
120m.Capture off the loop, split at the boundary the repo already uses.
_collect_stage_result_partswalks the assistant messages on the loop, becauseslot.messagesis live state the loop mutates, and hands an immutable tuple ofraw strings to
_write_stage_resulton a worker, which redacts and writes. Sothe redaction pass moves off the loop too, and nothing mutable is reachable from
that thread — the same split as
_previous_result_paths/_read_previous_results._capture_stage_resultis deleted: it was retained "for callers that are noton the event loop" and there were none (one
# noqa: F401re-export plus testfiles), so the retention rationale was fiction.
A plan whose stages are gone is refused out loud.
_stage_loopposts⚠️ This plan is no longer active …, logsauto_run_plan_expired/plan_shape_absent, closes the turn out (chat_done,slot.task = None) andreturns — before the tracker is built, so before the config is read. Not gated on
auto_run, so Go All is refused the same way. The same gate covers a planningturn that parsed no stages, which arrives in the identical state, so the message
names the state rather than a cause.
Scope change: cross-restart plan persistence is withdrawn
This PR opened with a fourth item — persisting plan state so a restart resumes.
That was implemented, reviewed over five rounds, and has been removed rather
than landed. It is worth stating why in the open, because the item is on #1783
and stays open there.
The review record. Nine of the eleven findings raised on this PR came from
that one item, and every one of them was the same class: a persisted record is
untrusted input and the design kept treating it as data it had written itself —
str()-coerced result values that let a hand-edited record skip a stage thatnever ran, an unconfined result path that inlined any readable file into the next
stage's prompt, a
snapshot()that iterated live ledger dicts from a workerthread,
bool("false")reading as started. Each was fixable and each was fixed.The last one was not: a crash between recording a stage result in memory and the
next slot save leaves a record that says a stage finished when its work is gone,
or the reverse, and there is no answer to that inside the persistence design —
only a durability contract the module does not have.
The boundary that actually resolves it. Autopilot is a lightweight executor
of a plan the user is watching, not a task runner that owns work across process
lifetimes. Resuming means restoring an execution ledger — which stage ran, how
many rounds it spent, which results are real — and every restored fact is a way
to re-run a completed stage's side effects or to skip a stage that never ran. A
plan is cheap to re-ask for; a mis-resumed plan is not.
What the module owes the user is therefore honesty, not continuity. That is
the refusal above: the plan shape stays in memory, a restart ends the plan, and
the button says so instead of doing nothing.
stage_*_result.mdfiles stillsurvive on disk and the message says so.
Removed with it, because it becomes unreachable: the escalation-cap entry gate
(it could only be reached by a restored tracker) and the
planmetadata field,snapshot(),from_snapshot(),resume_stage(),_plan_state_for_saveand_restore_plan_state.chat_persistence.pyandhistory.pyare back toorigin/mainbyte-for-byte.Kept from that work, because it fixes a pre-existing hole unrelated to
persistence:
budgets_unset/mark_budgets_loaded, which is what stops thegateway's lazily created tracker from running a plan on constructor defaults.
Tests
41 tests across four new files, each proven red on this tree before the fix.
test/test_stage_round_cap_enforced.py(9) — a stage whose waves spend itsround budget halts the plan, stops auto-run, keeps its result on disk (the
ordering assertion, paired with the halt so it cannot pass vacuously), and is
audited. Plus the off-by-one guard: two waves per stage must run the plan
through, which fails the moment stage entry starts spending a round again, and
the tracker-level pair —
start_stageregisters at zero rounds, and the fullMAX_STAGE_ROUNDSis spendable after entry. And an attended stage at exactlythe cap still gets its Go row rather than a halt.
test/test_plan_duration_watchdog.py(21) — the clock starts with the plan andis not re-armed per stage; timeout, disabled (
0), and the latched 75% warning;the loop halts at the boundary naming the budget and elapsed time; finished
stages stay on disk; a stage-gated plan driven through a second Go with the
clock already past the budget is not cut, while the auto-run cut still fires;
the config default is 2 h; a tracker the loop did not create loads both budgets,
one that already carries them does not reload, and a failed load is not
re-attempted.
test/test_stage_result_write_off_loop.py(7) — the write, themkdirand theredaction all run off the loop thread; the message walk stays ON it (live slot
state must not be reachable from the worker); plus preservation of file content,
the stage-separator boundary, and redaction before disk.
test/test_expired_plan_is_refused.py(4) — the refusal is audible rather thansilent, closes the turn out (
chat_done,slot.task, slot list), costs notracker and no config load, and applies to Go All identically.
Two existing files move with the code.
test_completion_result_read_off_loop.pymoves its intercept from
_capture_stage_resultto_write_stage_result—against the split its substitute would never run and it would pass vacuously.
test_orchestrator_config_load_off_loop.py's fixture now carries one stagetitle: it used an EMPTY plan to reach tracker initialisation, and the new refusal
turns an empty plan away before anything is built, so every assertion in that
file would have passed vacuously. Its subjects are unchanged.
Manual verification
N/A — unit coverage sufficient. Every path is exercised through the real
_stage_looprather than mocked seams; the off-loop assertions wrap the actualsyscalls (
Path.write_text,Path.mkdir) so they record the thread thatgenuinely performed the I/O.
Gates run locally on the rebased head:
black(the repo's baselined gate),isort,flake8(src/+test/),mypy src/kiro_crew/(1296 files clean),docs_lint, and 885 tests across the orchestrator / tracker / dashboard-chatfamilies.
Related Issues
Related: #1783
no linked issue: #1783 is an umbrella tracker with items this PR deliberately
leaves open, so a closing keyword would close it while most of its checklist is
still unchecked.
This PR covers two P1 items (the dead round cap, the missing total-plan watchdog)
and one P2 item (the blocking capture). Cross-restart plan persistence — also P1 —
is withdrawn by design (see the scope-change section); #1783's checklist entry
for it stays unchecked, and the reasoning above is the argument for closing it as
won't-do rather than for keeping it open.
#8798 is now moot and should be re-scoped or closed: it proposed deriving the
stage loop's start index from
resume_stage(), which no longer exists.Also left for follow-up, unchecked on #1783 rather than silently dropped:
(
gateway.py_retrigger_recovery); stage-1 timeout initialisation.of previous-result injection;
_pending_synthesisarmed but never consumed inorchestrator mode; one-member-per-sweep stuck-wave reconciliation.
Pattern harvest
Rule candidate: review-prompt
Pattern: a limit is only as real as the unit it counts, and wiring up an
ignored limit re-opens the definition of that unit. Enforcing
MAX_STAGE_ROUNDSwas a two-line change; the defect it created was that the loophad been calling
record_roundfor its side effects for as long as the counterwas ignored, so switching the counter on silently repurposed a bookkeeping call
as a budget spend — and made one path stricter than both the prompt and the other
path. The proposed rule: when you make a previously-ignored counter load-bearing,
enumerate every existing call site and say, for each, whether it is an instance
of the thing being counted. Its companion, from the same change: state a gate as
a property of the object being gated (
does this tracker have its budgets) ratherthan of the code path that reached it (
did I create this tracker) — the twoagree everywhere except the path you are adding.
Second, on scope rather than code: nine of eleven findings landing on one item
of a four-item PR is a design signal, not a run of bad luck. Every individual
finding on the persistence item was legitimate and fixable, and fixing them one at
a time is what kept the item alive for five rounds; the count was the thing worth
reading. The rule: when findings concentrate that heavily on one item, stop fixing
and ask whether that item's contract is the problem — and prefer withdrawing it
from the PR over converging it, since the other items are then reviewable on their
own merits.
Checklist
docs/system-specs/modules/autopilot.md: the stageloop's new gates and the plan-shape refusal,
start_stage, the splitcapture, the limits table with the escalation-cap reachability note, and
the restored "plan progress is not persisted" limitation)
Round 8 — advisory residue taken, one blocking finding overridden
Design Review and First Principles both PASS/CONCERNS; their notes are taken:
two stale persistence comments deleted (
chat_orchestrator.pybudget-load gate,the orphaned
Persistence across a gateway restartheader),_round_cap_verdict→
_round_cap_messagereturning just the string (itsoperationwas a constantwith one consumer), redaction dropped from
_halt_plan/_dead_msg/_warn_msg(every input is an integer, a humanized duration or a literal — the"model-authored" rationale was false), the baseline prune declared above, and the
attended→Go All clock asymmetry recorded in the spec as deliberate.
GPT's blocking finding (per-spawn cap enforcement) is overridden with the
reasoning in the round-cap section above.
Round 9 — Opus advisory taken; the remaining red is inherited from
mainThe round-cap halt is now gated on
auto_run(see the round-cap section).Round 10 — review lanes all green; one inherited red on
mainOn
5f1613d3d(rebased past #8844, which closed themembers.pyred from#8846) all four review lanes pass: Design Review PASS, First Principles PASS,
Opus "No findings", GPT human override accepted. The only failing check is
Backend Tests (3.12, 4), one test —test_snapshot.py::TestNotificationCopyWhenNoLiveFileExists::test_a_FRESH_gateway_still_orders_the_copy_against_a_delivery— which this PR does not touch (its diff is confined to
context_management.py,chat_orchestrator.py,config/*and their tests), which passes locally 3/3,and which is red on
mainitself at002081169(already in this branch's base).Tracked as #8915; it was introduced by #8576. This PR goes green when that is
fixed on
mainand this branch is rebased onto it.