Skip to content

fix(agent): restore token-usage telemetry after the OpenClaw JSONL→SQLite transcript move - #1631

Merged
krishagel merged 11 commits into
devfrom
claude/admin-dashboard-token-cache-b2aef3
Aug 11, 2026
Merged

fix(agent): restore token-usage telemetry after the OpenClaw JSONL→SQLite transcript move#1631
krishagel merged 11 commits into
devfrom
claude/admin-dashboard-token-cache-b2aef3

Conversation

@krishagel

@krishagel krishagel commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Agent token-usage telemetry has read zero on every turn since 2026-07-31 (dev) / 2026-08-01 (prod). OpenClaw 2026.7.2-beta.5 moved per-session transcripts out of

/home/node/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl

into a per-agent SQLite database

agents/<agentId>/agent/openclaw-agent.sqlite
  transcript_events(session_id TEXT, seq INTEGER, event_json TEXT, created_at INTEGER)

and deleted the JSONL files. harness_adapter._read_turn_usage() — the only usage source on the post-#1384 SigV4 path — kept opening the JSONL path, found nothing, and returned honest zeros.

Nothing failed. A zero read is indistinguishable from a genuine zero once it lands in agent_messages, so admin/agents → Cost showed $0 for ten days with no error, no alarm, and no failed turn.

Caching was never broken. A checkpointed workspace database shows the healthy steady state throughout: ~2 billable input tokens against a 54k–71k cache read per turn. Only the telemetry regressed.

Changes

1. Read the SQLite transcript (harness_adapter.py)

  • _fold_usage_records() now holds the whole accounting fold, shared by both stores (each supplies record JSON in append order), so the SQLite and JSONL paths cannot drift in windowing, completeness, or token extraction.
  • _sum_sqlite_transcript_usage(): read-only file: URI (mode=ro, which also refuses to create a missing file), session_id bound, created_at >= since_ms prefilter, ORDER BY seq.
  • _settle_usage() / _contained_transcript_path() extracted; OperationalError retries, DatabaseError (corrupt) breaks out immediately.
  • Booleans no longer count as tokens (bool is an int subclass, so a JSON true previously added 1).
  • JSONL remains a fallback; the database wins when both exist, so an archived JSONL cannot double-bill.

2. Correct the recorded model id (platform-model.ts)
AGENT_MODEL_IDus.anthropic.claude-sonnet-5. #1227 moved the provider onto bedrock-runtime's native endpoint, which echoes the request's inference-profile id verbatim instead of rewriting it to the bare form. New AGENT_MODEL_ID_ALIASES; getPricableModels() excludes all three forms. This class of drift doesn't fail loudly — rows just stop joining ai_models and cost reads $0 (bug #1083).

3. Make the next one loud (migration 177 + UsageCaptureZero)
The wrapper already computed usage_capture_complete — but only the eval harness consumed it, so production turns threw the answer away. Now persisted on agent_messages, nullable on purpose: true = measured, false = the token columns are a floor not a total, null = unknown (row or image predates the column). A DEFAULT TRUE would assert ten days of zeros were measured; DEFAULT FALSE would brand all history suspect.

New UsageCaptureZero metric + psd-agent-usage-capture-zero-<env> alarm fires on the arithmetically impossible case: a successful turn that made ≥1 model call yet reports zero across all four counters.

4. Backfill the history (scripts/db/backfill-agent-message-usage.ts)
Dry-run-first, idempotent. No write has been executed.

Verification

Re-run in full after the review fixes below:

Gate Result
python3 -m unittest (agent-image) ✅ 171 tests OK (pytest is not installed locally; unittest counts cases rather than expanded subtests)
bun run lint (--max-warnings 0) ✅ clean
bun run typecheck (full codebase) ✅ clean
bun run test:ci (full suite) ✅ 5,480 passed / 577 suites, 15 skipped. Two suites (atrium-bulk-run, gemini-live-provider) died on a jest worker SIGSEGV under parallel load; both pass in isolation (32/32), so this is a runner crash, not a failure
Full authenticated E2E suite (pre-push gate) ✅ 319 passed, 41 skipped, 0 failed
tests/e2e/admin-agents.spec.ts ✅ 11/11 passed authenticated — the admin suite ran rather than auto-skipping
cdk synth --all Dev + Prod ✅ exit 0; psd-agent-usage-capture-zero-dev present in the Dev template
infra tsc --noEmit ✅ clean
Migration 177 registered 177-agent-usage-capture-complete.sql in migrationFiles
Backfill migration guard (live, vs dev) ✅ aborts: "migration 177 has not been applied … nothing was read or written"
Backfill argument guards (live) ✅ bad --since, unknown flag, and --execute without the token each abort before any connection

Note on infra jest: CI does not run it (the CDK job runs tsc + cdk synth), and it does not resolve ts-jest from this worktree. tsc and synth both pass, which is the gate CI actually applies.

Verified against reality, not assumed

I pulled a real checkpointed openclaw-agent.sqlite from psd-agents-dev-390844780692 and the dev database, rather than trusting the handoff:

  • Schema confirmed exactly; event_json holds the same record objects the JSONL did, verbatim (message.usage.{input,output,cacheRead,cacheWrite}, stopReason ∈ {toolUse, stop, error}).
  • created_at >= message.timestamp always — 488 records, 0 inversions, skew 0–19,941 ms. That makes created_at >= since_ms a safe superset prefilter; the authoritative window test stays on the record timestamp.
  • created_at is NOT monotonic with seq28 of 32 sessions contain a row whose created_at precedes that of a lower seq. Completeness is decided by the last record, so ORDER BY created_at would misread it. This is why the scan orders by seq.
  • The JSONL→SQLite import preserved created_at — the same database holds events from 2026-07-27 (before its 2026-07-30 import) and no row has a skew above 60 s. So migrated rows don't mis-window a turn.
  • Recorded model id — every agent_messages row on/after 2026-07-31 carries us.anthropic.claude-sonnet-5 (58 rows); the bare form appears only through 2026-07-29 (630 rows). All three aliases are priced and inactive.

Backfill dry run — earlier numbers WITHDRAWN

An earlier revision of this PR quoted a dev dry run (57 rows to update, ~10.2M recovered cacheRead). Those numbers are withdrawn. They were produced under the row-timestamp attribution model that review then disproved (see below), so the per-row split cannot be trusted even where the aggregate looked plausible.

