Skip to content

fix(cli): stop the live token counter from silently freezing mid-run - #971

Merged
xdotli merged 1 commit into
mainfrom
fix/live-usage-capture-lag
Aug 9, 2026
Merged

fix(cli): stop the live token counter from silently freezing mid-run#971
xdotli merged 1 commit into
mainfrom
fix/live-usage-capture-lag

Conversation

@xdotli

@xdotli xdotli commented Aug 9, 2026

Copy link
Copy Markdown
Member

A fresh-user dogfood of the released 0.6.7 (skillsbench edit-pdf, prime-agent, deepseek-v4-flash, Daytona, 26.5 min) measured the footer climbing normally — 4.5k → 9.5k → … → 56.8k tokens — and then freezing at 56.8k for 20 min 39 s while the same row's tool-call counter kept climbing 26 → 53. The true total was 1,697,038 tokens: the displayed figure was 3.3% of truth, and it looked authoritative. That contradicts docs/reference/cli.md's promise that spend is visible mid-run, and it's the number users watch to decide whether to kill a run.

Mechanism — three causes, and the stated hypothesis was only partly right

The suspected cause was the per-tick byte budget. On the evidence that predicts a lagging counter, not a zero-progress one: at 24 KB per round trip the reader could have moved 12–29 MB during the freeze against a ~6–7 MB log. It moved 0 bytes.

The hard flatline came from the read path: _read_callback_chunk returned bare bytes, so b"" meant both "drained" and "the read failed" — the caller breaks either way with the offset unmoved, so a failing read repeats identically every tick, forever, logged only at debug. Both failure shapes are reachable (timeout 10 bash -c → rc 124 → b""; _poll_response raising on the same deadline → swallowed by a blanket except). dd bs=1 made tripping that 10 s deadline plausible: 24,576 read + 24,576 write syscalls per window (measured ~27 ms/24 KB vs ~6 ms seeked).

Fix

  • Distinguish drained from failed. New _CallbackChunk(data, size); a failed read is no longer indistinguishable from EOF.
  • Read shape: stat -c %s F; dd iflag=skip_bytes,count_bytes,fullblock bs=64K skip=<off> count=<limit> | base64 -w 0, one command per read via exec_transient. Budget 512 KB × 8 reads/tick (was 24 KB × 64) — ~20× per round trip with 8× fewer round trips; timeout 10 s → 20 s.
  • Lag is visible: byte-lag at debug on every behind poll, plus one latched WARNING when the tail stops advancing entirely. The displayed value is deliberately not marked approximate — it's a lower bound always, so a "~" only when lag is detected would imply the unmarked value is exact.
  • Phase label no longer runs backwards (the dogfood saw verifying…running agent… at 24:49+): cleanup()disconnect() unconditionally reset _phase to "installed" after verify had reached "verified"; terminal phases are now sticky, and "executed" maps to running agent… (the only window rendering under it is inside disconnect()).

Verification

The author flagged one honest gap — the new command had never run against real GNU coreutils. I closed it against a live Daytona sandbox: coreutils 9.7; a 500 KB ranged read from a mid-file offset returned rc 0 with a byte-exact slice (sha256 verified); a past-EOF read returned rc 0 with an empty payload — the drained signal, not an error.

Unit side: the regression drives a log growing at ~2× the per-tick budget and requires the counter to advance every tick and converge on the true total (102 round trips against a 121 bound); both failure shapes are covered; each fix was mutation-checked (invert drained/failed → 2 fail; drop the phase guard → 1 fails; restore the "executed" label → 1 fails).

Still not done: a full end-to-end Daytona re-measurement of the dogfood scenario. The wire behavior of the new command is now confirmed, but the 26-minute run was not repeated.

Gates: ruff format/check, ty clean; target files 82 passed; -k cli 400; -k "evaluation or eval_run or rollout" 312; -k "session or acp" 353; full suite 5398 passed (only the three known host-specific failures).

…alue

