Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3172f27
feat(timing): book each turn's head and tail as their own buckets
uipreliga Sep 11, 2026
f2ead83
test(lint): 2/4 — widen CE058 to the turn head/tail buckets
uipreliga Sep 11, 2026
67e0d4a
feat(evalboard): 3/4 — name the harness head and tail in the timeline…
uipreliga Sep 11, 2026
5237eb4
feat(timing): 4/4 — assert the buckets in replay, and record what the…
uipreliga Sep 11, 2026
b9104b5
fix: code review fixes for turn head/tail timing
uipreliga Sep 11, 2026
ede7085
docs(harness): record three guards the head/tail review could not close
uipreliga Sep 11, 2026
b00175d
docs(harness): widen the measured head/tail figures to six turns per …
uipreliga Sep 11, 2026
ca6f3d4
test(timing): unify the fixture clocks and assert the four-bucket ide…
uipreliga Sep 11, 2026
371c8cd
test(harness): pin why claude-code's zero head is left as a clamp
uipreliga Sep 11, 2026
430281a
docs(harness): claude-code's generation windows are not tool-subtracted
uipreliga Sep 11, 2026
82a4dd8
fix(claude-code): subtract tool execution from the generation windows
uipreliga Sep 11, 2026
a521b28
fix(antigravity): 1/3 — give every generation a message_id
uipreliga Sep 11, 2026
eec4c1b
test(lint): 2/3 — CE060, an AssistantMessage must declare its message_id
uipreliga Sep 11, 2026
d81e1e5
docs(harness): 3/3 — message_id is what splits the timeline
uipreliga Sep 11, 2026
e2efaa2
fix: code review fixes for antigravity-message-id
uipreliga Sep 11, 2026
3604bfd
docs(harness): register the message_id gaps the final review surfaced
uipreliga Sep 11, 2026
26da1b1
fix(timing): bracket the head and tail on the main thread only
uipreliga Sep 11, 2026
0c9a067
docs(timing): every harness subtracts tool time now, not two
uipreliga Sep 12, 2026
b1cbc4b
feat(timing): 1/6 — a two-sided residual gate for the four-bucket ide…
uipreliga Sep 12, 2026
5176bcb
refactor(timing): 2/6 — one close_window() for the tiling reducers
uipreliga Sep 12, 2026
026a324
fix(timing): 3/6 — a tool that closes between two windows is not mode…
uipreliga Sep 12, 2026
54f2495
test(lint): 4/6 — CE061, a window must come from the shared helper
uipreliga Sep 12, 2026
e5038d9
fix(timing): 5/6 — one clock basis per turn on antigravity and pi
uipreliga Sep 12, 2026
e6720ac
docs(harness): 6/6 — the timing architecture as it now stands
uipreliga Sep 12, 2026
eb7d3f7
fix: code review fixes for timing-architecture-standardization
uipreliga Sep 12, 2026
e2dbba4
test(harness): stamp the two codex fixtures that timed themselves wit…
uipreliga Sep 12, 2026
48426ae
docs(harness): register that the golden corpus cannot see a timing va…
uipreliga Sep 12, 2026
e67ad70
feat(timing): 1/7 — a committed, ms-exact magnitude sensor
uipreliga Sep 12, 2026
566c779
test(harness): 2/7 — OpenCode and Pi get a corpus worth replaying
uipreliga Sep 12, 2026
26cb2e5
fix(timing): 3/7 — a naive/aware mix names the pair that disagreed
uipreliga Sep 12, 2026
1099dd0
feat(timing): 4/7 — one meaning for harness_startup_ms, on all five
uipreliga Sep 12, 2026
8e29d48
refactor(timing): 5/7 — one tool-subtraction, at the collector seam
uipreliga Sep 12, 2026
937f6ea
feat(reports): 6-7/7 — the offline report carries the buckets; a TS N…
uipreliga Sep 12, 2026
df7f0ba
fix: code review fixes for turn-timing-p0-p3
uipreliga Sep 12, 2026
3df809b
docs(harness): register what the turn-timing run could not guard
uipreliga Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions .claude/harness-candidates.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,223 @@ divergences, so the deferred-work record is one place. Measurements in
candidate — a real bug needing its own change, with a decision about
whether legacy records can be distinguished from current ones at all.
Caught in: timing-capture final review (gpt-5.6-sol).

- [x] ~~No golden-corpus assertion of the four-bucket identity.~~ **DONE.** The
fixture clocks were unified (`_rebase_notifications` / `_rebase_lines` shift
codex's 2027 base and opencode's month-old base onto the replay's own clock,
keeping every derived duration exact) and `assert_timing_captured` now
asserts `Σ generation + ∪ tool + head + tail` against `duration_seconds`.
Mutation-verified: reintroducing the defect fails
`test_antigravity_golden[d_orphaned_tool]`, which previously passed.
22 of 27 scenarios are checked. The remaining 5 are exempt via
`FICTIONAL_DURATIONS` for a reason rebasing cannot fix: they inject SDK
stamps in integer MILLISECONDS (17-900 ms of declared item time) while the
replay runs in well under one, so closing that last gap needs the agent's
own clock faked, not the fixtures' rebased.

