Skip to content

fix: stop planning a sub-floor solar export the inverter will absorb (#630) - #633

Merged
johanzander merged 4 commits into
mainfrom
fix/issue-630-subfloor-solar-export
Aug 17, 2026
Merged

fix: stop planning a sub-floor solar export the inverter will absorb (#630)#633
johanzander merged 4 commits into
mainfrom
fix/issue-630-subfloor-solar-export

Conversation

@johanzander

Copy link
Copy Markdown
Owner

Summary

Root cause

The issue attributes the gap to SOE_STEP_KWH grid-snapping making a sub-step absorb unrepresentable. That is not the mechanism: the forward replay carries continuous SoE, so absorbing 0.0034 kWh is perfectly representable, and period 32's SoE is held bit-exactly, which snapping would not produce.

What actually happens is that the DP deliberately offers a second IDLE-shaped action — the SOLAR_EXPORT-below-max bypass (#313), consider(0.0, forced_next_soe=soe): hold SoE, export this period's own surplus. Nothing commands that hold directly. It is delivered by classify_strategic_intent labelling the period SOLAR_EXPORT, which is the intent that writes charge rate 0 and so stops the inverter absorbing the surplus.

That label requires grid_exported > FLOW_NOISE_FLOOR_KWH (0.01 kWh). At or below it the period falls through to IDLE, whose command is load_first at charge rate 100 — which absorbs. So the plan books export revenue on energy the hardware puts in the battery, and the battery runs fuller than planned until it reaches a bound and spills the difference.

At period 32 of regression_2026_08_17_624 (surplus 0.0034 kWh, export below the floor), the battery then runs 0.0033 kWh fuller than planned for 32 periods until period 64, where it hits max_soe and exports that much solar instead. The two periods' cost deltas sum to the observed +0.001555 SEK.

GRID_FLOW_RESOLUTION_KWH (0.1) is not a second band here, despite being what the discharge-side twin uses: EnergyData's noise fold applies only to battery_to_grid when the battery is discharging (models.py:193). Solar export is never folded, so the classifier's floor is the only binding threshold.

Fix

_solar_export_bypass_is_unexecutable() in action_selector.py — a per-period scalar (with no battery flow, _ac_flows reads only solar, load and the AC cap, so the export does not depend on SoE). The candidate is withheld at all three sites that define the action set (P1): the shared selector, and both backward passes.

The grid backward pass's existing "deliberately NOT masked by _discharge_is_unexecutable" exception does not transfer, and the code says why: that exception rests on the coarse lattice having no exact-cover point near the deficit breakpoint, so an in-band action is the better proxy. The bypass is an exact action, not a lattice approximation, and its executable neighbour (plain IDLE) is in the action set at every state.

Withholding is always safe: plain IDLE is offered unconditionally, and wherever the bypass diverts nothing (no surplus, or a battery already at max_soe) the two candidates coincide exactly.

Honest note on the two backward-pass gates: they are inert on today's corpus. Reverting them alone leaves every test green, including the new one. They are there because P1 requires one action-space definition — leaving those columns valuing an uncommandable action is the #236 class — not because they were measured to change a decision. Stated here rather than left for the reviewer to discover.

Declined one review suggestion: _solar_export_bypass_is_unexecutable re-derives the AC cap even though all three call sites hold it. Accepting it as a parameter is what _period_flows' docstring warns against — a cap a caller can get wrong is the reward-vs-flows divergence P4 exists to remove. One derivation per period is not worth reopening that seam.

Test plan

  • ./scripts/quality-check.sh passes locally (2052 passed / 50 skipped; Black, Ruff, TypeScript, ESLint, permission surface all clean)

  • pytest -m slow passes (549 passed, 7 skipped)

  • Every plan pin unchanged across the 32-fixture corpus: test_current_plan_is_pinned (bit-exact actions, intents, SoE trajectory), test_all_scenarios, the VPP baselines. Zero golden churn — consistent with a corpus scan finding exactly one affected period.

  • The real fixture, observed. regression_2026_08_17_624 (the user-derived quarterly day) run through the real optimizer and the real inverter simulator, with PR fix: bisect a tie window too long for one exact PWL solve (#624) #629 layered on locally so it can run at all:

    R=-47.814616  P=-47.814616  gap=+0.000000 SEK      (issue reports +0.001555)
    
    period 32  intent=IDLE
        soe        19.31579 -> 19.31910     (was: held at 19.31579)
        charged     0.00341                 (was: 0.00000)
        exported    0.00000                 (was: 0.00341)
    
    periods still planning a sub-floor held-SoE export: []
    

    R is unchanged from its pre-fix value — the entire gap was the plan overstating, and it closes exactly, so no second mechanism is hiding underneath. The two PRs also apply cleanly together.

  • App-level E2E via docker-compose.ci.yml + mock-HA (ci-growatt-vpp, libfaketime-pinned clock): backend healthy, optimizer ran end-to-end, served a full 96-period schedule at 11.36 SEK predicted savings. This confirms no regression in the service path; it does not exercise the bug — that scenario is a winter day with zero solar in all 96 periods. The bug-specific observation is the fixture run above.

Evidence the test discriminates

Two mutations, both run:

  • Reverted: the select_action gate only (if not _solar_export_bypass_is_unexecutable(...)if True:), leaving both backward-pass gates in place.
  • Result: test_subfloor_solar_export_is_never_planned FAILEDR=2.404206 P=2.401236 gap=+0.002970, 3× PLAN_EXECUTION_TOLERANCE_SEK. 1 failed, 8 passed in that file.
  • Reverted: the two backward-pass gates only, leaving the selector gate.
  • Result: all 134 tests passed — which is the basis for the "inert on today's corpus" note above, not something discovered afterwards.
  • Restored: tree clean, full suites re-run green.

The test was also written RED before the fix existed and observed failing at that same gap.

One bug the mutation work caught: the first version of the dp_battery_algorithm.py gate used continue, which would have skipped the residual-load-cover candidate later in the same loop iteration — harmless on a surplus period, a real defect on any period with a deficit. Restructured as an if block.

Outcome-level coverage

  • R == P via run_scenario_realized in test_subfloor_solar_export_is_never_planned — asserts realized cost reproduces planned cost, plus a secondary mechanism assertion that period 0 plans no sub-floor export so a future regression is diagnosable rather than merely red.
  • The scenario is built so the bypass is the DP's honest economic choice, not an accident: the 0.0099 kWh surplus sits just under the floor; periods 1–2 fill the battery to max_soe regardless, so storing period 0's surplus is worth only the export it displaces (sell 0.2) rather than its evening value; selling at 0.5 therefore beats storing; and a larger discharge-and-refill is unprofitable at 0.5 sell − 0.4 cycle − 0.2 displaced. terminal_value_per_kwh is pinned to 0.0 so the leftover-SoE term cannot quietly change which candidate wins.
  • Existing corpus pins (goldens, test_all_scenarios, VPP baselines) cover the no-churn half.

Documentation

docs/agents/bess-knowledge.md describes this exact bypass under "Deferring absorption uses the SOLAR_EXPORT-below-max bypass (#313)", and claimed unconditionally that such periods classify as SOLAR_EXPORT. That is now conditional, so the section records the executability bound. docs/SOFTWARE_DESIGN.md's classifier table (grid_exported > 0.01 kWh) and intent→mode table are both still accurate and unchanged.

Interaction with PR #629

The fixture that exhibits this bug lives in #629, which is still open, so this branch's regression test is a hand-built scenario on main instead — it does not depend on #629 merging.

Whichever of the two merges second must delete #629's KNOWN_PLAN_EXECUTION_GAP_SEK entry for regression_2026_08_17_624, since this fix takes that gap to exactly 0.000000 and the entry would then pin a gap that no longer exists. Verified above by running the two together.

Closes #630

…630)

The DP offers a "hold SoE, export this period's own solar surplus"
candidate -- the SOLAR_EXPORT-below-max bypass (#313). Nothing commands
that hold directly: it is delivered by classify_strategic_intent
labelling the period SOLAR_EXPORT, which is the intent that writes charge
rate 0 and so stops the inverter absorbing the surplus.

That label needs grid_exported > FLOW_NOISE_FLOOR_KWH. At or below it the
period falls through to IDLE, whose command is load_first at charge rate
100 -- which absorbs. So the plan booked export revenue on energy the
hardware put in the battery, and the battery ran fuller than planned
until it reached a bound and spilled the difference. On the #629 fixture
that is +0.0016 SEK over a quarterly day, from one 0.0034 kWh surplus.

This is the charge-side twin of the #282/#497 failure that
_residual_cover_p already gates against on the discharge side, so the new
predicate sits beside it and reuses the same constant -- one threshold,
so the candidate space and the classifier cannot drift apart.

Withheld at all three sites that define the action set (P1): the shared
selector and both backward passes. The grid backward pass's existing
"deliberately NOT masked by _discharge_is_unexecutable" exception does
not transfer -- that rests on the coarse lattice having no exact-cover
point nearby, whereas the bypass is an exact action whose executable
neighbour (plain IDLE) is in the action set at every state.

Withholding is always safe: plain IDLE is offered unconditionally, and
where the bypass diverts nothing the two candidates coincide exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DSyXRJCGQHrsTZ3TC7d8A
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot 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.

Review summary — APPROVE

Root cause vs. linked issue (#630): Matches, and the PR body's root-cause is more precise than the issue's own filed text. The issue attributes the gap to SOE_STEP_KWH grid-snapping; the PR body correctly identifies that as wrong (continuous SoE replay can represent 0.0034 kWh; period 32's SoE is held bit-exactly, which snapping would not produce) and instead traces it to the SOLAR_EXPORT-below-max bypass (#313) being offered even where classify_strategic_intent (strategic_intent.py:68, grid_exported > FLOW_NOISE_FLOOR_KWH) would never actually label the period SOLAR_EXPORT, leaving nothing to command the hold and letting load_first/charge_rate=100 absorb the surplus instead. I verified this chain directly in strategic_intent.py and action_selector.py — it's correct, and it explains the specific +0.001555 SEK number the issue reports.

Fix shape: _solar_export_bypass_is_unexecutable() (action_selector.py:131) is the charge-side twin of the existing _residual_cover_p/_discharge_is_unexecutable pattern (#497/#282), gated on the same FLOW_NOISE_FLOOR_KWH constant used by the classifier — exactly the "one threshold" discipline the codebase already established for the discharge side. Applied at all three P1 action-space-definition sites (action_selector.py:703 selector, dp_battery_algorithm.py:1398 grid backward pass, pwl_window_dp.py:264 PWL backward pass), which is what P1 (docs/agents/optimizer-architecture.md) requires — a candidate withheld in only one of the three would be the #236 mirror-drift bug class. I confirmed _effective_ac_cap_kwh(battery_settings, dt) is exactly what each of the three call sites already derives independently, so there's no divergence risk in re-deriving it inside the new predicate (matches the precedent set by _period_flows's own docstring, which I also checked).

Test — would it catch a regression? Yes, verified directly, not just read: I reverted only the select_action gate (action_selector.py:703, if not _solar_export_bypass_is_unexecutable(...)if True) in a scratch worktree and re-ran test_subfloor_solar_export_is_never_planned. It failed exactly as claimed: R=2.404206 P=2.401236 gap=+0.002970 (3× PLAN_EXECUTION_TOLERANCE_SEK). Reverted the edit immediately after. The test asserts realized cost against planned cost (run_scenario_realized) plus a mechanism-level assertion (period_data[0].energy.grid_exported == 0), i.e. outcome-level per docs/agents/testing.md, not a command/register assertion.

Full suite, run locally (not just re-quoted from the PR body):

  • pytest -m "not slow": 2052 passed, 50 skipped — matches PR claim exactly.
  • pytest -m slow: 549 passed, 7 skipped — matches PR claim exactly, confirming zero golden churn across the 32-fixture corpus (test_current_plan_is_pinned, test_all_scenarios, VPP baselines all still bit-exact).
  • black --check / ruff check on all four touched Python files: clean.

Minimality / scope creep: None found. Six files touched: the new predicate + its three call sites, one new regression test, one CHANGELOG line, one bess-knowledge.md addition documenting the now-conditional bypass. No new classes, no Optional[x], no hasattr/getattr-with-default, no exception-string matching, no hardcoded entity IDs — checked the diff directly for all of these. Single commit, no plan/scratch files.

Rule violations: None found.

Scope-assessment statement (rules.md Debugging Protocol step 9): The PR body doesn't use the literal words "local fix" / "structural fix", but it does the substantive equivalent — it explains why the change touches three sites ("withheld at all three sites that define the action set (P1)") rather than leaving that for the reviewer to infer, and the three-site shape is the architecturally-mandated one (P1 explicitly requires one candidate-space definition shared by the selector and both backward passes), not an ad-hoc spread across unrelated owners. I don't think this needed an explicit escalation — there's one clear new owner (the predicate function, mirroring the existing _discharge_is_unexecutable sibling) and P1 dictates where it's consumed. Flagging as a nit only: an explicit one-line "this is a local fix, mirroring #497's pattern" would have made this checklist item trivially checkable rather than requiring the reviewer to reconstruct it from the "Fix" section.

Notably good PR hygiene, worth calling out: The "Honest note" that the two backward-pass gates are inert on today's corpus (verified by the author's own mutation testing — reverting them alone leaves all 134 tests green) is exactly the kind of self-disclosure that makes a review actually verifiable rather than a trust exercise. Same for stating up front that R is unchanged (the entire gap was the plan overstating) and flagging the #629 interaction (KNOWN_PLAN_EXECUTION_GAP_SEK cleanup needed by whichever PR merges second).

No blockers. Nits only, noted above.

johanzander and others added 3 commits August 17, 2026 21:46
PR #629 merged while this branch was in review, bringing with it the
regression_2026_08_17_624 fixture and a KNOWN_PLAN_EXECUTION_GAP_SEK
entry recording the +0.0016 SEK gap as a known defect awaiting a fix.
This is that fix, so the entry goes -- the fixture's gap is now
+0.000000.

Four consequences, each measured rather than assumed:

- KNOWN_PLAN_EXECUTION_GAP_SEK loses the entry. Its comment described
  the cause as SOE-grid snapping; that was wrong (the forward replay
  carries continuous SoE, and period 32's SoE was held bit-exactly),
  so the replacement note records what it actually was.

- test_the_bisected_schedule_is_executable_as_planned asserted the gap
  equalled the recorded value. With the gap gone it asserts plain
  R == P, which is strictly stronger: a bisection seam error no longer
  has a nonzero expected value to hide inside.

- The fixture's expected_results move by +0.001550
  (battery_solar_cost -47.81617 -> -47.81462). Realized cost is
  unchanged at -47.814616 and planned now equals it exactly -- the plan
  became honest, no saving was lost.

- The selector golden moves. actions, intents and
  intra_period_discharge_allowed are all UNCHANGED; only soe_trajectory
  differs, in 32 of 97 entries (indices 33-64), every one by exactly
  +0.0033077 kWh -- the surplus period 32 now absorbs, carried forward
  until the battery reaches max_soe at index 64 and re-converges. That
  is the issue's own described mechanism, made visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DSyXRJCGQHrsTZ3TC7d8A
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot 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.

Review summary — PR #633 (fix/issue-630-subfloor-solar-export)

Root-cause match. The PR does not merely fix issue #630 as filed — it corrects the issue's own root-cause attribution. #630 blamed SOE_STEP_KWH grid-snapping; the PR shows (and I independently confirmed against dp_battery_algorithm.py/models.py) that the forward replay carries continuous SoE and period 32's SoE is held bit-exactly, which snapping wouldn't produce. The real mechanism is classify_strategic_intent's FLOW_NOISE_FLOOR_KWH (0.01 kWh, strategic_intent.py:29) gate on the SOLAR_EXPORT label: below it the period falls through to IDLE, whose derived command (load_first, charge_rate_pct=100) absorbs the surplus the plan had booked as export. This is disclosed explicitly in the PR body rather than left for the reviewer to find.

I traced the new predicate's logic by hand: _solar_export_bypass_is_unexecutable (action_selector.py:121-174) calls _ac_flows(solar, home, 0.0, 0.0, ac_cap) and checks only grid_exported <= FLOW_NOISE_FLOOR_KWH. Given battery_discharged=0 for this candidate, EnergyData._calculate_detailed_flows (models.py:152-195) reduces battery_to_grid to 0, so solar_to_grid == grid_exported exactly — meaning the single-condition check is logically equivalent to the classifier's two-condition SOLAR_EXPORT gate (grid_exported > FLOOR and solar_to_grid > FLOOR, strategic_intent.py:67-70) for this specific candidate shape. The gate is sound.

Test would catch a regression. I ran the mutation the PR describes myself, in a throwaway worktree off pr-633: reverting only the select_action gate (action_selector.py:703, if not _solar_export_bypass_is_unexecutable(...)if True:) reproduces the claimed failure exactly:

AssertionError: plan books a cost its own execution does not reproduce:
R=2.404206 P=2.401236 gap=+0.002970

This matches the PR body's reported numbers to the last digit. With the fix restored, core/bess/tests/integration/test_plan_faithfulness.py and core/bess/tests/unit/test_pwl_window_bisection.py both pass in full (13/13). test_subfloor_solar_export_is_never_planned asserts an outcome-level property (R == P via run_scenario_realized), consistent with docs/agents/testing.md's "assert the outcome, not the command."

Minimal, no scope creep. The candidate-withholding gate is applied at exactly the three sites P1 (docs/agents/optimizer-architecture.md) requires for one action-space definition: the shared selector and both backward passes (dp_battery_algorithm.py, pwl_window_dp.py). The PR is upfront that the two backward-pass gates are currently inert on the corpus and states why they're still required (P1, not measured behavior change) — exactly the kind of disclosure the checklist's "fitness of approach" section asks for. The scope assessment (charge-side twin of _residual_cover_p, same constant, same _discharge_is_unexecutable-style local addition) is stated directly in the PR body, satisfying rules.md's Debugging Protocol step 9. A review suggestion to parameterize the AC-cap derivation was declined with sound P4 reasoning (avoids a caller-supplied-cap divergence seam) rather than silently accepted or silently dropped.

Rule compliance. No Optional[x], no hasattr/getattr fallbacks, no new classes, no exception-message string matching, no hardcoded entity IDs (not applicable to this module). Golden-file and fixture-pin changes are all explained inline with the mechanism, and the now-obsolete KNOWN_PLAN_EXECUTION_GAP_SEK entry for regression_2026_08_17_624 is correctly removed with the interaction against the still-open PR #629 called out explicitly.

No blockers found. Approving.

@johanzander
johanzander marked this pull request as ready for review August 17, 2026 20:28
@johanzander
johanzander merged commit fc08944 into main Aug 17, 2026
8 checks passed
@johanzander
johanzander deleted the fix/issue-630-subfloor-solar-export branch August 17, 2026 20:52
johanzander added a commit that referenced this pull request Aug 17, 2026
Found by running the rule against the live fleet on its first pass, which is
the only reason it was caught before the PR merged.

`mergeable` reports whether the branch merges cleanly and nothing else, so it
reads MERGEABLE while CI is still running or has failed outright. #633 was
APPROVED, MERGEABLE and had Algorithm tests and E2E still IN_PROGRESS, and the
rule duly reported "gh pr ready 633 — then it is the maintainers to merge".
GitHub itself disagreed: `mergeStateStatus` was BLOCKED.

Flipping a red or pending PR out of draft is worse than leaving it there.
`ready` is supposed to mean the maintainer can merge without checking anything
else, and that claim is the only thing making the flag worth setting.

So `mark_ready` now also requires every check to have concluded SUCCESS,
SKIPPED or NEUTRAL. SKIPPED is green on purpose: this repo path-filters
Algorithm tests and Docker build, so every backend-only PR skips them and
treating that as not-green would withhold the action from almost everything.
An empty rollup is green too — a PR with no checks configured has nothing
failing.

The deferred list mirrors the same condition, so an approved-but-pending PR
with a P4 card appears in exactly one place rather than both.

Live effect: #633 falls back to resume_implementation until its CI settles,
which is the correct answer and the one the first version got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR
johanzander added a commit that referenced this pull request Aug 17, 2026
…e-argued (#636)

* feat: put PRs on the board so a decision about one is recorded, not re-argued

The board held issues only, so every judgement about a PR had nowhere to live.
"#167 and #354 are blocked", "#437 and #490 are lower priority, later" were
real decisions the maintainer had already made, and the rhythm pass re-reported
all four as due on every tick because nothing recorded them. The same
conversation happened every 30 minutes.

Projects v2 takes PRs as items with the identical field set, so the fix is
membership rather than a parallel mechanism. A PR card carries the same
`Priority` and `Awaiting` an issue card does; the digest emits them as
`pr_board`, and `backlog-rhythm.sh` joins by number and suppresses on them:
an `Awaiting` means parked on someone, `P4` means later-not-never.

`content.type` is what separates PR cards from issue ones, confirmed against a
real card rather than assumed — an added PR reports "PullRequest" with
number/title/url/repository alongside it. Numbers are unique across issues and
PRs in one repository, so this cannot collide with the existing issue lookup.

Suppressed PRs are COUNTED AND LISTED, never dropped: the pass ends with
`deferred: 4 (#490 priority P4; #167 awaiting discussion; ...)`. Silently
vanishing would trade one failure for another — the goal is to stop re-asking
about a settled decision, not to lose the item.

CONTRACT CHANGE: an APPROVED, green, still-draft PR is now its own action,
`mark_ready`, and it is the one thing no board decision can defer. It used to
hand back to `implement-issue` like any other unfinished draft, on the
principle that this pass must not grow a second review loop. That principle
still holds, but it is what left #629 sitting approved, green and draft: the
remedy on offer was a whole `implement-issue` session, and nobody spends one of
those to run a single command. `gh pr ready` is a terminal action, not a loop,
so naming it here duplicates nothing.

`awaiting_maintainer` is deliberately NOT carved out the same way. An approved
PR waiting on a merge is not broken; it is the maintainers call when to take
it, and P4 is exactly how they say later. #490 sat approved for a day and was
reported every tick as though that were news.

Live effect: 31 actions -> 27, with one `deferred: 4` line in place of four
recurring items, and #631 correctly still reported as merge-ready.

No CHANGELOG entry: agent tooling, no user-visible effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

* fix: mark_ready needs green checks, not just a clean merge

Found by running the rule against the live fleet on its first pass, which is
the only reason it was caught before the PR merged.

`mergeable` reports whether the branch merges cleanly and nothing else, so it
reads MERGEABLE while CI is still running or has failed outright. #633 was
APPROVED, MERGEABLE and had Algorithm tests and E2E still IN_PROGRESS, and the
rule duly reported "gh pr ready 633 — then it is the maintainers to merge".
GitHub itself disagreed: `mergeStateStatus` was BLOCKED.

Flipping a red or pending PR out of draft is worse than leaving it there.
`ready` is supposed to mean the maintainer can merge without checking anything
else, and that claim is the only thing making the flag worth setting.

So `mark_ready` now also requires every check to have concluded SUCCESS,
SKIPPED or NEUTRAL. SKIPPED is green on purpose: this repo path-filters
Algorithm tests and Docker build, so every backend-only PR skips them and
treating that as not-green would withhold the action from almost everything.
An empty rollup is green too — a PR with no checks configured has nothing
failing.

The deferred list mirrors the same condition, so an approved-but-pending PR
with a P4 card appears in exactly one place rather than both.

Live effect: #633 falls back to resume_implementation until its CI settles,
which is the correct answer and the one the first version got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
johanzander added a commit that referenced this pull request Aug 22, 2026
…ove (#653)

* fix: stop the fleet prune from destroying the worktrees it cannot remove

`git worktree remove` is sandbox-denied, and unlike `git worktree add` it
fails DESTRUCTIVELY. Removal deletes the working tree first and only then
unlinks `.git/worktrees/<name>` -- and that unlink is the denied one:

    error: failed to delete '.../worktrees/backlogger': Operation not permitted
    error: failed to delete '.git/worktrees/backlogger': Operation not permitted

By then ~393 tracked files are gone. It does not roll back. What is left is a
carcass: a registered worktree whose `git status` is a few hundred ` D` lines
and nothing else. Both prune loops read that as "uncommitted tracked changes"
and correctly refuse to auto-delete it -- so the failure makes the worktree
permanently unprunable BY ITSELF. Re-running hits the no-`--force` refusal;
`--force` re-hits the denial. `git worktree prune` performs the same unlink,
so it cannot clear the wreckage either.

13 carcasses accumulated across three sweeps (#568, #596, #597, #600, #601,
#603, #609, #612, #617, #629, #633, #634, #641) before anyone read the diff.
The last sweep reported them back as "a real backlog of stranded edits worth
reviewing" -- they were its own wreckage from the previous runs, and not one
byte of real work was in them.

Because the filename set is identical in every worktree, so is APFS's readdir
order, so every carcass loses the SAME ~393 paths (`core/`, `frontend/`,
`bess_manager/`, `pyproject.toml`, ...). Identical damage across many
worktrees is the signature, not a coincidence.

- Both prune loops now report `PRUNE` and emit one `!`-prefixed command for
  the maintainer to run unsandboxed, instead of removing anything themselves.
- Both classify a dirty set that is entirely ` D` as `CARCASS`, distinct from
  real edits. The predicate anchors on `^ D ` (unstaged deletions only), so a
  STAGED deletion still reads as intentional work.
- `local-agent-environment.md` gains the `remove`/`prune` half of the
  `.git/worktrees` denial, next to the `add` half it already documented.

`verify-sandbox.sh:88` predicted this in a parenthetical ("a stray directory
when `git worktree remove` then fails"); nothing acted on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FQA38o22RkHZJ4zEHDZS3

* fix: measure the prune half, which exits 0 while failing

The previous commit inferred that `git worktree prune` was denied because it
performs the same `.git/worktrees/<name>` unlink as `remove`. Measured now,
using a carcass as the probe -- `rm -rf` its directory, then prune:

    $ git worktree prune -v; echo "exit=$?"
    Removing worktrees/backlogger: gitdir file points to non-existent location
    error: failed to delete '.../.git/worktrees/backlogger': Operation not permitted
    exit=0

Denied as expected, but it EXITS 0. `remove` at least exits 255; prune reports
success having done nothing, so `git worktree prune && echo done` prints done.
That is the nastier of the two and was not in the inferred version.

Two further measured facts change the guidance:

- The denial is on the `.git/worktrees/<name>` unlink ONLY. `rm -rf` of the
  working tree from Bash SUCCEEDS. An agent can therefore always destroy the
  files and never the registration, and half-doing it converts a carcass into
  a `prunable` phantom -- no better, still needing the same unsandboxed fix.
- A phantom breaks both classification loops. Its directory is gone, so
  `git -C "$wt" branch --show-current` fails, `$b` comes back empty, and the
  detached-HEAD guard swallows it as "SKIP (detached)". Both loops now test
  `[ -d "$wt" ]` first and report PHANTOM. Verified over four cases: missing
  dir with and without a branch name, detached, and on a branch.

The emitted cleanup command gains a trailing `git worktree prune` (a phantom
has no directory, so `remove` has nothing to work with), uses `[ -n "$b" ]`
instead of `&&` since a phantom yields no branch name, and now says it must
run from a NON-worktree-isolated session -- an isolated one refuses the `cd`
to the shared checkout, which is why the first cleanup attempt silently did
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FQA38o22RkHZJ4zEHDZS3

* docs: record that `git branch -D` warns on .git/config but strands nothing

Third instance of the `.git/config` denial, alongside `checkout -b --track`
and `push -u`, and the same exit-0-while-partially-failing shape as the prune
bullet:

    $ git branch -D worktree-backlogger; echo "exit=$?"
    error: could not lock config file .../.git/config
    warning: update of config-file failed
    Deleted branch worktree-backlogger (was 6c70a77).
    exit=0

The first draft of this bullet asserted the denial leaves a stale
`[branch "<name>"]` stanza behind. Checked before committing, and it does not:
grepping .git/config afterwards found no stanza for the deleted branch, and
the only stale one in the file is an unrelated `undefined`. The branch had no
stanza to drop in the first place -- writing one needs `push -u` or
`checkout -b --track`, both denied by the two bullets above, so branches
created under this sandbox never have one. Recorded as the general case, with
the pre-sandbox branch explicitly marked untested rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FQA38o22RkHZJ4zEHDZS3

* docs: reconcile worktree-remove bullets with the rest of the permissions doc

The new destructive-failure bullets for `git worktree remove`/`prune`
contradicted three claims in the same file: the unattended list (which still
named `git worktree remove`), the "sandbox makes the unattended list safe"
thesis, and the "git already refuses the dangerous case" reasoning for the ask
list. A reader of only the Permissions section could conclude the command was
safe to call from sandboxed Bash — the exact bug the skills no longer call.
Carve the two verbs out as explicit exceptions and scope the git-refuses
reasoning to the cases git actually covers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: report worktree paths, not branch names, in the prune output

The sweep's emitted maintainer command reconstructed `.claude/worktrees/<name>`
from branch names, but `git worktree list` yields paths and includes sibling
worktrees outside `.claude/worktrees/` — the branch name alone cannot locate
the worktree, so removal would silently miss its target. Report `$wt` in the
PRUNE/CARCASS lines and drive the command from those paths. Also count PHANTOM
in implement-issue's emit-one-command line, matching sweep-prs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: fold the branch delete into Step 11's deferred worktree removal

The After-Merge cleanup splits in two: `ExitWorktree action=remove` clears the
worktree in-session, or removal is handed to the maintainer when the session
has already left. The old item 3 ran `git branch -D` right after either path,
but git refuses to force-delete a branch while its worktree registration
persists — the exact state the deferred path leaves behind, since only the
maintainer's not-yet-run command clears the registration. Emit the branch
delete as part of that same deferred command (remove first, then delete, as
sweep-prs does), and scope item 3 to the in-session path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Plan/execution divergence on sub-grid-step IDLE solar charging (impact negligible — canary, not lost savings)

2 participants