A fresh-user dogfood on 0.6.7 (skillsbench edit-pdf, prime-agent,
deepseek-v4-flash, Daytona) watched the footer climb to 56.8k tokens and
then hold that exact figure for 20m39s of a 26.5-minute run whose final
trusted total was 1,697,038 — 3.3% of the truth, rendered as if it were
current, while the same row's tool-call counter climbed 26 -> 53. Users
read that number to decide whether to kill a run, so a frozen-but-plausible
value is worse than no value.

The gateway tail read 24KB per `dd bs=1 skip=<offset>` per transient exec.
Two syscalls per byte transferred, one full Daytona round trip (create
session, run, poll on a 1s interval, fetch logs, delete) per 24KB window,
under a 10s per-read deadline. That ceiling sits at the same order as the
rate a full-message DeepSeek log grows at — and, decisively, a single failed
read cost the whole poll with the offset unmoved: `timeout` killing the
command (rc 124) and the poll deadline raising both landed on "return
nothing", which the caller could not distinguish from a drained log. Silent,
permanent, debug-level.

Read shape: byte-ranged `dd iflag=skip_bytes,count_bytes,fullblock bs=64K`,
512KB per read over 8 reads/tick (was 24KB over 64), and the command now
emits `<size>\n<base64>` so one round trip carries both the payload and the
log's size. Userland floor is unchanged in kind — `base64 -w 0` on the same
line already requires GNU coreutils; skip_bytes/count_bytes need 8.11+.

Falling behind is now a fact, not a silence. The size lets an empty read be
classified as drained (offset == EOF) or as a lag, raising reads are caught
at the read site so they still reach the lag accounting, every behind poll
logs the byte lag at debug, and a tail that stops advancing altogether
raises one latched WARNING. The counter itself is left alone: it is a
cumulative lower bound at all times, and blanking it would just hand the
cell to the equally lagging ACP signal.

Also fixes the phase label running backwards at the end of a run
("verifying…" at 24:04, then "running agent…" at 24:49/25:35/26:20).
cleanup() calls disconnect(), whose unguarded rewind to "installed"
relabelled the whole teardown stretch and transiently blanked
Rollout.result, which is gated on the same terminal phases; and "executed"
mapped to "verifying…" even though verify() has marked its own entry since
PR #957, so the only stretch rendering under it is the inside of
disconnect(). The between-scenes rewind is preserved.
@xdotli
xdotli temporarily deployed to pypi-internal-preview August 9, 2026 23:39 — with GitHub Actions Inactive

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ea2be2cd1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +345 to +349
logger.warning(
"Live token counter has stalled: the model-gateway log tail "
"has not advanced past byte %d in %d polls (%s bytes behind). "
"The live token/cost figures now UNDERCOUNT this run; the "
"final totals, imported from the full log, are unaffected.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface the stall while the live dashboard is running

In the TTY CLI path, main.py wraps the evaluation in live_session, whose quiet_root_logging replaces the root handlers with _WarningBuffer and replays warnings only after Evaluation.run() exits. Consequently, when callback reads fail and this warning fires, the user continues seeing the frozen token figure for the remainder of the run and receives the warning only after it is too late to use it to decide whether to stop the run; expose the stalled state in the dashboard or route this warning through a live-safe output path.

Useful? React with 👍 / 👎.

assert rollout._phase == "verifying"


def test_phase_labels_never_walk_backwards_through_a_run():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Name the guarded change in regression-test docstrings

This regression test and the following disconnect regression test describe previously shipped failures only in comments and have no docstrings identifying the PR or commit they guard. Add durable provenance in each test's docstring as required by the repository convention.

AGENTS.md reference: AGENTS.md:L16-L17

Useful? React with 👍 / 👎.

@xdotli
xdotli merged commit 15618e8 into main Aug 9, 2026
11 checks passed
@xdotli
xdotli deleted the fix/live-usage-capture-lag branch August 9, 2026 23:44
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.

1 participant