- [x] ~~No TypeScript counterpart to CE058.~~ **DONE.**
`evalboard/lib/__tests__/no-zero-coalesce.test.ts` is a vitest source scan
(there is no eslint in `evalboard/`) over `lib/runs.ts`, `lib/timing.ts` and
`_sections.tsx`. It is an ALLOWLIST rather than a ban, exactly because the
residual arithmetic uses `?? 0` correctly — each of its 14 entries carries a
one-line reason, and a new occurrence fails until its author justifies it or
keeps the value null. It is keyed on the codebase's own `…Ms` naming
convention rather than on every `?? 0`: a blanket scan matches 58
occurrences, ~40 of them token buckets where zero is a fine answer, and an
allowlist that long is one nobody reads. Blind spots are declared in the
file. Two meta-tests keep it honest — a negative control (so the scan cannot
pass by matching nothing) and an assertion that every allowlist entry is
still present, so an entry cannot outlive its reason.
Caught in: turn head/tail timing final review.

- [x] ~~**`timing.py::decompose_turn` raises an uncaught `TypeError` on a
naive/aware datetime mix**~~ **DONE.** `timing.py::_require_same_awareness`
now raises from five call sites (`decompose_turn`'s head and tail,
`busy_ms`'s window bounds and each span's two ends) with one message template
naming the field and which side is aware. Deliberately a GUARD and not a lint
rule: the invariant is still unviolated in-tree, and the exposure that
actually matters is a third-party agent registered through the
`coder_eval.plugins` SPI, which lives outside `src/coder_eval/agents/` and
which a rule scoped to that directory could never see — so the message
addresses that reader directly. An empty span list is checked not at all,
bounds included: nothing is compared, so there is no pair to be about.
Caught in: turn head/tail timing final review.

- [x] ~~**`claude-code` does not subtract tool execution from its generation
windows.**~~ **FIXED** in `_ClaudeTurnState._subtract_tool_time_from_windows`,
which runs at finalization (it cannot run at flush time — a tool issued by an
earlier emission is still running when the next window closes). Re-measured
on the same task: 481 ms / 2.691% -> **1.4 ms / 0.006%** over four turns that
all carried overlapping tool calls. Original report kept below for the
reasoning.

ORIGINAL: The other four
harnesses subtract the union (`timing.py::busy_ms`); claude-code is exempted
on the reasoning that it "marks the end of the previous SDK event and reads
again when the next message arrives, so a tool's execution falls between two
windows rather than inside one". But a tool's timer starts at the **emission**
carrying its `tool_use` block, and one assistant turn spans several emissions,
so a later emission's window runs concurrently with a tool already timing.
Measured live on a task with five parallel writes, five reads and two
concurrent `Bash` calls: the generation/tool overlap was **482 ms and 340 ms**
on two ~18-25 s turns, and the four-bucket residual came out at exactly
`-481 ms` / `-339 ms` — the overlap accounts for it to within 1.4 ms. The
other four harnesses overlapped by ~2.0-2.3 s on the same task and reconciled
to within 1.2 ms. Two claude-code turns with <1 ms of overlap reconciled to
within 0.1 ms, so the fault is precisely the missing subtraction.
Fix is to apply `busy_ms` in `on_assistant_message` as the other four do, but
it changes a PUBLISHED `generation_duration_ms` on the most-used harness, so
it needs its own golden regeneration and live pass. NOT introduced by the
head/tail work — generation-vs-tool timing predates it — but that work's
four-bucket identity is what made it visible.
Caught in: post-merge live verification of the head/tail buckets.

### Deferred lint-rule widenings

- [ ] **CE058 and CE059 still match `AssistantMessage` by a hardcoded constructor
NAME LIST** (`_MESSAGE_CONSTRUCTORS`), where CE060 derives the set from each
module's own `coder_eval.models` imports. The weakness is live, not
theoretical: `claude_code_agent.py` binds *only*
`AssistantMessage as AssistantMessageTelemetry` and never the bare name, so
the two shipped rules guard that file's two construction sites purely because
somebody wrote the current alias into a different file's frozenset — rename
the alias and both go silently blind there — and an arbitrary
`AssistantMessage as Msg` is missed outright by both. Adopting CE060's
alias-resolving `check()` pre-pass is about ten lines per rule, but it widens
two SHIPPED rules whose firing sets are load-bearing (CE058's constructor set
is a different, wider one: `CommandTelemetry`, `SlowestCommandInfo`,
`TurnRecord`), so it needs its own mutation check per rule and a re-measured
firing set over all of `src/`, not a drive-by edit. If a fourth same-scope
kwarg rule ever lands, extract `tests/lint/rules/_message_calls.py` at that
point rather than sooner.
Caught in: the CE060 / antigravity `message_id` run.

