Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
62 changes: 62 additions & 0 deletions .claude/harness-candidates.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,65 @@ with the two `action.yml` items above — one considered change to the action's
run. Guard would need cross-method dataflow over each `Agent`-subclass turn-state
class (write sites vs read sites), which the AST-only CExxx runner can't express
in ~30 min — deferred. Caught in: Pi harness Phase 2 quality review.

- [ ] A duration/count aggregate over run-task rows that fails to exclude
`mature_skipped`. Nothing guards it: the CExxx runner is Python-AST only and
this defect class lives in the evalboard's TypeScript. A codex nightly
rendered "1300 tasks · 15h 29m" for the 397 tasks that actually ran. Fixed
for the two whole-run sites by extracting `deriveRunDuration`; a general
guard needs a TS lint surface the harness does not have.
Caught in: timing-capture Phase 1 review.

- [ ] Subtracting a SUM of intervals from a wall span where the intervals can
overlap. Semantic, not syntactic — an AST rule cannot tell a sum of
durations from a union. Antigravity shipped it: four concurrent 400ms tool
calls inside a 1000ms window summed to 1600ms and clamped generation to the
0.0 the change existed to remove. The real guard is the replay-based
`assert_timing_captured` golden sensor, which now exists; a static rule
would not have caught it. Caught in: timing-capture Phase 3 review.

- [ ] A test fixture whose field name does not exist on the type it models.
`parseMessages`'s `CommandEntry` keys on `tool_id`; a fixture using
`tool_use_id` never resolves, params fall back to `{}`, and every tool
weighs exactly 1 — which silently turned a "split by content size" test
into a 99%-thinking assertion that passed. TypeScript accepts it because
the fixtures are untyped object literals. Typing the fixture factories
against the real interfaces would guard the whole class; that is a
sweep across the evalboard test suite, not ~30 min.
Caught in: timing-capture Phase 5 review.

### Deferred timing divergences (not guardrails — accounting gaps)

Recorded here as well as in `docs/agents/HARNESS_PARITY.md` § Known
divergences, so the deferred-work record is one place. Measurements in
`c/time-bugs-audit.md`.

- [ ] **Antigravity books orphan-poll waiting as agent duration** (audit P2-1).
A task can spend `0.8 × turn_timeout` waiting on a tool call that never
reaches DONE — 14 tasks, 9.6h of one 83h run. Only CLOSED tool intervals are
subtracted from a generation window, so that wait stays inside whichever
window contains it; the force-close records `execution_completed_at` while
leaving `duration_ms` as `None`. Deliberately out of scope of the timing
work: closing it means deciding whether a backgrounded tool's elapsed time
is model time (the model IS generating while it runs), which is a semantic
question, not a bug fix.

- [ ] **Delegate (`delegate-sdk`) records no execution bounds** (audit P3-1).
It reports `duration_ms` but neither `execution_started_at` nor
`execution_completed_at`, so its tool calls cannot be placed on a timeline.
Coverage is ~88%, so it is not urgent. The agent lives in the separate
`coder_eval_uipath` repo; mirror the Codex change there
(`_item_timing` + threading the SDK stamps through the telemetry builders).

- [ ] **Pre-existing, surfaced by this work's final review: `TokenUsage._adopt_legacy_input_tokens`
double-counts the cache buckets.** The validator copies a legacy record's
full-prompt `input_tokens` straight into `uncached_input_tokens`, and the
computed `input_tokens` then adds `cache_creation` + `cache_read` again. A
legacy record with `input_tokens=1000`, `cache_creation=200`, `cache_read=150`
reloads as 1350 prompt tokens and bills 1000 at the uncached rate instead of
650. Affects every report, budget check and detached regrade over a
pre-split run that used prompt caching. NOT touched by the timing work
(token accounting was explicitly out of its scope) and not a guardrail
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).
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions docs/agents/HARNESS_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,73 @@ This page is the contract for what each run limit means per harness, plus the sh
| `run_limits.task_timeout` | orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic |
| `run_limits.stop_early` | cooperative `should_stop` | cooperative `should_stop` | cooperative `should_stop` | cooperative `should_stop` (event granularity) | cooperative `should_stop` (event granularity — Pi streams incrementally) |

## Timing capture

What each harness records about *when* things happened, and how much of a task's
wall clock its numbers account for.

| Field | claude-code | codex | antigravity | opencode | pi |
|---|---|---|---|---|---|
| `generation_duration_ms` source | harness clock: previous SDK event → this message | SDK item stamps, minus tool execution inside the window | harness clock: previous flush → this flush, minus tool execution inside the window | harness clock per CLI step, minus tool execution inside the step | harness clock per CLI turn, minus tool execution inside the turn |
| tool `duration_ms` source | measured around the tool result | SDK `completed_at_ms − started_at_ms`; the item's own `duration_ms` only as a fallback | measured ACTIVE → DONE | measured around the tool event | measured around the tool event |
| `execution_started_at` / `execution_completed_at` | derived from the measured duration | SDK stamps (both, or neither) | measured at ACTIVE / DONE | measured | measured |
| `generation_completed_at` | set | `None` — see below | `None` | `None` | `None` |
| `Σ generation + Σ tool ≈ turn duration` | yes | yes | yes | yes | yes |

