Skip to content

fix(reporting): make the session's own record match what happened - #1178

Merged
xiaofei-zheng merged 25 commits into
mainfrom
fix/1146-followup-status-honesty
Aug 18, 2026
Merged

fix(reporting): make the session's own record match what happened#1178
xiaofei-zheng merged 25 commits into
mainfrom
fix/1146-followup-status-honesty

Conversation

@zoroyihan7

@zoroyihan7 zoroyihan7 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Refs #1146.

Scoped to reporting honesty for #1146: the artefacts of the field session
hold four places where the record does not match what happened. Session hard
budget enforcement -- admission, in-flight cancellation, PRELUDE spend caps --
is #1171, not here. This PR does touch resume leg bookkeeping
(bank_phase_segment on resume) so phase elapsed totals and exported duration
stay accurate across stop/resume, but it does not add or change deadline
enforcement.

The four mismatches are independent, and they share the same consequence:
whoever reads the record -- and the downstream analysis that depends on it --
concludes the opposite of what the session did.

  1. A skip recorded as KEEP. conc_sweep returns status=succeeded with
    was_skipped=true when there is no optimization to compare against. The
    status is right -- it did finish normally -- but the journal reads the status
    and files it as KEEP, so the session's own ledger gains an action that
    never happened. The timeline row does not show why either, because a skip
    writes its cause to skip_reason rather than reason.

  2. The final report says the sweep ran. conc_sweep_done is the reason
    shared by every terminal exit of the SWEEP phase, "did not run at all"
    included, so the report prints "Post-sweep concurrency sweep finished" in
    sessions where it never started.

  3. A healthy baseline looks like it just crawled out of a failure. The
    double-round baseline measures accuracy only in the warmup round but decides
    in the measure round, which by design has no accuracy of its own. Taking the
    score from warmup is the designed path, yet it is logged as
    WARNING: salvaged ... from a sibling attempt. Every normal baseline leaves
    a salvage warning behind, and the sessions that genuinely lost a score are
    buried among them with no way to filter.

  4. Exported session duration is always zero. session_meta reads
    elapsed_minutes from the session block, but that block has two producers
    -- the live recorder's snapshot and the collector -- and only the latter
    writes that key. A session that ran for over two hours exports as zero
    seconds, and every downstream metric normalized by duration is distorted with
    it.

Changes

  • Journal: was_skipped outranks both the per-status rule and the
    promotable rule, and classifies as no_promote; the timeline's reason falls
    back to skip_reason.
  • Report: the SWEEP exit records the skip and its cause as evidence, and the
    report says "did not run (cause)" on that basis. Sessions that did run read
    exactly as before.
  • Baseline: _is_double_run_accuracy_handoff() logs at INFO as a cold-start
    guard when, and only when, the decision is in the measure round and the score
    came from warmup. Every other salvage still warns. Two
    ("warmup_round", "measure_round") literals are folded into a constant on the
    way.
  • Breakdown: duration is computed from the start and end timestamps (to now
    for a session still running), with elapsed_minutes demoted to a last resort.
  • Resume bookkeeping: bank the previous leg's open phase segment before
    restamping resumed_ts, so stop/resume does not charge idle gap time to the
    phase the session stopped in.

Test plan

  • test_optimization_journal.py: a skip is no longer KEEP, and that rule
    outranks the per-status classification.
  • test_report.py: a skipped sweep is not described as finished, the
    wording for a sweep that did run is unchanged, and a skip with no recorded
    cause still says so honestly.
  • test_baseline_eval_fallback.py: the designed warmup handoff is not
    reported as a salvage (no WARNING, signed as a cold-start guard), while a
    cross-attempt salvage still warns.
  • test_breakdown_exporter_unit.py: duration from timestamps, a running
    session measured to now, fallback to elapsed_minutes without timestamps,
    and zero when there is nothing to read.

Made with Cursor

zoroyihan7 and others added 4 commits August 13, 2026 16:12
A concurrency sweep with no optimization to compare returns succeeded
with was_skipped set: correct as a status, wrong as a verdict. The
journal read the status and recorded KEEP, so the session's own record
claimed a step it never took, and the timeline showed no cause because
a skip states it under skip_reason rather than reason.

Skips now settle as no_promote ahead of both the per-status and the
promotable rules, and the timeline falls back to skip_reason.