It cannot be re-run yet: usage_capture_complete does not exist on dev (confirmed by querying information_schema), and the script correctly refuses to read anything without it. A fresh dry run is a post-deploy step, and its report must be read before anyone passes --execute.

Turn attribution — the row-timestamp model was WRONG

agent_messages.session_id is a PSD sessionKey (agent-chat-<sha256>), not the transcript UUID; the mapping (session_windows.session_key = "agent:main:" + session_id) was verified against the real database, and up to 8 turns share one sessionKey with an append-only transcript across all of them.

The original fix reconstructed each turn as ( created_at[k-1], created_at[k] ]. That is unsound. The router releases the session lock in the finally of its invocation wrapper but does not insert the telemetry row until after the Google Chat response is sent, so the next turn can begin and append transcript records before the previous turn's row is stamped. The windows overlap and model calls get billed to the wrong row — confidently wrong cost, which is worse than none.

Replaced with segmentation over the transcript's own turn structure, rows supplying only order and count:

  1. Walk a session's records in append order, cutting a segment after each terminal stopReason (stop / end_turn). OpenClaw writes toolUse on every hand-off to a tool, so a terminal reason is exactly the end-of-turn marker.
  2. Drop a trailing segment with no terminal reason — an in-flight or aborted turn, which has no row yet.
  3. Pair segment k with row k, rows ordered by created_at.
  4. If the counts disagree, attribute nothing for that session and report it (turnCountMismatches). A mismatch means the session model is wrong, and guessing would silently misprice turns.

