Skip to content

feat(PTYCAP-01): PTY token accounting — live mid-turn usage, interactive/programmatic bucket split - #395

Open
finedesignz wants to merge 26 commits into
mainfrom
feat/ptycap-01-token-accounting
Open

feat(PTYCAP-01): PTY token accounting — live mid-turn usage, interactive/programmatic bucket split#395
finedesignz wants to merge 26 commits into
mainfrom
feat/ptycap-01-token-accounting

Conversation

@finedesignz

Copy link
Copy Markdown
Owner

Milestone PTYCAP phase 1 of 9. Blocks phases 2-4, which in turn gate 7-9.

Goal

The hub knows what a PTY turn is spending while it spends it, not after. Post-hoc accounting
is too late — an interactive TUI turn can run for many minutes, which is exactly how the
2026-07-11 incident burned 2.83B cache-read tokens before anything noticed.

Success criteria

# Criterion Evidence
SC-1 A live PTY turn's token usage is observable mid-turn supervisor/test/pty-usage-tail.test.ts (17 tests, passing). Wired as its own Woodpecker step in 412ee0d because check-baseline only walks hub/test.
SC-2 Interactive and programmatic usage stay in separate buckets hub/test/token-usage-runner-type.test.ts + hub/test/usage-event-handler.test.ts pass locally. The live-Postgres CHECK-constraint proof is DB-gated and runs in CI.
SC-3 A long-running TUI turn crossing the ceiling mid-flight is detectable hub/test/pty-usage-midflight-visibility.test.ts — DB-gated, runs against CI's postgres:16.

On SC-2/SC-3: the DB-backed assertions are marked status: unknown in their SUMMARYs
because no local Postgres was reachable — deliberately not claimed as passing. They execute
for real in Woodpecker via REMO_E2E_DB_URL. This is the PGlite-vs-real-Postgres divergence
that bit #367 and #371; green CI here is the actual proof, not the local run.

What changed

New supervisor-side emitter (supervisor/src/usage/pty-usage-emitter.ts,
pty-transcript-tail.ts) tails the CLI transcript and threads runner_type end-to-end:
zod field in agent-protocol.ts -> handler in ws/agent.ts -> token-usage-dal.ts INSERT.

Invariants held

  • No provider API key on the PTY path. The three supervisor guard tests plus
    token-cap-coverage and mount-order are byte-identical to main
    git diff origin/main --stat over those paths is empty. Ran directly: 34/34 and 22/22 pass.
  • No cap enforcement change. Phase 1 is accounting and observability only; the gate-chain
    changes land in Phase 2.
  • Actor stays server-inferred. Additive idempotent DDL only.
  • Token accounting counts all four buckets including cache_read.

Verification

bun run check-baseline -> pass=2059 skip=255 fail=0 total=2314, exact baseline match.
(First run showed one failure in transcript-adapter-codex.test.ts; isolated 8/8 pass and a
re-run was clean — transient, consistent with the known Bun mock.module cross-file pollution.)

hub/typecheck reports 418 errors — all pre-existing, proved by running the same command
in a scratch worktree off bare origin/main and diffing the logs: identical error set, only
path text and line shifts differ.

Provenance

These 22 commits were cherry-picked off feat/ptycap-token-gate, which had accidentally
accumulated two unrelated workstreams. The triage/scheduler commits on that branch are already
on main via #394 and were discarded here. That branch is untouched.

🤖 Generated with Claude Code

finedesignz and others added 23 commits July 28, 2026 17:22
… interactive

Tracer slice for PTYCAP Phase 1 (SC-1/SC-2/SC-3): the supervisor now tails an
interactive PTY session's own on-disk Claude transcript JSONL
(supervisor/src/usage/pty-transcript-tail.ts, a verbatim port of the hub's
telegram transcript tailer) and, for each assistant record carrying a usage
block, emits the existing usage_event WS frame additively tagged
runner_type: 'pty-interactive' (supervisor/src/usage/pty-usage-emitter.ts).
session-bridge.ts's ensurePtyRunner() owns the tailer's lifecycle end to end
(start alongside the PTY, stop at all three teardown sites), never on the
critical path for PTY startup.

The hub's AgentUsageEvent zod schema, the usage_event handler, and
recordTokenUsage() all thread the tag through into a new, additive
token_usage.runner_type column (idempotent DDL, safe default, CHECK
constraint, index) so a PTY turn's spend is visible in the ledger while the
turn is still running, instead of never.

Fixed a real bug found in inherited partial work from an interrupted prior
run: hub/src/ws/agent-protocol.ts had named the new zod field `runner`
instead of `runner_type` — since zod strips unknown keys, every PTY row
would have silently landed mislabelled 'stream-json' with no error anywhere.
Caught by re-running the plan's own grep acceptance criterion before trusting
the earlier diff review.

RECORD-only: no gate, block, deny, or dispatch call site added. Scoped to
cli_kind='claude' only (Codex is a documented fast-follow). No API key, no
PTY write, no hub-side filesystem read of a homedir-derived path.

Verified: supervisor/test/pty-usage-tail.test.ts (17/17), bun run schema-lint,
hub/test/usage-event-handler.test.ts, and the no-api-key/no-streamjson-pty +
default-backend-selector + pty-reattach-persistence guard suites all green
unchanged (pty-byte-relay/pty-orphan-teardown fail only on missing native
node-pty on this host — pre-existing, unrelated to this change).
…ree config

- 01-01-SUMMARY.md documents the tracer slice, the inherited runner_type
  naming bug found and fixed, and the two pre-existing/ambient test issues
  ruled out as unrelated to this phase.
- STATE.md / ROADMAP.md updated via gsd-tools (phase begun, plan 01-01 marked
  complete).
- .planning/config.json: workflow.use_worktrees=false for the remainder of
  this phase run — this session resumed with uncommitted partial edits from
  an interrupted prior run, and a fresh isolated worktree per plan would fork
  from HEAD without that diff, risking duplicated or conflicting work on
  merge. All four plans in this phase execute sequentially in this worktree.
… and Postgres

hub/test/token-usage-runner-type.test.ts (new, DB-free):
- recordTokenUsage() with differing runnerType produces distinctly-tagged
  ledger rows; omitted runnerType defaults to 'stream-json'; both buckets
  still upsert ONE combined token_usage_daily row (P1-D-D held).
- AgentUsageEvent zod PRESERVES runner_type on a PTY-shaped frame (this is
  the exact case the inherited `runner` typo, fixed in 01-01, would have
  failed); an old-shape frame with no runner_type parses to undefined; a
  third enum value fails zod parsing outright.
- Source assertion against schema.sql: token_usage_runner_type_check names
  exactly 'stream-json' and 'pty-interactive', comments stripped first.

hub/test/e2e/schema-double-apply.e2e.test.ts (extended, REMO_E2E_DB_URL-gated):
one case proving the CHECK constraint is LIVE against real Postgres — an
out-of-enum runner_type value is rejected, 'pty-interactive' is accepted.
Skips cleanly without REMO_E2E_DB_URL (9 skip, 0 fail locally); the accept/
reject path runs in Woodpecker's postgres:16 service.

Verification-only — zero hub/src or supervisor/src changes.
…records stream-json

Extends hub/test/usage-event-handler.test.ts (3 pre-existing cases unchanged)
with two cases: runnerType omitted records 'stream-json' (the pre-PTYCAP
supervisor shape), and runnerType:'pty-interactive' is captured verbatim (so
the default is a genuine default, not a hardcode). fakeSql's INSERT
destructuring gets the trailing runner_type bind so it reads the current
column order rather than a stale shifted one.
01-02-SUMMARY.md documents the SC-2 proof; ROADMAP.md plan checkbox flipped.
…session-close needed