**`generation_duration_ms` is model-generation time, not `completed_at − started_at`.**
Four of the five harnesses interleave tool execution into a single generation
window. Antigravity reports a `Step` for the tool and only a later
`usage_metadata` `Step` cuts the message; Codex's message window is seeded from
the first item's start and extended to the last item's completion; OpenCode
opens its window at `step_start` and closes it at `step_finish`, and Pi at
`turn_start` / `turn_end`, with every tool call running inside. In all four the
span between the recorded bounds legitimately CONTAINS tool time that the model
did not spend generating, so all four subtract it — the **union** of the closed tool intervals
clipped to the window (`agents/_timing.py::busy_ms`), never the sum, because
tool calls overlap: Antigravity resolves several from one `Step` and backgrounds
anything over ten seconds, and Codex spawns collab agents concurrently. Summing
them over-subtracts by exactly the overlap and, with enough concurrency, drives
the result to a clamped zero.

The consequence worth knowing: on an emission that carries *only* a tool call,
the whole measured window was that tool running, so the recorded generation
time is legitimately `0.0`. That is a measurement, not a placeholder — `None`
is what "never measured" looks like. Only `claude-code` does not need the
subtraction: 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.

**Why Codex leaves `generation_completed_at` as `None`.** It means "when the
model finished emitting the `tool_use` block". Codex's stream does not carry
that per tool; deriving it from the flush time would be a guess. Note also that
`CommandTelemetry.timestamp` is the tool's own start on codex, antigravity,
opencode and pi, and the generation-completed moment on claude-code. Nothing
orders on it — `TurnRecord.commands` is sorted by `sequence_number` — but the
field's own docstring still describes only the claude-code reading.

**Codex `duration_ms` covers more than the command run.** Derived from the item
stamps, it is the item's lifecycle (queueing and approval included) rather than
the SDK's own narrower command-execution figure, which it deliberately overrides
— the SDK reported `0` for 70 of 211 commands in one nightly. `fileChange` and
generic tool items now carry a duration where they previously carried none, so
`avg_command_time_ms` and `total_command_time_ms` for a Codex run describe every
tool call rather than shell commands alone.

### Known divergences

- **Delegate (`delegate-sdk`, out of tree)** records `duration_ms` but no
execution bounds, so its tool calls cannot be placed on a timeline. Its
coverage is ~88%. Mirror the Codex change in `coder_eval_uipath`
(audit P3-1).
- **Antigravity books orphan-poll waiting as agent duration.** A task can spend
`0.8 × turn_timeout` waiting on a tool call that never reaches DONE — 14 tasks
and 9.6h of one 83h run. Only CLOSED tool intervals are subtracted, so that
wait stays inside whichever generation window contains it, and the force-close
records `execution_completed_at` while leaving `duration_ms` as `None`
(audit P2-1).

Both are deliberately deferred; see `c/time-bugs-audit.md` for the measurements.

## `max_turns` counts visible turns on Codex and Antigravity

A "visible turn" is one entry in the run's timeline: one resolved tool call. It is
Expand Down
52 changes: 44 additions & 8 deletions evalboard/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,40 @@ function fmtCost(c: number | null): string {
return `$${c.toFixed(2)}`;
}

// The Duration cell for a run row. `taskDurationSeconds` is compute time over
// the rows that actually ran, so when the nightly carried some forward as
// mature passes the cell says how many that was — otherwise "1300 tasks ·
// 15h 29m" reads as a per-task rate over 1300 tasks when it describes 397.
function RunDurationCell({
seconds,
tasksRun,
tasksExecuted,
}: {
seconds: number | null;
tasksRun: number;
tasksExecuted: number;
}) {
const skipped = tasksRun - tasksExecuted;
if (skipped <= 0) {
return (
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtDuration(seconds)}
</td>
);
}
return (
<td
className="py-3 px-4 text-right tabular-nums text-gray-700"
title={`compute time over the ${tasksExecuted} task(s) that executed; ${skipped} were carried forward as mature passes`}
>
{fmtDuration(seconds)}
<div className="text-[11px] text-gray-400">
{tasksExecuted} run
</div>
</td>
);
}

// Rail-level q filter: substring match on tag name only. This is narrower
// than getRunListing's q (which also matches taskId / humanized id) by
// design — rails are a tag namespace, the table is a task namespace.
Expand Down Expand Up @@ -428,9 +462,11 @@ export default async function Page({
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtCost(r.totalCostUsd)}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtDuration(r.taskDurationSeconds)}
</td>
<RunDurationCell
seconds={r.taskDurationSeconds}
tasksRun={total}
tasksExecuted={r.tasksExecuted}
/>
</tr>
);
})}
Expand Down Expand Up @@ -572,11 +608,11 @@ export default async function Page({
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtCost(r.totalCostUsd)}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtDuration(
r.taskDurationSeconds,
)}
</td>
<RunDurationCell
seconds={r.taskDurationSeconds}
tasksRun={total}
tasksExecuted={r.tasksExecuted}
/>
</tr>
);
})}
Expand Down
Loading
Loading