- [ ] **Nothing pins that `message_id` is only ever a WITHIN-TURN identity.** Ids
repeat across retry attempts of one turn on every synthetic-id harness —
`Agent.discard_pending_turn` rolls the iteration counter back, so a crashed
partial and its retry both emit `<harness>-1-msg-0` (antigravity, codex, and
the out-of-tree delegate agent alike). Harmless today, and verified so: the
evalboard declares its grouping list INSIDE the per-turn loop
(`runs.ts:1822`, flushed at `:2217`) and only ever compares adjacent raws, and
no Python consumer reads the field at all. It stops being harmless the moment
anything joins on the id run-wide (a React key across turns, a cost join, a
dedup) — which is a natural thing to reach for once every harness populates
it. No cheap guard exists: the property to assert is "no consumer treats this
as run-unique", which is a negative over two languages, and asserting
within-turn uniqueness instead would pass today and catch nothing. Cheapest
real option is a comment on the model field; the durable one is a run-level
id if a consumer ever needs one.
Caught in: the CE060 / antigravity `message_id` final review.

- [ ] **No evalboard test is fed by a Python golden snapshot.** The two halves of
a capture fix are pinned by two hand-written fixtures that never meet: the
golden (`tests/_fixtures/golden_streams/expected/antigravity_e_multi_generation.json`)
pins what the reducer emits, and `evalboard/lib/__tests__/parseMessages.test.ts`
pins what the consumer does with a fixture an author typed from the same
understanding. Nothing feeds a real recorded shape through `parseMessages`, so
a reducer change that makes the TS fixture unrepresentative breaks no test on
either side. Deferred as architectural: it needs a loader, a scrub-aware
timestamp story (the goldens mask exactly the stamps the grouping reads), and
a convention for which snapshots the JS suite owns — well over 30 min, and
wider than any one capture fix.
Caught in: the CE060 / antigravity `message_id` final review.

- [x] ~~**`AssistantMessage.message_id`'s field description names one harness of
five**~~ **DONE.** It said "Anthropic API message_id … when the Claude Code
CLI splits one API response", while five backends write the field and four
synthesize it — so `docs/agents/HARNESS_PARITY.md`'s row was the real SSOT
and the model, which this project's DRY principle designates as
authoritative, described claude-code only. Rewritten agent-agnostically: what
the id MEANS (the generation an emission belongs to), that all five write it
and four synthesize it, each scheme named, a pointer to the per-harness row,
and the fact that it is a WITHIN-TURN identity that repeats across retry
attempts. No mechanical guard was added and none is obvious — "a field
description must not name a single harness when the union has five writers"
needs a writer census per field, which is CE054-shaped but over a `str`
description rather than a key; the cheap version was exactly this, fixing the
sentence in the next change that touches the model.
Caught in: the CE060 / antigravity `message_id` final review.