Co-authored-by: Cursor <cursoragent@cursor.com>
conc_sweep_done is the SWEEP exit for any sweep that reached a terminal
result, a skip included, so the final report told the reader a
"post-sweep concurrency sweep finished" on runs where none happened.

The exit now records the skip and its reason as evidence, and the
report names it instead of reusing the generic wording.

Co-authored-by: Cursor <cursoragent@cursor.com>
…vage

The double-run baseline evaluates accuracy only in the warmup round and
decides on the measured round, which therefore has no accuracy of its
own. Reading the warmup's score there is the design, but it logged as
"salvaged ... from a sibling attempt" at WARNING, so every healthy
baseline looked like it had survived a fault and the log gave triage no
way to find the runs where a score really did go missing.

That one case now logs at INFO under the cold-start guard's own name;
every other salvage keeps the warning.

Co-authored-by: Cursor <cursoragent@cursor.com>
…tamps

session_meta read elapsed_minutes off the resolved session section, but
two producers fill that section and only the collector writes the key.
A live-recorded run therefore exported session_duration_seconds=0 after
hours of work, and every downstream rate derived from it was wrong.

The duration is now computed from start to end, with the recorded
elapsed_minutes kept as the last resort.

Co-authored-by: Cursor <cursoragent@cursor.com>
@chaojhou

Copy link
Copy Markdown
Collaborator

Blocking items only, verified at PR head. The four diagnoses are all correct — a skip really was recorded as KEEP, the timeline really never carried a reason, the cold-start WARNING really was unconditional, and session_meta duration really was always 0. The problems below are in where the fixes land.

1. "did not run" is false for the sweep that ran out of budget

was_skipped is not only set by the declined-to-run envelope. It is also set for a sweep that ran every variant and spent its whole budget without producing a comparable pair:

    if budget_limited_no_pair:
        payload["was_skipped"] = True
        payload["skip_reason"] = "budget_exhausted_no_successful_pairs"

The report reads was_skipped alone:

    if reason == "conc_sweep_done" and text:
        skip_reason = _conc_sweep_skip_reason(state)
        if skip_reason:
            return f"Post-sweep concurrency sweep did not run ({skip_reason}); the phase settled and the run closed."
    return text

So a session that burned its entire sweep budget on variants is reported as one where the sweep "did not run". That is a different false statement rather than a fix, and under a time-budget investigation it is the more expensive one to believe. _skip() (kernel/conc_sweep.py:1119-1135) is the only source that means "declined to run"; the two need distinguishing.

This one gets worse with #1171 in: that PR enforces the sweep/grid budget, which drives budget_exhausted_no_successful_pairs — so the two together produce a report denying the sweep ran in precisely the sessions #1171 exists to catch.

2. Re-classifying a skip as no_promote feeds it into the agent's "dead ends" prompt

    if (result_dict or {}).get("was_skipped"):
        return OUTCOME_NO_PROMOTE

OUTCOME_NO_PROMOTE is not neutral. The trajectory reviewer harvests exactly REVERT and no_promote as evidence that a direction is exhausted (trajectory_reviewer.py:68-71), clustering on (kind, change) and treating max_gain is None as a dead end (:81-85). Both key components are deterministic for a conc_sweep skip: classify_change_kind("conc_sweep", None) returns KIND_OTHER and summarize_change returns the bare kind, because the skip payload carries no kernel_id / patch_path / pr_url. Two benign skips in one session — the ordinary case, since no_optimization_to_compare and no_validated_gain_since_last_conc_sweep recur per macro-cycle — render conc_sweep as exhausted in the advisory block the model reads. Before this PR those rows were KEEP and were filtered out.

A distinct OUTCOME_SKIP is the right shape; every existing consumer reads no_promote as "we tried and it did not pay".