Step 4 is also what contains the two remaining corruption routes, converting both into a reported gap rather than a bad number: a duplicate agent_messages row (the router's INSERT has no idempotency key, unlike its agent_sessions upsert), and a terminal record carrying no usage object (dropped at parse time, so its segment never gets cut).

Two consequences that changed call sites:

  • Pairing needs every row of a session, populated ones included — a populated row still consumes a segment. The SELECT no longer filters to zero rows; planSessionBackfill alone decides what is writable, and a populated row is left strictly alone.
  • --since now filters the resulting updates, never the query. Removing a session's earlier rows would delete the anchors that establish its turn order.

Two bugs only a real run could find, both fixed:

  • immutable=1 is required to open a checkpointed transcript — it's WAL-mode but arrives without its -wal/-shm, and a plain read-only open must build the -shm, which read-only forbids. Safe there (static snapshot, no writer) and deliberately not used by the live adapter.
  • The -wal sidecar is intentionally never fetched: pairing a current .sqlite with a stale uploaded -wal can surface a torn state. Reading the main file alone under-counts rather than corrupting.

One bug found in self-review: the JSONL fallback was keyed on the database file existing rather than the transcript_events table existing. Those differ on every host older than 2026.7.2-beta.5 — which would have reintroduced the identical silent-zero bug for older/candidate images. Fixed with TranscriptTableMissing + a sqlite_master probe, because a missing table and a locked table both surface as OperationalError yet need opposite remedies.

Review response — two more real defects in the adapter

Beyond the attribution rewrite above, review found two defects in the live read. Both are fixed with regression tests.

1. Completeness was decided by the wrong record. _fold_usage_records documented that completeness comes from the newest in-window assistant record with a terminal stopReason, but the code took it from the newest record carrying a usage dict — records without usage hit a continue placed before stopReason was read. So a turn whose terminal record has no usage object had its verdict set by the preceding toolUse call and reported incomplete despite having finished.

That is not cosmetic: it burns the full settle-retry budget on an already-finished turn, and writes FALSE into usage_capture_complete, which per migration 177 means "these token columns are a floor, not a total" — manufacturing the exact broken-capture signature the new alarm exists to catch, on turns that captured correctly.

Completeness is now decided independently of usage. Any in-window assistant record carrying a stopReason updates the verdict; a record with no stopReason leaves it untouched rather than clearing it, so a trailing non-model-call record cannot un-finish a finished turn either. The in-window test moved ahead of the usage test so an out-of-window record can never reach the verdict and report a previous turn's ending as this turn's. Token accumulation still requires a usage dict, so a usage-less record adds no tokens and is not counted as a model call.

2. The success-path read sat outside every exception boundary. _read_turn_usage promises zeros on any failure ("telemetry must never break a chat turn") but only handled sqlite3.OperationalError and sqlite3.DatabaseError. Its success-path call site runs after the WebSocket try/except has closed, and neither process() nor the wrapper's executor await wraps it — so any other exception would propagate and discard a reply the model had already produced. That turn's tool calls have run, so a retry at a higher layer would re-run their side effects, exactly what _should_retry_upstream is written to avoid.

Fixed on the callee, not the call site, so the guarantee is intrinsic and cannot be reintroduced by moving a call: the body is now _read_turn_usage_unguarded and _read_turn_usage is a thin wrapper catching Exception. Both call sites inherit it.

Also closed a facade gap this PR introduced: mkdtemp/mkdtempSync were in neither method set in validated-fs.ts, so validatedFs passed them through unvalidated. The backfill's temp-dir call is safe only because its prefix is hardcoded; both are now in WRITE_METHODS.

Bot-review findings, both addressed

  • Stale OperationalError comment (CONFIRMED). It claimed the branch also covers a host whose schema predates transcript_events; that case is raised as TranscriptTableMissing by the sqlite_master probe. Comment corrected, and it now states why the split exists.
  • complete not seeded before the settle loop (PLAUSIBLE). Verified not live: the corrupt-database branch breaks before complete is read, and the post-loop return uses a literal False. Seeded it anyway — one line to make the fast-break path self-contained rather than correct-by-coincidence, since a future edit returning the variable would fail on the hardest path to reach in testing.
  • Accepted, not fixed: the reviewer notes applyUpdate()'s zero-guard has no automated regression test. Fair. It is an unexported function in an ops script gated behind --execute plus a confirmation token, and the guard is enforced by the database in the WHERE clause rather than by the plan; making it testable would mean restructuring the script, which I left out of this PR.

Two findings deliberately not acted on

  • Router INSERT idempotency. insertTelemetrySummary is a plain INSERT with no idempotency key, so a Lambda retry can double-write a turn. Real, but pre-existing and outside this PR; adding a uniqueness guard is new blocking behaviour I won't add unasked. The backfill's count-mismatch refusal (step 4) already converts a duplicate row from "silently mispriced turn" into "reported gap". Say the word and I'll do it as a follow-up.
  • Cold-boot alarm false positives. Review suggested a fresh container's first turn could fail the read-only open before the WAL -shm exists, tripping the alarm on every scale-out. Ordering refutes it: this read happens strictly after the runtime appended this turn's transcript records, which requires the writer to have opened the database, so -shm necessarily exists by then. If it somehow failed, the settle loop retries and degrades. No change made.

Known pre-existing limitation (verified, not fixed here — your call)

The realpath containment catches a symlinked transcript file, but not a symlinked containing directory. I confirmed empirically: if agents/main/agent/ is itself a symlink pointing outside the workspace, the read follows it (a planted database returned its 31,337 tokens).

This is unchanged behaviour — the old JSONL path had the identical property for agents/<id>/sessions/, and the existing comment scopes the check to symlinked transcripts, with the dot-name component check being what stops traversal. Impact is telemetry integrity, not confidentiality: the sandboxed node agent already owns that tree, and the only thing it could achieve is having another database's token counts billed to its own turns. Nothing is exfiltrated — only aggregate numbers reach agent_messages.

I did not add a guard for it, since that would be new blocking behaviour outside this PR's scope. Happy to do it as a follow-up if you want it closed.

Evidence

Agent Platform Dashboard → Cost tab, captured by the authenticated E2E run. Note the local fixture has no agent messages, so the figures are $0.00 — this shows the tab renders correctly and the "Compare to" list no longer offers the harness model against itself. The numeric evidence is the dry run above.

admin-agents-cache-cost

admin-agents-iteration-telemetry

Deploy notes

  1. agentImageDigest OVERRIDES agentImageTag. A stale digest silently deploys the wrong image — this caused a prod outage on 2026-08-10. The fix here lives in the agent image, so it does nothing until a rebuilt image is actually deployed. Rebuild via infra/agent-image/build-and-push.sh and confirm the digest is updated.
  2. Migration ordering is already enforced. The router INSERT now names usage_capture_complete (migration 177), and devAgentPlatformStack.addDependency(devDbStack) puts the migration before the router. A deliberately partial router-only deploy would break inserts — don't partial-deploy.
  3. The alarm needs --context alertEmail=<address>. Without it the topic isn't created and this alarm — like all 15 existing agent-platform alarms — evaluates correctly and notifies nobody. The stack emits a synth warning for that case.
  4. The backfill has not been run. Dry-run only; it needs an explicit per-run OK, and migration 177 must be applied first (the script fails fast if not).

Prod confirmed broken the same way (queried this session)

Read via the RDS Data API against aistudio-prod-cluster, daily totals on agent_messages:

Day Model Msgs input output cache_read cache_write
2026-08-10 us.anthropic.claude-sonnet-5 181 0 0 0 0
2026-08-05 us.anthropic.claude-sonnet-5 638 0 0 0 0
2026-08-01 us.anthropic.claude-sonnet-5 17 0 0 0 0
2026-07-31 claude-sonnet-5 39 280 71,043 9,681,047 1,147,776
2026-07-30 claude-sonnet-5 44 810 123,031 29,351,220 2,059,875
2026-07-29 claude-sonnet-5 89 222 37,079 9,319,427 928,548
2026-07-28 and earlier claude-sonnet-5 0 0 0 0

This pins the whole story: prod went dark 2026-08-01, exactly when the recorded model id flipped to the inference-profile form, and healthy cache telemetry (29.3M cache_read against 810 billable input on 7/30) is what the dashboard should be showing. Schema note for anyone re-running this: the columns are model, cache_read_input_tokens, cache_write_input_tokens — not model_id/cache_read_tokens.

Still to confirm post-deploy

  • A real agent turn writes non-zero input/output/cache_read to agent_messages, and the Cost tab renders it.
  • Re-run the backfill dry run (dev, then prod) and read its report — including turnCountMismatches — before anyone passes --execute. The withdrawn numbers above must not be used as the expectation.
  • Prod rows for 2026-07-21→28 are all-zero under the old model id, so usage capture appears to have only started working ~2026-07-29; those are likely unrecoverable, and the dry run will say so per row rather than guess.
  • 3 prod rows on 2026-08-10 carry model 'unknown'.

… removed JSONL

OpenClaw 2026.7.2-beta.5 migrated per-session transcripts out of
<workspace>/agents/<agentId>/sessions/<sessionId>.jsonl into the per-agent
SQLite database <workspace>/agents/<agentId>/agent/openclaw-agent.sqlite
(table transcript_events) and DELETED the JSONL files. OpenClawAdapter's usage
read still opened the JSONL path, so every turn reported input=0 output=0
cache_read=0 cache_write=0 model_calls=0 with capture_complete=False.

Nothing failed loudly: the wrapper treats a zero read as an honest zero, so the
admin/agents dashboard has shown all-zero token usage since 2026-07-31 (dev)
and 2026-08-01 (prod) with no error surfaced anywhere. Caching itself was never
broken -- a checkpointed workspace database shows cacheRead 56k-71k per turn
against ~2 billable input tokens. Only the telemetry read regressed.

Verified against a real checkpointed 2026.7.2-beta.5 database rather than
assumed:
  - transcript_events(session_id TEXT, seq INTEGER, event_json TEXT,
    created_at INTEGER), STRICT, PRIMARY KEY (session_id, seq).
  - event_json holds the SAME record objects the JSONL did, verbatim:
    {id, message, parentId, timestamp, type} with
    message.usage.{input,output,cacheRead,cacheWrite} and message.stopReason
    in {toolUse, stop, error}.
  - created_at is the row's INSERT time and is always at or after the record's
    own message.timestamp (488 records, 0 inversions, skew 0..19941ms).
  - created_at is only WEAKLY ordered against seq: 28 of 32 sessions contain a
    row whose created_at precedes that of a lower seq.

Implementation:
  - _fold_usage_records() now holds the whole accounting fold and is shared by
    both stores, which take an iterable of record JSON in APPEND order. The
    SQLite and JSONL paths therefore cannot drift in window filtering,
    completeness, or token extraction.
  - _sum_sqlite_transcript_usage() opens a read-only file: URI (mode=ro, which
    also refuses to create a missing file), binds session_id as a parameter,
    prefilters on created_at >= since_ms, and orders by seq.
      * created_at >= since_ms is a safe SUPERSET prefilter given
        created_at >= message.timestamp, so it narrows the scan without ever
        hiding an in-window record; the authoritative in-window test stays on
        the record timestamp exactly as before.
      * ORDER BY seq, not created_at: completeness is decided by the LAST
        record, so ordering by the weakly-ordered column would mis-read it.
  - Session isolation moved from the filename to a WHERE clause, so a regression
    there would bill concurrent sessions to one turn; covered by a test.
  - _settle_usage() extracts the bounded retry. sqlite3.OperationalError
    (locked/busy DB, or a pre-transcript_events schema) retries through it;
    DatabaseError (corrupt file) breaks out immediately rather than paying the
    full wait on every turn. Telemetry still never breaks a chat turn.
  - _contained_transcript_path() extracts the realpath containment shared by
    both stores, still paired with the dot-only component rejection that the
    containment check cannot substitute for.
  - JSONL remains a fallback for hosts older than 2026.7.2-beta.5; the database
    wins when both exist, so an archived JSONL cannot double-bill.
  - Booleans no longer count as tokens (bool is an int subclass in Python, so a
    JSON `true` previously added 1).

The sessionId is no longer a path component at all on the SQLite path -- it is a
bound SQL parameter, and the filename is a constant. Only the agentId directory
component remains untrusted path input.

Tests: transcript usage suite rebuilt on a sqlite fixture whose DDL is copied
from the real database, covering the window, cross-session isolation, seq-vs-
created_at ordering, the created_at lag, malformed/non-object event_json, a
genuinely locked database (verified to raise OperationalError, not pass
incidentally), a corrupt file, and read-only-ness (the read neither creates nor
mutates the DB). Traversal, dot-name, and symlink security tests are retained
for both stores, and the JSONL fallback keeps its own suite.

766 passed, 1 skipped in the infra/agent-image suite.
agent_messages.model has carried `us.anthropic.claude-sonnet-5` since
2026-07-31, not the bare `claude-sonnet-5` this module named. #1227 moved the
provider off Bedrock Mantle onto bedrock-runtime's native anthropic-messages
endpoint, and that endpoint echoes the request's inference-profile id verbatim
instead of rewriting it to the bare form -- so the RECORDED id and the REQUEST
id became equal again without anything being updated to say so.

Verified against the dev database rather than inferred: every agent_messages
row on/after 2026-07-31 carries `us.anthropic.claude-sonnet-5` (58 rows), and
the bare `claude-sonnet-5` appears only through 2026-07-29 (630 rows).

This class of drift does not fail loudly -- the rows simply stop joining
ai_models and the cost UI reads $0, which is bug #1083 all over again. All
three id forms are already priced by migration 092 and all three are inactive,
so no cost was actually lost; the constants were just describing the wrong
world, and the next reader would have trusted them.

Changes:
  - AGENT_MODEL_ID -> `us.anthropic.claude-sonnet-5`, with the verification
    recorded in the doc comment and the reason the two constants are now equal
    (they stay separate: they are distinct concepts and the next provider swap
    can split them apart again).
  - New AGENT_MODEL_ID_ALIASES listing every id form the harness model has ever
    been recorded under.
  - getPricableModels() excludes the whole alias list instead of only the
    current id, so a re-activated historical alias cannot reappear as a
    projection candidate against the harness itself. Replaces ne() with
    notInArray().
  - agentcore_wrapper.py DEFAULT_AGENT_MODEL_ID follows, keeping the existing
    cross-deployable drift guard meaningful; its comment now names the silent
    failure mode.

Two new drift-guard tests: every alias must be priced by migration 092 (the
cost UI aggregates across the whole date range, so an unpriced historical alias
silently zeroes those rows), and the alias list must contain both the recorded
and request ids.
…en turns

The 2026-07-31 telemetry outage was invisible for ten days for one structural
reason: once a broken usage read reaches agent_messages, a zero is
indistinguishable from an honest zero. There was no persisted signal and no
alarm for "capture returned nothing", so the Cost tab read $0 and nothing
anywhere disagreed.

The wrapper already COMPUTED the answer. agentcore_wrapper.usage_capture_is_complete
produced a per-turn `usage_capture_complete` boolean, and metadata carried it --
but only infra/agent-image/eval consumed it. The router never read it and the
schema had nowhere to put it, so every production turn threw it away. This wires
that existing signal through to storage and adds the alarm.

Migration 176 -- agent_messages.usage_capture_complete BOOLEAN, nullable:
    TRUE  = usage was positively measured this turn.
    FALSE = the read did not complete; the token columns are a FLOOR, not a
            total. This is the outage signature.
    NULL  = unknown: the row predates the column, or the reporting image does.
  Nullable is deliberate. A DEFAULT TRUE would assert that ten days of all-zero
  rows were fully measured; a DEFAULT FALSE would brand all history suspect.
  Neither is true. Added to migrationFiles; files 001-005 untouched; re-runnable
  (ADD COLUMN IF NOT EXISTS). Partial index on the FALSE case only, since that is
  the only value anything queries for and a full index would be dead weight on
  the insert path.

Router (infra/lambdas/agent-router/index.ts):
  - AgentCoreResult.usageCaptureComplete: boolean | null, threaded through
    agentResultTelemetry -> TelemetryParams -> normalizeTelemetryParams -> the
    agent_messages INSERT.
  - Parsed as `typeof metadata.usage_capture_complete === 'boolean' ? ... : null`
    rather than `=== true`. An image that omits the field is UNKNOWN, not
    incomplete; collapsing absent to false would brand every turn from an older
    image a capture failure and bury the real signal.
  - normalizeTelemetryParams uses `?? null`, not `|| null`, so a legitimate
    false survives.
  - A router-side failure records null, not false: no usage read was attempted,
    so it must not look like the transcript regression this column exists to
    surface.

Alarm (UsageCaptureZero, PSD/AgentPlatform/<env>):
  - New agentcore_wrapper.usage_capture_looks_broken() fires only on the
    arithmetically impossible case -- a SUCCESSFUL turn that made at least one
    model call yet reports zero across all four token counters. Failed turns and
    zero-model-call turns are excluded so the signal stays actionable. An
    unparseable counter counts as broken rather than as zero.
  - Emitted via emit_agent_metric (put_metric_data), because the AgentCore log
    group name carries a runtime-generated suffix that a CDK MetricFilter cannot
    attach to -- the same escape hatch BootOk/BootTruncationWarn use. Also logs
    a USAGE_CAPTURE_ZERO warning line.
  - UsageCaptureZeroAlarm in agent-platform-stack, threshold 5-in-5-min: a rare
    edge turn can trip the heuristic once, but a BROKEN capture path trips it on
    every turn, so a real regression clears the threshold in a single period.
    Joins the existing iterationAlarms SNS wiring.

VERIFIED, not assumed: `cdk synth AIStudio-AgentPlatformStack-Prod
--context alertEmail=...` emits the alarm with AlarmActions pointing at
AgentAlarmTopic. NOTE FOR DEPLOY: without --context alertEmail=<address> the
topic is not created and this alarm -- like all 15 existing agent-platform
alarms -- evaluates correctly and notifies nobody. The stack already emits a
synth warning for that case.

Tests: 7 new cases for usage_capture_looks_broken, including the verbatim outage
shape (model_call_count=10 with all-zero counters) and the healthy cache-only
turn (2 input tokens against a 65,301-token cache read) that must never be
mistaken for it. CDK assertion that the alarm ships with the right
metric/threshold/comparison and an SNS action.
…ge telemetry

The ten days of all-zero agent_messages rows are recoverable: the turns were
fine and the transcripts survive in the S3-checkpointed workspaces. This adds
the backfill, plus docs for the whole outage.

NO WRITE HAS BEEN EXECUTED. Dry-run is the default and writing requires explicit
per-run approval, per the standing "never write to Aurora directly" rule.

TURN ATTRIBUTION IS THE HARD PART, and the reason the logic is a separate pure
module. agent_messages.session_id is a PSD sessionKey (agent-chat-<sha256>), NOT
the OpenClaw transcript UUID. The mapping was verified against a real
checkpointed database:

    session_windows.session_key = "agent:main:" + agent_messages.session_id

Up to 8 rows (turns) share one sessionKey and the transcript is append-only
across all of them, so summing a session into one row would inflate it by the
entire session history -- the same mistake the live adapter's since_ms window
exists to prevent. Each turn's window is reconstructed from the row timestamps:

    turn k = ( created_at[k-1], created_at[k] ]
    turn 1 = ( session start,   created_at[1] ]

Sound because turns in a session are strictly serial: the router inserts a row
only after the wrapper finishes the turn, and turn k cannot start until k-1 ends.
Windows are half-open at the start so a boundary record bills to exactly one
turn. A sessionKey with several windows (rollover/compaction/fork) has them all
merged first, so a mid-session rollover cannot drop the pre-rollover calls.

Safety properties:
  - Dry-run default; writing needs BOTH --execute and
    --confirmation=BACKFILL_AGENT_USAGE, so no single typo mutates telemetry.
  - Idempotent: every UPDATE re-asserts "all four token counters are still zero"
    in its WHERE clause. The guard is enforced by the DATABASE at write time, not
    by the plan computed earlier, so the run is safe to interrupt and safe to
    repeat; a second run updates 0 rows and a row the fixed live capture has
    since populated is never clobbered.
  - Only fills zero rows -- it cannot overwrite a real measurement.
  - Prints reconciliation (transcript totals vs planned totals vs unattributed
    model calls) BEFORE any write. Records outside every window are reported,
    never forced into the nearest turn, because inventing attribution would
    corrupt per-turn cost.
  - Fails fast when migration 176 is absent, instead of emitting one opaque SQL
    error per workspace that never names the prerequisite.
  - One unreadable workspace is logged and skipped, not fatal.

A turn whose covering records carry no usage themselves is reported
UNRECOVERABLE rather than written as zeros-over-zeros -- that is what the
pre-2026-07-29 rows look like, and claiming to have fixed them would be a lie.

FUNCTIONALLY VERIFIED against real dev data, not just unit-tested. A dry run
over psd-agents-dev-390844780692 (--since=2026-07-25) plans 57 recoverable rows
carrying input=651,790 output=51,724 cacheRead=10,207,165 cacheWrite=2,600,380,
with 2 rows correctly classified unrecoverable and 6 unattributed calls
(records past the newest row, plus background sessions that have no telemetry
row by design). Recovered rows show the caching pattern the outage hid, e.g.
input=2 output=473 cacheRead=54,256.

Two bugs that only a real run could find, both fixed:
  - `immutable=1` is REQUIRED to open a checkpointed transcript. The file is in
    WAL mode but arrives without its -wal/-shm sidecars, and a plain read-only
    open must build the -shm, which read-only forbids ("unable to open database
    file"). Safe here -- a static snapshot in a private temp dir with no
    concurrent writer -- and explicitly NOT used by the live adapter, which reads
    a database the runtime is actively writing.
  - The -wal sidecar is deliberately not fetched at all: pairing a current
    .sqlite with a stale uploaded -wal can surface a torn state. Reading the main
    file alone may miss the newest uncheckpointed turns, which under-counts
    rather than corrupting -- the right direction to fail.

Also:
  - scripts/db/bun-sqlite.d.ts: a narrow ambient declaration for bun:sqlite.
    Adding bun-types to tsconfig would override the global fetch/DOM types for
    the whole tsc program and break unrelated DOM-typed tests (see
    docs/learnings/build-errors/2026-06-29-bun-types-triple-slash-tsc-dom-pollution.md);
    this is the contained alternative that learning points to.
  - createIterationMonitoring split: the new alarm pushed it past
    max-lines-per-function, so the log-derived metric filters moved into
    createIterationMetricFilters. Behaviour-preserving -- verified by diffing
    `cdk synth` before and after: identical logical IDs, no resource
    replacement, only synth nonces differ.
  - docs/operations/agent-usage-telemetry-backfill.md documents the outage, the
    fix, why ORDER BY seq (not created_at), the attribution model, how to read
    the report, and the alertEmail deploy caveat.

Tests: 22 cases over the pure module, covering boundary records billing to
exactly one turn, session history never collapsing into one row, seq-vs-window
completeness, unrecoverable classification, unattributed counting, the real
transcript record shape, and the confirmation-token gate.
The created_at >= since_ms prefilter rests on created_at never preceding the
record's own message.timestamp. The obvious way that could break is the
2026.7.2-beta.5 import stamping migrated records with the migration time.
Checked rather than assumed: the same database holds events from 2026-07-27,
before its 2026-07-30 import, and no row has a skew above 60s -- the import
preserved the original timestamps.

Also notes why this is not load-bearing anyway: a too-large created_at only
widens the prefilter, so the record-timestamp filter still decides. That is the
reason the authoritative window test was deliberately NOT pushed into SQL.
…events

Found in self-review: the fallback was keyed on the transcript database FILE
existing, not on the transcript_events TABLE existing. Those differ on every
OpenClaw host older than 2026.7.2-beta.5 -- openclaw-agent.sqlite already existed
there for other state, while transcripts were still per-session JSONL.

On such a host the reader would open the database, fail with 'no such table',
retry six times, and report zeros -- reintroducing the exact silent-zero
regression this change set exists to fix, just for older hosts instead of newer
ones. The repo explicitly supports harness candidates pinned to another host, so
this is a live path, not a hypothetical.

Adds TranscriptTableMissing and a sqlite_master probe to raise it. The probe is
needed because a missing table and a locked table BOTH surface as
sqlite3.OperationalError from a plain SELECT, yet the remedies are opposite: a
lock clears on retry, a missing table never will. _settle_usage keeps absorbing
read failures (telemetry must never break a turn) but lets this one through,
because it is a routing signal rather than a failure.

Also drops a dead local left over from extracting the containment helper.

Tests: an older-host database plus a JSONL transcript now reads the JSONL
(31 input tokens, not 0); the same database with no JSONL returns zeros
immediately instead of paying the bounded retry on every turn; and the missing
table is asserted to raise TranscriptTableMissing rather than be mistaken for a
lock. 776 passed in the agent-image suite.
Copilot AI lite review requested due to automatic review settings August 11, 2026 01:58
@krishagel krishagel self-assigned this Aug 11, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

dev took 176 for the OpenClaw OAuth client redirect repair (#1629) while this
branch was open. Renumbers the migration file and every reference to it so the
numbering stays collision-free and migrationFiles can hold both.
…-token-cache-b2aef3

# Conflicts:
#	infra/database/migrations.json

@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: dfa89a4794

ℹ️ 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 thread scripts/db/backfill-agent-message-usage.ts Outdated
Comment thread lib/agents/usage-backfill.ts Outdated
Comment thread lib/agents/usage-backfill.ts Outdated
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review

Thorough, well-documented fix for the telemetry outage — the investigation depth (verifying created_at/seq ordering, skew bounds, and schema against a real checkpointed database rather than assuming) and the test coverage (Python SQLite/JSONL paths, TS backfill attribution logic, CDK alarm wiring) are excellent. A few notes:

Nit: misnamed function in the backfill script

scripts/db/backfill-agent-message-usage.ts:459

async function requireMigration176(): Promise<void> {
  ...
  log.fail(
    "agent_messages.usage_capture_complete is missing — migration 177 has " +
    "not been applied to this database. ..."
  );

The function is named requireMigration176 but it checks for the column added by migration 177 (usage_capture_complete), and its own error message correctly says "migration 177." Purely cosmetic (it's not referenced anywhere else and works correctly), but worth a rename to requireMigration177 to avoid confusing a future reader who greps for "176".

Things I verified and did not find issues with

  • Bool-as-token bug fix: _fold_usage_records now excludes bool (an int subclass) from token accumulation on both the SQLite and JSONL paths since they share the same fold function — good, this was a real latent bug in the pre-PR JSONL-only code.
  • created_at >= since_ms prefilter vs. ORDER BY seq: the reasoning for using seq instead of created_at for ordering (since completeness depends on the last record and the two aren't strictly correlated) checks out against the described real-database findings, and there's a dedicated regression test (test_completeness_follows_seq_not_created_at) pinning it.
  • Path containment / traversal: _contained_transcript_path correctly separates the dot-segment rejection (stops agent_id="..") from the realpath-containment check (stops symlink escapes) — the tests explicitly verify both are needed independently, which is a good defense-in-depth pattern to lock in with tests.
  • mode=ro vs. immutable=1: the live adapter correctly avoids immutable=1 (would risk a torn read against the actively-written WAL DB) while the offline backfill script correctly requires it (checkpointed snapshot with no -wal/-shm sidecars). This distinction is easy to get backwards and it's handled correctly in both places, with tests only for the adapter path — reasonable since the backfill script is I/O-only per its own header comment.
  • Idempotency of the backfill UPDATE: the all-zero guard is enforced in the SQL WHERE clause itself (not just the precomputed plan), so a concurrent live-capture write or a second run can't double-write. Verified against the PR's dry-run/idempotency evidence.
  • AGENT_MODEL_ID_ALIASES / cost projection: excluding all three historical id forms (not just the current one) from getPricableModels() closes a real gap — a reactivated alias would otherwise silently reappear as a self-projection candidate.
  • Nullable usage_capture_complete semantics: the three-state handling (true/false/null) is threaded consistently from the wrapper → router → schema → UI-adjacent test assertions (?? used instead of ||/=== where absence must not collapse to false).

Turn-attribution edge case (not a blocker, worth being aware of)

assignTurnWindows in lib/agents/usage-backfill.ts builds windows as (created_at[k-1], created_at[k]]. If two agent_messages rows for the same session ever share an identical created_at (millisecond collision), the earlier row gets a zero-width window (x, x] that can never match any transcript record (since timestampMs > x && timestampMs <= x is never satisfiable), so that row would land in unmatchedRowIds as "unrecoverable" even if transcript coverage technically exists. Given created_at is a timestamptz column and turns are serialized by the router, this is likely rare-to-nonexistent in practice, and the failure mode is safe (reports as unrecoverable rather than misattributing), so this is just a note rather than something to fix now.

Test coverage

Coverage looks comprehensive for the risk surface (path traversal, WAL locking/corruption degradation, completeness/ordering, turn-window attribution, idempotent writes, migration guard). No gaps stood out.

No security concerns — all SQL is parameterized (both the RDS Data API calls and the SQLite queries), the read-only mode=ro URI is well-reasoned, and the path-traversal containment has explicit regression tests.

…mestamps

The backfill reconstructed each turn's window from agent_messages timestamps:
turn k covered (created_at[k-1], created_at[k]]. That model is wrong. The router
releases the session lock in the finally of its invocation wrapper but does not
insert the telemetry row until after the Google Chat response is sent, so the
next turn can begin and append transcript records BEFORE the previous turn's row
is stamped. The windows overlap and model calls get billed to the wrong row.
Writing confidently wrong per-turn cost is worse than writing none.

Replace windows with segmentation over the transcript's own turn structure, and
use the rows only for order and count:

  1. Walk a session's records in append order, cutting a segment after each
     record whose stopReason is terminal (stop / end_turn). OpenClaw writes
     toolUse on every call that hands off to a tool, so a terminal reason is
     precisely the end-of-turn marker.
  2. Drop a trailing segment with no terminal reason -- an in-flight or aborted
     turn, which has no row yet. Keeping it would shift the pairing.
  3. Pair segment k with row k, rows ordered by created_at.
  4. If the counts disagree, attribute NOTHING for that session and report it.
     A mismatch means the model of the session is wrong, and guessing would
     silently misprice turns.

Step 4 is also what contains the two remaining ways attribution could be
corrupted, both surfacing as a reported count mismatch rather than a bad number:
a duplicate agent_messages row (the router's INSERT carries no idempotency key,
unlike its agent_sessions upsert), and a terminal record with no usage object
(dropped at parse time, so its segment never gets cut).

Two consequences that required call-site changes:

  - Pairing needs EVERY row of a session, including already-populated ones. A
    populated row still consumes a segment, so the SELECT no longer filters to
    zero rows -- filtering there would slide every later segment onto the wrong
    row. planSessionBackfill alone decides which rows are writable, and a
    populated row is left strictly alone.
  - --since is applied to the resulting UPDATES, never to the query. Removing a
    session's earlier rows would delete the anchors that establish its turn
    order. New restrictPlanToRowsSince does this after pairing and recomputes
    plannedTotals.

Also make argument parsing fail closed. A malformed --since previously degraded
to "no filter", which would widen a production run from the outage window to ALL
history -- the opposite of what a mistyped date asked for. Any unparsed argument
(bad date, empty --prefix, unrecognized flag) is now collected into
BackfillArguments.errors and aborts before the environment and migration probes
run, so a malformed command never even connects. The --execute-without-token
check moved into the same gate.

New reporting: turnCountMismatches counts skipped sessions, and
unattributedModelCalls now means "records no paired segment claimed" rather than
"records outside every window".

Tests rewritten against the segmentation model, pinning the behaviours that a
regression would silently break: a populated row consumes its segment without
being touched, a count mismatch attributes nothing, a trailing in-flight turn is
reported as unattributed, and a novel or absent stopReason is never terminal.
…e last usage

Two defects in the transcript usage read, both found by review of the SQLite
migration work.

1. Completeness was decided by the wrong record.

_fold_usage_records documented that "complete" is set by the newest in-window
assistant record carrying a terminal stopReason. The code actually set it from
the newest record carrying a usage dict: records without usage were skipped by a
continue placed BEFORE the stopReason was read. So a turn whose terminal record
has no usage object had its verdict decided by the preceding toolUse call, and
reported incomplete despite having finished.

That is not cosmetic. It pays the full settle-retry budget on a turn that was
already done, and it writes FALSE into agent_messages.usage_capture_complete,
which per migration 177 means "these token columns are a floor, not a total" --
manufacturing the exact broken-capture signature the UsageCaptureZero alarm
exists to catch, on turns that captured everything correctly.

Completeness is now decided independently of usage: any in-window assistant
record carrying a stopReason updates the verdict, and records with no stopReason
at all leave it untouched rather than clearing it, so a trailing non-model-call
record cannot un-finish a finished turn either. The in-window test moved ahead of
the usage test so an out-of-window record can never reach the verdict and report
a previous turn's ending as this turn's. Token accumulation is unchanged and
still requires a usage dict, so a usage-less record adds no tokens and is not
counted as a model call.

2. The success-path read sat outside every exception boundary.

_read_turn_usage promises "returns zeros with capture_complete=False on any
failure; telemetry must never break a chat turn", but it only handled
sqlite3.OperationalError and sqlite3.DatabaseError internally. The success-path
call site runs after the WebSocket try/except has already closed, and neither
process() nor the wrapper's executor await wraps it, so any other exception --
an OverflowError out of an absurd ISO timestamp, or any future non-defensive
edit to the fold -- would propagate and discard a reply the model had already
produced. Worse, that turn's tool calls have run, so a retry at any higher layer
would re-run their side effects, the precise outcome _should_retry_upstream is
written to avoid.

Fixed on the callee rather than at each call site, so the guarantee is intrinsic
and cannot be reintroduced by moving a call: the implementation is now
_read_turn_usage_unguarded, and _read_turn_usage is a thin wrapper that catches
Exception, logs it, and returns the zeroed result. Both call sites (success path
and the chat-error path) inherit it.

Tests added for all four behaviours: a terminal record without usage completes
the turn, a record with no stopReason does not un-finish it, a previous turn's
terminal record cannot complete this one, and a non-sqlite exception forced out
of the fold degrades to zeros instead of escaping.

Also close a filesystem-facade gap this work introduced: mkdtemp and mkdtempSync
were in neither READ_METHODS nor WRITE_METHODS, so validatedFs passed them
straight through with no path validation. The backfill script's temp-dir call is
safe only because its prefix is hardcoded; adding both to WRITE_METHODS confines
the target like any other write, so a future caller with a dynamic prefix stays
inside the allowed roots.
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review: restore token-usage telemetry after OpenClaw JSONL→SQLite move

Reviewed the full diff (20 files, ~3020 additions). This is a well-tested, carefully documented fix for a real 10-day silent-telemetry outage — the shared _fold_usage_records() fold, the bool-guard fix, the read-only SQLite URI construction, and the migration all look sound. A few notes below, nothing blocking.

Bugs/Correctness

1. _settle_usage()complete not initialized before the retry loop (PLAUSIBLE, likely harmless)
infra/agent-image/harness_adapter.py, _settle_usage():

totals: Dict[str, int] = {"input": 0, "output": 0, "cache_read": 0,
                            "cache_write": 0, "model_calls": 0}
for attempt in range(self.USAGE_SETTLE_ATTEMPTS):
    try:
        totals, complete = read()
    except sqlite3.OperationalError as exc:
        ...
        complete = False
    except sqlite3.DatabaseError as exc:
        ...
        break
    if complete:
        return {**totals, "capture_complete": True}
    ...

totals is seeded before the loop but complete is not. If read() raises sqlite3.DatabaseError on the very first attempt, the break fires before complete is ever assigned in that branch. If anything after the loop references complete (e.g. return {**totals, "capture_complete": complete}), that's an UnboundLocalError. I wasn't able to fetch the exact post-loop return statement in this environment (network/tool access for direct file retrieval was blocked), so I can't confirm this is live — and if _read_turn_usage's outer blanket except Exception degrades to zeros anyway, it'd currently be masked and harmless in production, just producing a less precise log line than intended. Worth a quick look: initialize complete = False alongside totals before the loop so the corrupt-DB fast-break path is self-contained regardless of what wraps it.

2. Stale comment on the OperationalError branch (minor, CONFIRMED)
The comment says "Locked/busy database, or a host whose schema predates transcript_events" — but a missing table is raised as a distinct TranscriptTableMissing via the sqlite_master probe, not OperationalError. Looks like a leftover from an earlier iteration of the error-splitting design; doesn't affect behavior (the OperationalError-before-DatabaseError ordering is correct, since OperationalError subclasses DatabaseError), just worth tidying so it doesn't mislead the next reader.

Security

No issues found. _sum_sqlite_transcript_usage() builds the read-only URI via pathlib.Path(db_path).as_uri(), which percent-encodes ?/#, so a path containing those characters can't smuggle extra URI params past the appended ?mode=ro. session_id is passed as a bound parameter, never interpolated into SQL. _contained_transcript_path() enforces containment for both the SQLite and JSONL paths before either file is opened. validated-fs.ts's new mkdtemp/mkdtempSync guard is scoped correctly and doesn't appear to regress the other mkdtemp call sites in lib/agent-github/command-executor.ts / lib/agent-workspace/command-executor.ts, which use node:fs/promises directly rather than this helper.

Performance

No concerns. Re-opening the SQLite connection per settle attempt mirrors the prior JSONL design's per-attempt reopen, operates on a local per-container file, and is bounded by USAGE_SETTLE_ATTEMPTS.

Test Coverage

Coverage is thorough on both sides — bool-vs-int, corrupt DB, locked DB, missing table vs. missing file, path containment/.. traversal, JSONL/SQLite precedence, and turn-segmentation edge cases are all directly tested in test_harness_adapter.py / test_iteration_telemetry.py, and usage-backfill.test.ts / platform-model.test.ts cover the TS side. One gap: the idempotency of scripts/db/backfill-agent-message-usage.ts's applyUpdate() (the WHERE input_tokens = 0 AND ... guard that makes re-running safe) doesn't appear to have an automated regression test — the PR description notes it was checked manually. Reasonable for an ops script gated behind explicit --execute/confirmation flags, but a future refactor of that function would have no safety net.

Code Quality/Style

  • No any types in the new/changed TS.
  • scripts/db/backfill-agent-message-usage.ts logs via a proper logger, not console.*.
  • ?? is used correctly (not ||) where usageCaptureComplete/similar booleans are threaded through defaults, so false isn't accidentally collapsed to a default.
  • Migration 177-agent-usage-capture-complete.sql is additive only (ADD COLUMN IF NOT EXISTS, new partial index) and migrationFiles in infra/database/migrations.json is appended correctly, consistent with the immutable-migrations rule.

Nice work tracking down a genuinely subtle silent-failure bug (zero telemetry reads being indistinguishable from real zeros) and building both the fix and a verified backfill path for the affected window.

Two review notes on _settle_usage, both addressed.

The comment on the OperationalError branch claimed it also covers "a host whose
schema predates transcript_events". That is no longer true and was a leftover
from an earlier iteration of the error split: a missing table is raised as
TranscriptTableMissing by the sqlite_master probe specifically so the retryable
case (locked or busy) and the fall-back-to-JSONL case stay distinguishable.
Corrected, and the reason the distinction exists is now stated where the next
reader will need it.

The reviewer also flagged that "complete" was not seeded before the retry loop,
and could not confirm from their environment whether that was live. It is not:
the corrupt-database branch breaks out before "complete" is read, and the
post-loop return uses a literal False rather than the variable, so no
UnboundLocalError is reachable today. Seeded it anyway, alongside totals. The
cost is one line, and it makes the fast-break path self-contained rather than
correct-by-coincidence -- a future edit returning "complete" instead of the
literal would otherwise fail on the single hardest path to reach in testing.

No behaviour change; 171 agent-image tests still pass.

Copy link
Copy Markdown
Member Author

🤖 pr-fix routine round 1 complete.

  • Review comments addressed: 0 new code changes needed — all 5 outstanding findings were already fixed in the current head
  • CI failures fixed: 0 (all 8 checks were already green)
  • Commits pushed: 0
  • Review threads resolved: 3

What I did: re-verified every open finding against the current head (966dd7e) rather than trusting the PR description, then resolved the three stale Codex threads with a pointer to the fixing commit.

Finding Raised on Status
Codex P1 — retain noncandidate rows as anchors dfa89a4 Fixed in 6c1d057loadCandidateChunk returns every row (return rows.map(toCandidateRow)), no date predicate in the SELECT
Codex P1 — derive turn windows from invocation boundaries dfa89a4 Fixed in 6c1d057assignTurnWindows removed entirely, replaced by segmentTurns + count-mismatch refusal
Codex P2 — reject malformed --since dfa89a4 Fixed in 6c1d057parseBackfillArguments collects errors[]; requireUsableArguments exits before any connection
claude[bot] — requireMigration176 misnamed dfa89a4 Already requireMigration177 (scripts/db/backfill-agent-message-usage.ts:472)
claude[bot] — complete unseeded before settle loop pre-966dd7e Fixed in 966dd7eharness_adapter.py:1041

Marking pr-fix-done. CI is green, no reviewer is blocking, and no review thread is left unresolved. The label clears automatically on the next commit or comment, so the routine will re-check if anything changes.

Two items in the PR description remain deliberately open and are not routine-actionable — the pre-existing router INSERT idempotency gap and the symlinked-directory containment limitation, both explicitly scoped out by the author. The post-deploy steps (rebuild the agent image so agentImageDigest updates, re-run the backfill dry run and read turnCountMismatches before any --execute) are human calls.


Generated by Claude Code

@krishagel krishagel added the pr-fix-done pr-fix routine processed and PR is clean label Aug 11, 2026 — with Claude
@krishagel
krishagel merged commit faa2a4c into dev Aug 11, 2026
14 checks passed
@krishagel
krishagel deleted the claude/admin-dashboard-token-cache-b2aef3 branch August 11, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-fix-done pr-fix routine processed and PR is clean

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants