Skip to content

[sprites 6-1] Storage metering: bytes written, not allocation - #1990

Merged
2witstudios merged 6 commits into
masterfrom
pu/sprites-6-1-storage-bytes
Jul 11, 2026
Merged

2witstudios merged 6 commits into
masterfrom
pu/sprites-6-1-storage-bytes

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

The storage reconcile billed every machine a flat 5 GB-months accrual (SANDBOX_RESOURCE_CAPS.storageGB) on the premise that Sprites' provisioned volume is what costs. It isn't: the platform bills the bytes a machine has actually written (TRIM-friendly — deleting files lowers the bill), not the allocation (docs.sprites.dev/concepts/lifecycle — "you pay for the bytes you actually write, not the full 100 GB"). A machine that wrote 200MB was being metered at 5GB. This fixes the meter to measured usage, and never wakes a sprite to measure it.

Requirements → how satisfied

  • Bill from last persisted measured bytes, never SANDBOX_RESOURCE_CAPS.storageGB, and never wake a sprite (zero sprite calls from the cron).

    • Reworked the pure core: computeElapsedGbMonths({ measuredGB, elapsedMs }) (was storageGB) and new pickBillableGB({ lastMeasuredGB, lastMeasuredAt, awake, now }) -> { gb, stale }.
    • ReconcileTerminalStorageDeps no longer has a storageGB field at all — the cron's deps expose no sprite handle, so it cannot exec against a machine (compile-time guarantee, asserted by test). It reads only persisted storageMeasuredBytes/storageMeasuredAt off terminal_sessions.
    • Test: "NEVER bills from a provisioned allocation cap — a 200MB machine bills ~0.2 GB-months, not 5" and "never wakes a sprite to measure".
  • Sprite awake for real work → opportunistically refresh measurement (cheap exec, throttled — pure throttle decision).

    • New terminal-storage-measure.ts: pure parseDuBytes (du -sbx of the workspace subtree), bytesToGB (decimal GB), shouldRefreshMeasurement({ lastMeasuredAt, now, throttleMs }), plus a DI'd refreshStorageMeasurement shell that runs one du -sbx /workspace through an already-awake MachineHandle.exec and persists. (Measures the workspace subtree, not the whole filesystem, so it never counts the OS/base-image bytes — see the review-round comment.)
    • Wired into the agent tool-runner via a new best-effort measureStorage? seam on SandboxRunDeps (mirrors the existing notifyTerminalActivity?/billing? seams), invoked in openSession — a sprite is awake there because real work is happening on it. ExecutableSandbox.runCommand shares MachineHandle.exec's signature. Throttled to 1 measure/machine/hour (TERMINAL_STORAGE_MEASURE_THROTTLE_MS), fire-and-forget, never blocks or fails the op.
  • Never-measured machine → conservative documented floor.

    • Decision: bill 0 until the first measurement lands (not the cap). pickBillableGB(null) -> { gb: 0, stale: true }. Documented in the schema column, the reconcile module doc, and pickBillableGB.
  • Existing accrual rows transition cleanly (no double-billing at cutover; idempotency preserved).

    • A never-measured row bills $0 and advances its watermark past the settled window, so once a measurement lands later it bills only forward, never retroactively over the pre-measurement span. Back-to-back reruns (elapsed 0) advance nothing → pure no-op.
    • Regression tests: "CUTOVER: a never-measured row bills $0 and advances its watermark" and "CUTOVER continuity: the FIRST measured window bills only from the advanced mark (no over-bill)", plus the original "second run is a pure no-op" and per-row failure isolation tests, updated & green.

Also

  • Schema: terminal_sessions.storageMeasuredBytes (bigint) + storageMeasuredAt (timestamp), nullable — migration 0194_yielding_the_leader.sql (generated, not hand-edited).
  • staleMeasurements health counter (measured-but-stale rows) surfaced in the cron log line + audit details.
  • Updated the module/const docstrings that asserted the allocation premise (terminal-storage-reconcile.ts, the cron route, TERMINAL_STORAGE_USD_PER_GB_MONTH); noted HOT/COLD are infra layers not billing tiers (per-tier split = follow-up, per Out-of-scope).