Two related points on the same change. The guard sits unconditionally ahead of the status-driven branch, so it also applies to integrate_patch / framework_agent, where no producer sets was_skipped — harmless today, but it leaves a trap where a genuinely kept patch becomes no_promote if that key is ever reused. And the journal has no schema_version and no migration (optimization_journal.py:317-333, load_or_create at :185-237), so the same event serialises as KEEP before this PR and no_promote after with nothing to tell them apart — across timeline.py:88, decision.py:682, the shipped decision_trace.jsonl, and tools/backfill_langfuse.py:237-240, which counts both into Langfuse scores. (The KB is not affected: writeback.py:1045's lesson branch also requires a positive gain, and _pitfall_severity_for returns None here.)

3. Claim 4 is fixed at one consumer; the field the PR body names is still zero

The root cause is right and I confirmed it: _pick prefers the recorder fragment for the whole session section (exporter.py:251-253) and the live recorder's fragment has no elapsed_minutes (recorder/instrument.py:876-888). But the fix lands only inside collect_session_meta, and session.elapsed_minutes — the key the PR body identifies as broken — is still absent on that path. Three consumers still read it:

        elapsed_minutes=to_float(session.get("elapsed_minutes")),

plus compose.py:252, which prints elapsed=0min, and _renderers/session.py:35, which drops the "Wall-clock elapsed" line entirely when the key is missing. So the human-readable report — the artifact an operator actually reads — is unchanged; only the machine field session_meta.session_duration_seconds is corrected. The same function on the same path also still loses image for the same reason (collectors/sessions.py:733).

While fixing that, two semantics questions need an explicit answer, because the new computation is not equivalent to the old one. Across resume: state.start_ts is deliberately reset (cli/__init__.py:1829) while the manifest's created_at_utc is not (write_manifest runs only at fresh-session creation), and the collector path falls back to created_at_utc — so it reports time since first launch including the dead time between crash and resume, while the recorder path reports time since resume. Neither matches the previous meaning, and no test covers it. For ended sessions: the recorder fragment carries stop_reason but no ended_at_utc, so end falls back to now() (sessions.py:700), and since build_breakdown is a pure disk-reading function, any re-export after the session ends inflates the duration without bound. The fragment already carries enough to detect this.


One note outside the blocking set: claim 2's fix is log-only. nonfatal_warnings still gets baseline_accuracy_salvaged_from_sibling_attempt on every healthy double-run baseline (baseline.py:1823-1824), read by baseline.py:1281/:1341 and merged into the rebench specialist's warnings at specialists/rebench.py:180. Given the PR's thesis is that the record should match what happened, that is the one claim where the record did not move.

Also: this branch and #1171 do share two files (baseline.py, machine_state.py), contrary to how it has been described. The hunks are disjoint and will auto-merge, which is exactly why it is worth stating now rather than discovering later — both PRs write the same nonfatal_warnings channel on the same result dict, and both add evidence to phase-exit tuples.

zoroyihan7 and others added 2 commits August 13, 2026 10:18
Measuring the duration from the session's own timestamps replaced a
stable zero with a moving number: a run that had already stopped was
still measured up to now(), so a session from ten weeks ago exported as
ten weeks and grew with every re-export. A plausible-looking duration is
worse than the zero it replaced, because zero is visibly missing data.

Only a session that is still running may be measured against the export
clock now. One that carries a stop reason but no end timestamp falls
back to elapsed_minutes, or to zero, and stays there.

That end timestamp was missing because nobody recorded it: the
recorder's session snapshot wrote a start and a stop reason but no end,
and a recorder fragment replaces the collector's section wholesale.
SharedState stamps stop_ts whenever a stop reason is written, so the end
of the run is the Coordinator's final write in its finally block rather
than CLOSE's early one on entry or the reader's clock; ordinary saves
leave it alone. Both producers of the session section now report it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

CI E2E report — ✅ Succeeded

item value
result ✅ Succeeded
model Qwen/Qwen3-0.6B (dense)
resources 1× GPU, TP=1
PR branch fix/1146-followup-status-honesty
commit 6425737e1266a1aa450ce3c289a021179fa7bf50
session_id a4aa8215-566b-4330-9282-d9938fb65400
queue → dispatch 0s
run time 209m 47s
total 209m 47s

details

zoroyihan7 and others added 5 commits August 13, 2026 12:51
…o run

was_skipped marks two different conc_sweep outcomes: the pre-flight
envelope, which declines before a server boots, and a sweep that ran its
whole ladder and exhausted its budget without a comparable pair. The
stop-reason explanation keyed on the flag alone, so the second was
reported as a sweep that "did not run" — a different false statement,
and the more expensive one to believe in exactly the sessions where the
time budget is what is under investigation.

Only the budget path can set budget_exhausted, so the two separate
without reading skip_reason. The predicate lives next to the producers
in conc_sweep.py, and the report names each outcome for what it was.

Co-authored-by: Cursor <cursoragent@cursor.com>
Recording a conc_sweep skip as no_promote put it in front of the
trajectory reviewer, which harvests REVERT and no_promote as evidence
that a direction is exhausted. Both cluster keys are deterministic for a
skip, so the two benign skips an ordinary session produces
(no_optimization_to_compare, no_validated_gain_since_last_conc_sweep)
were enough to advise the model to abandon conc_sweep entirely.

OUTCOME_SKIP says what happened without claiming a measurement. The
consumers: the trajectory reviewer ignores it (nothing was learned), the
KB fact write already declines it (no gain, no pitfall severity), and
the breakdown timeline, decision trace and Langfuse score carry it
through as its own category.

The guard is now scoped to the kinds that can actually declare
was_skipped, so reusing that key on a patch kind cannot rewrite a kept
patch into a non-KEEP.

Co-authored-by: Cursor <cursoragent@cursor.com>
Measuring the duration from the session timestamps fixed the machine
field, but session.elapsed_minutes -- the key the rendered report prints
and the cross-section reads -- is written only by the collector, and the
recorder fragment replaced that section wholesale. A live-recorded run
therefore still printed elapsed=0min, and lost the image, host and pid
the manifest resolves and the live state cannot know.

The fragment now overlays the collected section instead of replacing it,
and the elapsed minutes are recomputed from whichever timestamps won, so
the human-readable field and session_duration_seconds measure the same
window by construction.

Resume semantics, previously undefined: elapsed_minutes measures the
current leg from state.start_ts, the same anchor --max-hours is counted
against, so the number stays comparable with max_minutes instead of
including the dead time before a resume. The manifest's created_at_utc
is exported alongside it and still names the first launch, so the gap
remains visible. Both producers now carry start_ts and the collector
falls back to created_at_utc only for a session that never recorded one.

Co-authored-by: Cursor <cursoragent@cursor.com>
The double-run handoff was already excluded from the log line, but
nonfatal_warnings still recorded baseline_accuracy_salvaged_from_sibling_attempt
on every healthy double-run baseline, where reading the warmup round's
accuracy is how the measured round is meant to get it. The structured
channel feeds the report and the specialists, so it deserves the same
treatment as the log: a run that hit no fault should not carry a warning
saying it recovered from one.

No consumer keys on this particular marker; the failure scanners that
read nonfatal_warnings look for eval-failure markers instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s CLOSE

The CLOSE fallback recovers a terminal reason for a session whose
stop_reason never reached state.json, but it read the whole of
phase_history. A resume clears state.stop_reason and stop_ts and cannot
clear the previous leg's CLOSE row, so a running session was exported as
having stopped days earlier for the reason it stopped last time, with
ended_at_utc stamped from that old transition. The end then predated the
start, and the guard that refuses a negative duration turned the report
into elapsed=0min -- the same "0min" this PR set out to remove, reached
by a different route.

The scoping lives in the extractor rather than in
_should_use_close_stop_reason, because the timestamp is stamped as the
session's end even when the reason is not adopted, so the predicate
alone would leave that second path reading the old leg. The extractor
now answers for the current leg only and the predicate stays what its
name says: a comparison of two reasons.

The leg boundary is state.start_ts, the same anchor elapsed_minutes is
measured from, so the two definitions cannot drift apart. Only two
parseable timestamps can disqualify a row: a missing or unparseable one
leaves the CLOSE in force, since a session that never recorded a start
is the case the fallback was built for.

The recorder path reads the live state's own stop_reason and so never
resurrects one, but its snapshot carries an empty reason, which the
export merge treats as absence of evidence -- the collector's value won
there too, so fixing the collector fixes both paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zoroyihan7

Copy link
Copy Markdown
Contributor Author

All three blocking items and the non-blocking one are fixed, and reviewing your item 3 turned up one more instance of the same class. One deliberate refusal is flagged below.

1. A sweep that spent its budget is not one that declined to run — d914966b3

There is a structured signal, so no parsing of skip_reason: only the budget path sets budget_exhausted. conc_sweep_declined_to_run(record) is was_skipped and not budget_exhausted, defined next to both producers and exported; the report calls _explain_conc_sweep_skip and gives the two outcomes different sentences.

One correction to the wording you proposed. The budget path also covers the case where the initial budget is below a single variant's timeout, and there every variant is displaced by _budget_skip_result — nothing ran at all. So "ran but produced no comparable pair" would be false for that sub-case; the text is "exhausted its budget without a comparable baseline/optimized pair", which holds for both. The predicate's docstring records the constraint this creates: any skip raised after a variant has started must also set budget_exhausted, or it reads as "declined".

2. OUTCOME_SKIP, and the consumer audit — e2c46ba1a

Added, and the skip check is narrowed to _SKIPPABLE_JOURNAL_KINDS = {"conc_sweep"} so the trap you identified is closed: was_skipped appearing on an integrate_patch can no longer rewrite a genuine kept into something else. There is a test for that specifically.

Consumer by consumer: the trajectory reviewer harvests only REVERT and no_promote, so skips fall outside the clustering (docstring updated, plus a test that two skips do not make a dead end); KB writeback's lesson branch requires a positive gain and _pitfall_severity_for returns None, so it never wrote these; breakdown's timeline and decision sections pass the value through, so only the vocabulary doc changed; backfill_langfuse.py's dry-run counter now imports the constant and reports skipped= separately, so the Langfuse categorical score gains a value instead of miscounting these as no_promote.

On schema_version: deliberately not added. It cannot earn its place in this PR — no reader would branch on it, so it would ship as a field nobody reads. And the discriminator already exists: an old journal has no skipped value, a new one does, so absence dates the row. The shape change here is between an unreleased PR and its own correction, not a migration of a released format. Versioning the journal is worth doing alongside a reader that actually branches on the version; I would rather not plant the constant first. Happy to be overruled if you see a reader coming.

3. session.elapsed_minutes and imageb3ffe3fad

Root cause confirmed as you described, and image was collateral from the same line. _pick replaced the collector's session section wholesale; _merge_session() now overlays it, treating an empty or None fragment value as absence of evidence, so image, host and pid survive. elapsed_minutes is then recomputed from the merged timestamps by a newly public session_elapsed_minutes(), which shares _measured_duration_seconds() with session_meta.session_duration_seconds — the two can no longer disagree by construction.

Both semantics questions answered explicitly, since you are right that the new computation is not equivalent:

  • Across resume: elapsed_minutes measures the current leg, from state.start_ts — the same anchor --max-hours bills against, so the number stays directly comparable with max_minutes and excludes the dead time between crash and resume. First launch is still on record via the manifest's created_at_utc, exported alongside, so the gap remains visible; the manifest is only a fallback when a session never recorded a start. Written down in all three places the field is produced.
  • For ended sessions: 38a140eb5 pins the end instead of measuring to now(), so re-exporting a finished session no longer inflates it. SharedState gained stop_ts, set atomically with stop_reason.

The same class, one layer down: a resumed session was still reported as stopped — cf194422b

Found while testing the resume semantics above. Resume clears state.stop_reason and stop_ts, but it cannot clear the previous leg's CLOSE row in phase_history. _close_phase_stop_reason finds it, the "close reason is set and session reason is empty" rule resurrects it, and a session that is running right now exports as stop_reason=time_exhausted, ended_at_utc six days ago, elapsed_minutes=0.0.

The fallback exists for a real case, so it is constrained rather than removed: a CLOSE that predates the current leg's start_ts is not evidence about the current leg. The comparison lives in the extractor rather than in _should_use_close_stop_reason, because the CLOSE timestamp is also consumed independently to stamp ended_at_utc without passing through that predicate — gating only the predicate would have left the second path intact. A CLOSE is only disqualified when both timestamps parse, so a session that never recorded a start keeps the fallback it was built for.

Non-blocking: the salvage marker — d5c0cc9e4

You were right that the fix was log-only. _apply_salvaged_accuracy() now takes expected_handoff and appends baseline_accuracy_salvaged_from_sibling_attempt to nonfatal_warnings only when this is not the double-run design's handoff. No consumer depends on the marker's presence — baseline.py:1281/:1341 scan for eval-failure markers and rebench.py:180 only merges the list — so the record now moves with the log.

On #1161

Checked, since you raised it there as possibly landing here: it does not need to. The Critic's raw intent is never appended to the bus; the only review_verdict message is the rebroadcast built from the post-hold verdict, and the hold runs before it. The report highlights already render the verdict that actually took effect.

Comment thread src/hyperloom/orchestrator/actions/executors/report.py Fixed
zoroyihan7 and others added 11 commits August 13, 2026 16:45
stop_ts is the timestamp half of stop_reason, written by the same
setter, but only the reason was registered as Coordinator-owned. Since
apply_changes is a denylist, an update_state intent had its stop_reason
rejected and its stop_ts accepted -- the one path where the recorded
reason and the recorded end can disagree, which is the invariant the
pair exists to hold.

Co-authored-by: Cursor <cursoragent@cursor.com>
_commit_stop_reason re-stamped on every call, and coordinator.py's
finally block re-asserts the reason CLOSE already wrote. CLOSE ships
session_breakdown.json in between, so the artifact an operator reads and
any later re-export disagreed about ended_at_utc and elapsed_minutes.
The first terminal reason is when the session ended; later writes may
still refine the reason.

Co-authored-by: Cursor <cursoragent@cursor.com>
… began

cf19442 disqualifies a CLOSE transition that predates start_ts, but a
resume only re-anchors start_ts after a crash or a stop with a recorded
reason. A clean stop keeps it, so the previous leg's CLOSE still sat
inside the window: a session running for three hours exported as
stopped, for the reason it stopped last time, with an end time in the
past and less than half its real elapsed time.

The state file had no record of where the current leg began, so
resumed_ts now stamps it on both branches and the collector measures the
leg from the later of the two. A session that was never resumed still
recovers its reason and end from its CLOSE.

Co-authored-by: Cursor <cursoragent@cursor.com>
Four places claimed a resume resets start_ts. Only the crash / recorded-
stop branch does; a clean stop deliberately keeps it so --max-hours is
still counted from the original session start, and on that branch
elapsed_minutes spans the dead time the docstring said it excluded. The
value is right either way -- it tracks the anchor the budget is charged
against -- so this is what we tell the reader, not what we compute.

Co-authored-by: Cursor <cursoragent@cursor.com>
iso_z assumes UTC for a naive timestamp and to_unix assumed the host's
local time, and both now feed the same comparisons: the measured session
duration and the CLOSE-transition boundary. Under a non-UTC TZ the same
string placed the session at two different instants, which would let a
stale CLOSE through the boundary check. Not reachable today -- every
producer writes an offset -- but the check now rests on it.

Co-authored-by: Cursor <cursoragent@cursor.com>
was_skipped covers both a sweep that declined before booting a server and
one that spent its whole budget without a comparable pair, which is the
ambiguity conc_sweep_declined_to_run exists to resolve; the exit evidence
copied the flag without the budget_exhausted that separates them.

The report's budget-skip test now builds last_conc_sweep through
record_conc_sweep and a save/load round trip, so it proves the flag it
reads survives persistence rather than assuming the shape by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
Four narrow ones, all in what the exported session section claims:

- A disqualified CLOSE row ended the scan instead of continuing it, so a
  phase_history written out of order lost the fallback entirely.
- An unparseable stop_ts landed in ended_at_utc verbatim and collapsed
  the duration to zero, where the CLOSE transition or the export clock
  still answers.
- The recorder writes every session key on every save, so an unset
  max_minutes arrives as 0 and overwrote the collector's value; phase,
  which only the recorder knows, vanished from the section when blank.
- session_elapsed_minutes claimed it cannot drift from
  session_meta.session_duration_seconds. The two measure the same window
  from the same fields, which is a weaker and truthful guarantee.

test_a_recorded_session_exports_the_time_it_actually_ran passed with the
pin reverted, because a session that stops in a unit test stops now. It
now stops three days before the export.

Co-authored-by: Cursor <cursoragent@cursor.com>
``phase_started_unix`` is only rewritten by ``record_phase_transition``, and
exiting the process is not a transition. The entry it stamps therefore spans
both run legs, so a session resumed days later charges the whole idle gap to
whichever phase it stopped in: a three-hour session resumed after three days
reads 259200s spent in PRELUDE against a 4320s ceiling, and every optional arm
bounded by that figure is silently dropped before the leg does any work.

Floor the live segment at the current leg's boundary (``resumed_ts``) instead
of re-stamping ``phase_started_unix`` on resume. The recorded facts stay true —
the phase really was entered when ``phase_started_ts`` says, and that stamp
stays equal to the last ``phase_history`` row — while every reader derives the
right number. It also repairs sessions already resumed by older code, and needs
no ``stop_ts``, which the crash branch never writes. The floor is
self-limiting: the next entry stamps a later ``phase_started_unix``.

Dropping the gap alone would make the previous leg's real work free, and a
session that stopped and resumed repeatedly could re-spend a phase's whole
share every time. So a resume also banks the segment the stopped leg never
transitioned out of, using ``stop_ts`` as its end — the only recorded evidence
of when that leg finished. Without it (a clean stop or a crash writes none) the
segment stays unbanked, under-charging the phase, which is the direction
``phase_cumulative_seconds`` documents as tolerable since over-charging ends a
phase early. Banking runs before ``resumed_ts`` is restamped, so two resumes in
the same phase bank one segment each.

The phase clock moves on both resume branches. It answers a different question
from ``start_ts`` — which phase spent what, not when the budget started — and
neither answer includes time nothing was running, so the branch that keeps
``start_ts`` gets the same treatment.

Banking is now one write-owner, ``bank_phase_segment``, shared by the
transition path and the resume path so the per-phase totals and the EXPLORE
accumulator cannot drift apart.

Co-authored-by: Cursor <cursoragent@cursor.com>
``stop_ts`` bounds the segment a resume banks for the leg that stopped, and it
was floored at zero but never clamped to now. A stamp ten days after
``phase_started_unix`` would bank 864000s in one call — the over-charge
direction both this helper and ``bank_phase_segment`` document as the one that
must not happen, since over-charging ends a phase early.

Nothing reaches it today: ``stop_ts`` only ever comes from ``_now_iso()``, and
the branch that could carry a stale one clears it. Clamping makes the
safe-direction claim structural rather than incidental.

The second-resume test built its second leg from a ``stop_ts`` an hour in the
future, which is the shape the clamp rejects; it now pins both legs in the past.

Co-authored-by: Cursor <cursoragent@cursor.com>
Flooring the live segment at the leg boundary left three notes overstating what
the phase clock promises.

The charge-back note claimed ``session_remaining`` and ``phase_elapsed`` sum to
a constant across the phase. That holds within a run leg only: a resume keeping
``start_ts`` leaves the session charged for the idle gap while the phase is not,
so the sum — and the base the phase charges back against — drops by the gap. The
smaller base is the honest one and stays, so the comment is what changes, and a
test pins the base a kept anchor produces.

``phase_elapsed_totals`` names its writer, which is ``bank_phase_segment`` for
both the transition and the resume path.

``bank_phase_segment`` sits under a block comment promising a ``SharedState``
forwarding shim it has never had. Those shims exist so call sites written when
the functions were methods keep working; a helper born in this module with two
callers needs none, and adding one would put another mutating method on a class
the same convention calls a passive persisted record. Say that instead, and file
the export under b.

``phase_elapsed_totals_from_history`` claimed to rebuild a LOWER bound. Two
history rows either side of a process exit bound a "segment" that includes the
idle gap and charge it to the phase named before the boundary, so across a
resume the rebuild over-charges: 261120s reconstructed where 1920s was spent.
The live half of that is exactly what the floor fixes, but the rebuild cannot
see a leg boundary because ``phase_history`` records none, and only the legacy
migration path reads it — so this corrects the claim, not the arithmetic.

Co-authored-by: Cursor <cursoragent@cursor.com>
kernel.conc_sweep already imports the grid runner in this package, so
a module-level import from report.py is the edge CodeQL reports as a
cycle. The helper is only used in _explain_conc_sweep_skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

@ZhengGong-amd ZhengGong-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved

@xiaofei-zheng
xiaofei-zheng enabled auto-merge (squash) August 17, 2026 07:11
@xiaofei-zheng
xiaofei-zheng disabled auto-merge August 17, 2026 07:30
Co-authored-by: Cursor <cursoragent@cursor.com>
@xiaofei-zheng
xiaofei-zheng merged commit 8079856 into main Aug 18, 2026
28 checks passed
@xiaofei-zheng
xiaofei-zheng deleted the fix/1146-followup-status-honesty branch August 18, 2026 02:41
xiaofei-zheng added a commit that referenced this pull request Aug 25, 2026
fix(reporting): make the session's own record match what happened
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.

5 participants