Skip to content

agy-cli 0.1.20: per-step SQLite model usage and agy 1.5.1+ promptUsage accounting - #983

Open
tungloong wants to merge 1 commit into
magpie-community:mainfrom
tungloong:feat/agy-cli-step-usage
Open

tungloong wants to merge 1 commit into
magpie-community:mainfrom
tungloong:feat/agy-cli-step-usage

Conversation

@tungloong

@tungloong tungloong commented Nov 11, 2026 •

Copy link
Copy Markdown

What

Fixes two usage-reporting mismatches observed on agy 1.5.1+ (verified on agy 1.5.5):

  1. ACP session/prompt inputTokens shape (agy 1.5.1+):
    agy 1.5.1+ reports result.usage.inputTokens as the uncached prompt tokens alone, while cachedReadTokens and cachedWriteTokens are separate and totalTokens sums all prompt and completion buckets (e.g. 2,133 uncached input + 28,610 cache read + 6 output = 30,749 total). Treating inputTokens as inclusive of cachedReadTokens truncated cache_read_input_tokens to Math.min(inputTokens, cachedReadTokens) (0 uncached input, 2,133 cache read) and clamped recordPromptUsage's learned lastCacheRatio to 1.0 whenever cachedReadTokens > inputTokens.

  2. Per-step model usage across multi-step tool loops:
    ACP reports accumulated usage only when session/prompt ends (agy 1.5.x emits no usage_update notifications), while each Google model call is recorded in ~/.gemini/antigravity-cli/conversations/<sessionId>.db (gen_metadata in agy 1.5.1+, steps with step_type = 15 in 1.4.x–1.5.0). Reading those completed rows settles intermediate and final HTTP steps from agy's actual model calls instead of character-based approximations, with these real-world invariants enforced:

    • Strictly read-only, zero file-handle leaks, zero sidecar writes on checkpointed stores: step-usage.mjs prefers bun:sqlite under Bun (constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) with stmt.finalize() before db.close() (releasing Windows file locks immediately without waiting on GC) and timeout: 0 (no synchronous busy sleep), never opening a read-write handle, and resolves paths via path.resolve() so relative file:... paths are never misinterpreted as SQLite URIs under SQLITE_OPEN_URI. When a WAL-flagged store (header[18..19] = [2, 2]) has neither -wal nor -shm on disk after agy exits, opening ${immutableUri(resolved)} (?immutable=1, formatting Windows \\... UNC / extended-length paths without a non-empty URI authority) keeps checkpointed reads sidecar-free across macOS, Linux, and Windows (avoiding SQLITE_CANTOPEN under Apple's system libsqlite3 and -wal/-shm creation under upstream SQLite), re-reading against the resolved live file path if -wal appears concurrently; on uncheckpointed WAL stores, s.db and s.db-wal remain byte-identical while ordinary SQLite reader -shm WAL-index behaviour applies.
    • Cross-turn isolation & non-answering store memory: if a turn cancels, hits SQLITE_BUSY, or falls back without reconciling against ACP's total (including .pb cold starts or incompatible schemas), Proc sets stepUsageTrusted = false so late-arriving rows from an earlier turn cannot be billed to a later turn and subsequent turns do not spend 1,000ms polling a store that never answers.
    • Multiple MCP tool calls from one generation vs slow commits: when a single LLM generation emits multiple tool calls, agy dispatches MCP tools/call sequentially without invoking the model between them. measuredUsage() settles same-generation continuations at 0 tokens (agy_same_generation) after 75ms only when an earlier step in the turn already measured a generation, no new message/thought output arrived, and no pending pre-send row exists; otherwise it waits up to 1,000ms (stopping immediately on SQLITE_BUSY / unreadable store).
    • Cold-start .pb fallback on clipped initial replays: when a fresh agy process starts a session before its SQLite trajectory flag initializes and writes <sessionId>.pb (leaving .db with 0 model rows) on an initial replay clipped by maxInitialPromptBytes() (112 KiB) / HISTORY_BLOCK_CLIP_BYTES (12 KiB per block), Proc.omittedHistoryTokens subtracts the clipped-out history tokens from fallback window estimates so responseUsage() stays bounded by what actually reached agy.

Measured

Across a continuous agy 1.5.5 workload on macOS (39 conversations, 210 /v1/messages responses, 0 request errors), 38 conversations (193 HTTP responses = 139 model-backed + 54 same-generation continuations) recorded SQLite generation rows (140 generations serving those 193 responses):

Metric SQLite ground truth (140 generations) This PR (0.1.20) HTTP usage (193 responses) Delta (HTTP − SQLite)
Uncached input_tokens 1,847,973 1,845,391 -2,582
cache_read_input_tokens 8,192,420 8,109,150 -83,270
output_tokens (incl. thinking) 134,291 134,291 0
Cache hit ratio (read / (in + read)) 81.59% 81.46% -0.13%
  • 138 / 139 model-backed HTTP responses match their single SQLite generation (input_tokens, cache_read_input_tokens, output_tokens) tuple with 0 token difference.
  • The remaining 1 / 139 response spanned two internal agy generations inside one HTTP turn (in: 2,582, read: 83,270, out: 453 followed by in: 2,355, read: 83,803, out: 115); measuredUsage() reports the latest call's context window (2,355 / 83,803, accounting for the exact -2,582 / -83,270 prompt delta) and the exact sum of both outputs (568), giving 0 output_tokens delta across all 193 responses.
  • All 54 same-generation sequential MCP tool continuations settled at 0 tokens (agy_same_generation).
  • The remaining 1 / 39 cold-start conversation (17 tool_use responses on a ~375K-token request body clipped to maxInitialPromptBytes() 112 KiB) wrote <sessionId>.pb with 0 SQLite rows before agy's trajectory flag initialized, motivating the omittedHistoryTokens fallback bound.

The change

  • packages/agy-cli/step-usage.mjs: strictly read-only reader (bun:sqlite preferred with SQLITE_OPEN_READONLY | SQLITE_OPEN_URI and finalized statements, node:sqlite fallback with timeout: 0, path.resolve() normalization, and ?immutable=1 for closed checkpointed WAL stores including Windows \\... UNC/extended paths and live re-read if -wal appears concurrently) for gen_metadata (agy 1.5.1+) and steps step_type = 15 (1.4.x–1.5.0); Protobuf layout credited to lili-luo/dsh-plugin-bundle (MIT) and SQLite reader adapted from DylanLIiii/opencode-antigravity-bridge (MIT). Controlled by AGY_BRIDGE_STEP_USAGE (default on; 0/false/off disables) and AGY_BRIDGE_CONVERSATIONS_DIR.
  • packages/agy-cli/index.mjs:
    • promptUsage(usage): distinguishes exclusive (agy 1.5.1+) vs inclusive (< 1.5.1) inputTokens via totalTokens, used across translateUsage, responseUsage, recordPromptUsage, and measuredUsage.
    • measuredUsage(turn, step, content, result) & settleUsage(...): settles per-response usage from step-usage.mjs when reconcilable with ACP's total, handles agy_same_generation 0-token continuations vs pending / text-bearing slow commits, aborts immediately on SQLITE_BUSY, and marks proc.stepUsageTrusted = false on cancelled or fallen-back turns.
    • Proc.omittedHistoryTokens / Proc.estimatePromptTokens(body): bounds fallback estimates when initial history replay is clipped by maxInitialPromptBytes() / HISTORY_BLOCK_CLIP_BYTES.
  • packages/agy-cli/package.json: bump version to 0.1.20 (rebased onto main after #980 and #989) and include step-usage.mjs in files.

Tests

  • 3-OS verification (Linux, Windows, macOS):
    • Linux (ubuntu-latest, Bun 1.4.4, Node v24.22.2): HOME=$(mktemp -d) bun test packages/agy-cli 14 pass, 0 fail (test/step-usage.mjs 10/10, test/usage-settlement.mjs 33/33), node --test packages/agy-cli/test/step-usage.mjs 10/10, full repo HOME=$(mktemp -d) bun test 8386 pass, 6 skip, 0 fail, bun scripts/check.mjs ok.
    • Windows (windows-latest, Bun 1.4.4, Node v24.22.2, fresh temp HOME/USERPROFILE): bun test ./packages/agy-cli/test/step-usage.mjs 10/10 (70.21ms on EXCLUSIVE-locked store, \\?\ extended/UNC + #/%20 paths & strict fs.rmSync ok), node --test ./packages/agy-cli/test/step-usage.mjs 10/10, bun test ./packages/agy-cli/test/usage-settlement.mjs 33/33, all other agy-cli suites green (smoke.mjs only has the known #973 check, 333/334), bun scripts/check.mjs ok.
    • macOS (macos-latest arm64, Bun 1.4.4, Node v24.22.2 + local arm64 Bun 1.4.2, Node v26.2.0): HOME=$(mktemp -d) bun test packages/agy-cli 14 pass, 0 fail, node --test packages/agy-cli/test/step-usage.mjs 10/10, bun scripts/check.mjs ok, npm pack --dry-run includes step-usage.mjs in 0.1.20 tarball.
  • Broken by hand (each mutated individually and restored):
    • pending = true removed in step-usage.mjs -> red in both test/step-usage.mjs and test/usage-settlement.mjs;
    • over-count guard (prompt > expected + max(16, 1%)) removed in index.mjs -> red in test/usage-settlement.mjs;
    • "latest call's prompt" (last.input / last.read) changed to sum across steps -> red in test/usage-settlement.mjs;
    • isCheckpointedWal (?immutable=1) forced false -> red in test/step-usage.mjs under both Bun (SQLITE_CANTOPEN on macOS, sidecars on Linux/Windows) and Node (-wal/-shm sidecars created);
    • concurrent -wal re-read removed in withStore -> red in test/step-usage.mjs under both Bun and Node;
    • no--wal catch guard removed in withStore -> red in test/step-usage.mjs under both Bun and Node;
    • path.resolve() removed in resolveStorePath -> red on relative file:... path under both Bun and Node;
    • stepUsageTrusted = false removed -> red on both cross-turn late-row isolation and .pb Turn 2 latency;
    • !batch break removed in measuredUsage -> red on EXCLUSIVE-locked SQLITE_BUSY latency (>= 1000ms);
    • !sawOutput removed from canShareGeneration -> red when text precedes tool_use with a >75ms gen_metadata commit.

Existing work reviewed

@tungloong
tungloong force-pushed the feat/agy-cli-step-usage branch 2 times, most recently from b8d82e5 to 6fcc909 Compare November 11, 2026 09:56
@tungloong
tungloong marked this pull request as ready for review November 11, 2026 10:04

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this. The promptUsage fix is right and well guarded, and reading gen_metadata / steps is a real improvement over estimates. Reviewed at 6fcc909, every line, and run on Linux (WSL, Bun 1.4.2 / Node 22.23) and Windows. A few things have to change before it can merge.

Must fix

  1. Rebase on main and take 0.1.19. main now has agy-cli 0.1.18 (9ec6962, #980: cloak.mjs). This PR also says 0.1.18, and index.mjs conflicts on the import line. Merged by hand (both imports kept), bun test packages/agy-cli passes 14/14 on Linux and bun scripts/check.mjs is clean. The version still has to be 0.1.19.

  2. The read-write fallback in query() rewrites agy's store (step-usage.mjs:160-178, and the queryOnlyFallback openers at :29-46). Every error except "no such table" (an unknown column, SQLITE_BUSY, a corrupt page) retries with a normal read-write handle. PRAGMA query_only stops statements from writing, but it does not stop SQLite's checkpoint when the last connection closes. Probe: a WAL store with uncheckpointed frames left behind by an agy that has exited, and a steps table without status. Then lastModelStep() gives:

    before {"s.db":"…:4096","s.db-wal":"…:416152"}
    after  {"s.db":"…:110592"}        MAIN DB REWRITTEN: true  WAL GONE: true
    

    Same on Bun and Node. The plugin must never write agy's files.
    The fallback also isn't needed for the case the PR gives as its reason. A plain readOnly: true open reads a checkpointed store with no -shm (and a crashed one with -wal and no -shm) fine on both runtimes. Removing the fallback ([false, true] → [false]) leaves every test green, including "WAL-mode stores stay readable after writer close removes -shm". So that test doesn't guard the fallback, and SQLITE_CANTOPEN didn't reproduce here. If you hit it on macOS, please post the SQLite version and the exact files in the folder. Either way, an unreadable store should be null (unknown), never a read-write open.

  3. A late row from the previous turn is billed to the next turn's first tool_use response. turn.stepCursor is MAX(idx) when the turn starts (index.mjs:1499). Mid-turn responses have expected === null, so nothing reconciles them. Any model call agy commits after that point is counted in this turn. That covers a cancelled turn's generation (cancelled turns skip measurement entirely) and a turn whose final settlement gave up after its 1s wait. Probe (fake agy through the testkit): turn 1 is cancelled and its generation {input 50000, output 900} lands 150ms later. Turn 2 writes {input 1000, read 49000, output 20} and calls a tool:

    AssertionError: turn 2's first response billed the cancelled turn's 900 output tokens
    920 !== 20
    

    Please make a turn whose rows were never consumed (cancelled, or fell back) keep the next turn from trusting rows it can't attribute. Add a test for it. I couldn't check whether agy 1.5.x writes a gen_metadata row for a cancelled generation. If you can show it never does, say so with a store. But a fallen-back final turn hits this anyway.

  4. Every read leaves agy's store open until GC, and on Windows that locks the file. The plugin host runs under Bun, so loadOpener picks node:sqlite. query() does db.prepare(sql).all(...) and then db.close(). With Bun's node:sqlite, the statement that is never finalized keeps the file handle open after close(). Probe (Bun 1.4.2, fixture store, no plugin code except the opener):

    Windows: node:sqlite open/close            rm ok
             node:sqlite prepare.all/close     rm FAIL EBUSY
             bun:sqlite  query.all/close       rm ok
             bun:sqlite  prepare/finalize/close rm ok
    Linux:   50 × lastModelStep()  -> 50 fds open on s.db, 0 after Bun.gc(true)
    

    measuredUsage polls every 25ms for up to 1s, so one turn can hold dozens of handles on agy's conversation file. On Windows, agy can't delete or replace that file while they're open. The same thing is why the PR's own tests are red on Windows. Each fs.rmSync(dir) in a finally throws EBUSY:

    • test/step-usage.mjs: 4 of 6 red. Three are EBUSY. The fourth, "a missing or unreadable store…", asserts /^\/x\/f6faf504/, but path.join gives \x\f6faf504-….db on Windows, so the assertion needs path.join too.
    • test/usage-settlement.mjs: the 3 SQLite tests are red, all with EBUSY.

    On main, Windows has only the existing smoke red (#973, the same 1 of 333 checks in both trees). Fix: under Bun, prefer bun:sqlite (or finalize the statement before close()), and test on Windows that the directory can be removed right after a read.

Should fix

  1. The event loop is blocked while the store is locked. Every query() is synchronous, with a 50ms busy timeout per open and two opens per query. On a store held under an EXCLUSIVE lock, modelUsageCursor() and modelUsageAfter() each took about 215ms (Bun and Node). The poll loop in measuredUsage (index.mjs:4341-4350) repeats that until 1s has passed, so the plugin host is blocked for most of that second. In WAL mode this is rare, and #2 removes half of it (and #4 the handles). Please also make a SQLITE_BUSY stop the measurement for the turn, not poll again. A large store is fine: 550MB with 40k steps and 13k generations, each cursor or tail read 0.1–1ms with agy's indexes.

  2. A store that never answers costs 1s on every turn. When the .db exists but gives no usable row (the .pb cold start, or a future agy whose blob layout changes), the first response of every turn waits the full STEP_USAGE_WAIT_MS before tool_use reaches the client. After one turn like that, remember it on the Proc and stop waiting.

  3. The same-generation heuristic bills a slow commit as 0 and drops its prompt. If a new generation's row isn't committed within 75ms (STEP_USAGE_SAME_GEN_WAIT_MS), that response is billed 0. The next response then reports only the latest call's prompt, so the delayed call's input/cache-read is never reported. agy 1.5.5's binary writes gen_metadata through a background cache (dbtrajectory.newGenMetadataCache.func1 / gowrap1), so commit timing isn't guaranteed to come before the tool call. Your macOS numbers (54/54) are good evidence, but please add a test where the row lands after 75ms and pin what should happen.

Not guarded by any test

Each of these was broken by hand in turn, and test/step-usage.mjs and test/usage-settlement.mjs stayed green:

  • pending ignored in modelUsageAfter (step-usage.mjs:237);
  • the over-count guard removed (index.mjs:4374, prompt > expected + max(16, 1%));
  • "latest call's prompt" changed to the sum of calls (index.mjs:4390-4394), the multi-call case your body describes;
  • the queryOnlyFallback retry removed (see #2).

These ones did go red, so the tests hold them: promptUsage forced inclusive (3 red) or exclusive (5 red), settleUsage without measuredUsage (2), the same-generation branch removed (1), omittedHistoryTokens held at 0 (1), a gen_metadata cursor falling through to steps (2), and the cursor kept after a fallback (1).

Smaller

  • The PR body and the cold-start test's title say HISTORY_BLOCK_CLIP_BYTES is 112 KiB. It is 12 KiB (per block). 112 KiB is maxInitialPromptBytes(). The gate at index.mjs:4841 works only as a necessary condition. Please fix the wording.
  • step-usage.mjs's header credits lili-luo/dsh-plugin-bundle, and the body says it was adapted from DylanLIiii/opencode-antigravity-bridge (MIT). Credit both in the file.
  • The promptUsage fix could ship on its own now if you split it out. It's independent of the store reader.

What ran

  • PR head 6fcc909: HOME=$(mktemp -d) bun test packages/agy-cli 12/12 and bun scripts/check.mjs ok (WSL).
  • Merged with origin/main 9ec6962 (import conflict resolved by keeping both): agy-cli 14/14 on WSL, check.mjs ok.
  • Schema checked against agy 1.5.5's binary (strings only, nothing run): GORM tags primaryKey;autoIncrement:false;column:idx, index;column:step_type, index;column:status, column:metadata, column:data, plus setPageSizeAndWAL. So steps(idx, step_type, status, metadata) and gen_metadata(idx, data) in WAL mode match your queries. I had no real conversation store to read.
  • Old-schema store (no status column, no gen_metadata): cursor null. A truncated gen_metadata blob: pending: true. Missing store: null / empty, as intended.
  • Merged tree, root HOME=$(mktemp -d) bun test on WSL: 8199 pass, 19 skip, 0 fail (580 files).
  • Windows 11 (Bun 1.4.2, fresh HOME and USERPROFILE), bun test packages/agy-cli. On main: 12 pass, 1 fail, which is smoke's #973. Merged: 11 pass, 3 fail, the extra two being test/step-usage.mjs and test/usage-settlement.mjs (see #4). Smoke fails the same single check in both trees.
  • Broken by hand, one at a time, with a touch after each restore. The results are listed above.
  • No network and no real accounts. Every probe ran on fixtures under the testkit's fake agy.

@yetone

yetone commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Heads-up on the version: main now has agy-cli 0.1.19 too (5341ac1, #989: client tools are listed with antigravity/alwaysLoad, so agy no longer defers large ones). When you rebase for the review above, please take 0.1.20 and keep both the cloak import and the new listTools() change. The must-fix items in the review still stand.

@tungloong
tungloong force-pushed the feat/agy-cli-step-usage branch from 6fcc909 to c742d08 Compare November 11, 2026 11:40
@tungloong tungloong changed the title agy-cli 0.1.18: per-step SQLite model usage and agy 1.5.1+ promptUsage accounting agy-cli 0.1.19: per-step SQLite model usage and agy 1.5.1+ promptUsage accounting Nov 11, 2026
@tungloong

tungloong commented Nov 11, 2026 •

Copy link
Copy Markdown
Author

Thanks for the thorough review and probes — especially catching the PRAGMA query_only close-time checkpoint hazard and the Bun + node:sqlite unfinalized statement handle leak on Windows. All items in Must fix, Should fix, Not guarded by any test, and Smaller are now addressed on the rebased branch:

Must fix

  1. Rebased on origin/main (4b0aef5) & bumped to 0.1.20:
    Kept cloak.mjs (#980), listTools()'s antigravity/alwaysLoad (#989), and step-usage.mjs in packages/agy-cli/index.mjs, and bumped packages/agy-cli/package.json to 0.1.20.
  2. Removed read-write queryOnlyFallback completely + macOS SQLITE_CANTOPEN root cause:
    • Why SQLITE_CANTOPEN happened on macOS (and why the old test missed it):
      On macOS, Bun 1.4.2 dynamically links Apple's system /usr/lib/libsqlite3.dylib (reporting 3.54.0), whereas Node (3.53.1) and Linux/Windows builds link upstream SQLite. When agy 1.5.5 exits cleanly, Go's SQLite checkpoints and unlinks <id>.db-wal and <id>.db-shm, leaving only <id>.db (-rw-------, mode 0600) with WAL header bytes offset 18..19 = [0x02, 0x02]:
      • Under macOS Bun 1.4.2 (Apple libsqlite3 3.54.0), new Database(file, { readonly: true }) followed by SELECT ... immediately throws SQLITE_CANTOPEN (errno 14): unable to open database file because Apple's libsqlite3 refuses to create -shm on a SQLITE_OPEN_READONLY handle. (And in our earlier test, bun:sqlite on macOS did not unlink -wal/-shm on writer close(), which is why removing the fallback left that test green!)
      • Under upstream SQLite (node:sqlite), new DatabaseSync(file, { readOnly: true }) on that standalone [2, 2] file actually creates <id>.db-wal and <id>.db-shm sidecar files in agy's folder on first query.
    • Fix: step-usage.mjs never opens a read-write handle. When isCheckpointedWal(file) sees header[18..19] === [2, 2] with none of -wal, -shm, or -journal present on disk, withStore() opens ${pathToFileURL(file).href}?immutable=1 ({ readonly: true }), re-reading once against file if -wal appears concurrently. Any unreadable store returns null.
    • Added two unit regressions in test/step-usage.mjs:
      1. Standalone checkpointed WAL store (fs.readdirSync(dir) strictly ["session.db"] before and after read — goes red on both macOS Bun and Node if isCheckpointedWal returns false).
      2. Uncheckpointed -wal + incompatible schema (steps without status, no gen_metadata) asserting Buffer.compare === 0 on both s.db and s.db-wal.
  3. Cross-turn isolation after cancelled or fallen-back turns:
    • Observation on a real agy 1.5.5 cancelled store: inspecting ~/.gemini/antigravity-cli/conversations/76127e71-b2e3-4dbd-8f2f-e556e27ce2fd.db from a cancelled turn shows steps(idx=1, step_type=15, status=8 /* CANCELED */) with decodeStepUsage(metadata) === null and 0 rows in gen_metadata.
    • Even so, a late flush from newGenMetadataCache or any fallen-back final turn hits the exact cross-turn attribution bug you probed. Proc now tracks this.stepUsageTrusted = true and flips it to false whenever settleUsage falls back (measured === null) or finalizeTurn ends a turn with turn.promptCount > 0 && !turn.stepUsageReconciled. Added end-to-end regressions in test/usage-settlement.mjs covering both a cancelled Turn 1 whose {input: 50000, output: 900} row lands 150ms later and a fallen-back Turn 1 whose row lands after Turn 1 finishes.
  4. Windows file-handle leak & path assertion:
    • loadOpener() in step-usage.mjs (and test helpers in test/step-usage.mjs and test/usage-settlement.mjs) now prefers bun:sqlite first and explicitly calls stmt.finalize() in finally before db.close().
    • Fixed the POSIX /^\/x\// regex in test/step-usage.mjs to path.join(...), and added strict fs.rmSync(dir, { recursive: true }) (without force: true) right after reading.

Should fix & Not guarded by any test

  1. Non-blocking SQLITE_BUSY (< 1ms): removed PRAGMA busy_timeout = 50, passed timeout: 0 on node:sqlite, reduced each call to a single read-only open, and broke out of measuredUsage's poll loop immediately when !batch (null). Guarded in both test/step-usage.mjs (< 50ms, ~0.9ms actual) and test/usage-settlement.mjs (EXCLUSIVE-locked store settles in < 500ms instead of 1,000ms).
  2. Remember non-answering stores on Proc: proc.stepUsageTrusted = false after the first fallen-back turn stops subsequent turns on .pb / incompatible stores from waiting 1,000ms again (verified in the .pb cold-start test's Turn 2 < 500ms assertion).
  3. >75ms gen_metadata commit behavior + all 4 previously unguarded invariants:
    • measuredUsage now also tracks sawOutput (turn.sawGenerationOutput from agent_message_chunk / agent_thought_chunk, or text/thinking blocks in content) so any step that emitted text/thinking cannot take the 75ms canShareGeneration shortcut and waits up to 1,000ms.
    • Added regressions in test/step-usage.mjs and test/usage-settlement.mjs pinning all three >75ms (150ms) cases (with pre-send pending: true row, with text before tool_use, and silent tool-only late row whose prompt settles on the latest call's window while summing output on the following step), plus the prompt > expected + max(16, 1%) over-count guard. Every single one of the 10 hand-break mutations listed in the updated PR body now turns the suite red.

Smaller

  • Updated beginTurn (Math.min(HISTORY_BLOCK_CLIP_BYTES, maxInitialPromptBytes())), test titles/comments, and the PR body to distinguish maxInitialPromptBytes() (112 KiB) from HISTORY_BLOCK_CLIP_BYTES (12 KiB per block).
  • Credited both lili-luo/dsh-plugin-bundle (dsh-llm-antigravity-acp, MIT) and DylanLIiii/opencode-antigravity-bridge (MIT) in step-usage.mjs's header.

@tungloong
tungloong force-pushed the feat/agy-cli-step-usage branch from c742d08 to 6aab6a4 Compare November 11, 2026 14:02
@tungloong tungloong changed the title agy-cli 0.1.19: per-step SQLite model usage and agy 1.5.1+ promptUsage accounting agy-cli 0.1.20: per-step SQLite model usage and agy 1.5.1+ promptUsage accounting Nov 11, 2026

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this round fixes almost everything from the last review. I re-reviewed every changed line at 6aab6a4 and ran it on macOS (mote), Linux (WSL Ubuntu 24.04) and Windows 11, all on Bun 1.4.2. One thing still blocks the merge: the new ?immutable=1 path only works on macOS. On Linux and Windows the PR's own new tests are red.

Must fix

  1. ?immutable=1 is never opened as a URI by bun:sqlite on Linux or Windows (step-usage.mjs:34-35, :225). Only Apple's system libsqlite3 has URI filenames on by default. Bun's bundled SQLite on Linux and Windows doesn't, so new Database("file:///…?immutable=1", { readonly: true }) fails with "unable to open database file", withStore() returns null, and every read of a checkpointed store (the WAL-flagged file with no -wal/-shm that you described) gives no measurement. The turn then falls back and stepUsageTrusted turns off for the whole process. I opened a checkpointed WAL store (one row, no sidecars) in each of these ways:

                       plain {readonly:true}           "file:…?immutable=1" {readonly:true}   READONLY|URI flags
    darwin  bun 1.4.2  ERR unable to open (as you saw)  [{"x":42}], no sidecars              [{"x":42}], no sidecars
    linux   bun 1.4.2  [{"x":42}], leaves -wal/-shm     ERR unable to open database file      [{"x":42}], no sidecars
    win32   bun 1.4.2  [{"x":42}], leaves -wal/-shm     ERR unable to open database file      [{"x":42}], no sidecars, strict rm ok
    

    The PR's own tests at this head, run with bun test per file:

    • Linux: test/step-usage.mjs 7/8. "checkpointed WAL stores without -wal/-shm read cleanly…" gets null at test/step-usage.mjs:169. test/usage-settlement.mjs 31/33: "单次生成串行派发多工具 + WAL 关库…" (:1128) and "异步延迟 >75ms 落盘…" (:1423) are red. Their fake writers close the store after every write, so each read finds a checkpointed file. (node --test step-usage.mjs passes 8/8 on Linux, because Node's SQLite has URIs on. The plugin host runs Bun, though.)
    • Windows: the same three, plus the timing bar in item 2.
    • macOS: everything green, 14/14.

    Fix: pass the raw open flags. const { Database, constants } = require("bun:sqlite") and new Database(target, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI). With only that change (two lines), test/step-usage.mjs is 8/8 and test/usage-settlement.mjs 33/33 on Linux. On Windows usage-settlement is 33/33, and step-usage is 7/8, the one red being item 2's bar. A strict fs.rmSync(dir, { recursive: true }) right after the read passes on all three. Please run the package on Linux and Windows before pushing. CI didn't run on this PR, and the macOS run alone can't show this.

  2. test/step-usage.mjs:230's elapsed < 50 is a fixed bar that Windows misses. Two non-blocking reads of an EXCLUSIVE-locked store took 58.5, 50.4 and 58.5 ms in three runs on Windows (Linux 18 ms, macOS under 1 ms). SQLite's Windows lock path costs that much without any busy timeout. The old cost was a 50 ms busy timeout per open, about 200 ms per pair. So a bar like < 150 still catches a busy timeout coming back, and it leaves room on a loaded or slower machine. I added PRAGMA busy_timeout = 200 and the test went red at 481 ms, so a wider bar keeps it meaningful.

Verified fixed

  • Rebased on main 4b0aef5. cloak.mjs import and #989's ALWAYS_LOAD listTools() change are both kept. The version is 0.1.20. origin/dist has opencode-agy-cli-auth-0.1.19.tgz as its newest, so 0.1.20 is the right next number.
  • The read-write fallback is gone. A crashed store (uncheckpointed -wal, with and without -shm, old schema and new) leaves s.db and s.db-wal byte-identical after lastModelStep, modelUsageCursor and modelUsageAfter, on Bun and Node. The new-schema row is read correctly from the WAL. The only file a reader touches is -shm (the WAL index): it's created when missing and updated when present. That's ordinary SQLite reader behaviour, and agy rebuilds it. The body's "zero sidecar writes" holds only for the checkpointed case, though, so please word it that way.
  • Handles: with bun:sqlite preferred, 50 reads leave 0 fds open on macOS. On Windows the strict rmSync in the tests passes. When I forced the old node:sqlite opener on Windows, three step-usage tests went red with EBUSY, so the strict remove guards the leak.
  • Cross-turn late rows, the cancelled/fallen-back turn and the store that never answers are all guarded now (see below). Your note that a real cancelled agy 1.5.5 store had no gen_metadata row is useful.
  • The >75 ms commit cases are pinned. Reporting only the latest call's prompt for a multi-call response, so its context window isn't doubled, is a stated choice now, and the body's measured -2,582 / -83,270 delta matches it.
  • Credits and the 12 KiB / 112 KiB wording are fixed.

Broken by hand (macOS, one at a time, restored and touched after each)

Red: immutable path off (step-usage), busy_timeout back (step-usage), pending ignored, finalizeTurn's trust reset removed, both trust resets removed, stepUsageTrusted ignored at turn start, polling on a null batch, the over-count guard removed, prompts summed in place of the latest call's, sawOutput ignored, promptUsage forced inclusive, omittedHistoryTokens held at 0 (all in usage-settlement unless named).

Green, and why that's acceptable:

  • settleUsage's reset alone, the cursor-null reset alone and measuring a cancelled turn: each is covered by finalizeTurn's unreconciled check.
  • Leaving stmt.finalize() out under bun:sqlite: Bun closes those statements itself.
  • tableQuery swallowing every error: the result is the same null.
  • Removing the re-read when -wal appears after an immutable read: no test covers it. It's a narrow race, and a test that writes a -wal mid-read would be worth adding, but it doesn't block.

Smaller

  • The node:sqlite opener (step-usage.mjs:57) still doesn't finalize. Only plain Node reaches it, and Node finalizes on close(), so this is fine. A one-line comment would save the next reader the question.

What ran

  • macOS (mote, Bun 1.4.2, Node 22.22): HOME=$(mktemp -d) bun test packages/agy-cli 14/14. bun scripts/check.mjs ok. node --test test/step-usage.mjs 8/8.
  • Linux (WSL, Bun 1.4.2): step-usage 7/8, usage-settlement 31/33, as above. Node 8/8. With the flags fix: 8/8 and 33/33.
  • Windows 11 (Bun 1.4.2, fresh HOME/USERPROFILE): bun test packages/agy-cli 11 pass, 3 fail. Those are step-usage, usage-settlement and smoke. Smoke is the known #973, 1 of 334 checks. With the flags fix, usage-settlement is 33/33 and step-usage 7/8 (item 2).
  • I didn't run the root suite this round, because item 1 blocks the merge anyway. I'll run it on the next head.
  • No network and no real agy accounts. Every probe ran on fixture stores and the testkit's fake agy.

@tungloong
tungloong force-pushed the feat/agy-cli-step-usage branch from 6aab6a4 to 262145f Compare November 11, 2026 14:59
@tungloong

tungloong commented Nov 11, 2026 •

Copy link
Copy Markdown
Author

Thanks for catching the bun:sqlite URI flag difference on Linux/Windows and the Windows lock timing bar! All items from your latest review are addressed in 9054f47 (rebased onto origin/main dba2406, 0.1.20) and verified across Linux, Windows, and macOS before pushing:

Must fix

  1. bun:sqlite SQLITE_OPEN_READONLY | SQLITE_OPEN_URI (step-usage.mjs):
    • loadOpener() now destructures { Database, constants } = require("bun:sqlite") and opens with constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI so ${uri}?immutable=1 is parsed as a URI on Linux and Windows as well as macOS.
    • Also normalized store paths via path.resolve() (so relative file:... paths are never misinterpreted as SQLite URIs under SQLITE_OPEN_URI) and formatted Windows \\... UNC / \\?\ extended-length paths without a non-empty URI authority when constructing ${uri}?immutable=1.
  2. Windows EXCLUSIVE-locked store timing bar (test/step-usage.mjs):
    Widened the assertion from elapsed < 50 to elapsed < 150 (measured 70.21ms under Bun and 83.66ms under Node on windows-latest, ~1–3ms on macOS/Linux, while still catching any busy_timeout regression).

Wording, Race Test & Smaller

  • PR body wording: scoped "zero sidecar writes" explicitly to checkpointed WAL stores without -wal/-shm, noting that active uncheckpointed WAL reads leave s.db and s.db-wal byte-identical while ordinary SQLite reader -shm WAL-index behaviour applies.
  • Concurrent -wal appearance mid-read & URI path edge cases (step-usage.mjs, test/step-usage.mjs):
    • withStore() falls through to the resolved live path if -wal appears when the lockless ?immutable=1 read returns stale rows or throws mid-write, while returning null without opening the live path (no sidecars) when no -wal appeared.
    • Added "re-reads against the live file when -wal appears mid-read during an immutable open" and "handles URI-sensitive path characters, relative file: prefixes, and Windows extended/UNC paths" (test/step-usage.mjs 10/10), verified red when hand-broken under both Bun and Node.
  • node:sqlite opener comment (step-usage.mjs): added a one-line comment explaining that only plain Node reaches the node:sqlite fallback and DatabaseSync.close() finalizes prepared statements automatically.

What ran (3-OS GitHub Actions run 43048343981 + local macOS)

  • Linux (ubuntu-latest, Bun 1.4.4, Node v24.22.2):
    • HOME=$(mktemp -d) bun test ./packages/agy-cli/test/step-usage.mjs: 10 pass, 0 fail
    • HOME=$(mktemp -d) node --test ./packages/agy-cli/test/step-usage.mjs: 10 pass, 0 fail
    • HOME=$(mktemp -d) bun test packages/agy-cli: 14 pass, 0 fail (usage-settlement.mjs 33/33)
    • Root suite HOME=$(mktemp -d) bun test: 8386 pass, 6 skip, 0 fail (590 files); bun scripts/check.mjs: ok
  • Windows (windows-latest, Bun 1.4.4, Node v24.22.2, fresh temp HOME & USERPROFILE):
    • bun test ./packages/agy-cli/test/step-usage.mjs: 10 pass, 0 fail (70.21ms on EXCLUSIVE-locked store, \\?\ + #/%20 paths & strict fs.rmSync ok)
    • node --test ./packages/agy-cli/test/step-usage.mjs: 10 pass, 0 fail (83.66ms on EXCLUSIVE-locked store)
    • bun test ./packages/agy-cli/test/usage-settlement.mjs: 33 pass, 0 fail
    • All other agy-cli test files green (cloak 2/2, platform 14/14, compat 12/12, prefill 9/9, usage 17 pass / 3 skip, catalog 17/17, repeat-batch 14/14, conversation-key 31/31, re2-pattern-unit & array-items-unit ALL OK); smoke.mjs hits only the known #973 check (333/334); bun scripts/check.mjs: ok
  • macOS (macos-latest arm64 Bun 1.4.4 / Node v24.22.2 + local arm64 Bun 1.4.2 / Node v26.2.0):
    • HOME=$(mktemp -d) bun test packages/agy-cli: 14 pass, 0 fail
    • node --test ./packages/agy-cli/test/step-usage.mjs: 10 pass, 0 fail
    • bun scripts/check.mjs: ok

…e semantics

agy 1.5.1+ (verified on agy 1.5.5) changed two usage surfaces:

1. ACP `session/prompt` usage now reports `inputTokens` as the uncached prompt
   tokens alone, while `cachedReadTokens` and `cachedWriteTokens` are reported
   separately and `totalTokens` sums all prompt and completion buckets. Treating
   `inputTokens` as inclusive of `cachedReadTokens` truncated `cache_read` to
   `inputTokens` (e.g. 2,133 uncached + 28,610 cached reported as 0 input and
   2,133 cached) and clamped `recordPromptUsage`'s learned ratio to 1.0 whenever
   `cachedReadTokens > inputTokens`. `promptUsage()` uses `totalTokens` to tell
   the two shapes apart while preserving pre-1.5.1 inclusive payloads.

2. Each live ACP session writes per-generation Google model usage rows to
   `~/.gemini/antigravity-cli/conversations/<sessionId>.db` (`gen_metadata` in
   agy 1.5.1+, `steps` type 15 in 1.4.x-1.5.0). `step-usage.mjs` (Protobuf
   layout decoded by lili-luo/dsh-plugin-bundle, MIT; SQLite reader adapted from
   DylanLIiii/opencode-antigravity-bridge, MIT) reads those rows strictly
   read-only:
   - Prefer `bun:sqlite` under Bun with `SQLITE_OPEN_READONLY | SQLITE_OPEN_URI`
     and explicit `stmt.finalize()` before `db.close()` so URI parameters work
     across macOS/Linux/Windows and Windows file handles release immediately
     without waiting on GC; fall back to `node:sqlite` (`{ readOnly: true,
     timeout: 0 }`) with no busy timeout and no read-write open.
   - Normalize store paths with `path.resolve()` so relative `file:...` paths
     are never misinterpreted as SQLite URIs under `SQLITE_OPEN_URI`, and format
     Windows `\\...` UNC / extended-length paths without a non-empty authority
     segment when opening `${uri}?immutable=1`.
   - When a checkpointed WAL store (`header[18..19] = [2, 2]`) has neither
     `-wal` nor `-shm` on disk, open `${uri}?immutable=1` so Apple's system
     `libsqlite3` on macOS does not fail with `SQLITE_CANTOPEN` and upstream
     SQLite does not create `-wal`/`-shm` sidecar files on checkpointed stores,
     re-reading once against the resolved live path if `-wal` appears
     concurrently.
   - Stop polling immediately on `SQLITE_BUSY` / unreadable stores, and mark
     `proc.stepUsageTrusted = false` whenever a turn cancels or falls back so
     unconsumed late rows cannot leak into a subsequent turn and non-answering
     (`.pb` / incompatible) stores never cost 1s again on later turns.
   - Settle sequential MCP `tools/call` continuations emitted by one LLM
     generation at 0 tokens (`agy_same_generation`) after a 75ms poll when no
     new message/thought output arrived and no `pending` row exists, while
     waiting up to 1,000ms whenever text/thinking streamed or a `pending`
     pre-send row is present.
   - Bound fallback window estimates via `Proc.omittedHistoryTokens` when an
     initial replay exceeds `maxInitialPromptBytes()` (112 KiB) /
     `HISTORY_BLOCK_CLIP_BYTES` (12 KiB per block).

Tests:
- `HOME=$(mktemp -d) bun test packages/agy-cli`: 14 pass, 0 fail (Linux/macOS;
  all `step-usage` 10/10 and `usage-settlement` 33/33 pass on Windows)
- `node --test packages/agy-cli/test/step-usage.mjs`: 10 pass, 0 fail
- `bun scripts/check.mjs`: all packages and `registry.json` ok
- Broken by hand (13 checks across `test/step-usage.mjs` and
  `test/usage-settlement.mjs`): `pending = true` removed, over-count guard
  removed, latest-call prompt changed to sum of calls, checkpointed-WAL
  `?immutable=1` disabled (both Bun and Node), concurrent `-wal` re-read
  removed, no-`-wal` catch guard removed, `path.resolve()` removed on relative
  `file:` path, `stepUsageTrusted = false` removed (both cross-turn late row and
  `.pb` Turn 2 latency), `!batch` break removed on `SQLITE_BUSY`, and
  `!sawOutput` removed on `>75ms` commits all turn tests red.
@tungloong
tungloong force-pushed the feat/agy-cli-step-usage branch from 262145f to 9054f47 Compare November 11, 2026 15: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.

2 participants