Skip to content

fix: release VPP control when IDLE at the reserve floor (#592) - #619

Merged
johanzander merged 9 commits into
mainfrom
fix/issue-592-vpp-idle-at-floor
Aug 18, 2026
Merged

fix: release VPP control when IDLE at the reserve floor (#592)#619
johanzander merged 9 commits into
mainfrom
fix/issue-592-vpp-idle-at-floor

Conversation

@johanzander

@johanzander johanzander commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • Growatt VPP: when the plan says IDLE and the battery is at its configured minimum SoC, release remote control instead of holding battery_first, so the inverter and its BMS can sleep.
  • Above the floor, nothing changes — the Question: How is IDLE used? #466 hold is preserved.

Root cause

_intent_to_vpp mapped every IDLE period to vpp_power=+1, remote control enabled, with no state-of-charge input at all. Because remote control stays enabled, _apply_period_vpp's needs_write is unconditionally true, so the command is re-asserted every 15-minute period to refresh the inverter's fallback timer (#404). Through a long overnight idle the inverter is therefore never handed back, and the BMS never idles down.

The battery_first hold is correct where IDLE is holding energy back for a later peak (#466): IDLE's own DP cost model (_idle_battery_flows) never credits battery discharge for load, so self-consumption must come from grid/solar. At the reserve floor there is no stored energy left to protect — the hold buys nothing and costs the inverter its sleep.

Fix

IDLE + at_reserve_floor(0, False): released to the inverter's own load_first self-use.

at_reserve_floor is derived live in BatterySystemManager._at_reserve_floor() and threaded through apply_period exactly as block_passive_charging (#355) and strategic_intent (#413) already are. It is read fresh at every write — including the retry path (which fires 3–8 minutes later) and the discharge-inhibit path, both of which would otherwise default it to False and silently re-assert the hold.

Why release rather than vpp_power=0 with remote control still enabled. The reporter tested the latter and recommended it, but that is grid_first, and it is not flow-neutral: it holds the battery against charging, so passive solar surplus is bypassed to the grid. IDLE's DP cost model does credit that absorption. Releasing to load_first keeps it, matching today's hold exactly. The reporter's overnight test could not distinguish the two — there is no sun at 03:00. Their other listed suggestion (load_first) is what this implements.

The SoE conversion deliberately mirrors min_soe_kwh's own arithmetic (capacity * pct / 100), because the case that decides this branch is exact equality — a battery parked on its floor overnight.

Test plan

  • ./scripts/quality-check.sh passes locally

  • pytest -m slow passes (554 passed, 8 skipped). The VPP regression baseline did move, and the movement is itself the evidence — see "Corpus" below. The earlier revision of this PR claimed the baseline was unchanged and read that as flow-neutrality; that claim was vacuous and has been withdrawn.

  • Observed on the real stack (docker-compose.ci.yml, ci-growatt-vpp scenario, libfaketime at 2025-01-15 20:00, battery sensor driven to the 10% floor). The DP independently planned Period 80 (20:00): Intent=IDLE — the reported condition, produced by the real optimizer. Reading the actual inverter registers, with the fix reverted and restored:

    vpp_remote_control Meaning
    Fix reverted Enabled (power 1) inverter commanded, BMS stays awake
    Fix restored Disabled inverter released, BMS can sleep

    (vpp_power still reading 1 afterwards is expected: set_growatt_vpp_period documents power_pct as ignored when remote_control_enabled is False.)

Evidence the test discriminates

  • Reverted: return (0, False) if at_reserve_floor else (1, True)return 1, True
  • Result: 3 tests FAILED — test_idle_at_reserve_floor_releases_remote_control, test_idle_at_the_floor_releases_the_inverter, test_released_control_stops_re_asserting_every_period. The production-path test showed the bug directly: four identical power_pct: 1, remote_control_enabled: True writes across four consecutive periods, which is "the inverter is never released".
  • Restored: tree clean, 60/60 green.
  • The same mutation was also run end-to-end against the live stack (table above), not only against the unit suite.

Outcome-level coverage

  • Production write pathtest_vpp_idle_at_reserve_floor.py drives BatterySystemManager._apply_period_schedule and asserts the command that actually lands on the inverter, not _intent_to_vpp with hand-built arguments. This is what stops the new branch being unreachable in production while a unit test still passes.

  • Execution modeltest_vpp_simulator_branches.py::TestIdleAtReserveFloor proves the released command produces the same battery power as today's hold at the floor, under both solar surplus and load deficit, plus a guard rail showing it does discharge one kWh above the floor (i.e. why the gate is needed).

  • Corpus — the VPP baseline, re-derived. The previous claim ("unchanged across 37 fixtures") could not have held: derive_vpp_commands never received at_reserve_floor, so the release branch was unreachable from the capture harness and the pin could not move whatever the fixtures contained. With the flag threaded through, the corpus exercises it, and the pin moves in exactly the shape the fix predicts:

    Half Entries Periods changed Δ realized cost Δ SoE
    v10.0.2 (historical plan, today's model) 23 234 0.000000000000 0.000000000000 kWh
    Current (today's plan) 27 265 0.000000000000 0.000000000000 kWh
    Total 50 499 0 0

    499 periods change their command from [1, True] to [0, False]; not one moves a joule or an öre. That is the corpus-level flow-neutrality evidence the withdrawn claim asserted without support.

    Re-pin method. Both pins moved, test_vpp_execution_of_the_baseline_plan_is_unchanged included — expected, since this PR edits the execution model that half replays the historical plan through. --repin-current skips that half by design, so the historical plans were extracted from the baseline's own plan fields and replayed via --from-plans, then --add-new restored the three post-tag plan: null fixtures. No tag checkout. Verified after writing: fixture set identical, zero plans altered on either half, only the 50 command arrays re-pinned — so the drift signal test_drift_from_the_released_version_is_recorded depends on is intact.

run_scenario_realized / R == P does not apply: that harness is Growatt TOU-only. vpp_simulator is VPP's execution model, and it is covered above.

Scope assessment

Local — stays inside the existing contract. _intent_to_vpp is already a pure (inputs) → (power_pct, remote_control_enabled) lookup fed by flags computed once upstream in BSM; this adds a fourth flag on the identical, twice-established path. Only one subclass overrides apply_period. TOU platforms never reach this code path and ignore the flag.

Workaround check: the diff adds nothing whose job is to route around an ordering, timing, or dependency problem. at_reserve_floor is a genuine new input the mapping needs and does not currently receive — same category as block_passive_charging. No fallback, no second construction site, no extra trigger.

Review round 2 — what changed

Both Stage 4 reviews (09:40 and 10:55) requested changes. All three findings are addressed:

  1. at_reserve_floor was dead at 2 of 3 call sites (09:40). The = False default let both non-production callers keep passing while never supplying the real value.
    • _vpp_display_state now receives it via _planned_at_reserve_floor(), which derives the flag from the plan's SoE trajectory (the display counterpart to the write path's live SoC). Without this the schedule API reported the old hold for periods production releases, breaking _mode_display_fields's own no-fabrication contract.
    • derive_vpp_commands now threads a per-period flag from the simulated SoE trajectory, which is what makes the corpus evidence above mean anything.
  2. _at_reserve_floor() could raise on an unreadable sensor (10:55, plus the inline comment on battery_system_manager.py:2816). get_battery_soc() is float | None, and the helper multiplied it raw — an uncaught TypeError on every period write, on every platform, from two apscheduler jobs with no exception handling. It now reuses _get_current_battery_soc()'s soc is not None and 0 <= soc <= 100 validation and holds on an invalid reading, logged. Holding is chosen deliberately: it is the safe direction and is exactly the pre-VPP idle mode #592 behaviour, since releasing is what could let self-use draw the battery down. Explicit and logged, not a silent fallback.
    • Writing that test RED surfaced a second bug the review did not mention: a negative SoC reading was releasing, because a negative SoE is trivially <= min_soe_kwh. The range check closes it.

Known limitation — please read before merging

How far the battery can actually fall under released self-use is the inverter's own discharge_stop_soc, not BESS's min_soc — and in VPP mode BESS never writes that register (initialize_hardware returns before sync_soc_limits, per #309). If the inverter's own floor sits below the configured min_soc, released self-use can draw the gap between them.

This is not new to this PR: LOAD_SUPPORT (#413) and SOLAR_STORAGE already release control the same way at any SoC, so this extends an existing exposure to IDLE-at-floor rather than creating one, bounded by the gap between the two floors (zero on a correctly configured inverter). vpp_simulator models the release as a hold at min_soe_kwh, i.e. it assumes the two floors agree.

Ships experimental pending hardware confirmation from the reporter that (a) the BMS does sleep, and (b) the battery does not discharge below the configured minimum.

Documentation

  • docs/INVERTER_PLATFORMS.md — new "IDLE at the reserve floor" subsection under IDLE semantics, including the caveat above.
  • docs/agents/bess-knowledge.md — the IDLE/VPP mapping paragraph stated the hold unconditionally and would otherwise have gone stale.
  • CHANGELOG.md — one line under ## [Unreleased] → Fixed.

Refs #592 — deliberately not Closes, since the reporter still needs to confirm on real hardware.

johanzander and others added 2 commits August 17, 2026 00:06
Growatt VPP mapped every IDLE period to vpp_power=+1 with remote control
enabled ("battery first"), regardless of state of charge. Because remote
control stays enabled, _apply_period_vpp rewrites the command every period
to refresh the inverter's fallback timer, so through a long overnight idle
the inverter is never handed back and its BMS never idles down. Reported on
real hardware.

The battery_first hold exists to protect stored energy from self-consumption
(#466): IDLE's own DP cost model never credits battery discharge for load,
so self-consumption must come from grid/solar. At the configured minimum SoE
there is no stored energy left to protect, so the hold buys nothing and costs
the inverter its sleep.

IDLE at the floor now returns (0, False) -- released to the inverter's own
load_first self-use. Above the floor nothing changes, so #466 is preserved.

Releasing rather than writing power=0 with remote control still enabled
(grid_first) is what keeps this flow-neutral. load_first still absorbs
passive solar surplus exactly as the battery_first hold does, where
grid_first holds against charging and would bypass that surplus to the grid
-- a real change, since IDLE's DP cost model does credit that absorption.
The v10.0.2 VPP regression baseline is unchanged on all 37 fixtures, 24 of
which contain IDLE-at-floor periods.

at_reserve_floor is derived live in BatterySystemManager._at_reserve_floor()
and threaded through apply_period the same way block_passive_charging (#355)
and strategic_intent (#413) already are. It is read fresh at every write --
including the retry path and the discharge-inhibit path, both of which would
otherwise default it to False and silently re-assert the hold.

Caveat, documented in INVERTER_PLATFORMS.md: how far the battery can fall
under released self-use is the inverter's own discharge_stop_soc, and VPP
mode never writes that register (#309). Not new here -- LOAD_SUPPORT and
SOLAR_STORAGE already release control at any SoC -- but it means the fix
ships experimental pending the reporter's hardware confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tmoey3FXSMsMsmVv8zmKNB
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

1 similar comment
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

johanzander added a commit that referenced this pull request Aug 17, 2026
Review of #622 found the previous commit's fix incomplete, and it was right.
`pr-review.yml:75` documents COMMENT as a legitimate FINAL verdict
("questions/observations only"), submitted with `gh pr review --comment`,
which produces the same state == "COMMENTED" as the bot's inline-notes
placeholder. Treating every COMMENTED as non-terminal therefore swallowed a
real COMMENT verdict: the loop waited out the full timeout and reported "never
submitted a summary" while a summary with findings sat on the PR. That
over-generalised "the placeholder is COMMENTED" into "COMMENTED is always the
placeholder".

Fixed at the source and in the consumer.

Source: pr-review.yml step 3 permitted `gh pr review` for inline notes, and
that is what submits the extra review. It now requires `gh api
.../pulls/N/comments`, so exactly ONE review is submitted per run -- the step
4 summary. No placeholder means no ambiguity.

Consumer: the script no longer decides by state alone, and deliberately does
NOT parse the placeholder's body -- that text is bot-generated prose with no
contract behind it. APPROVED/CHANGES_REQUESTED return immediately; a
COMMENTED-only state is held `grace` seconds (180, against observed
placeholder-to-summary gaps of 16s on #622 and 50s on #617) to let a summary
supersede it, and is returned as the verdict if none does. The grace window is
what keeps this correct for reviews already on older PRs and if the bot
regresses.

Verified against #622's real review history: with both reviews visible the
decisive branch returns CHANGES_REQUESTED and grace is never entered; in a
window containing only the placeholder the COMMENTED branch finds it while the
decisive branch is empty, so grace holds.

The timeout message is also now correct rather than merely different: a
COMMENTED-only run can no longer reach it, so reaching it means no review of
any state was submitted -- a trigger fault, which is what #619 hit twice.

SKILL.md's Step 11 said "It will never hand you COMMENTED"; a COMMENTED that
now reaches the caller IS the verdict, so it is documented as carrying
findings and not earning the ready flag, same as CHANGES_REQUESTED.
@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.

test-permission-check-only, will be replaced

@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.

(Disregard my previous one-line comment on this PR — that was a tooling check, not the review.)

Review: fix/issue-592-vpp-idle-at-floor

Root cause match

Matches #592. The reporter described battery_first (vpp_power=+1, remote control enabled) being re-asserted every period during an overnight IDLE at min SoC, keeping the BMS awake. The PR traces this to _apply_period_vpp's needs_write = remote_control_enabled or (...), which is unconditionally True while remote control stays enabled — confirmed correct by reading core/bess/solax_modbus_growatt_controller.py.

Does the fix hold everywhere it needs to? — No, two of three call sites of the changed function were left un-updated

_intent_to_vpp gained a fourth parameter, at_reserve_floor: bool = False (core/bess/solax_modbus_growatt_controller.py:349), and the new branch:

core/bess/solax_modbus_growatt_controller.py:428
    return (0, False) if at_reserve_floor else (1, True)

There are three call sites. Only one — _apply_period_vpp (the production hardware-write path, line ~509) — passes the real, live-computed flag. The other two keep the default False, so for them the new branch is dead code:

1. core/bess/simulation/vpp_simulator.py:93-98 (derive_vpp_commands, not touched by this PR):

power_pct, remote_control_enabled = controller._intent_to_vpp(
    grid_charge,
    discharge_rate,
    block_passive_charging,
    intents[period],
)

This is the function core/bess/tests/unit/vpp_capture.py::simulate_plan (via scripts/capture_vpp_baseline.py) uses to build the 37-fixture VPP regression corpus. The PR description's central evidence for flow-neutrality is:

"pytest -m slow passes; the v10.0.2 VPP regression baseline is unchanged on all 37 fixtures — 24 of which contain IDLE-at-floor periods. No re-baselining, which is the corpus-level confirmation that no energy flow moves."

That's not what this shows. Because at_reserve_floor always defaults to False here, _intent_to_vpp's new branch is unreachable through this harness regardless of what any fixture's SoE trajectory does — every one of those "24 IDLE-at-floor" fixtures still gets (1, True) out of _intent_to_vpp when this baseline is captured. The baseline is unchanged because the mapping change is never exercised by it, not because it's flow-neutral. Per docs/agents/rules.md's testing section ("A new test must be seen to fail without its fix... 'The suite is green' is evidence the suite is satisfied, never that the behavior holds"), this specific piece of evidence needs to be re-derived or the claim withdrawn. (The TestIdleAtReserveFloor class in test_vpp_simulator_branches.py does legitimately pin flow-neutrality — but it constructs VppCommand(0, False) by hand and calls vpp_command_to_power directly, bypassing _intent_to_vpp/derive_vpp_commands entirely, so it can't catch a wiring defect in the mapping itself, only in the physics model.)

2. core/bess/solax_modbus_growatt_controller.py:432-449 (_vpp_display_state, not touched by this PR):

def _vpp_display_state(
    self,
    grid_charge: bool,
    discharge_rate: int,
    block_passive_charging: bool = False,
    strategic_intent: str = "",
) -> tuple[int, bool]:
    """Display-facing alias for _intent_to_vpp().
    ...
    """
    return self._intent_to_vpp(
        grid_charge, discharge_rate, block_passive_charging, strategic_intent
    )

This feeds _mode_display_fields() (core/bess/inverter_controller.py:682, called from get_period_settings()), i.e. the schedule data the API/UI shows the user. _mode_display_fields's own docstring states it "Never fabricates a label the hardware doesn't back." After this PR ships, for any IDLE period the UI predicts to be at the reserve floor, the API will still report vpp_power_pct: 1, vpp_remote_control: true — the old hold — even though production will actually write vpp_power_pct: 0, vpp_remote_control: false. That's a real, user-facing accuracy regression this PR introduces on a path with an explicit no-fabrication contract, and neither the new tests nor the fixture corpus cover it (no test references _vpp_display_state together with the reserve floor).

Both gaps exist for the same structural reason: giving the new parameter a default (= False) let both call sites keep compiling and keep passing their existing tests without anyone having to notice they don't supply the real value. A required parameter (no default) would have turned both into TypeErrors the moment any existing test exercised them, surfacing the gap immediately instead of shipping it silently.

Fixing this doesn't look like a redo of the whole PR: _vpp_display_state's fix is straightforward, since get_period_settings() already has the period's planned SoE available and can derive at_reserve_floor from it the same way _at_reserve_floor() derives it from the live one. derive_vpp_commands is more involved — it currently derives all commands before simulate_vpp computes any SoE, so wiring this through means computing/threading a per-period at_reserve_floor (from the plan's own SoE trajectory) into that loop rather than deriving commands eagerly, up front.

Is the change minimal?

Yes for the plumbing that is there — the at_reserve_floor parameter follows the exact established pattern of block_passive_charging/strategic_intent, threading through _apply_period_schedule, the retry path, and apply_discharge_inhibit correctly (verified each one re-reads live SoC rather than reusing a stale value). The _at_reserve_floor() helper's SoE arithmetic matches min_soe_kwh's own (total_capacity * pct / 100.0, same order of operations) as claimed. No scope creep, no new classes, no Optional/hasattr/silent-fallback violations, no hardcoded entity IDs. The gap isn't scope creep — it's incomplete scope: the new input needed to reach every caller of the function it was added to, and didn't.

Would the new tests catch a regression?

The production-write-path tests (test_vpp_idle_at_reserve_floor.py) and the mapping tests (test_solax_modbus_growatt_vpp.py) are well constructed — they drive _apply_period_schedule end to end, assert the actual command, and the PR's mutation-testing note (reverted the fix, watched 3 tests fail) satisfies the "write it RED" rule. But they only cover the one call site that was actually wired up. They give no signal on the two gaps above.

Scope assessment statement

Present and appropriately labeled "Local" per rules.md's Debugging Protocol step 9 — I agree with that characterization for the write-path change itself. It just needs to additionally cover the two other callers of the function it's editing.

Verdict: Request changes. The core write-path fix looks correct and well tested. Before merging, either wire at_reserve_floor through _vpp_display_state and derive_vpp_commands (recommended, so the display and the corpus baseline both reflect real behavior), or explicitly scope this PR to the write path only, drop the corpus-baseline claim from the PR description, and file a follow-up issue for the display/baseline gaps.

@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.

Summary

Root-cause match: Yes. The diff traces cleanly to the reported bug — _intent_to_vpp's IDLE branch unconditionally returned (1, True), and because _apply_period_vpp's needs_write is True whenever remote_control_enabled, the command was re-asserted every period, so the inverter was never released. The fix ((0, False) if at_reserve_floor else (1, True)) addresses that directly, and I verified initialize_hardware really does return before sync_soc_limits for control_mode == "vpp" (solax_modbus_growatt_controller.py:648-653) — so the PR's "Known limitation" section is correct, and it actually corrects a wrong premise in the issue's own analysis comment (which claimed the min-SoC register is always synced at startup).

Would the new tests catch a regression? Mostly yes — test_vpp_idle_at_reserve_floor.py drives the real _apply_period_schedule write path (not just the mapping function) and pins the "stop re-asserting" behavior, and test_vpp_simulator_branches.py::TestIdleAtReserveFloor proves flow-neutrality at the floor. Good outcome-level coverage. However, see the blocking correctness finding below — the new code path doing the reserve-floor check is not itself exercised in a state that's realistic for production (sensor unavailable / None), and no test covers it.

Minimal / no scope creep? Yes — the diff stays inside _intent_to_vpp's existing (inputs) → (power_pct, remote_control_enabled) contract, and the fourth flag follows the same threading pattern already used for block_passive_charging/strategic_intent. Docs and changelog updates match the change.

Scope assessment statement: Present and accurate — PR body states this is a "Local" fix per the Debugging Protocol.

Blocking finding

BatterySystemManager._at_reserve_floor() (core/bess/battery_system_manager.py:2796-2819) does:

current_soe = (
    self.battery_settings.total_capacity
    * self.controller.get_battery_soc()
    / 100.0
)

get_battery_soc()_get_sensor_value("battery_soc") is documented and typed to return float | None: "Returns: float: The sensor value, or None if the sensor is unavailable, unknown, or could not be read" (ha_api_controller.py:1419-1433). This codebase already has an established pattern for this exact fallibility — _get_current_battery_soc() (battery_system_manager.py:1737-1744) explicitly checks soc is not None and 0 <= soc <= 100 before using it, with a warning + fallback when it isn't. _at_reserve_floor() skips that check entirely, so a transient "unavailable"/"unknown" HA sensor state raises an uncaught TypeError (float * NoneType) instead of degrading gracefully.

This isn't just a theoretical edge case — it's now reachable from three call sites added by this PR, with different (and in two cases, worse) failure behavior than before:

  • _apply_period_schedule (battery_system_manager.py:2718) — wrapped by update_battery_schedule's outer try/except, so this fails the entire period write (grid_charge, discharge_rate, everything), not just the reserve-floor decision, and is silently absorbed as "Failed to update battery schedule".
  • The period retry closure (battery_system_manager.py:2870, inside retry_period_write) — this runs via a bare apscheduler DateTrigger job with no exception handling at all, so a failure here bypasses _runtime_failure_tracker.record_failure(...) entirely — unlike the existing if not success: branch it sits next to, which is precisely how failures are supposed to surface to the dashboard banner.
  • apply_discharge_inhibit (battery_system_manager.py:3492) — registered directly as an every-minute CronTrigger job (backend/app.py:424-429) with no surrounding try/except in battery_system_manager.py. A sensor hiccup here silently breaks discharge-inhibit monitoring for that tick with no failure-tracker entry, unlike every other failure path in this file.

Also worth noting: _at_reserve_floor() is called unconditionally in _apply_period_schedule for every inverter platform (TOU Growatt, Solis, Huawei, SolaX, ...), not just Growatt VPP where the flag is actually consumed — so this crash risk is not scoped to the platform this PR is fixing.

None of the new tests exercise this: MockHomeAssistantController.settings["battery_soc"] is always set to a real float (10.0/50.0/the default 50), never None, so the gap is completely untested. Suggest mirroring _get_current_battery_soc()'s validation (or reusing it) before requesting changes are re-reviewed.

Verdict

REQUEST_CHANGES — solid root-cause fix and strong outcome-level test design, but the new _at_reserve_floor() helper needs to handle get_battery_soc() -> None the way the rest of this file already does, since as written it turns a routine, transient HA sensor hiccup into an unhandled exception on every period write, across every inverter platform, not just Growatt VPP.

Comment thread core/bess/battery_system_manager.py Outdated
scenario.
"""
current_soe = (
self.battery_settings.total_capacity

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.

self.controller.get_battery_soc() can return None ("unavailable"/"unknown" HA sensor state — see ha_api_controller.py's _get_sensor_value, typed float | None). Multiplying that into current_soe raises an uncaught TypeError instead of degrading gracefully.

This codebase already has the fix for this exact fallibility: _get_current_battery_soc() a few hundred lines up validates soc is not None and 0 <= soc <= 100 before using it. _at_reserve_floor() should do the same (or reuse it) rather than reading the sensor raw.

This matters more than a normal null-check nit because of where this method is now called from: the retry closure in _schedule_period_retry (line ~2870) has no surrounding try/except at all, and apply_discharge_inhibit (line ~3492) runs as a bare every-minute APScheduler job with none either — both bypass _runtime_failure_tracker entirely on this exception, unlike every other failure path in this file. And since this is computed unconditionally in _apply_period_schedule for every platform (not just Growatt VPP), it's a new crash risk for Solis/Huawei/SolaX/TOU-Growatt installs too, not just the VPP path this PR targets.

johanzander added a commit that referenced this pull request Aug 17, 2026
Every follow-up rule in the backlog skill had been written down and NONE had
ever fired. The 14-day reporter chase, the 28-day park, the reporter-replied
re-check, the stale-worktree handoff: all decoration, because each needed a
model to notice it and nothing scheduled one.

So the noticing is deterministic now and lives in scripts/backlog-rhythm.sh.
Every rule is a comparison over the digest — no judgement, no tokens. A quiet
backlog prints "RHYTHM: nothing due." for the cost of one process, which is
what makes it worth running on a timer at all. The PO agent is needed only to
ACT, and only when something is due.

The pass covers BOTH halves of the path to an approvable PR:

Issue side — recheck_ready, nudge_reporter, park, surface_discussion,
set_awaiting, set_priority, triage_labels, dispatchable.

PR side — and this is the half that actually hands the maintainer something:
  mark_ready           approved but still a draft   <- the finish line
  awaiting_maintainer  approved and out of draft
  request_review       draft with no review at all
  rework               changes requested
  resolve_conflict     CONFLICTING (produces no CI run, so it reads as
                       "checks never fired" and nobody investigates)

Those two states were invisible in practice. #615 and #617 sat APPROVED and
still drafts overnight with nothing left but the merge; #619 was never
reviewed at all. Nothing was watching either transition.

Ordering is load-bearing. PR actions come first because they are closest to
the finish line, and recheck_ready outranks the chases: nudging someone who
has already replied is the worst output this pass could produce.

Quiet time is measured from the LAST COMMENT, not updatedAt — a label change
or a board move bumps updatedAt, so an issue nobody has spoken on for a month
would look active and never age into a chase.

A bare COMMENTED review is not treated as a verdict, because the review bot
posts its inline notes as one before the summary.

Against the live board the pass finds 30 due actions, including PR #490
awaiting the maintainer, #162 park (quiet 54d), three stale worktrees and
three conflicted PRs.

RHYTHM_DIGEST_FILE / RHYTHM_PRS_FILE are test seams, the same shape as
BESS_ENV_FILE in gh-agent.sh. 16 tests pin the rules, including that a quiet
backlog is a noop and that a reply beats the chase.

Still not wired to a schedule — that is the invocation, not the logic, and it
is deliberately a separate step.
johanzander added a commit that referenced this pull request Aug 17, 2026
* fix: make backlog grooming reflect what actually blocks an issue

The digest misclassified enough of the board that grooming could not be
trusted, and every misclassification pushed work in the same direction:
towards looking more ready than it was. Measured against the live board,
Ready went 1 -> 0 and In Progress 5 -> 1.

Five defects, each traced to a real item.

1. `analyzed` was tested BEFORE any wait, so an analysed-but-blocked item
   reported Ready. #96 was labelled `analyzed`, prioritised P2, carried no
   blocking label, and still could not be built because its approach was
   undecided. It read as dispatchable, a session was dispatched at it, and
   that session deadlocked on three design questions. Waits now outrank
   `analyzed`.

2. The board's `Awaiting` field was never read. 17 items have it set
   (`discussion` x11, `reporter` x6) and the digest derived its own value from
   labels instead, so recorded grooming had no effect on anything. The field is
   now authoritative, with `awaiting_source` and `awaiting_suggested` exposed
   so an unset field can be reconciled rather than silently invented.

3. `awaiting: discussion` was returned for ANY human comment, which is not a
   blocker -- thanks, a "me too" and a follow-up question all pushed an item to
   Analysis. Only a recorded wait or a blocking label does that now.

4. A worktree left on disk pinned its issue to In Progress forever. #593,
   #571, #542 and #466 all reported In Progress while their PRs (#618, #579,
   #591, #517) had merged. Staleness is decided by comparing the worktree's own
   branch against merged PRs -- an exact match, deliberately not the fuzzy
   issue-number match used to associate a worktree with an issue.

5. `blocked` did not fail Ready, so #571 reported Ready for Dev while labelled
   `blocked`. Definition of Ready criterion 5 now holds, including an
   unresolved `Blocked by #N`.

Ready for Dev also finally requires a Priority, which the design always
specified and the code deferred "until a board exists". It exists.

New: `last_comment` {author, days, is_reporter, is_bot}. Without it the digest
could not represent the transition that matters most to grooming -- the
reporter answering us. A count and a date cannot tell that from a nudge we
posted, which is why #621 crossed the Definition of Ready line unnoticed.

REJECTED while building this: mapping a merged PR to Done. It reclassified 7
open issues (#118, #120, #403 among them) as finished, and it contradicts this
project's rule that beta PRs omit `Closes #N` until graduation -- an open issue
with a merged fix is the NORMAL state. `merged_pr` is reported; it moves no
column, and a test pins that.

Merged PR bodies are reduced to their closing references before reaching jq;
passing 200 of them through --argjson overflows the argument list.

Tests: 25 pass. The `gh` shim now branches on the full argument string, because
`pr list` is called twice with different `--state` values and matching only the
subcommand returned the open list for both -- which would have made every open
PR look merged. Fixtures gained `createdAt` on comments, which real gh always
sends and whose absence failed `strptime` rather than testing anything. `_run`
now surfaces the digest's stderr instead of a bare "exit status 5".

* fix: only a still-open Blocked by #N fails Ready

Review of #623 found a real gap, and it contradicted an explicit claim: both
the commit message and SKILL.md said this enforced an "unresolved" blocker,
while `blocked_by` was a pure text scan that never checked whether the blocker
was still open.

A `Blocked by #N` line is never edited out of an issue body once N lands, so
that scan pins the item out of Ready for Dev permanently. That is the same
failure this script exists to fix, pointing the other way: an item reading
wrong relative to its real state.

`$issues` is already the open-issue list, so membership decides it with no
extra API call. `blocked_by` keeps the raw parse so the reference stays
visible; the new `blocked_by_open` is the subset that actually blocks, and
`$blocked` reads that.

Also pins the precedence question the review asked me to confirm rather than
guess at: a recorded wait DOES outrank a live worktree in `column`, because
unsettled scope must not read as progress. The risk is hiding active
undelivered code, so the worktree stays reported on the item — the wait
changes the column, not the evidence. Now tested and documented in the
reconciliation table instead of being implied.

27 tests pass.

* feat: a Rhythm pass that carries work from incoming to a ready PR

Every follow-up rule in the backlog skill had been written down and NONE had
ever fired. The 14-day reporter chase, the 28-day park, the reporter-replied
re-check, the stale-worktree handoff: all decoration, because each needed a
model to notice it and nothing scheduled one.

So the noticing is deterministic now and lives in scripts/backlog-rhythm.sh.
Every rule is a comparison over the digest — no judgement, no tokens. A quiet
backlog prints "RHYTHM: nothing due." for the cost of one process, which is
what makes it worth running on a timer at all. The PO agent is needed only to
ACT, and only when something is due.

The pass covers BOTH halves of the path to an approvable PR:

Issue side — recheck_ready, nudge_reporter, park, surface_discussion,
set_awaiting, set_priority, triage_labels, dispatchable.

PR side — and this is the half that actually hands the maintainer something:
  mark_ready           approved but still a draft   <- the finish line
  awaiting_maintainer  approved and out of draft
  request_review       draft with no review at all
  rework               changes requested
  resolve_conflict     CONFLICTING (produces no CI run, so it reads as
                       "checks never fired" and nobody investigates)

Those two states were invisible in practice. #615 and #617 sat APPROVED and
still drafts overnight with nothing left but the merge; #619 was never
reviewed at all. Nothing was watching either transition.

Ordering is load-bearing. PR actions come first because they are closest to
the finish line, and recheck_ready outranks the chases: nudging someone who
has already replied is the worst output this pass could produce.

Quiet time is measured from the LAST COMMENT, not updatedAt — a label change
or a board move bumps updatedAt, so an issue nobody has spoken on for a month
would look active and never age into a chase.

A bare COMMENTED review is not treated as a verdict, because the review bot
posts its inline notes as one before the summary.

Against the live board the pass finds 30 due actions, including PR #490
awaiting the maintainer, #162 park (quiet 54d), three stale worktrees and
three conflicted PRs.

RHYTHM_DIGEST_FILE / RHYTHM_PRS_FILE are test seams, the same shape as
BESS_ENV_FILE in gh-agent.sh. 16 tests pin the rules, including that a quiet
backlog is a noop and that a reply beats the chase.

Still not wired to a schedule — that is the invocation, not the logic, and it
is deliberately a separate step.

* refactor: hand unfinished PRs back to implement-issue instead of duplicating it

The previous commit built request_review / mark_ready / rework into the Rhythm
pass, which is a second copy of implement-issue Step 11. That contradicts the
argument used to put resume in Step 0 rather than in a separate skill: two
copies of one review loop means one of them goes stale.

It also mis-diagnosed the symptom. #615 and #617 did not sit APPROVED-but-draft
because nothing was watching for that state; they sat there because the
sessions that owned them exited before Step 11 finished. The fix belongs where
the loop already lives.

So every unfinished draft now resolves to ONE action, `resume_implementation`,
carrying the issue number so the handoff is directly runnable. Step 0 re-enters
at the earliest incomplete step, whether the PR needs a first review, a rework,
or just the ready flag it never got. Two fleet-level exceptions stay in the
pass, because implement-issue deliberately does not widen to them:
`awaiting_maintainer` (report only) and `resolve_conflict` (sweep-prs).

Adds the stalled-work rule this was missing: a LIVE worktree with no session
behind it is an implementation that stopped mid-flight -- the machine
restarted, the session was killed, or the agent exited between steps. Nothing
picked these up, and an audit found 34 such worktrees, 8 holding real unpushed
commits and one with 32. Against the live board it finds #466 and #602.

`pr == null` guards that rule so work with a PR is reported once, by the PR
branch, rather than twice.

It is always a RESUME, never a restart: Step 4 branches fresh from origin/main
and would delete commits that exist nowhere else. The detail string says so,
and a test pins it.

SKILL.md gains the reasons a future pass must not re-learn this: do not drive
the review loop here; a session reporting `working` may have written nothing
(three dispatches produced zero writes in one day while reporting healthy
state); and read `claude agents --json` unsandboxed, since ~/.claude/jobs is
sandbox-denied and a sandboxed listing returned 1 session where the truth was
17.

19 rhythm tests, 27 digest tests, gate green.

* fix: match Blocked by #N per line, and drop dead code the refactor left

Review of #623 found a real misclassification path in a PR whose whole point is
eliminating them.

`blocked_by` was a free `scan` over the issue body, so it matched the substring
regardless of what preceded it. "not blocked by #500 anymore" and "no longer
blocked by #500" both registered as live blockers -- and those are the natural
way to update an issue once its blocker resolves, so the false positive fired
exactly when the blocker was GONE. The item would be pinned out of Ready for
Dev permanently.

The severity is new, not latent: on main `blocked_by` was extracted and never
fed into `column()`, so a bad parse was inert. Gating `column()` on it is what
gave it teeth.

Matched per LINE and anchored to the line start now, optionally bulleted, which
is the convention the skill documents ("a `Blocked by #N` line in the issue
body"). Anchoring rejects the negations without a blacklist that would only
cover the phrasings someone happened to think of. It also fixes the reviewer's
third point: an untriaged issue merely mentioning a blocker in prose no longer
moves Backlog -> Analysis with no human triage behind it.

Four tests: the bulleted form still counts, three negated phrasings do not, and
an incidental mid-sentence mention does not reclassify.

Also from the same review:

- `human_comments` became dead code when `awaiting` stopped deriving
  `discussion` from comment activity. Removed, and the stale comment on
  `comments:` that still described that mechanism is corrected.
- SKILL.md claimed the board's custom-field JSON shape was "confirmed" where
  the previous text had explicitly said unconfirmed, without showing the
  evidence. It was verified live; the command and its result are now recorded,
  with a note that the tests fabricate that shape and so cannot prove it.

49 tests pass (27 digest + 19 rhythm, plus the 3 new negation cases).

* fix: resume a PR by its own number when no issue is linked

`implement-issue` is used for TODO.md items and refactors, not only for
issues, so a draft PR with no linked issue is normal rather than a defect. The
pass reported "no issue references this PR; finish it by hand", which left every
self-directed PR with no owner in the loop -- exactly how #620, #622 and #623
all ended up driven by hand today.

No flag distinguishes the two cases: GitHub numbers issues and PRs from ONE
sequence per repo, so a bare number is already unambiguous and Step 0 can
resolve whichever it is. An earlier draft of this used `--pr <n>`; that
distinction carries no information.

Where an issue IS linked it is still named, because it carries the diagnosis.
Where none is, the PR number is the handle, and a strong one: it holds the
branch, the diff, the scope assessment and the review verdict, which is
everything Step 0 reads.

Step 0 accepting a PR number is a matching change to implement-issue's SKILL.md,
which lives on the fix/review-verdict-placeholder branch (#622) where Step 0 was
added. Both have to land for the loop to cover this case.
johanzander added a commit that referenced this pull request Aug 17, 2026
…sume for dead sessions (#622)

* fix: wait for a terminal review verdict, not the bot's placeholder

`request-pr-review.sh` took the LAST review newer than its trigger and
called it the verdict. The review bot posts its inline notes first, as a
COMMENTED review whose body is "Inline notes below; summary review to
follow.", then submits the real APPROVED/CHANGES_REQUESTED summary seconds
later. Measured on PR #617: placeholder at 06:57:13Z, APPROVED at
06:58:03Z — 50 seconds apart.

Any poll landing in that window returned COMMENTED. `implement-issue`
Step 11 then saw a non-APPROVED verdict and skipped `gh pr ready`, so an
approved PR stayed a draft with nothing left to do but the merge. PR #615
sat that way overnight: CHANGES_REQUESTED, fixed, APPROVED at 21:13, still
a draft the next morning.

Filter on state BEFORE taking `last`, so only APPROVED or
CHANGES_REQUESTED ends the wait. Verified against #617's real review
history by simulating a poll at 06:57:30Z, when the placeholder was the
newest review: the old expression returns COMMENTED, the new one returns
empty and keeps waiting.

The timeout path also conflated two opposite faults that printed the same
message — a review that started and never summarised, versus a trigger
that never reached the workflow. It now reports which one happened. PR #619
is currently the second kind, and that was invisible before.

Step 11's own text told the agent to act on `COMMENTED`, so it is corrected
to match; a bare COMMENTED can no longer reach the caller at all.

* feat: resume an issue whose session died mid-flight (implement-issue Step 0)

Sessions die mid-issue routinely and nothing picked them up. A fleet audit
found 34 worktrees whose sessions had exited: 8 with real unpushed commits
and no PR (one with 32 commits), plus three PRs sitting green-or-reviewed
with no owner left. #615 was APPROVED and still a draft the next morning;
#614 carried CHANGES_REQUESTED with nobody to act on it. `sweep-prs`
refuses that job by design, so the work simply stopped.

This lives in `implement-issue` rather than a new skill because the loop
that acts on review feedback is Step 11 and already lives here. A second
skill would duplicate it, and duplicating a review loop is how one of them
goes stale.

Step 0 keys off state observable from OUTSIDE the dead session — branch,
worktree, commits, PR body sections, CI status, review verdict — and
re-enters at the earliest incomplete step. The one thing that dies with the
session is Step 2's diagnosis, which Step 11 depends on holding; it is
recoverable only because this skill already forces it to be written down
(the Stage 2 analyze comment, and the PR body's `## Scope assessment` and
`## Test plan`). When those do not reconstruct a coherent approach, Step 0
STOPS rather than re-diagnosing on top of commits encoding decisions it
cannot see.

Hard rules, each from an observed failure:
- never run Step 4's fresh-from-origin/main worktree when a branch for the
  issue already has commits — that deletes them
- never reset or force-push a resumed branch; its commits are the only copy
- check for a live session unscoped AND unsandboxed: a sandboxed
  `claude agents --json` returned 1 session where the truth was 17, because
  ~/.claude/jobs is sandbox-denied, so every other session read as dead
- treat uncommitted tracked changes as unfinished work; WIP-commit first
- if the same issue has died twice, say so and stop

CI mode gets a Step 0 row too: Stage 3 is re-triggered by hand, so a second
`@claude-bot fix` on an issue that already has a has-fix-pr PR is a resume,
not a restart, and must not open a second PR.

* fix: resolve an ambiguous COMMENTED review by time, and stop emitting it

Review of #622 found the previous commit's fix incomplete, and it was right.
`pr-review.yml:75` documents COMMENT as a legitimate FINAL verdict
("questions/observations only"), submitted with `gh pr review --comment`,
which produces the same state == "COMMENTED" as the bot's inline-notes
placeholder. Treating every COMMENTED as non-terminal therefore swallowed a
real COMMENT verdict: the loop waited out the full timeout and reported "never
submitted a summary" while a summary with findings sat on the PR. That
over-generalised "the placeholder is COMMENTED" into "COMMENTED is always the
placeholder".

Fixed at the source and in the consumer.

Source: pr-review.yml step 3 permitted `gh pr review` for inline notes, and
that is what submits the extra review. It now requires `gh api
.../pulls/N/comments`, so exactly ONE review is submitted per run -- the step
4 summary. No placeholder means no ambiguity.

Consumer: the script no longer decides by state alone, and deliberately does
NOT parse the placeholder's body -- that text is bot-generated prose with no
contract behind it. APPROVED/CHANGES_REQUESTED return immediately; a
COMMENTED-only state is held `grace` seconds (180, against observed
placeholder-to-summary gaps of 16s on #622 and 50s on #617) to let a summary
supersede it, and is returned as the verdict if none does. The grace window is
what keeps this correct for reviews already on older PRs and if the bot
regresses.

Verified against #622's real review history: with both reviews visible the
decisive branch returns CHANGES_REQUESTED and grace is never entered; in a
window containing only the placeholder the COMMENTED branch finds it while the
decisive branch is empty, so grace holds.

The timeout message is also now correct rather than merely different: a
COMMENTED-only run can no longer reach it, so reaching it means no review of
any state was submitted -- a trigger fault, which is what #619 hit twice.

SKILL.md's Step 11 said "It will never hand you COMMENTED"; a COMMENTED that
now reaches the caller IS the verdict, so it is documented as carrying
findings and not earning the ready flag, same as CHANGES_REQUESTED.

* feat: Step 0 resolves a bare number to an issue OR a pull request

This skill is used for TODO.md items and for refactors that never had an issue,
so "issue number" was too narrow a contract. Step 0 already keys off observable
state; a PR is simply another entry point to it, and the stronger one -- it
carries the branch, the diff, the `## Scope assessment` and the review verdict,
which is everything Step 0 reads.

No flag is needed. GitHub numbers issues and PRs from ONE sequence per
repository, so a bare number is unambiguous: try `gh pr view <n>`, fall back to
`gh issue view <n>`. An earlier draft used `--pr <n>`; that distinction carries
no information.

Why it matters beyond tidiness: scripts/backlog-rhythm.sh hands unfinished
drafts back to this skill, and for a PR with no linked issue it had nothing to
hand -- it reported "no issue references this PR; finish it by hand". That left
every self-directed PR with no owner in the loop, which is how #620, #622 and
#623 all ended up driven by hand in one session.

Where an issue IS linked, nothing changes: it is still read for the diagnosis.
Where none is, Step 2's root cause comes from the maintainer's own framing
rather than a Stage 2 comment, and Step 9 records it in the PR body as usual.

* fix: decide a COMMENTED review by run state, and test the decision

Third round on the same finding, and the previous two "fixes" were asserted
rather than demonstrated -- verification was quality-check.sh plus `bash -n`,
neither of which executes the decision path. So this commit changes the
mechanism AND adds the missing tests.

The grace window was the wrong instrument, and the review named the reason: it
competed against the ORIGINAL deadline instead of extending it, so a COMMENTED
first seen in the last `grace` seconds of the window could never satisfy the
`elapsed >= grace` branch -- the loop exited first and reported "no review
landed", which was false. Observed live on #622: the stub landed 12:15:14, the
real CHANGES_REQUESTED 12:16:39, and the script returned the stub.

Sizing it differently would not have helped. The gap that matters is not
placeholder-to-summary (16s on #622, 50s on #617) but placeholder-to-END-OF-RUN:
the bot posts an early permission-check comment within a couple of minutes and
works for five to eight more. No constant is both short enough to return a real
COMMENT promptly and long enough never to pre-empt a summary.

So ask the run instead. `review_run_state` reads the PR Review workflow run
started since the trigger:

  running  -> a COMMENTED decides nothing; keep waiting
  finished -> a COMMENTED last word IS the verdict, per pr-review.yml's own
              three-verdict contract (APPROVE / REQUEST_CHANGES / COMMENT)
  failed   -> report at once; do not burn the timeout on a dead run
  none     -> the trigger never reached the workflow, a different fault

That last one matters as much as the first. A dead run and a thinking one are
both silence if you only poll for reviews, which is how #623's run -- already
failed on "Reached maximum number of turns (60)" -- was waited on for 16
minutes.

Also from this review round:

- pr-review.yml no longer REQUIRES `gh api` for inline comments. The reviewer
  reported that `gh api` is permission-gated and unavailable to it, and that it
  probed with `gh pr review` to find out -- which submits, and is where the
  stray "test permission check" reviews came from. The prompt now states the
  one hard rule (submit exactly ONE review, never probe with it), prefers
  `gh api` for inline notes, and says to fold findings into the summary with
  file:line when it is unavailable, rather than falling back to a second
  review.
- SKILL.md Step 0 keyed a resume signal on `## Scope assessment`, which only
  CI mode writes into the PR body. An interactive-mode PR never carries it, so
  the row would not match for most PRs this skill opens. It now keys on the PR
  existing, which is what actually proves Step 9 was reached.

7 new tests, REVIEW_POLL_INTERVAL added as their seam. Two of them are the
discriminating pair: identical reviews (COMMENTED only), opposite outcomes,
differing only in run state -- so the decision is provably driven by the new
signal and not by timing. The shim applies `--jq` like real gh does; an earlier
version echoed raw JSON and the script reported it as a verdict.

* fix: give the verdict tests their own env file so they pass in CI

The new test file could not pass in CI, and the review caught it with the
failing run: `Fast tests` was red on this PR while local `quality-check.sh` was
green. Reproduced both sides before fixing.

Cause: request-pr-review.sh posts its trigger through scripts/gh-agent.sh,
which reads a real BESS_AGENT_TOKEN and exits 1 before `gh` is reached.
Shimming `gh` on PATH does not help -- gh-agent.sh is invoked by a
repo-relative path, not looked up on PATH. Worse, it resolves its env file from
the MAIN checkout (`dirname $(git rev-parse --git-common-dir)`), so this
worktree having no `.env` of its own was irrelevant: the developer's real token
was read anyway. CI provisions no `.env` and no such secret, so the suite failed
unconditionally there. The abandoned `(d / "scripts").mkdir(...)` in the fixture
was an attempt at this that did nothing.

Fixed with the seam gh-agent.sh already documents for exactly this
(`BESS_ENV_FILE`, "a seam tests use to point at a fixture .env instead"), so no
production code changes: each test now supplies its own env file carrying a
dummy token.

Also adds a test that PINS that dependency, because the fix is otherwise
invisible and could be dropped again silently: point the seam at an empty file
and the script must fail before polling, naming the missing token.

Worth recording how I nearly mis-verified this: my first attempt set
BESS_ENV_FILE from OUTSIDE pytest and saw the tests still pass, which looked
like proof of CI-safety. It was not -- the test sets that variable in the
subprocess env, so it overrides any outer value and the experiment could not
fail. The in-test pin above is the version that can actually discriminate.

Full backend suite: 507 passed.

* fix: an unreadable run state must not promote a placeholder to a verdict

Review of #622 found a real correctness bug, reproduced against the shipped
script: `review_run_state` falls back to `unknown` whenever `gh run list` itself
fails -- network blip, rate limit, transient auth error -- and the COMMENTED
branch tested only `state = running`, so `unknown` fell through to the else and
reported the bot's placeholder as the verdict.

That re-opens the exact race this script exists to close, gated on API
flakiness instead of timing. "I could not tell whether the reviewer is still
working" must never mean "it finished". It now waits, same as `running`:
waiting costs one more poll, and a genuine COMMENT verdict still returns as soon
as the state resolves.

A test drives it, with the shim making `run list` exit 1 rather than returning a
run shape -- which is what the failure actually looks like.

That test also exposed a second defect in the same path: the timeout branch ends
with `gh run list ... >&2` for diagnostics, and under `set -e` a failing `gh` --
precisely the `unknown` case -- aborted the script with exit 1 instead of the
exit 2 that means "no verdict". Diagnostics must not decide the exit code, so it
is `|| true` now.

Also fixes the doc/implementation mismatch the review flagged: SKILL.md still
described the 180s hold from the superseded commit, which would actively mislead
the next reader about how Step 11 decides. It now describes the run-state
mechanism, including that an unreadable state waits.

9 tests. Worth noting the `pr-review.yml` change from this PR is already
working: this round the bot folded its findings into a single summary review and
posted no stray placeholder.
johanzander added a commit that referenced this pull request Aug 17, 2026
…e approvals as merge-ready

Two defects, both in the same direction: the surface told the maintainer to
act on work that was already handled.

REVIEW STATE (addresses the Stage 4 review on this PR, which reproduced it).
The previous commit asked "is there an APPROVED review anywhere" before
consulting `reviewDecision`. GitHub never rewrites an old review when a later
round requests changes, so an approved-then-reworked PR keeps its stale
APPROVED entry forever and was reported "nothing left but your merge" — the
exact failure the commit set out to close, reintroduced by the fix for it.

The review proposed keying on `reviewDecision` instead. That alone is also
wrong here, and measurably: `reviewDecision` is only populated when the repo
REQUIRES reviews, and this one does not. It reads CHANGES_REQUESTED for
#619/#620/#614 but "" for #490, which carries two genuine APPROVED reviews —
so keying on it alone reports an approved PR as never reviewed. Neither
signal is sufficient, and each fails toward "merge it", so the order is the
whole content of the rule: trust `reviewDecision` when set, otherwise fall
back to the LAST non-COMMENTED review. Last, not any — same staleness trap.

SESSION LIVENESS. `resume_implementation` keyed off `session == null`, and
`session` comes from `claude agents`, which lists BACKGROUND agents only. A
session started in the terminal — `claude`, then `/implement-issue <n>` — is a
foreground session and never appears; even a background agent carries a
generated descriptive name rather than the `issue-<n>` the dispatch convention
promises. Measured: 41 worktrees on disk, `claude agents --json` returning one
entry. So every worktree read as abandoned, and #624 was reported "no live
session, /implement-issue 624 to resume" while actively being worked — routing
a second session onto a branch the advice itself calls the only copy.

The worktree LOCK is what tracks a live session: git records
`locked claude session <name> (pid N start ...)`, and 4 of those 41 were
locked — exactly the four live sessions, foreground and background alike.

Live effect: #626 moves request_review -> rework_review (the review landed),
#490 stays awaiting_maintainer despite its empty reviewDecision, and #624 is
no longer reported as stalled.

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
* fix: make a backlog pass runnable without a shell preamble

`/loop /backlog` died on its first line, every tick. `backlog-digest.sh`
required PROJECT_NUMBER in the environment, but it lives in the gitignored
`.env` that nothing exports — so the script exited "the backlog board has not
been created yet", which is the most misleading message it could emit: the
board is Project #1, populated, and entirely fine. The documented workaround
(`set -a; . ./.env; set +a; ...`) cannot survive an unattended pass, because
the caller is a skill and not a shell someone typed into.

The digest now sources `.env` itself, located via
`git rev-parse --path-format=absolute --git-common-dir`. That is one rule
rather than a search over candidate paths: `.env` is gitignored, so it exists
only in the main checkout and never in a worktree, and the common dir resolves
to the main checkout from either. `--path-format=absolute` is load-bearing —
git otherwise answers the relative `.git`, whose dirname is `.`, silently
resolving against the caller's cwd. The environment still wins over the file,
or an explicit `PROJECT_NUMBER=2` would be un-overridable.

Two gaps the skill could not close without a second API call by hand:

- `board_status` — where the card sits NOW, alongside `column` (where the
  evidence says it belongs), in the same six Status strings. The `board` verb
  says "reconcile every card against the derived column, the digest always
  wins" and had nothing to reconcile against. Seven live mismatches were
  invisible.
- `issue_no_card` — an open issue with no card at all. `Ready for Dev`
  requires a Priority and priority is a board field, so an off-board issue can
  never become dispatchable however well analysed, while reading as an
  ordinary Backlog item. #621 and #624 were both in that state.

The rhythm pass told the PO to "set Priority" on those two, i.e. to set a
field on a card that does not exist; it now asks for `add_card`, and reports
`move_card` for a card the evidence disagrees with.

Tests pin all of it. The git shim answers `rev-parse` so `.env` discovery is
deterministic — letting the real git through would read the maintainer's own
gitignored file, which CI does not have.

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

* fix: never report an unreviewed PR as ready to merge

The rhythm surface resolved any non-draft PR to `awaiting_maintainer`,
"nothing left but your merge". That rests on an assumption that does not
hold: that only Step 11 clears the draft flag, and only after an APPROVED
verdict.

#626 broke it in the obvious way. It was flipped out of draft by hand because
it LOOKED stuck, had zero reviews at the time, and the next pass duly reported
it as ready to merge. A Stage 3 CI-mode PR has the same hole from the other
direction — it never runs Step 11 at all. Stage 4 is the gate the entire
pipeline is built around, so a surface that routes around it is worse than no
surface.

An APPROVED review is now the bar, matching Step 11 exactly. Not
`reviewDecision`, which reads "" both for never-reviewed and for
approved-then-dismissed; and not COMMENTED, which the bot also posts as a
placeholder before its real verdict. Two new actions carry the cases that used
to be silently collapsed into "merge it": `request_review` (Stage 4 never ran)
and `rework_review` (out of draft with changes requested).

Both fields were already fetched by `gh pr list` and simply unused.

Live effect on the current fleet: #490 stays `awaiting_maintainer` — it
carries real APPROVED reviews — and #626 becomes `request_review`.

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

* fix: stop the rhythm surface reporting live work as stalled, and stale approvals as merge-ready

Two defects, both in the same direction: the surface told the maintainer to
act on work that was already handled.

REVIEW STATE (addresses the Stage 4 review on this PR, which reproduced it).
The previous commit asked "is there an APPROVED review anywhere" before
consulting `reviewDecision`. GitHub never rewrites an old review when a later
round requests changes, so an approved-then-reworked PR keeps its stale
APPROVED entry forever and was reported "nothing left but your merge" — the
exact failure the commit set out to close, reintroduced by the fix for it.

The review proposed keying on `reviewDecision` instead. That alone is also
wrong here, and measurably: `reviewDecision` is only populated when the repo
REQUIRES reviews, and this one does not. It reads CHANGES_REQUESTED for
#619/#620/#614 but "" for #490, which carries two genuine APPROVED reviews —
so keying on it alone reports an approved PR as never reviewed. Neither
signal is sufficient, and each fails toward "merge it", so the order is the
whole content of the rule: trust `reviewDecision` when set, otherwise fall
back to the LAST non-COMMENTED review. Last, not any — same staleness trap.

SESSION LIVENESS. `resume_implementation` keyed off `session == null`, and
`session` comes from `claude agents`, which lists BACKGROUND agents only. A
session started in the terminal — `claude`, then `/implement-issue <n>` — is a
foreground session and never appears; even a background agent carries a
generated descriptive name rather than the `issue-<n>` the dispatch convention
promises. Measured: 41 worktrees on disk, `claude agents --json` returning one
entry. So every worktree read as abandoned, and #624 was reported "no live
session, /implement-issue 624 to resume" while actively being worked — routing
a second session onto a branch the advice itself calls the only copy.

The worktree LOCK is what tracks a live session: git records
`locked claude session <name> (pid N start ...)`, and 4 of those 41 were
locked — exactly the four live sessions, foreground and background alike.

Live effect: #626 moves request_review -> rework_review (the review landed),
#490 stays awaiting_maintainer despite its empty reviewDecision, and #624 is
no longer reported as stalled.

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 and others added 3 commits August 17, 2026 23:20
…dable SoC

Addresses both Stage 4 reviews on #619.

- _at_reserve_floor() reuses _get_current_battery_soc()'s validation and
  holds (does not release) on an unreadable sensor, logged explicitly.
  Previously float * None raised an uncaught TypeError from two unguarded
  apscheduler jobs, on every platform.
- _planned_at_reserve_floor() derives the flag from the plan's SoE
  trajectory and feeds _vpp_display_state, so the schedule API no longer
  reports the old hold for periods production releases.
- derive_vpp_commands threads a per-period at_reserve_floor from the
  simulated SoE trajectory, so the VPP fixture corpus actually exercises
  the new branch.

Refs #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiPyLE5Kz9RvNiQemAdjym
Both halves of the pin moved once derive_vpp_commands actually receives
at_reserve_floor -- including test_vpp_execution_of_the_baseline_plan_is_unchanged,
since this PR edits the execution model that half replays the historical
plan through. --repin-current skips that half by design, so the historical
plans were extracted from the baseline's own plan fields and replayed via
--from-plans, then --add-new restored the three post-tag plan:null fixtures.
No tag checkout, so the v10.0.2 plans are byte-identical.

  half             entries  periods  d(cost)         d(SoE)
  v10.0.2 (hist)        23      234  0.000000000000  0.000000000000
  current               27      265  0.000000000000  0.000000000000

499 periods change [1, True] -> [0, False]; nothing moves a joule or an ore.
Verified after writing: fixture set identical, zero plans altered on either
half, only the 50 command arrays re-pinned -- so the drift signal that
test_drift_from_the_released_version_is_recorded depends on survives intact.

Refs #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiPyLE5Kz9RvNiQemAdjym
johanzander added a commit that referenced this pull request Aug 18, 2026
The fixture tests could not see either of these. Both turned up on the
first live run.

1. GraphQL node limit. `--json commits` expands each commit's authors
   connection, so gh's cost estimate is limit x commits x authors. At
   --limit 100 that is 1,000,000 possible nodes and the query is rejected
   outright, so the command produced no output at all. --limit 30 keeps
   the worst case at 300,000. There is no cheaper field for "when did HEAD
   last move": `gh pr list --json` has no last-commit date, and a review's
   own commit SHA is REST-only. The cap is announced when hit rather than
   silently truncating, per sweep-prs.

2. Lazy `mergeable`, which is the dangerous one. The first query on a cold
   PR returns UNKNOWN *and* only then triggers the computation, so
   treating UNKNOWN as "not conflicted" hides precisely the stale PRs this
   script exists to surface.

   Measured: a first fleet run classified #167 and #619 with no conflict
   flag; once earlier queries had warmed them, the identical command
   returned `needs-refresh` for both. They were CONFLICTING the whole
   time. sweep-prs documents this trap and retries for the same reason;
   this reintroduced it.

   Now: re-ask while anything is UNKNOWN, and if it survives, render it as
   "(+mergeability UNKNOWN — re-run)" rather than letting it fall through
   to the clean branch.

Verified by mutation: forcing $mergeUnknown to false reddens
test_unknown_mergeability_is_never_reported_as_clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
johanzander added a commit that referenced this pull request Aug 18, 2026
…iewer"

The Stage 4 bot only ever acts when triggered by an `@claude-bot review`
comment. So "green, no verdict yet" has two completely different
meanings, and only one of them belongs to the reviewer:

  request NEWER than last push -> the bot is genuinely working: reviewer
  request OLDER, or absent      -> nobody has asked: DISPATCHER

Collapsing those parked six of eleven open PRs on someone who had not
been asked and was never going to act. Measured on the live fleet:

  #637, #635  never requested at all
  #620        requested 17:40:53, pushed 18:33:05
  #619        requested 10:50:40, pushed 21:53:00
  #614        requested 06:55:38, pushed 07:08:20
  #490        requested 08-15 13:41, pushed 08-16 15:30

Every one had been reported as `awaiting-review [reviewer]`. After this
change the same fleet shows zero PRs waiting on the reviewer and zero
waiting on the maintainer — the pipeline owes an action on all of them.

That is the failure this whole branch is about, seen from the other side.
The gate stops a confused loop asking too often; this surfaces the loop
that stopped asking at all. Both are the same lost state — whether the
last verdict has been consumed — and both are recoverable from the PR
rather than from a session that died.

`comments` joins the field set for this. It fits inside the GraphQL node
budget at --limit 30, which is already bounded by `commits`.

Verified by mutation: disabling the request-feed check reddens
test_a_review_never_requested_is_the_dispatchers_turn_not_the_reviewers
and test_a_push_after_the_last_request_owes_a_new_round.

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

Verdict: REQUEST_CHANGES

The fix's core mapping (_intent_to_vpp's new at_reserve_floor branch, _at_reserve_floor()'s live-SoC read with fail-safe holding) matches #592's root cause and the maintainer's own recommended fix (release to load_first, not grid_first). The production write path is well covered — test_vpp_idle_at_reserve_floor.py drives _apply_period_schedule end to end and would clearly catch a regression of the reported bug. But two things need fixing before merge.

1. Rule violation — forbidden getattr(obj, key, default)

core/bess/inverter_controller.py:697 (_planned_at_reserve_floor):

soe = getattr(self.current_schedule, "state_of_energy", None)
if not soe or period >= len(soe):
    return False

docs/agents/rules.md → Python: "Never use hasattr, getattr(obj, key, default), or any silent fallback." .github/claude-bot.md's checklist repeats this explicitly. current_schedule is typed DPSchedule | None (inverter_controller.py:176), and DPSchedule.state_of_energy is a required constructor arg with no default — the attribute always exists on a real instance. The only thing getattr is masking here is current_schedule being None. That should be:

if self.current_schedule is None:
    return False
soe = self.current_schedule.state_of_energy
if period >= len(soe):
    return False

Small in isolation, but it's a bright-line rule the checklist calls out by name, not a style nit.

2. Off-by-one: _planned_at_reserve_floor reads the wrong end of the period

Same method, next line:

return soe[period] <= self.battery_settings.min_soe_kwh

The docstring says this checks whether the battery is at the floor entering period — matching what BatterySystemManager._at_reserve_floor() reads live (the SoC that exists before this period's write). But state_of_energy[period] is not that. The only production writer of this field is _create_updated_schedule (battery_system_manager.py:2254):

combined_soe[target_period] = period_data.energy.battery_soe_end

— explicitly battery_soe_end, not battery_soe_start (both are distinct fields on EnergyData, populated separately in dp_battery_algorithm.py:1024-1025: battery_soe_start=soe, battery_soe_end=next_soe). So state_of_energy[period] is the SoE after period's own action runs, i.e. the SoE entering period + 1, not period. _planned_at_reserve_floor(period) is answering the question for the wrong period — it's reading one step ahead of what _at_reserve_floor() reads for the same period at write time.

Concretely: at the boundary period where the plan first reaches the floor (battery discharges down to exactly min_soe_kwh during that period), the live write-time check would see the battery still above the floor entering that period (hold expected), but _planned_at_reserve_floor would already report "at floor" for that same period, one period too early — and symmetrically one period too late when the plan leaves the floor. This directly contradicts the PR's own stated contract for this method: "it must answer the same question, or the UI shows a hold for periods production releases."

This also isn't reachable by the elsewhere-correct soe_trajectory convention this same PR establishes in vpp_simulator.py — there, _simulate's loop passes command_at(t, soe) where soe is the running entering SoE at the point command_at is invoked (core/bess/simulation/vpp_simulator.py, _simulate's loop body), i.e. index t's entering state — the correct convention. _planned_at_reserve_floor reuses the same "index by period" idea against a differently-shaped production array (combined_soe, which stores ending SoE) without the shift.

Why the new tests don't catch this: TestDisplayAgreesWithWhatIsWritten in test_vpp_idle_at_reserve_floor.py builds its fixture with state_of_energy=[soe] * 97 — a uniform value across every index. Under a uniform trajectory, soe[period] and soe[period - 1] are identical, so the off-by-one is invisible. A fixture with a varying SoE trajectory (e.g. one that crosses the floor between period - 1 and period) would discriminate this; the current one can't, regardless of which side of the bug the implementation is on.

Scope / minimality

Scope is otherwise appropriate — the vpp_simulator.py restructuring (splitting simulate_vpp/simulate_vpp_commands/_simulate) is justified in the PR body and is a direct consequence of the flag becoming SoE-dependent, not unrelated cleanup. The "Known limitation" section (VPP mode never writing discharge_stop_soc) is disclosed honestly and I confirmed it against solax_modbus_growatt_controller.py:647-655 (initialize_hardware returns early for control_mode == "vpp" before super().initialize_hardware()/sync_soc_limits would run) — accurate, not overstated.

Scope assessment statement

Present and reasonable ("Local" — the PR explains why _intent_to_vpp gaining a fourth input flag stays within its existing pure-lookup contract).


Please fix both issues in _planned_at_reserve_floor (the getattr and the index) and add a test where the SoE trajectory actually varies across the boundary period, so the display-vs-write agreement claim is actually exercised.

johanzander added a commit that referenced this pull request Aug 18, 2026
… branch

Two halves of the same failure: work landing outside a worktree, and
nobody noticing when it does.

## The hook

CLAUDE.md has said "never edit any file on main, even a one-line doc fix"
unconditionally for a long time, and it keeps being skipped. The reason
is structural, not carelessness: it is prose, so it has to be REMEMBERED
at the moment of the first edit — and that is exactly the moment a
session which opened as a question has no reason to reconsider it. Six
live sessions currently sit in the main checkout for perfectly good
read-only reasons; nothing catches the one that quietly starts editing.

check-worktree-path.sh already guarded CROSS-checkout edits and passed
same-checkout ones, so main-to-main sailed through. It now also refuses
any edit made from the main checkout, detected by --git-dir equalling
--git-common-dir. That is a path comparison, the only shape
docs/agents/rules.md sanctions here — it never guesses what a command
will touch. Linked worktrees and sibling checkouts both differ, so both
still work; the rule is "be in a worktree", not "be under .claude/".

The denial names the remedy (EnterWorktree) and says what the main
checkout still does — questions, gh, backlog, dispatch — because a block
without a next move gets worked around.

Residual gap, stated plainly: this governs Edit/Write/NotebookEdit. A
Bash `sed -i` still writes. Guarding that would mean parsing command
strings, which rules.md forbids for this hook and which has produced
false positives here four times.

## The detector

pr-state.sh gains a local-writer section. GitHub cannot see this: a
branch with two writers looks normal through the API, because the
divergence exists only between a local checkout and the remote and it
collapses into an ordinary merge the moment someone reconciles.

#619 is the worked example. One writer took the branch at 08:09 and
worked from that base; another pushed 23031e7 at 09:34. The reviewer
reviewed 23031e7 three times, twice with blocking findings, while the
first line never held that commit. Fifteen hours later it landed as
`Merge remote-tracking branch 'origin/fix/...' into fix/...` — a branch
merged into itself, which is the fingerprint. `git rev-list --left-right`
would have caught it at 09:34.

Run against the live fleet it also surfaces the precursor state: #437 is
5 commits behind its own remote and #614 is 3, so a commit in either
worktree diverges immediately.

Skipped LOUDLY outside a checkout, since this script is also meant for a
container fleet where a silent skip would read as "no divergence found".

Verified by mutation: disabling the main-checkout guard reddens 2 of the
6 hook tests. The divergence detector is tested against a real two-clone
scenario, not fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both findings from the Stage 4 round on 43295e6, confirmed against source.

1. rules.md:57 forbids getattr(obj, key, default). state_of_energy is a
   required constructor arg on DPSchedule, so the attribute always exists on
   a real instance -- the getattr was masking current_schedule being None,
   which is now checked directly.

2. Off-by-one. state_of_energy is combined_soe, whose only writer stores
   period_data.energy.battery_soe_end (battery_system_manager.py:2254);
   battery_soe_start and battery_soe_end are distinct fields on EnergyData.
   So index p is the SoE LEAVING period p, and the SoE entering it is p-1.
   Reading p reported the release one period early at the crossing, and one
   late on the way back up -- the exact display/write disagreement the method
   exists to prevent.

The existing display tests could not see this: their fixtures pin
state_of_energy to a uniform value, where p and p-1 agree by construction.
test_the_crossing_period_still_displays_the_hold varies the trajectory across
the boundary and fails on the old index.

Removing the getattr also surfaced a fixture that was never shaped like a real
DPSchedule: MagicMock(spec=DPSchedule) specs off the class, so it omits
state_of_energy, which __init__ sets. The silent fallback had been hiding it.

Refs #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiPyLE5Kz9RvNiQemAdjym
@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.

Summary

Root cause and fix match the linked issue (#592) exactly: _intent_to_vpp mapped every IDLE period to a battery_first hold with no SoC input, so an empty battery at the reserve floor never released the inverter and its BMS never slept. The fix threads a live at_reserve_floor flag through the write path (battery_system_manager.py), the display path (_planned_at_reserve_floor), and the simulator/corpus (vpp_simulator.py), releasing to load_first rather than the reporter's suggested power=0/enabled (grid_first) — correctly reasoned, since grid_first would bypass passive solar surplus to the grid instead of absorbing it, which IDLE's DP cost model credits.

Two rounds of prior review findings (dead flag at 2/3 call sites, unguarded TypeError on an unreadable SoC) are visibly addressed with dedicated regression tests (test_unreadable_soc_holds_rather_than_releasing, test_out_of_range_soc_holds_rather_than_releasing).

Test coverage is strong and outcome-focused (per rules.md's testing rules): test_vpp_idle_at_reserve_floor.py drives the real production write path (_apply_period_schedule) rather than asserting on _intent_to_vpp alone, so the branch can't go dead in production while a unit test still passes — exactly the failure mode the file's own docstring calls out. test_vpp_simulator_branches.py::TestIdleAtReserveFloor proves flow-neutrality at the outcome level (same vpp_command_to_power result as the old hold), with a guard rail (test_release_would_discharge_above_the_floor) showing the branch does matter above the floor. The scope-assessment section required by the checklist is present and accurate — this stays inside _intent_to_vpp's existing (inputs) → (power_pct, remote_control_enabled) contract, adding one more input flag the same way block_passive_charging and strategic_intent already are.

Blocking: shipped docs contradict the PR's own evidence

Two doc additions in this PR assert a claim the PR body explicitly withdrew.

docs/INVERTER_PLATFORMS.md:397-399 (as it lands on the branch):

"Verified across the fixture corpus: the v10.0.2 VPP regression baseline is unchanged by this fix, on all 37 fixtures, 24 of which contain IDLE-at-floor periods."

docs/agents/bess-knowledge.md:311:

"...and there is no headroom to discharge — so the VPP regression baseline is unchanged."

But the PR description's own "Test plan" section says the opposite, in these words:

"The VPP regression baseline did move, and the movement is itself the evidence... The earlier revision of this PR claimed the baseline was unchanged and read that as flow-neutrality; that claim was vacuous and has been withdrawn."

And the "Corpus" section quantifies it: 50 baseline entries / 499 periods change their command from [1, true] to [0, false] — which is exactly what core/bess/tests/unit/data/baselines/vpp_baseline_v10_0_2.json shows in this diff (over 1000 changed lines, 1,true0,false throughout). I confirmed via git log that both doc lines were written in the first commit (c05b58e) and never touched by the later commit that actually re-pins the baseline (43295e6 test: re-pin the VPP baseline commands, plans untouched) or the commit that fixed the off-by-one SoE index (900f5e5).

This isn't a nit: docs/agents/bess-knowledge.md is explicitly required reading for any agent answering BESS behavior questions (per this repo's own CLAUDE.md), so shipping a factually wrong "baseline unchanged" claim there actively misinforms future work — and it directly contradicts the "claim... has been withdrawn" language sitting right above it in the same PR. What's actually unchanged is realized_cost and SoE (0.000000000000 delta per the Corpus table) — the commands changed by design in every IDLE-at-floor period. Both doc passages need to say that: baseline commands re-pinned in 499 periods across 50 entries, cost/SoE unchanged.

Non-blocking observation

_planned_at_reserve_floor (core/bess/inverter_controller.py) special-cases period == 0 to read soe[0] directly rather than soe[period - 1], to avoid Python's negative-index wraparound. The docstring justifies this by asserting period 0 is always "the optimization period" (i.e. combined_soe[0] freshly written as current_soe), citing _create_updated_schedule. That holds when the active schedule's optimization_period == 0, but I don't see it holds generally: for a schedule re-optimized mid-day, index 0 is a past (historical) period, and battery_system_manager.py's own SOE-progression loop (~line 1876-1922) fills historical indices with event.energy.battery_soe_end — the SoE leaving that period, same convention as every other index — not a freshly-captured "entering" value. That would make soe[0] semantically the wrong quantity for a historical period 0. This is display-only (get_period_settings, not the live-SoC write path), so it can't misbehave the hardware, and I don't see a test exercising get_period_settings(0) on a schedule where optimization_period != 0 to confirm either way. Worth a look, not a blocker.

Checklist

  • Fix matches the linked issue's root cause: yes.
  • Tests would catch a regression: yes — several would fail RED without the fix (confirmed by the PR's own revert-and-run evidence), and they assert outcomes/production paths, not just the mapping.
  • Change is minimal / no scope creep: yes — the wider touch surface (display, simulator, three call sites) is required by the correctness fixes from prior review rounds, not scope creep, and is explained in the PR body.
  • Rule violations: none found in Architecture/Error-handling/Comments/Security categories (no Optional[x], no hasattr/getattr fallback, no new production classes, no entity-ID hardcoding, no exception-string matching).

Requesting changes only for the doc/PR-evidence contradiction above — that's a two-line fix in each of the two files.

Two doc passages still asserted the claim this PR withdrew -- that the
v10.0.2 VPP baseline is unchanged. It is not, and it could not have been:
before at_reserve_floor reached derive_vpp_commands the branch was
unreachable from the capture harness, so an unchanged baseline meant the
change was never exercised, not that it was flow-neutral.

Both now state what is actually unchanged. Commands move at every
IDLE-at-floor period (499 across 50 entries, [1, true] -> [0, false]);
realized cost and the SoE trajectory are bit-identical, 0.000000000000 on
both halves. Commands moving with the energy fixed is the evidence.

This matters most in bess-knowledge.md, which CLAUDE.md designates as ground
truth for any agent answering BESS behaviour questions -- a wrong claim there
misinforms future work rather than just this PR.

Also corrects _planned_at_reserve_floor's docstring, which justified reading
index 0 for period 0 by asserting period 0 is always the optimization period.
It is not: on a schedule re-optimized mid-day, index 0 is historical and holds
battery_soe_end like every other index, so the read is one period early there.
Display-only and bounded to period 0. Stated rather than papered over -- the
array has no entering value for period 0 to read, so recording one is a change
to _create_updated_schedule, not to this method.

Refs #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiPyLE5Kz9RvNiQemAdjym
@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: #619 (fix/issue-592-vpp-idle-at-floor)

Root cause match: Confirmed against issue #592's own text. The reporter described IDLE holding battery_first overnight at min SoC, preventing BMS sleep, and suggested either "load first" or "power 0 at soc min" as fixes, explicitly noting they'd tested the latter ("forced power to 0 when soc is at min ... recommend this"). The PR's root-cause section (_intent_to_vpp mapping every IDLE to vpp_power=+1/remote-control-enabled with no SoC input, re-asserted every period per #404's fallback-timer refresh) matches the reported mechanism exactly, and the fix explains — correctly — why it implements the reporter's other suggestion (load_first/release) rather than their tested one: power=0 with remote control still enabled is grid_first, which bypasses passive solar surplus to the grid instead of absorbing it, unlike battery_first's hold. That's a real behavioral difference the reporter's overnight test (no sun at night) couldn't have caught. Good catch, well-reasoned choice.

Traced the implementation: _at_reserve_floor() (battery_system_manager.py:2793) reuses _get_current_battery_soc()'s validation (so it can't raise on None/out-of-range SoC — verified _get_current_battery_soc already wraps everything in try/except and returns None on failure) and mirrors min_soe_kwh's own total_capacity * pct / 100.0 formula (verified against settings.py:153), so the floor comparison is exact-equality-safe as claimed. _apply_period_vpp's needs_write = remote_control_enabled or (remote_control_enabled != self._last_written_vpp_remote_control) (verified in solax_modbus_growatt_controller.py) confirms the "stops re-asserting" claim: once released, both sides of the or are False on subsequent identical periods.

Tests / regression coverage: Very strong, multi-layered:

  • test_vpp_idle_at_reserve_floor.py drives the actual production write path (_apply_period_schedule), not just _intent_to_vpp with hand-built args — this is the right level, since a unit test on the mapping alone would pass even if the flag never reached it in production (which is exactly what happened in round 1).
  • TestIdleAtReserveFloor in test_vpp_simulator_branches.py proves flow-neutrality at the outcome level (battery power, not just the command), per docs/agents/testing.md's "assert the outcome, not the command" guidance, with a guard-rail test proving the release would discharge one kWh above the floor (i.e., why the gate matters).
  • The corpus re-pin (499 command changes, 0 cost/SoE delta) is credible: I verified derive_vpp_commands genuinely required a real soe_trajectory before (it previously received nothing at all, matching the PR's own admission that the earlier "unchanged baseline" claim was vacuous), and vpp_capture.py's move from eager derive_vpp_commands to lazy simulate_vpp is what makes the release branch reachable during capture.
  • Round-2 fixes (dead flag at 2 of 3 call sites, crash-on-unreadable/negative-SoC) are exactly the right things to have caught in earlier rounds, and are independently tested (test_unreadable_soc_holds_rather_than_releasing, test_out_of_range_soc_holds_rather_than_releasing).

I traced the SoE-indexing logic in _planned_at_reserve_floor (the "entering vs leaving" off-by-one it flags) against test_the_crossing_period_still_displays_the_hold and it checks out: state_of_energy[p] is genuinely the leaving-value per _create_updated_schedule, and reading p-1 for the entering value is correct.

Scope: Matches the stated "local" assessment — _at_reserve_floor/at_reserve_floor is threaded as a new input alongside the already-established block_passive_charging/strategic_intent pattern, not a new responsibility bolted onto an unrelated method. The vpp_simulator.py refactor (splitting _derive_vpp_command/_simulate, adding simulate_vpp_commands) is larger than the core fix, but it's justified: it's the direct fix for the round-1 finding that the corpus evidence was vacuous, not speculative cleanup.

Rule compliance: No Optional[x], no hasattr/silent-fallback getattr, no new classes, all sensor access goes through _controller.get_battery_soc() / _get_current_battery_soc() (no hardcoded entities), no exception-message string matching. CI is green across all checks (algorithm, fast, E2E, docker, quality, frontend).

Known limitation: The PR is upfront that VPP mode never writes the inverter's discharge_stop_soc register (#309), so if that floor sits below the configured min_soc, released self-use could draw the gap — explicitly documented, scoped as pre-existing exposure shared with #413/#413's SOLAR_STORAGE, and the PR deliberately doesn't Closes #592 pending the reporter's hardware confirmation. That's the right call, not a blocker.

No correctness bugs or rule violations found.

Verdict: APPROVE

@johanzander
johanzander marked this pull request as ready for review August 18, 2026 06:40
@johanzander
johanzander merged commit 47d3c48 into main Aug 18, 2026
8 checks passed
@johanzander
johanzander deleted the fix/issue-592-vpp-idle-at-floor branch August 18, 2026 18:56
johanzander added a commit that referenced this pull request Aug 21, 2026
* fix: give the mypy gate the same environment locally and in CI

The Code quality job installed `black ruff mypy` and nothing else, so mypy
resolved `pytest` to Any there while the local `.venv` had the real package.
That divergence is the opposite of what the step promises ("a green local
gate and a green CI gate mean the same thing"), and it is not closeable by
annotating: with an untyped `pytest`, annotating a decorated test function
only converts `no-untyped-def` into `untyped-decorator`. Measured on
test_agent_permissions.py -- 6 errors before, 5 after. Install the dev
requirements instead, which is where pytest, black and ruff are already
pinned.

With the environments matched, annotate the functions the ratchet had no
baseline for. These files predate the gate (#614), so nothing charged them
until a release PR compared them against a stale mirror:

- test_agent_permissions.py and test_vpp_idle_at_reserve_floor.py are new
  files, so every error in them counts; both are now clean.
- vpp_simulator.py's `_simulate` gains the callback type its docstring
  already describes.
- the four functions #619 added to test_vpp_simulator_branches.py and the
  two it added to test_solax_modbus_growatt_vpp.py get return types. The
  pre-existing untyped functions in those two files are left alone -- the
  ratchet does not charge them, and burning them down is separate work.

Narrowing `_inverter_controller` surfaced a real mismatch the `| None` error
had been masking: `current_schedule` is a `DPSchedule`, and the test assigns
a `SimpleNamespace` stub. Cast it, with a note that only `.actions` is read.

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

* fix: install the app requirements too, or the gate still diverges

Installing only requirements-dev.txt closed the divergence for `pytest` and
left the identical one for everything in backend/requirements.txt. Every
other Python job in this workflow installs both files; this one now does too.

It cuts both ways, so neither half is optional:

- Missing `fastapi` makes `@router.get` untyped exactly as missing `pytest`
  made `@pytest.fixture` untyped. backend/api.py reports 53 errors without
  site-packages against 43 with, and the 10-error delta is entirely
  `untyped-decorator` -- so a new annotated endpoint would pass locally and
  fail here, unfixable by annotating.
- Missing `numpy` MASKS errors instead. core/bess/pwl_window_dp.py reports
  7 errors with it installed and 3 without, so a genuine type error in a
  numpy-using optimizer file would clear the merge gate and surface only on
  the maintainer's machine.

Found in review of the first commit.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bess-product-owner bess-product-owner mentioned this pull request Aug 22, 2026
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.

2 participants