hub/test/pty-usage-midflight-visibility.test.ts (REMO_E2E_DB_URL-gated, real
Postgres): getTodayTokenTotal() for a fresh user starts at 0; six incremental
pty-interactive writes produce a strictly monotonically increasing sequence
of readings whose final value equals the exact arithmetic sum, with the
ceiling crossing observable at the expected index (SC-3's "detectable
mid-flight" claim, expressed as an observation, not a gate call); a
stream-json row for the same user adds to the SAME total (no second
aggregation path); a cache-read-only record still increases the total (the
2026-07 incident shape an I/O-only sum would have missed). No exit, kill, or
session-close event anywhere in the suite. Imports/invokes no gate — RECORD/
OBSERVE only. Skips cleanly without a DB (6 skip, 0 fail locally).
…system read

hub/test/no-hub-side-transcript-fs.test.ts: walks every .ts file under
hub/src recursively, strips block then line comments, and asserts the set of
files whose remaining code references the home-directory resolver equals
exactly the single allowlisted prefix (hub/src/telegram/transcript/, Phase
20's pre-existing, flag-gated-OFF Telegram tail). The matcher is proven
non-vacuous (it finds both known adapters before filtering), the allowlist
size is asserted so growing it is a deliberate act, and a comment mentioning
the pattern neither trips nor satisfies the check. Dependency-free
(node:fs/node:path/bun:test only), no DB, no network.
…fused before any read

supervisor/test/pty-usage-path-containment.test.ts: a relative projectDir, one
carrying a parent-directory segment, and one carrying a NUL byte each cause
PtyUsageEmitter.start() to log a warn and return without creating any
watcher, timer, or emitted frame. When the located transcript's real path
resolves outside the projects base (simulated via the projectsBase seam
pointing at an unrelated temp dir — no symlink needed, unreliable on Windows
without elevation), start() refuses with a path_escape-shaped log and NEVER
reads the file, even though it holds a valid usage record. A well-formed,
properly contained path still tails and emits normally, proving the guard is
selective. Every refusal path leaves stop() safe to call. Confirms
resolveSessionDir/realPathContained are imported from ../commands/session-read
verbatim, never re-derived.
.woodpecker/qc.yaml: check-baseline walks hub/test only, so it added a
dedicated step running supervisor/test/pty-usage-tail.test.ts (the SC-1
proof) so it actually gates the PR, not just a developer's laptop.

tools/regression-baseline.json: fresh floor-configuration measurement
(no REMO_E2E_DB_URL, no DATABASE_URL) — actual pass=2059 skip=255 fail=0
total=2314 (from recorded 2010/247/0/2257). New _skip_note_ptycap names
every delta: token-usage-runner-type.test.ts (7), no-hub-side-transcript-fs.test.ts
(4), and 2 new usage-event-handler.test.ts cases add 13 DB-free passes;
pty-usage-midflight-visibility.test.ts (6) and one new schema-double-apply.e2e.test.ts
case add 7 REMO_E2E_DB_URL-gated skips. skip_max raised 248 -> 256 (+8, not +7)
because the measured delta also carries 1 pre-existing DB-gated skip in
coolify-webhook-triage-e2e.test.ts already on this branch before Phase 1's
planning commit (unrelated scheduler/triage work) — named explicitly rather
than silently absorbed. pass_min left at 1850 (~10.2% headroom, consistent
with the WORK milestone's precedent of not tightening on every re-measurement).
fail_max stays 0; no unconditional skip introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/usage-cost.md gains a PTYCAP Phase 1 section: the end-to-end flow
(interactive CLI's own transcript JSONL -> PtyUsageEmitter tail ->
usage_event tagged runner_type:'pty-interactive' -> same recordTokenUsage()
path), why the tail lives supervisor-side (no ~/.claude/projects in the
hub's Coolify container, same reason REMO_TELEGRAM_TRANSCRIPT_TAIL stays
OFF there) with a pointer to the no-hub-side-transcript-fs.test.ts guard,
the runner_type domain + its live CHECK constraint, and the four decisions
a future reader would otherwise mistake for oversights: token_usage_daily's
PK stays unsplit, sessions.transcript_path/setSessionPtyIdentity() stays
unwired to this path, accounting is claude-only with Codex as a verified
fast-follow, and PTY cost is always cost_source:'estimated'. Preserves
01-RESEARCH.md Assumption A2 as a named operator smoke check, not a CI gate.

CLAUDE.md's Usage cost ledger Docs map row is extended in place (one
sentence) naming the new PTY source and the runner_type split; nothing
else in the row or table changed.

No HTTP route changed, so docs/openapi.json and docs/api.md are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All 4 plans in PTYCAP-01-pty-token-accounting now executed and summarized.
ROADMAP.md: 01-04-PLAN.md checked off, 4/4 plans executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review of Phase 1 (18 files, standard depth): 1 critical, 3 warning.
CR-01: supervisor/test/pty-usage-path-containment.test.ts (the ASVS V4
containment negative test) is never executed by any CI gate. WR-01: async
race in the ported tailJsonl's fromStart:false initial-offset assignment
could replay a resumed transcript's history as new PTY usage. WR-02:
docs/usage-cost.md misattributes the runner_type default to zod instead of
the hub handler. WR-03: new PTY-tailer tests are real-timer-dependent
(flakiness risk, not novel to this phase).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qc.yaml's explicit belt-and-suspenders step only ran pty-usage-tail.test.ts,
leaving the ASVS V4 path-traversal/symlink-escape negative test for the PTY
tailer unexecuted by any CI gate despite the file's own comment claiming
coverage was needed for exactly this reason. Also corrects the misleading
regression-baseline.json narrative that implied this file was already
CI-gated.
fromStart:false set the initial byte offset via an un-awaited stat().then(),
while fs.watch and the poll timer were armed synchronously right after. For
a resumed transcript whose recent mtime is caused by the CLI's own append
landing right as tailing starts, a watch notification could fire pump()
before offset resolved, replaying the file's entire history as new usage.
Offset now settles before the watcher/poll timer are armed. Ported to both
the supervisor copy and its hub source of truth per the file's own header
comment.
The doc claimed zod defaults the omitted runner_type field to
'stream-json'; the field is actually optional with no zod default and
resolves to undefined when absent — the fallback lives in the hub's
usage_event handler (msg.runner_type ?? 'stream-json'). Reworded to match
what the schema and handler actually do, per token-usage-runner-type.test.ts.
@finedesignz

finedesignz commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

AI Review Gate

Gate verdict: FAILURE — blocking finding from Codex
Head SHA: a822d74bdb7f

Claude Code (QC): pass

  • no findings

Codex: BLOCK

  • [blocking] supervisor/src/usage/pty-usage-emitter.ts — Initial PTY usage can be dropped: After the poll-based locator finds a new transcript, pinAndTail starts tailJsonl with fromStart:false. tailJsonl sets the offset to the file's current size, so any assistant usage record written between CLI spawn and the locator pin is skipped permanently. Fast first turns or files created with content will not be accounted.
  • [blocking] supervisor/src/usage/pty-usage-emitter.ts — PTY cost is always zero: The emitter sends cost_source:'estimated' but hardcodes cost_usd:0 for every nonzero PTY usage record. That under-reports PTY cost instead of emitting the promised estimated cost for the usage ledger.

Policy: both reviewers are blocking — a genuine blocking finding from either fails the gate. An infrastructure failure (quota exhausted, timeout, auth failure, no parseable output) is ADVISORY and never blocks: it means the reviewer never saw the code, which is not a verdict about the code.

finedesignz and others added 3 commits July 28, 2026 17:55
…OCKING)

Codex found that resolveTranscriptPath's mtime/slack window can pin a
SIBLING session's actively-typed-in transcript instead of the new
session's own file, silently attributing another session's token spend
under this session_id -- the worst failure mode for a metering system.

Fix: session-bridge.ts now snapshots the .jsonl basenames already
present in the project's Claude session dir BEFORE spawning the PTY
process (snapshotPreExistingTranscripts). That snapshot is threaded
into PtyUsageEmitter as preExistingNames and excludes those names from
resolveTranscriptPath's candidates outright, regardless of how fresh
their mtime looks -- only a genuinely new file (absent from the
pre-spawn snapshot) can qualify. When nothing new has appeared yet the
locator keeps polling instead of guessing, matching the fail-closed
principle: no usage emitted beats another session's usage misattributed.

Also fixes the QC WARNING: cliKind was derived from a second,
independent resolveHumanBackend() call instead of the id already
resolved for the PTY runner itself -- now resolved once and reused, so
the two can never disagree.

The QC INFO (TOCTOU between the one-time realPathContained check and
tailJsonl's repeated reopens) is judged, not fixed: both sides of the
window are supervisor-local reads on the same host, never hub-reachable
-- an attacker able to plant a symlink there already has local write
access to this user's home dir, which subsumes the check. Documented
inline rather than adding ceremony that buys no real containment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck (QC round 2)

Round-1 fix excluded pre-existing sibling transcripts via a pre-spawn snapshot,
but snapshotPreExistingTranscripts() returned an empty Set whether the session
dir genuinely had no pre-existing files OR simply didn't exist yet / wasn't
readable at snapshot time — indistinguishable, so resolveTranscriptPath() would
still accept any fresh file in the latter case, reopening the same
mis-attribution door through a different route (round-2 Codex BLOCKING finding).

snapshotPreExistingTranscripts() now returns { reliable, names } instead of a
bare Set. reliable:false (dir absent/unreadable at snapshot time) makes
PtyUsageEmitter.start() refuse outright for that session's whole lifetime —
no timer, no watcher, nothing ever pinned — rather than fall back to
"accept any fresh file." resolveTranscriptPath() also now refuses when more
than one qualifying new candidate exists at once (previously silently picked
the lowest mtime, which is exactly the kind of guess between two candidates
that causes mis-attribution); it keeps polling until it resolves to exactly
one, or gives up.

Governing principle unchanged: emitting NO usage for a session is strictly
better than emitting ANOTHER session's usage under it.

Remaining accepted gap: a project directory's very first-ever PTY session
(before ~/.claude/projects/<dir> exists) gets zero usage accounting for that
one session, since the pre-spawn snapshot can't be proven reliable. This is a
one-time-per-project gap, not a recurring one — every subsequent session in
that project dir snapshots a readable directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st-session PTY usage

DEFECT 1: the ADD CONSTRAINT idempotency guard for sessions_cli_kind_check,
sessions_runner_type_check, and token_usage_runner_type_check checked
information_schema.check_constraints by bare constraint_name, unscoped by
table. A same-named constraint anywhere on the search_path (a decoy table,
another schema) satisfies IF NOT EXISTS and the real per-table constraint is
silently never created. Switched all three to pg_constraint scoped by
conrelid. Added an e2e proving a same-named constraint on an unrelated
decoy table no longer fools the guard, both sessions and token_usage
constraints actually bite, and a second full apply stays a clean idempotent
no-op.

DEFECT 2: snapshotPreExistingTranscripts() returned reliable:false both when
the project's session dir didn't exist yet (normal for a project's very
first PTY session) and on genuine read/permission failures, conflating "we
don't know" with "nothing was there." start() then permanently refused to
locate any transcript, so first-session PTY token usage was never recorded
-- undercounting the daily token cap's own inputs. Now distinguishes ENOENT
(dir absent -> reliable:true, empty set: nothing to skip, nothing
ambiguous) from a genuine read failure (reliable:false, unchanged). Added
unit tests: dir-absent now yields reliable:true+empty and the session's own
usage IS recorded once its transcript lands; a genuine unreadable dir
(ENOTDIR) still yields reliable:false. Both new assertions were confirmed
to fail against the pre-fix code before the fix landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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