Out of scope (untouched)

The credit price constant's value (kept env-tunable); the HOT/COLD tier split.

Test evidence

bun run typecheck (db + lib + web): clean. Lint (lib + web): clean (only pre-existing unrelated warnings).

Full sandbox suite:

Test Files  31 passed (31)
     Tests  443 passed (443)

New/updated files specifically:

✓ terminal-storage-reconcile.test.ts (22 tests)   # pure fns + cutover + stale + billing loop
✓ terminal-storage-measure.test.ts   (14 tests)   # du parse + bytesToGB + throttle + shell (fake handle, 0 sprite calls)
✓ terminal-storage-billing.test.ts   (13 tests)   # listMachines projection, no storageGB, persist, opportunistic capture
✓ tool-runners.test.ts               (+3 tests)   # measureStorage seam: called w/ pageId, skipped w/o, best-effort on throw
✓ reconcile-terminal-storage/route.test.ts (4)    # staleMeasurements surfaced in audit + body

For the orchestrator to verify

  • Cross-leaf touchpoint: added one additive, optional measureStorage? seam to SandboxRunDeps + a call in openSession (packages/lib/src/services/sandbox/tool-runners.ts) and its composition in apps/web/.../sandbox-tools-runtime.ts. This is the shared agent tool-runner path — additive only (no signature changes to existing seams), but flag if another sprites leaf is mid-edit there.
  • React component tests: none touched; all changes are lib/pure + API route + schema, validated via typecheck + unit tests.
  • The measurement rides on the agent tool-runner wake. The human realtime-terminal PTY wake is a natural second capture point but lives in the realtime lane — left for that lane to add the same one-line best-effort call if desired (measurement is idempotent/throttled, so multiple wake sources are safe).

🤖 Generated with Claude Code


Update — proactive review round (f6fd166c3)

A high-effort adversarial review surfaced 7 billing-correctness findings. Fixed in code: (1) measure workspace subtree via du -sbx not whole-FS df (avoids counting base-image bytes); (2) sub-cent residuals no longer discarded — zero-charge watermark advance now applies only to never-measured rows; (3) in-process per-page throttle cache removes the per-tool-op DB SELECT; (4) honest docs on the bounded (≤ one cron interval, one-time) retroactive charge. Acknowledged/scoped: PTY-only measurement lives in the lane-shared realtime handler (follow-up); billing-last-known-after-shrink is inherent to "never wake"; concurrent du is a separate sprite.spawn process. Full detail in the review-round comment.

Update — reviewer threads addressed (d046ffe3a)

  • P1 (terminal-connect coverage): wired measureMachineStorageOpportunistically into apps/realtime/src/index.ts buildMachineSandbox.acquire() so interactive-PTY-only machines are measured on connect (were never-measured → billed the 0 floor forever). The attach is lazy via a new resolveHandle thunk, gated behind the throttle + row-existence checks.
  • P2 (measure-after): moved the agent-path measurement from openSession (pre-op) into the session release thunk (called in finally, post-op) so it captures what the op wrote and runs sequentially rather than concurrently with it.

Update — verification-review corrections (9e495b9b7)

A second adversarial review of the review-fix diff caught 5 billing-correctness bugs I'd introduced; all fixed: (1) reverted the measured-sub-cent watermark freeze (it would retroactively over-bill a footprint that later grows) — the reconcile now advances the watermark on every zero-cost window, losing only a negligible sub-cent residual; (2) du -sbx (apparent size) → du -sxB1 (actual allocated bytes) so sparse files aren't over-billed; (3) restored the du exit-code guard so a partial "cannot-read" walk isn't persisted as an under-count; (4) the in-process throttle now stamps only on a definitive outcome so a transient measure failure doesn't lock out re-measurement for the window; (5) bounded the throttle Map (10k entries, oldest-first eviction) to prevent a long-lived-process memory leak. Regression tests added for the no-retroactive-over-bill and transient-retry cases.