- [ ] **The golden corpus pins that a timing value EXISTS, never what it is.**
`tests/_fixtures/golden_streams/_scrub.py::SCRUB_KEYS` masks
`generation_duration_ms`, `started_at`, `completed_at` and both
`execution_*_at` to a placeholder, and the one assertion that does look at
magnitudes (`assert_timing_captured`'s four-bucket check) is an UPPER BOUND —
it catches a bucket claiming more time than the turn contains and says
nothing about one claiming less. So the committed suite cannot see a
per-harness generation number move at all, in either direction. Not
hypothetical: a whole phase of the timing plan was written on the premise
that changing those numbers would turn the golden master red, and it never
did. The two-sided check exists (`scripts/timing/decompose_run.py
--max-residual-pct`) but runs only against live `task.json` files, by hand.
Not cheap to guard: porting the two-sided residual into `_scrub.py` means
deciding a per-scenario tolerance for replays whose real wall clock is under
a millisecond while their SDK stamps declare hundreds — the same problem
`FICTIONAL_DURATIONS` already exempts six scenarios from, so the honest
version needs those scenarios to fake the agent's own clock too, not just
their item stamps. Interim cover is the per-reducer ms-exact identity test
added on pi and opencode
(`test_the_four_bucket_identity_closes_exactly_across_the_boundary`).
Caught in: the timing-architecture-standardization final review.

## From the turn-timing P0–P3 run (2026-09-12)

- [ ] **A golden scenario's justification comment can contradict its own
snapshot, and nothing notices.** Three did in this run: two orphan-tool
comments asserted bounds the committed JSON plainly carries (`pi_d`,
`opencode_d`), and `opencode_c`'s exemption claimed "the snapshot still
records the tiling" while `SCRUB_KEYS` masks both bounds and the duration.
Each was found by a human/model reading the JSON beside the prose — nothing
mechanically ties an exemption's stated reason to what its snapshot contains.
A rule would have to parse prose, so this is probably not guardable; the cheap
substitute is the review instruction that already exists ("read every new
snapshot before committing") plus the habit of quoting the actual JSON in the
comment. Caught in: turn-timing P0–P3, phases 2 and 5.

- [ ] **A rationale comment asserting a now-false premise survives a ripple that
updated its siblings.** The "in-process SDK" claim was corrected in six files
and left standing in two (`test_event_collector.py`,
`message-timeline.test.tsx`), one of them directly beside a sibling that WAS
updated. Same shape as CE026/CE047 (doc-surface parity) but over a PHRASE
rather than a symbol, so a rule would be a phrase blocklist with an
ever-growing allowlist. Deferred on cost, not on value — a grep for the retired
phrase in the acceptance criteria is what actually caught these, and that is
cheap to write into a plan.

- [ ] **`EventCollector` retains `_commands` and `_turn_starts` across a retry's
`AgentStartEvent`**, which resets only `_agent_end`. Pre-existing and NOT
introduced by the timing work. Blast radius is narrower than it first looks:
the persisted record, the reports and `max_turns` all read the AGENT's
collector, which is fresh per `communicate()`. Only `EarlyStopWatcher`'s
long-lived collector accumulates — where carrying a turn's whole engagement
across retry attempts is arguably what a live "did it engage the skill"
verdict wants, and `_check_round`'s docstring already reasons about crashed
attempts. Needs a decision on intent before any guard. Caught in: turn-timing
P0–P3 final review.

- [ ] **claude-code has no `TurnClock`.** Its window bounds and span now share
one basis (raw `datetime.now()`), so they cannot disagree with each other —
but both carry the naive-local exposure `TurnClock` exists to remove: a DST
transition or NTP step inside a turn lands directly in a generation window,
and nightly runs are hours long. antigravity and pi already derive wall stamps
from monotonic; codex and opencode cannot (their spans are the CLI's epoch
stamps). claude-code is the one that could and does not. Caught in: turn-timing
P0–P3, phase 5.

- [ ] **`pi_agent` publishes a `duration_ms` and a subtracted tool SPAN for an
UNRESOLVED orphan.** `_close_tool` guards on `execution_started_at is not
None` while its own comment claims it guards on "resolved", and the
`execution_completed_at` it stamps is only the instant the orphan sweep ran.
claude-code's `_finalize_commands` deliberately leaves the field `None` here,
for the reason CE058 exists. Captured in `pi_d_orphaned_tool.json`. Caught in:
turn-timing P0–P3, phase 2.

- [ ] **`pi_agent` republishes a turn's content on a duplicate `turn_end`.**
`turn_text_parts` / `turn_tool_ids` are cleared only in `on_turn_start`, so a
second `turn_end` with no intervening start emits the previous turn's text as
its own assistant message and re-lists the same `tool_use_ids`. The TIMING
half of that same reset was deliberately fixed (`turn_started_at` moved into
`on_turn_end`, with a comment making exactly this argument); the content half
was not. Pi retries internally, so a replayed `turn_end` is a transport hiccup
rather than a hypothetical. Captured in `pi_f_duplicate_turn_end.json`.
Caught in: turn-timing P0–P3, phase 2.
15 changes: 15 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,21 @@ jobs:
test "$FAILED" = "0" || { echo "smoke-pass had unexpected failures"; exit 1; }
test "$ERRORED" = "0" || { echo "smoke-pass had errors"; exit 1; }

# The four wall-clock buckets (head + generation + UNION(tool) + tail)
# must account for each turn's own duration. This is the TWO-SIDED gate:
# the committed golden sensor only catches an OVERSHOOT, so a bucket that
# claims LESS time than it should — the defect class this area keeps
# producing — passes every test in the suite. It needs live task.json
# files, which the smoke-pass run above already leaves on disk.
#
# COVERS CLAUDE-CODE ONLY: experiments/default.yaml sets type: claude-code,
# so every turn here is that harness. The other four are covered by
# tests/test_timing_identity_contract.py, which is ms-exact but synthetic.
- name: Verify timing residual (claude-code only)
run: |
.venv/bin/python scripts/timing/decompose_run.py \
$(find runs/ci-smoke-pass -name task.json) --max-residual-pct 5

- name: Verify smoke-fail bucket
run: |
F=runs/ci-smoke-fail/experiment.json
Expand Down
Loading
Loading