Update — review round 3 (0a5de6f37)

Resolved the du exit-code flip-flop definitively and restored concurrent-dedup: (1) persist any parseable du total even on non-zero exit — it's a valid conservative lower bound (only unreadable entries omitted), so a permanently-unreadable subtree no longer bills $0 forever nor re-walks the workspace every op; true failures (no numeric output) still retry; (2) synchronous in-flight Set collapses a burst of N parallel ops for one page to a single measurement (the window clock only stamps after the awaits); (3) added a test-reset seam for the in-process caches. Regression tests for both.

Update — rebased onto master's TERMINAL→MACHINE rename (ca01de0e1)

Rebased onto master (post-#1992 PageType.TERMINAL→MACHINE + FK terminalId→machineId rename). All 6-1 storage-metering work is intact — this was a pure rename/rebase adaptation, no scope dropped:

  • Migration renumbered 0194_yielding_the_leader → 0196_yielding_the_leader (after master's new 0194 + 0195); _journal.json gets a matching idx-196 tail entry and a fresh 0196_snapshot.json chained off 0195 (prevId = 0195 snapshot id) carrying the two new nullable terminal_sessions columns. Master's 0194/0195 are untouched; the rename was not regenerated.
  • Rename sweep of touched files: the realtime terminal-connect measurement block now uses the renamed machineId local. My storage-billing files (terminal-storage-*.ts, credit-pricing.ts) key on pageId/machineId and needed no field renames.
  • Completed 3 pre-existing rename-misses that master HEAD (1f26f9f) itself does not typecheck cleanly through (rename landed out of order with [sprites 4-1] Keep TerminalView mounted across navigation (LRU warm-hold) #1989): buildAgentTerminalSessionKey({ terminalId }) shorthand → { terminalId: machineId }, and PageType.TERMINAL → MACHINE in terminal-keepalive.ts + TerminalKeepAliveHost.tsx.

Validation after rebase: bun run typecheck → 16/16 packages, 0 errors; sandbox suite 519/519; turbo build --filter=@pagespace/db --filter=@pagespace/lib green; lint clean. No merge conflicts with master.

Update — rebased again onto master (7d3661bf6; #1991 Phase 3 + #1993 rename-fix)

Master advanced to d357a6741 (Phase 3 ClickHouse #1991 + #1993 which fixed the same three rename-misses I had). Re-rebased:

Re-validated: bun run typecheck 16/16, 0 errors; sandbox suite 519/519; db+lib build green; lint clean. No conflicts.

Summary by CodeRabbit

  • New Features
    • Persistent storage usage is now measured opportunistically while terminals are already active, without delaying operations or waking inactive machines.
    • Storage billing now reflects measured disk usage rather than provisioned capacity.
    • Billing supports both active and hibernating terminals, with conservative handling for terminals that have never been measured.
  • Bug Fixes
    • Improved billing accuracy and prevented retroactive overcharging across reconciliation periods.
    • Storage measurement failures no longer interrupt terminal operations.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Terminal storage billing now relies on opportunistic filesystem measurements persisted on terminal sessions. Sandbox operations trigger best-effort measurement, reconciliation bills measured footprints without waking machines, and cron responses report stale measurement counts.

Changes

Terminal storage metering

Layer / File(s) Summary
Storage measurement persistence contract
packages/db/drizzle/*, packages/db/src/schema/terminal-sessions.ts, packages/lib/src/billing/credit-pricing.ts
Terminal sessions add nullable measured-byte and measurement-time fields, with migration metadata and updated persistent-storage billing terminology.
Opportunistic measurement engine
packages/lib/src/services/sandbox/terminal-storage-measure.ts, packages/lib/src/services/sandbox/terminal-storage-billing.ts, packages/lib/src/services/sandbox/__tests__/*measure*, packages/lib/src/services/sandbox/__tests__/terminal-storage-billing.test.ts
The system executes bounded du measurements, parses byte totals, applies throttling and in-flight deduplication, persists successful results, and treats failures as non-fatal.
Measured storage reconciliation
packages/lib/src/services/sandbox/terminal-storage-reconcile.ts, packages/lib/src/services/sandbox/terminal-storage-billing.ts, packages/lib/src/services/sandbox/__tests__/terminal-storage-reconcile.test.ts
Reconciliation bills measured footprints, tracks stale measurements, avoids provisioned-cap billing and wakeups, advances watermarks for zero-cost windows, and preserves per-machine failure isolation.
Sandbox operation measurement wiring
packages/lib/src/services/sandbox/tool-runners.ts, apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts, apps/realtime/src/index.ts, packages/lib/src/services/sandbox/__tests__/tool-runners.test.ts
Sandbox release and interactive PTY acquisition start fire-and-forget measurements when a page ID is available, using attached or command-running machine handles.
Reconciliation result reporting
apps/web/src/app/api/cron/reconcile-terminal-storage/route.ts, apps/web/src/app/api/cron/reconcile-terminal-storage/__tests__/route.test.ts
The cron endpoint logs, audits, and returns staleMeasurements alongside reconciliation results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SandboxOperation
  participant StorageMeasurement
  participant Machine
  participant TerminalSessions
  participant ReconcileCron
  SandboxOperation->>StorageMeasurement: trigger best-effort measurement
  StorageMeasurement->>Machine: execute du on active sandbox
  Machine-->>StorageMeasurement: return filesystem byte output
  StorageMeasurement->>TerminalSessions: persist bytes and timestamp
  ReconcileCron->>TerminalSessions: read measured storage and watermark
  ReconcileCron-->>SandboxOperation: charge measured GB-months and report stale count
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: storage metering now bills measured bytes written instead of provisioned allocation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/sprites-6-1-storage-bytes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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: 0e3d4c94ee

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +173 to +175
if (costDollars <= 0) {
if (elapsedMs > 0) {
await deps.advanceWatermark({ pageId: machine.pageId, billedThrough: now });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wire measurements before zero-settling terminal-only sessions

For machines used only through the realtime PTY or machine APIs, this branch can run every cron tick with measuredBytes still null/zero and then advance storageLastBilledAt, permanently forgiving that storage window. I checked repo-wide (rg measureMachineStorageOpportunistically|refreshStorageMeasurement|storageMeasured): the only production measurement wiring is the agent tool runtime, while apps/realtime/src/index.ts and apps/web/src/lib/machines/* acquire/wake terminal sandboxes without persisting storageMeasured*. A user who writes data in the terminal but never invokes an agent tool will therefore be billed $0 forever after this migration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d046ffe3a. Wired the terminal-CONNECT wake: apps/realtime/src/index.ts buildMachineSandbox.acquire() now calls measureMachineStorageOpportunistically after acquireTerminalSandbox succeeds, so PTY-only machines (no agent tool ops) are measured on connect instead of staying never-measured. The network attach is lazy — measureMachineStorageOpportunistically gained a resolveHandle thunk it invokes only after the in-process throttle + terminal_sessions row-existence checks pass, so a throttled connect pays no attach. This closes the "$0 forever" path for the interactive-terminal population. (The never-measured watermark advance is retained deliberately to bound the first-measurement retroactive charge to ≤ one cron interval; with connect-time measurement the never-measured window is now transient, not permanent.) Leaving this open for your verification.

Comment on lines +434 to +436
if (acquired.pageId && deps.measureStorage) {
const pageId = acquired.pageId;
void deps.measureStorage({ sandbox, pageId }).catch((error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Measure after disk-mutating operations

This fires the storage measurement as soon as openSession reconnects, before runBashInSandbox executes the command and before writeSandboxFile/editSandboxFile mutate the filesystem. For a first operation that writes a lot of data, the persisted measurement can be the old/zero footprint with a fresh storageMeasuredAt; the one-hour throttle then suppresses a post-write measurement, and if the reconcile runs before another wake it advances the watermark using the stale footprint, permanently dropping that interval from storage billing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d046ffe3a. Moved the measurement out of openSession (before the op) into the session release thunk, which all four runners call in finally AFTER the op completes. It therefore observes any bytes the op just wrote and stamps storageMeasuredAt post-write, so the throttle no longer suppresses the write from being reflected. As a bonus this also removes the concurrent-exec contention (the measurement now runs sequentially after the op, not alongside it). Leaving this open for your verification.

@2witstudios

Copy link
Copy Markdown
Owner Author

Proactive review round (high-effort adversarial review) — fixes pushed (f6fd166c3)

Ran a multi-agent adversarial review of the diff. 7 findings survived verification; addressed as follows.

Fixed in code:

  1. Measure workspace subtree, not whole FS — switched df -kP → du -sbx /workspace. df of the mount would include the read-only OS/base-image bytes if the workspace isn't a dedicated mount, over-billing every machine by the base-image size (the exact allocation-vs-written bug this PR removes). du counts only workload-written bytes. Trade-off documented: bytes written outside /workspace aren't counted — a deliberate conservative under-count consistent with the never-measured 0 floor.
  2. Sub-cent residuals no longer discarded — the zero-charge watermark advance now applies only to never-measured rows (the cutover case). A measured tiny footprint whose per-window cost rounds to $0 no longer advances the watermark, so the residual accrues across windows until it crosses the pricing floor and bills. (Previously a small machine on a frequent cron billed $0 forever — a regression I introduced; now covered by a new regression test.)
  3. In-process throttle cache — measureMachineStorageOpportunistically is called on every tool op; it now short-circuits on a per-page in-process clock before the DB read, so a 30-tool-call turn does one measurement attempt, not 30 wasted SELECTs. Persisted storageMeasuredAt remains the authoritative throttle (survives restart / multi-instance).
  4. Honest docs on the bounded retroactive charge — corrected the "never retroactive" claim: the first measured window spans from the last watermark advance to now, i.e. at most one cron interval of pre-measurement time, billed once. Documented as the deliberate, bounded cost of a single watermark.

Acknowledged, not changed (scope / inherent):
5. PTY-only machines bill the 0 floor until measured — the realtime-PTY-connect measurement hook lives in the lane-shared apps/realtime/.../agent-terminal-handler.ts, which is out of this leaf's scope. The agent-run path is wired and measureMachineStorageOpportunistically is ready for the realtime lane to call. This under-count is strictly better than the old flat-5GB over-bill and is documented as a follow-up.
6. Billing last-known after storage is freed — inherent to the hard "never wake a paused sprite to measure" constraint; self-corrects on the next real-work wake, and staleMeasurements surfaces how many rows bill on an ageing measurement.
7. Concurrent du on the handle — runCommand uses sprite.spawn (a separate VM process), so the measurement does not serialize with the primary op; with the in-process throttle it runs at most ~once/hour per machine.

Validation: typecheck (db+lib+web) clean · lint clean · sandbox suite 443/443 · cron route test 4/4. All baseline CI checks were green before this round.

@2witstudios
2witstudios force-pushed the pu/sprites-6-1-storage-bytes branch 2 times, most recently from ca01de0 to 0c0c689 Compare July 10, 2026 23:06
2witstudios and others added 6 commits July 10, 2026 21:47
…ned allocation (#890 / sprites 6-1)

The storage reconcile billed every machine a flat 5 GB-months accrual
(SANDBOX_RESOURCE_CAPS.storageGB) on the false premise that allocation is
what costs. The platform bills the bytes a machine has ACTUALLY written
(TRIM-friendly), not the 100GB-style allocation
(docs.sprites.dev/concepts/lifecycle) — so a machine that wrote 200MB was
metered at 5GB.

- Bill from the last PERSISTED measured footprint, never the provisioned cap,
  and NEVER wake a sprite to measure (the cron has no sprite handle by
  construction). New pure fns: computeElapsedGbMonths({measuredGB,elapsedMs})
  and pickBillableGB({lastMeasuredGB,lastMeasuredAt,awake,now})->{gb,stale}.
- Opportunistic measurement (terminal-storage-measure.ts): pure df parser +
  bytes→GB + throttle decision, plus a DI'd shell that runs one cheap `df`
  through an ALREADY-awake machine handle and persists {bytes,at}. Wired into
  the agent tool-runner's session-open seam (real work = sprite already awake),
  throttled and fully best-effort.
- Never-measured machines bill a conservative 0 floor (not the cap) and still
  advance their watermark, so a later measurement never bills the un-measured
  span retroactively — clean cutover from allocation-billing, idempotency
  preserved.
- Schema: terminal_sessions.storageMeasuredBytes/At (migration 0194).
- staleMeasurements health counter surfaced in the cron log + audit.
- Updated the docstrings that asserted the allocation premise; noted HOT/COLD
  are infra layers, not billing tiers (per-tier split is a follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
…tree, keep sub-cent residuals, throttle in-process

Adversarial code-review pass (high effort) on the measured-bytes cutover surfaced
several billing-correctness issues. Fixes:

- Measure the workspace SUBTREE via `du -sbx /workspace`, not `df` of the whole
  filesystem. `df` would also count the read-only OS/base-image bytes if the
  workspace is a dir on the root overlay rather than a dedicated mount — over-
  billing every machine by the base-image size, the very "bill allocation not
  bytes-written" bug this leaf removes. `du` counts only what the workload wrote.
  (Trade-off: bytes written outside the workspace aren't counted — a deliberate
  conservative under-count, consistent with the never-measured 0 floor.)
- Don't discard sub-cent residuals: the zero-charge watermark advance now applies
  ONLY to never-measured rows (the cutover case). A MEASURED tiny footprint whose
  per-window cost rounds to $0 no longer advances the watermark, so the residual
  accrues across windows until it crosses the pricing floor and actually bills —
  previously a small machine on a frequent cron billed $0 forever.
- In-process per-page throttle cache in measureMachineStorageOpportunistically:
  the tool-runner calls it on every op, but a machine needs measuring once per
  window — the cache short-circuits before the DB read, so a 30-tool-call turn
  does one measurement attempt, not 30 wasted SELECTs. Persisted measuredAt is
  still the authoritative throttle (survives restart / multi-instance).
- Documented the bounded (≤ one cron interval, one-time) retroactive charge when
  a first measurement lands, and the "never wake to measure" trade-offs
  (PTY-only coverage gap → 0 floor; shrink-lag billing last-known, surfaced by
  staleMeasurements) instead of over-claiming "never retroactive".

Not changed (scope): realtime-PTY-connect measurement lives in the lane-shared
agent-terminal-handler.ts (out of this leaf); the agent-run path is wired and the
helper is ready for other wake paths to call. Concurrent `du` is a separate
spawned process on the VM (sprite.spawn), so it does not serialize with the
primary op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
…p (review threads)

Two reviewer findings on the measured-bytes cutover:

- P1 — PTY-only machines were never measured (measurement was wired only at the
  agent tool-runner), so a machine used solely through the interactive terminal
  stayed never-measured and the reconcile advanced its watermark every tick,
  billing $0 forever. Wire the terminal-CONNECT wake in apps/realtime's
  buildMachineSandbox.acquire() to measure opportunistically. The network attach
  is lazy — measureMachineStorageOpportunistically now takes a `resolveHandle`
  thunk it calls ONLY after the in-process throttle + row-existence checks pass,
  so a throttled connect pays no attach.

- P2 — the agent-path measurement fired in openSession BEFORE the op ran, so it
  captured the pre-write footprint and the throttle then suppressed the
  post-write measurement. Move it into the session `release` thunk, which the
  runners call in `finally` AFTER the op, so it observes what the op wrote and
  runs sequentially (not concurrently) with it.

Tests: resolveHandle lazy-attach path (attaches only when due + row exists; not
on a throttled repeat or a missing row); existing tool-runner seam tests still
green (measurement fires via release). typecheck lib/web/realtime + lint clean;
sandbox suite 446/446.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
…ttle too

Follow-up to the terminal-connect wiring: check the persisted storageMeasuredAt
throttle BEFORE calling resolveHandle, so a caller with a cold in-process cache
(e.g. a freshly-restarted realtime node) doesn't pay a wasted network attach when
another instance already measured the page within the window. refreshStorageMeasurement
still re-checks; this purely gates the attach. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
…ion review

A second high-effort adversarial review of the review-fix diff found 5 confirmed
billing-correctness bugs I introduced; all fixed:

- Watermark freeze → retroactive over-bill (the worst): my previous change stopped
  advancing storageLastBilledAt for measured sub-cent footprints. A machine at a
  tiny footprint that later grew (e.g. to 100GB) would then be billed the new size
  across the ENTIRE frozen span. Revert to advancing the watermark on every
  zero-cost window (measured or not): the residual lost is a sub-cent for sub-2.4MB
  footprints that genuinely cost ~$0, whereas freezing over-charges the payer for
  storage they didn't hold. Added a regression test proving a grown footprint bills
  only the post-growth window.
- du apparent-size over-bill: `du -sbx` (-b = apparent size) billed a sparse 100GB
  file occupying 1GB of real blocks as 100GB. Switched to `du -sxB1` (actual
  allocated bytes), matching the real disk footprint the platform bills.
- Partial du under-bill: parsing du output regardless of exit code persisted an
  under-counted total when du couldn't read part of the tree. Restored the
  exit-code guard (skip + retry next window on non-zero).
- Transient-failure lockout: the in-process throttle stamped BEFORE the exec, so a
  transient measure failure suppressed re-measurement for the whole window on that
  instance. Now stamp only on a definitive outcome (measured success / no-row /
  already-fresh); transient failures stay retryable. Added a regression test.
- Unbounded Map memory leak: the in-process throttle Map grew per distinct pageId
  forever. Bounded to 10k entries with oldest-first eviction.

Tests: sandbox suite 449/449; typecheck + lint clean (lib/web/realtime).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
… (review round 3)

A third adversarial review showed the round-2 du exit-code guard over-corrected,
and flagged a lost concurrent-dedup. Fixes:

- Persist any PARSEABLE du total, even on non-zero exit. `du -s` always prints a
  valid cumulative total of what it read; a non-zero exit only means it skipped
  unreadable entries, so the total is a conservative LOWER BOUND, never garbage.
  The prior "reject non-zero" guard meant a permanently-unreadable subtree
  (chmod 000 / root-owned path) billed the 0 floor or a stale value FOREVER and —
  since the caller only caches on measured:true — re-walked the whole workspace
  with `du` on every tool op for the entire window. Persisting the lower bound is
  the right conservative trade-off (self-corrects when readable) and caches, so
  no re-walk storm. True failures (exec threw / no numeric total) still return
  not-measured and stay retryable. This resolves the round-1/2/3 flip-flop.
- Restore synchronous concurrent-dedup: the window clock is stamped only after
  the awaits, so a burst of N parallel ops for one page all passed the gate and
  each spawned a DB read + attach + du walk. Added an in-flight Set added-to
  before the first await and cleared in finally, so a burst collapses to one
  measurement. (Kept off the persisted-throttle path so a transient failure
  still retries — the round-2 fix.)
- Added __resetStorageMeasurementCachesForTests seam so the in-process caches are
  cleared between tests (no cross-case bleed); regression tests for the
  non-zero-exit persist and the 5-parallel-calls→1-du dedup.

Cleanup notes (bounded-LRU vs quota.ts's TTL sweep) left as-is: the LRU is
correct and bounded; aligning the two in-process maps is a separate cleanup.

Tests: sandbox suite 450/450; typecheck + lint clean (lib/web/realtime).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HarN68Tuc5judwB1V7qL4w
@2witstudios
2witstudios force-pushed the pu/sprites-6-1-storage-bytes branch from 0c0c689 to 7d3661b Compare July 11, 2026 02:54
@2witstudios
2witstudios merged commit e608ba5 into master Jul 11, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/sprites-6-1-storage-bytes branch July 11, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant