Skip to content

feat(bin): harvest fleet task usage into a cost ledger - #3584

Open
npayette84 wants to merge 16 commits into
kunchenguid:mainfrom
npayette84:fm/fm-harvester-ledger
Open

feat(bin): harvest fleet task usage into a cost ledger#3584
npayette84 wants to merge 16 commits into
kunchenguid:mainfrom
npayette84:fm/fm-harvester-ledger

Conversation

@npayette84

@npayette84 npayette84 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Intent

Deliver firstmate's fleet usage ledger - the harvester and its report reader - as ONE clean change against upstream main, carrying only harvester-owned code, tests, and documentation.

WHY THIS BRANCH EXISTS. An earlier PR (#3392, no-mistakes run 01M1BVFHA9P1978ZASDF0RRPNJ) was declared invalid by the captain and closed WITHOUT merging because it was built on the wrong base. That branch targeted upstream main, which does not contain the harvester at all, so it structurally bundled a SEPARATE crewmate launch-hardening change (bin/fm-spawn.sh permission flags plus tests/fm-backend-orca.test.sh, tests/fm-secondmate-harness.test.sh and tests/fm-spawn-dispatch-profile.test.sh, which assert those flag strings). Its two required workflows also never ran, both sitting at action_required. That branch and its exact head 1b34b95 are deliberately PRESERVED FOR AUDIT and must not be force-pushed or deleted. This replacement was created fresh from current upstream main and carries the harvester ONLY. The launch hardening is deliberately absent and belongs to its own separate change; do not add, restore, or document it here, and treat any reappearance of bin/fm-spawn.sh or .agents/skills/harness-adapters/SKILL.md in this diff as a regression.

WHAT THE CHANGE DOES. bin/fm-usage-harvest.sh appends one JSON line per finished task to the gitignored data/usage-ledger.jsonl; bin/fm-usage-report.sh renders that ledger as a per-model and per-task cost report. The harvester's header comment is the SINGLE authoritative owner of the ledger line schema and of every per-harness usage source; docs/scripts.md carries only the two one-line script rows and AGENTS.md only a one-line data/ inventory entry, both pointing at that owner. No docs/verification/ record was added because the colocated behavioral tests own the guarantee.

Per-harness token sources, each verified against real logs: claude sums per-request usage from the encoded project directory, deduping on .message.id because Claude logs one entry per content block; codex sums per-turn token_count deltas from sessions whose recorded cwd equals the task worktree; pi and pi-signed share ONE scan of ~/.pi/agent/sessions because pi-signed is a signed wrapper around the same Pi application over the same state tree, with each assistant message record carrying one request's usage and the binding taken from the cwd in the log's own session record rather than any reconstructed directory encoding, which is immune to encoding drift. Pi's model is reported provider-qualified (zai/glm-5.3-flash) so a log-derived model matches the spelling fm-spawn already records in task metadata. Pi usage fields are disjoint counts, verified on real logs where input + output + cacheRead equals Pi's own totalTokens. opencode, grok, kimi, cursor, muse, a task with a recorded remote_host, and any scan yielding no assistant usage all report null tokens with source unavailable, so an available source asserts the same thing across all three parsers.

The task window runs from the epoch embedded in the meta's spawn_gen incarnation token to the last status append. Metadata modification time is deliberately NOT a start source: fm-spawn writes spawn_gen once per incarnation and never rewrites it, while later meta writes such as PR registration move the mtime forward to near the end of the task, which collapsed wall_secs to zero AND inverted the log-matching window so every session log was dropped. A relaunch never narrows a row below the turn count's own span, and a start later than the end is pinned to the end rather than inverting the window.

Teardown harvests BEFORE status_retire_presentation_task, because that call deletes state/.status, which supplies both the task window and the turn count. The harvest is best effort: a failure warns on stderr and never blocks teardown.

Robustness the review rounds established and that must be preserved: parsers skip a malformed or valid-non-object log line rather than losing the whole file's usage; neither the harvester nor the report collapses empty TSV fields, so an absent model or harness cannot shift the token columns; the epoch-to-touch stamp is rendered in the same local zone touch -t reads, since a UTC-rendered stamp shifted both window refs by the UTC offset on non-UTC hosts; the ledger append happens inside the existing lock with no temp file to leak; and the report fails loudly on an unparseable ledger rather than reporting empty sections.

CONSTRAINTS. This is firstmate's shared tracked material, so .agents/skills/firstmate-coding-guidelines governs: one owner per contract, one full sentence per line in tracked Markdown, plain dash never em dash, no agent co-author on any commit, and bin/*.sh shellcheck-clean under bin/fm-lint.sh. Tests must exercise behavior through the public script interface and must never assert implementation source bytes. Preserve behavior for claude, codex, pi, pi-signed, opencode, grok, kimi, cursor, muse, remote-secondmate tasks, and every runtime backend. ask-user findings are never mine to answer: I escalate them to firstmate and do not resolve them myself, and --yes must not be used.

ALREADY VERIFIED ON THIS EXACT HEAD (base 4ad8cba, current upstream main): tests/fm-usage-harvest.test.sh passes 23 of 23 including the relaunch-scope contract, corrupt-line and non-object-line tolerance, absent-model and absent-harness column safety, and source-named-only-after-a-parsed-match; bin/fm-lint.sh exits 0 on ShellCheck 0.11.0 with full extended analysis and actionlint 1.7.12 with 3 workflow files valid; bin/fm-doc-audience-check.sh ok at 89 surfaces and 299 local links; bin/fm-test-run.sh --check-coverage ok at total=176. End-to-end against the REAL unmodified ~/.pi/agent/sessions tree, a pi task whose metadata was rewritten to the end of the task reports wall_secs 3660, turns 2, input 24664, cached 25088, output 1084, reasoning 879 from source pi-sessions, matching an independent jq recomputation over the same log exactly on all four token fields, where the pre-change harvester reported wall_secs 0 with null tokens and source unavailable. bin/fm-spawn.sh, .agents/skills/harness-adapters/SKILL.md and the three launch-flag test files are confirmed untouched on this branch.

What Changed

  • New bin/fm-usage-harvest.sh appends one JSON line per finished task incarnation to the gitignored data/usage-ledger.jsonl, keyed on the (task, spawn_gen) pair so a recycled task id earns a row per spawn. Its header is the sole owner of the line schema and of every per-harness source: claude sums per-request usage from the encoded project directory and dedupes on .message.id, codex sums per-turn token_count deltas for sessions whose recorded cwd is the task worktree, pi and pi-signed share one scan of ~/.pi/agent/sessions bound by the cwd in each log's own session record and report the model provider-qualified, and opencode, grok, kimi, cursor, muse, remote-host tasks and any scan with no assistant usage all report null tokens with source=unavailable. The task window runs from the epoch in the meta's spawn_gen token to the last status append, not from meta mtime, which later writes such as PR registration push toward the end of the task.
  • New bin/fm-usage-report.sh renders that ledger as aligned per-model and per-task plain text. Both sections are computed before anything prints, so an unparseable ledger exits non-zero with a named diagnostic instead of empty sections, and rows are read over the unit separator so an absent model or harness cannot shift the token columns.
  • bin/fm-teardown.sh now harvests at three sites, all best effort: a failure warns on stderr and never blocks teardown. The local task path and the forced secondmate child cleanup split the work, scanning before the worktree release while the task still holds its pooled slot and appending from a per-run staging directory once the retaining refusals have passed, immediately before status_retire_presentation_task deletes state/<id>.status and with it the window and the turn count; the remote secondmate path harvests in one shot, since a remote task has no local worktree to scan. tests/fm-usage-harvest.test.sh drives both scripts through their public interfaces across the harnesses, relaunch scope, corrupt and non-object log lines, absent model and harness columns, ledger locking, and the teardown ordering, with one-line entries added to docs/scripts.md and the data/ inventory in AGENTS.md.

Risk Assessment

✅ Low: The change is additive and well-bounded: two new scripts plus best-effort, warning-only call sites in teardown that cannot alter its control flow or exit status, backed by 41 behavioral test cases, with only cosmetic duplication surfacing in review.

Testing

I ran the colocated behavioral suite tests/fm-usage-harvest.test.sh (40 checks, all pass) and then demonstrated the feature end-to-end against the real, unmodified ~/.pi/agent/sessions and ~/.claude/projects trees on this host, using task records pointed at worktrees that real sessions actually ran in and with each meta's mtime deliberately touched forward to the end of the task. The pi row reports wall_secs 52221, turns 2, input 116211, cached 1368832, output 15567, reasoning 6976 from source pi-sessions with the model reported provider-qualified, and the claude row reports input 2240, cached 216242085, output 578158, reasoning 279700 - both matching independent jq recomputations over the same logs on every token field, with the claude case exercising a dedupe that collapses 1773 raw entries onto 1125 requests. Stripping spawn_gen from the same pi meta reproduces the collapse the change exists to prevent (wall_secs 52221 to 0), a cursor task and a remote_host task both land as null tokens with source unavailable, re-harvesting appends nothing, and the report renders the ledger, tolerates an absent one and fails loudly on a truncated one. I also confirmed the diff carries only the six harvester-owned files with no reappearance of bin/fm-spawn.sh, the harness-adapters skill, or the three launch-flag test files, and that the ledger path is gitignored. The temporary fixture home was removed and the worktree is clean; no source or test files were modified. This change is CLI-only with no rendered UI surface, so the reviewer-visible evidence is the ledger JSON lines and the rendered report transcript rather than screenshots.

Evidence: End-to-end transcript against the real pi and claude session trees

Source: End-to-end transcript against the real pi and claude session trees

# fleet usage ledger: end-to-end against the real harness session trees

Read-only against the unmodified `~/.pi/agent/sessions` and `~/.claude/projects`
trees on this host. Two synthetic task records (`state/<id>.meta` +
`state/<id>.status`) were pointed at worktrees that real sessions actually ran
in, and each meta's mtime was deliberately touched forward to the END of the
task, which is what a late PR-registration write does in production.

## 1. pi task, meta rewritten to the end of the task

meta: harness=pi, model=default, spawn_gen=s1788344756..., worktree=
/Users/npayette/.treehouse/specira-website-34a67e/1/specira-website
meta mtime and status mtime both forced to 2026-09-03T00:56:17Z (the task end).

    $ bin/fm-usage-harvest.sh ship-website-perf
    $ cat data/usage-ledger.jsonl
    {"task":"ship-website-perf","spawn_gen":"s1788344756.4711.a1b2c3","harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":null,"spawned_at":"2026-09-02T10:25:56Z","completed_at":"2026-09-03T00:56:17Z","wall_secs":52221,"turns":2,"input_tokens":116211,"cached_input_tokens":1368832,"output_tokens":15567,"reasoning_tokens":6976,"source":"pi-sessions"}

Independent recomputation over the same real log, straight from jq:

    $ jq -s '[.[]|select(.type=="message" and .message.role=="assistant" and .message.usage!=null)]
             | {records:length, input:(map(.message.usage.input//0)|add),
                cacheRead:(map(.message.usage.cacheRead//0)|add),
                cacheWrite:(map(.message.usage.cacheWrite//0)|add),
                output:(map(.message.usage.output//0)|add),
                reasoning:(map(.message.usage.reasoning//0)|add)}' \
        ~/.pi/agent/sessions/--Users-npayette-.treehouse-specira-website-34a67e-1-specira-website--/*.jsonl
    { "records": 40, "input": 116211, "cacheRead": 1368832, "cacheWrite": 0,
      "output": 15567, "reasoning": 6976 }

input 116211, cached 1368832 (cacheRead + cacheWrite), output 15567 and
reasoning 6976 match the ledger row exactly. wall_secs 52221 is the real span
from the spawn_gen epoch to the last status append, and the model is reported
provider-qualified as openai-codex/gpt-5.6-sol.

### counterfactual: strip the durable spawn_gen from that same meta

The start then has no durable source and falls back to the file timestamps,
which is the collapse the change exists to avoid:

    {"task":"ship-website-perf","spawn_gen":null,"harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":null,"spawned_at":"2026-09-03T00:56:17Z","completed_at":"2026-09-03T00:56:17Z","wall_secs":0,"turns":2,"input_tokens":116211,"cached_input_tokens":1368832,"output_tokens":15567,"reasoning_tokens":6976,"source":"pi-sessions"}

wall_secs drops 52221 -> 0 and spawned_at collapses onto completed_at.

## 2. claude task: per-request dedupe on .message.id over real logs

meta: harness=claude, model=default, effort=high, worktree=
/Users/npayette/.no-mistakes/worktrees/337060f4dfd2/01M1D1VTTW7SQ2RWF5T2H7NH68
That project directory holds 1773 raw usage entries across 1125 distinct
.message.id values, so the dedupe is load-bearing here rather than a no-op.

    $ bin/fm-usage-harvest.sh fix-ledger-window
    {"task":"fix-ledger-window","spawn_gen":"s1788218000.9021.zz9plu","harness":"claude","model":"claude-opus-5","effort":"high","spawned_at":"2026-08-31T23:13:20Z","completed_at":"2026-09-03T08:12:43Z","wall_secs":205163,"turns":7,"input_tokens":2240,"cached_input_tokens":216242085,"output_tokens":578158,"reasoning_tokens":279700,"source":"claude-projects"}

Independent deduped recomputation over the same logs:

    $ cat ~/.claude/projects/-Users-npayette--no-mistakes-worktrees-337060f4dfd2-01M1D1VTTW7SQ2RWF5T2H7NH68/*.jsonl |
      jq -rs '[.[]|select(.type=="assistant" and .message.usage!=null)] | group_by(.message.id)
              | map(.[0].message.usage)
              | {reqs:length, input:(map(.input_tokens//0)|add),
                 cached:(map((.cache_read_input_tokens//0)+(.cache_creation_input_tokens//0))|add),
                 output:(map(.output_tokens//0)|add),
                 reasoning:(map(.output_tokens_details.thinking_tokens//0)|add)}'
    { "reqs": 1125, "input": 2240, "cached": 216242085,
      "output": 578158, "reasoning": 279700 }

All four token fields match the ledger row.

## 3. sources that report no tokens

A cursor task and a pi task carrying remote_host=orca-02.fleet both land as
null tokens with source unavailable, and the remote one is not credited with
any local pi log even though its worktree has one:

    {"task":"audit-cursor-run","spawn_gen":"s1788300000.100.aaa","harness":"cursor","model":null,"effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"}
    {"task":"remote-crew-task","spawn_gen":"s1788300000.100.aaa","harness":"pi","model":"zai/glm-5.3-flash","effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"}

## 4. the report reader over that ledger

    $ bin/fm-usage-report.sh
    usage ledger: /var/folders/70/p814fs691f103nxddhx58mhh0000gn/T//fm-usage-e2e.LVOWSu/home/data/usage-ledger.jsonl (4 rows)
    
    per-model totals (source-available rows):
    model                     tasks        input       cached       output    reasoning  wall_secs
    claude-opus-5                 1         2240    216242085       578158       279700     205163
    openai-codex/gpt-5.6-sol      1       116211      1368832        15567         6976      52221
    
    per-task rows:
    task                     spawned_at           harness  model                effort          input       cached       output    reasoning  wall_secs source          
    audit-cursor-run         2026-09-01T22:00:00Z cursor   -                    -                   -            -            -            -       3600 unavailable     
    fix-ledger-window        2026-08-31T23:13:20Z claude   claude-opus-5        high             2240    216242085       578158       279700     205163 claude-projects 
    remote-crew-task         2026-09-01T22:00:00Z pi       zai/glm-5.3-flash    -                   -            -            -            -       3600 unavailable     
    ship-website-perf        2026-09-02T10:25:56Z pi       openai-codex/gpt-5.6-sol -              116211      1368832        15567         6976      52221 pi-sessions     

Unavailable rows render "-" in every token column without shifting the columns,
and they are excluded from the per-model totals.

## 5. idempotency and the report's failure mode

    $ for t in ship-website-perf fix-ledger-window audit-cursor-run remote-crew-task; do
        bin/fm-usage-harvest.sh "$t"; done
    $ wc -l data/usage-ledger.jsonl
    rows before=4 after=4

    $ bin/fm-usage-report.sh /absent/usage-ledger.jsonl
    no usage ledger at /absent/usage-ledger.jsonl
    exit=0

    $ printf '{"task":"half-written"\n' >> broken.jsonl && bin/fm-usage-report.sh broken.jsonl
    jq: parse error: Unfinished JSON term at EOF at line 6, column 0
    error: broken.jsonl: ledger is not readable as JSON lines
    exit=1
Evidence: Harvested usage ledger (data/usage-ledger.jsonl)

Source: Harvested usage ledger (data/usage-ledger.jsonl)

{"task":"ship-website-perf","spawn_gen":"s1788344756.4711.a1b2c3","harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":null,"spawned_at":"2026-09-02T10:25:56Z","completed_at":"2026-09-03T00:56:17Z","wall_secs":52221,"turns":2,"input_tokens":116211,"cached_input_tokens":1368832,"output_tokens":15567,"reasoning_tokens":6976,"source":"pi-sessions"} {"task":"fix-ledger-window","spawn_gen":"s1788218000.9021.zz9plu","harness":"claude","model":"claude-opus-5","effort":"high","spawned_at":"2026-08-31T23:13:20Z","completed_at":"2026-09-03T08:12:43Z","wall_secs":205163,"turns":7,"input_tokens":2240,"cached_input_tokens":216242085,"output_tokens":578158,"reasoning_tokens":279700,"source":"claude-projects"} {"task":"audit-cursor-run","spawn_gen":"s1788300000.100.aaa","harness":"cursor","model":null,"effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"} {"task":"remote-crew-task","spawn_gen":"s1788300000.100.aaa","harness":"pi","model":"zai/glm-5.3-flash","effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"}

{"task":"ship-website-perf","spawn_gen":"s1788344756.4711.a1b2c3","harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":null,"spawned_at":"2026-09-02T10:25:56Z","completed_at":"2026-09-03T00:56:17Z","wall_secs":52221,"turns":2,"input_tokens":116211,"cached_input_tokens":1368832,"output_tokens":15567,"reasoning_tokens":6976,"source":"pi-sessions"}
{"task":"fix-ledger-window","spawn_gen":"s1788218000.9021.zz9plu","harness":"claude","model":"claude-opus-5","effort":"high","spawned_at":"2026-08-31T23:13:20Z","completed_at":"2026-09-03T08:12:43Z","wall_secs":205163,"turns":7,"input_tokens":2240,"cached_input_tokens":216242085,"output_tokens":578158,"reasoning_tokens":279700,"source":"claude-projects"}
{"task":"audit-cursor-run","spawn_gen":"s1788300000.100.aaa","harness":"cursor","model":null,"effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"}
{"task":"remote-crew-task","spawn_gen":"s1788300000.100.aaa","harness":"pi","model":"zai/glm-5.3-flash","effort":null,"spawned_at":"2026-09-01T22:00:00Z","completed_at":"2026-09-01T23:00:00Z","wall_secs":3600,"turns":1,"input_tokens":null,"cached_input_tokens":null,"output_tokens":null,"reasoning_tokens":null,"source":"unavailable"}
Evidence: Rendered report (bin/fm-usage-report.sh)

Source: Rendered report (bin/fm-usage-report.sh)

usage ledger: .../data/usage-ledger.jsonl (4 rows) per-model totals (source-available rows): model tasks input cached output reasoning wall_secs claude-opus-5 1 2240 216242085 578158 279700 205163 openai-codex/gpt-5.6-sol 1 116211 1368832 15567 6976 52221 per-task rows: task spawned_at harness model effort input cached output reasoning wall_secs source audit-cursor-run 2026-09-01T22:00:00Z cursor - - - - - - 3600 unavailable fix-ledger-window 2026-08-31T23:13:20Z claude claude-opus-5 high 2240 216242085 578158 279700 205163 claude-projects remote-crew-task 2026-09-01T22:00:00Z pi zai/glm-5.3-flash - - - - - 3600 unavailable ship-website-perf 2026-09-02T10:25:56Z pi openai-codex/gpt-5.6-sol - 116211 1368832 15567 6976 52221 pi-sessions

usage ledger: /var/folders/70/p814fs691f103nxddhx58mhh0000gn/T//fm-usage-e2e.LVOWSu/home/data/usage-ledger.jsonl (4 rows)

per-model totals (source-available rows):
model                     tasks        input       cached       output    reasoning  wall_secs
claude-opus-5                 1         2240    216242085       578158       279700     205163
openai-codex/gpt-5.6-sol      1       116211      1368832        15567         6976      52221

per-task rows:
task                     spawned_at           harness  model                effort          input       cached       output    reasoning  wall_secs source          
audit-cursor-run         2026-09-01T22:00:00Z cursor   -                    -                   -            -            -            -       3600 unavailable     
fix-ledger-window        2026-08-31T23:13:20Z claude   claude-opus-5        high             2240    216242085       578158       279700     205163 claude-projects 
remote-crew-task         2026-09-01T22:00:00Z pi       zai/glm-5.3-flash    -                   -            -            -            -       3600 unavailable     
ship-website-perf        2026-09-02T10:25:56Z pi       openai-codex/gpt-5.6-sol -              116211      1368832        15567         6976      52221 pi-sessions     
- Outcome: ⚠️ 1 info across 1 run (10m25s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ℹ️ bin/fm-usage-harvest.sh:444 - LEDGER=&#34;$DATA/usage-ledger.jsonl&#34; is assigned twice with the identical value: once at line 267 with the other path constants, and again at line 444 after the window setup. The second assignment is dead and invites the two from drifting apart if the ledger path ever moves. Remove line 444.
  • ℹ️ bin/fm-usage-report.sh:84 - Each report section writes its printf format string twice: the 7-field per-model format at lines 73 and 78, and the 11-field per-task format at lines 84 and 89. The header and the row body must stay byte-identical or the columns silently misalign, and nothing enforces that. Hoist each format into a variable used by both the header printf and the loop body.
  • ℹ️ bin/fm-usage-harvest.sh:528 - The [ -z &#34;$REMOTE_HOST&#34; ] &amp;&amp; [ -n &#34;$WORKTREE&#34; ] scan precondition is repeated verbatim in all three harness branches (lines 528, 556, 586). It is one rule the header already states once for every harness, so it can be hoisted to guard the whole case block, leaving each branch to carry only its own directory, probe, and jq program.
⚠️ **Test** - 1 info
  • ℹ️ bin/fm-usage-report.sh:84 - Cosmetic only: in the report's per-task section the model column is printf '%-20s', so a longer model id such as "openai-codex/gpt-5.6-sol" (24 chars) overflows and shifts that row's remaining columns right by four characters. Fields stay space-separated and every value is still correct and readable, and the per-model section (%-24s) is unaffected. Visible in the attached usage-report.txt, last row. Flagging it because it shows up in the reviewer-facing artifact, not because anything is wrong with the data.
  • bash tests/fm-usage-harvest.test.sh - 40 behavioral checks, all pass
  • End-to-end harvest of a pi task against the real unmodified ~/.pi/agent/sessions tree, meta mtime forced to the task end: bin/fm-usage-harvest.sh ship-website-perf
  • Independent recomputation of that log's usage with jq -s over .message.usage (input/cacheRead/cacheWrite/output/reasoning), compared field by field against the ledger row
  • Counterfactual with spawn_gen stripped from the same meta, showing wall_secs 52221 -> 0
  • End-to-end harvest of a claude task against the real ~/.claude/projects tree (1773 raw usage entries / 1125 distinct .message.id): bin/fm-usage-harvest.sh fix-ledger-window
  • Independent deduped recomputation with jq -rs &#39;group_by(.message.id)&#39; over the same logs, compared against the ledger row
  • Unavailable-source harvests: a harness=cursor task and a harness=pi task carrying remote_host=orca-02.fleet
  • bin/fm-usage-report.sh rendering the four-row ledger (per-model totals and per-task rows)
  • Idempotency: re-harvesting all four tasks, ledger row count unchanged at 4
  • bin/fm-usage-report.sh /absent/usage-ledger.jsonl (exit 0) and the same on a truncated ledger (exit 1 with a named diagnostic)
  • git diff --name-only 28fb5ac..6d5d71f checked for fm-spawn, harness-adapters, and the three launch-flag test files - none present
  • git check-ignore -v data/usage-ledger.jsonl
⚠️ **Document** - 1 info
  • ℹ️ docs/configuration.md:786 - Judgment call, left unchanged deliberately: the new harvester knobs (FM_USAGE_LEDGER_LOCK_WAIT, FM_USAGE_CLAUDE_DIR, FM_USAGE_CODEX_DIR, FM_USAGE_PI_DIR, FM_USAGE_HARVEST_APPEND_DELAY) are documented only in bin/fm-usage-harvest.sh's header, and the data/ summary at docs/configuration.md:13 still names its records with 'such as' rather than listing the ledger. I did not add either copy: docs/configuration.md's 'Environment variables' list is selective rather than an inventory (roughly 180 other bin/ tunables with defaults, including FM_KIMI_, FM_CONTROL_, FM_LINT_JOBS and FM_REMOTE_JOB_*, are absent from it), docs/scripts.md:4 declares each script's header authoritative for its behavior and contracts, and AGENTS.md:84-93 owns the data/ inventory, so synchronizing a second prose copy would work against the one-owner policy. Flagging it because an operator scanning docs/configuration.md alone will not discover the harvester's log-directory or lock-wait knobs; if the repo wants those knobs indexed there, that is a small follow-up rather than part of this change.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-09-03T08:47:43.649837Z 6d5d71f New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR is not yet safe to merge because normal retirement of a local secondmate can permanently discard its completed child-task usage rows.

Completed children write their rows into the secondmate home's ledger, while normal retirement removes that home without transferring those rows to the surviving fleet ledger; only unfinished children encountered during forced cleanup are harvested into the parent.

Files Needing Attention: bin/fm-teardown.sh

Reviews (5): Last reviewed commit: "no-mistakes: apply CI fixes" | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Triage of #3584 (replacement for closed-invalid #3392; harvester-only, no launch-hardening).

HEAD 25368798315403fecf9d05a9d1418fa4e7ebd292 — attestation MATCH (body no-mistakes-pipeline-attestation:v1 binds this exact SHA). MERGEABLE/UNSTABLE vs main 5fb0ce7628f240f9844f8b4bcd32ecd6155c3778.

Contract-class: new-default. Main has no bin/fm-usage-harvest.sh / no usage-ledger promise. This PR wires harvest into bin/fm-teardown.sh at three sites (main local path scan+append, forced secondmate child cleanup, remote secondmate one-shot) with no config / FM_USAGE_* enable gate — every unconfigured teardown now always scans local session trees and appends data/usage-ledger.jsonl. bin/fm-usage-report.sh is a manual reader (opt-in to read), but the write path is always-on. That is not restore and not opt-in harvest.

VISION (inspected teardown hooks + harvest/report headers + AGENTS/docs rows):

  1. One captain, one interface — aligns (ledger/report stay below-deck CLI; no new captain surface noise).
  2. Authority explicit — does not align as default (always-on harvest + session-log scan without an enable grant; VISION wants new capability as option to enable).
  3. Scripts own mechanics — aligns (deterministic parsers/ledger; no agent judgment in the write path).
  4. Restart is a non-event — aligns (append-only home-private ledger outlives task meta deletion).
  5. Delegation with a spine — aligns (observability only; no merge/dispatch autonomy change).
  6. Fleet outlives vendor — aligns (claude/codex/pi parsers; others null/unavailable; no vendor lock-in).
  7. Scope — aligns (command-layer cost ledger, not forge/CI/merge). Closing align/resist: peace-of-mind cost visibility aligns, but always-on consent assumption resists until captain decides default-on vs opt-in.

Security: clean. Author npayette84 not in blocked-authors. workflow-zero (no .github/** vs main). Local jq/stat/find only; writes gitignored home data/usage-ledger.jsonl; no network/exfil; spawn/launch files untouched (unlike #3392). Greptile SUCCESS only so far.

CI/NM: first-time fork runs were action_required; approved this pass after diff review:

  • CI 33705683075 (now queued)
  • Require no-mistakes 33705683052 (now in_progress)

Land-eligible: NO (new-default; CI/NM not green yet). Captain-flag now: no — wait for green CI+NM+MATCH, then flag for the always-on vs opt-in decision (do not auto-merge).

Overlaps: same-author kitchen-sinks #3298 / #3518; independent ledger attempt #3470 (GodKimba). Prefer this harvester-only replacement over #3392/#3298 style bundles if the captain accepts default-on harvest.

Waiting on: CI/NM this HEAD.

@npayette84
npayette84 force-pushed the fm/fm-harvester-ledger branch from 04ce4fe to b9d3b1d Compare September 3, 2026 04:38

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

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread bin/fm-teardown.sh
Comment on lines +3062 to +3064
elif [ -n "${USAGE_STAGE_MAIN:-}" ] && [ -s "$USAGE_STAGE_MAIN" ]; then
"$FM_ROOT/bin/fm-usage-harvest.sh" --append-from "$USAGE_STAGE_MAIN" "$ID" >/dev/null \
|| echo "warning: usage harvest for $ID failed; continuing teardown" >&2

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 Append the staged row before post-return refusals

Captain, when a local Herdr teardown returns the pooled worktree at line 2941 but endpoint confirmation subsequently refuses at lines 3016-3018, execution never reaches this append and the EXIT trap deletes the staged measurement. If the pool slot is assigned to another task before teardown is retried, the retry scans that same path through the new harvest time and can silently attribute the next occupant's session log to the original task. Append the staged row once the worktree has been released, or persist it across these post-release refusal paths.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Comment thread bin/fm-usage-harvest.sh
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
CLAUDE_DIR="${FM_USAGE_CLAUDE_DIR:-${HOME:-}/.claude/projects}"

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 Read Claude logs from the configured store

When Firstmate runs with CLAUDE_CONFIG_DIR set, fm-spawn.sh explicitly forwards that directory to every Claude worker, so its project session logs are written under that configured store. This default nevertheless always scans $HOME/.claude/projects; unless the operator also knows to set the test seam FM_USAGE_CLAUDE_DIR, every such task is silently recorded with source:"unavailable" and null token counts. Derive the default project-log directory from CLAUDE_CONFIG_DIR when it is present, or persist the selected store in task metadata.

Useful? React with 👍 / 👎.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is waiting on the author.

Re-triage after newer tip b9d3b1d5648217ce8e5ae757703b7926623fea64 (no-mistakes: apply CI fixes) vs main d3fcdfa548f5. Prior stamp was waiting-ci on MATCH head 25368798….

Attestation: MISMATCH — body no-mistakes-pipeline-attestation:v1 still binds 253687983154…, not tip b9d3b1d5….

Contract-class: new-default (unchanged). bin/fm-teardown.sh still calls harvest at three sites with || warn only — no config / FM_USAGE_* enable gate. Unconfigured teardowns always scan session logs and append data/usage-ledger.jsonl. Report reader is opt-in to read; the write path is always-on.

VISION:

  1. One captain, one interface — aligns (below-deck CLI).
  2. Authority explicit — does not align as default (always-on harvest without enable grant).
  3. Scripts own mechanics — aligns.
  4. Restart non-event — aligns (append-only home ledger).
  5. Delegation with a spine — aligns (observability only).
  6. Fleet outlives vendor — aligns (multi-harness parsers).
  7. Scope — aligns (command-layer cost ledger). Closing: cost visibility aligns; always-on consent assumption resists until captain picks default-on vs opt-in.

Security: clean. Author not blocked. No .github/**.

Workflow approval this pass (tip action_required after diff review):

  • Require no-mistakes 33715790353
  • CI 33715790399

Land-eligible: NO (new-default + attestation MISMATCH; CI/NM just re-armed). Captain-flag now: no — wait for MATCH + green CI/NM, then flag for the always-on vs opt-in decision (do not auto-merge; do not rebase solely for that flag).

Please re-raise via no-mistakes so attestation matches tip b9d3b1d5…. Waiting on you.

@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: 834cc7aa56

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread bin/fm-usage-harvest.sh
Comment on lines +529 to +530
encoded=${WORKTREE//\//-}
encoded=${encoded//./-}

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 Use collision-free binding for Claude sessions

Captain, when distinct worktree paths differ only by dots versus hyphens, this encoding maps them to the same Claude project directory (for example, /tmp/foo.bar/wt and /tmp/foo-bar/wt both become -tmp-foo-bar-wt). Because Claude logs contain no cwd for a secondary check, overlapping tasks at those paths will each sum the other task's usage and silently corrupt cost attribution; persist a collision-free session identity or detect ambiguous bindings instead.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Comment thread bin/fm-usage-harvest.sh
Comment on lines +434 to +436
# Both renderings are LOCAL time because that is what touch -t reads; a UTC
# stamp would shift both window refs by the host's offset and drop real logs.
date -r "$1" +%Y%m%d%H%M.%S 2>/dev/null || date -d "@$1" +%Y%m%d%H%M.%S

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 Create mtime references without ambiguous local timestamps

On hosts in a DST-observing timezone, an epoch during the repeated fall-back hour is formatted as an ambiguous local touch -t timestamp. Both occurrences of, for example, 01:30 format identically, and touch can resolve the reference to the first occurrence; a task running during the second occurrence can then have valid logs rejected by the find prefilter and be recorded with unavailable usage. Create the references from unambiguous epochs or use a consistently UTC-aware mechanism.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Comment thread bin/fm-usage-report.sh
# same id can appear more than once here. The rows are sorted by task and then
# by spawned_at, and spawned_at is rendered, so those runs are told apart by
# when they started and their order is defined rather than append order.
if ! TASK_ROWS=$(jq -rs '

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 Read the ledger from one consistent snapshot

When a task finishes while this report is running, the first jq, this second jq, and the later wc -l each read the append-only ledger independently without participating in its lock. A concurrent append can therefore produce per-model totals that omit a task shown in the per-task section, a row count that matches neither section, or a transient parse failure if a read catches the new row mid-write. Read the file once into a snapshot, or hold the ledger lock across all three computations.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Comment thread bin/fm-usage-harvest.sh
# depends on that order to resolve the model to the last incarnation's.
while IFS= read -r f; do
m=$(file_mtime_epoch "$f") || continue
if [ "$m" -ge "$START_EPOCH" ] && [ "$m" -le "$SCAN_END_EPOCH" ]; then

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 Exclude prior occupants at the start-second boundary

When a pooled worktree is reassigned in the same whole second that the previous task's session log receives its final write, this inclusive comparison admits that previous log into the new task's window. Both spawn_gen and file mtimes are reduced to integer seconds, and the reused cwd or Claude directory then provides no way to distinguish the prior occupant, so its tokens are silently charged to the new task. Preserve subsecond timing or filter on each session's recorded start identity before summing it.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Comment thread bin/fm-usage-harvest.sh
+ ($l.message.usage.cacheWrite // 0))
| .ot += ($l.message.usage.output // 0)
| .rt += ($l.message.usage.reasoning // 0)
| (if .m == null and ($l.message.model // null) != null then

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 Track the final model in a Pi session

When a Pi task changes models within one session, every assistant record carries its actual model but this condition assigns .m only for the first such record. Usage from later requests is still added to the row, while the row and per-model report remain labeled with the obsolete initial model rather than the final one. Update the selected model on each accepted usage record, or consume the session's model-change records, so the task is not attributed to the wrong model.

AGENTS.md reference: AGENTS.md:L90-L90

Useful? React with 👍 / 👎.

Record what each finished task actually cost. bin/fm-usage-harvest.sh
appends one JSON line per harvested task to the gitignored
data/usage-ledger.jsonl, and bin/fm-usage-report.sh renders that ledger as
a per-model and per-task cost report. The harvester's header is the sole
owner of the line schema and of every per-harness usage source.

Token usage is read from each worker's own session log. claude sums
per-request usage from the encoded project directory, deduping on
.message.id because Claude logs one entry per content block. codex sums
per-turn token_count deltas from sessions whose recorded cwd matches the
task worktree. pi and pi-signed run the same Pi application over one
~/.pi/agent state tree, so one scan covers both: each assistant message
record carries one request's usage, bound to the task by the cwd in the
log's own session record, with the model reported provider-qualified to
match the spelling fm-spawn records in task metadata. Every other harness,
a task with a recorded remote_host, and any scan that yields no assistant
usage report null tokens with source unavailable, so an available source
asserts the same thing for all three parsers.

The task window runs from the epoch embedded in the meta's spawn_gen
incarnation token to the last status append. Metadata modification time is
deliberately not a start source: later writes such as PR registration move
it forward to near the end of the task, which collapsed the window to zero
and dropped every session log. A relaunch narrows nothing below the turn
count's own span, and a start later than the end is pinned rather than
inverting the window.

Teardown harvests before retiring the task's status presentation, because
that retirement deletes state/<id>.status, which supplies both the window
and the turn count. The harvest is best effort: a failure warns on stderr
and never blocks teardown.

Parsers skip a malformed or non-object log line rather than losing the rest
of the file, and both the harvester and the report read their TSV rows
without collapsing empty fields, so an absent model or harness cannot shift
the token columns.
@npayette84
npayette84 force-pushed the fm/fm-harvester-ledger branch from 834cc7a to 6d5d71f Compare September 3, 2026 08:46
@npayette84 npayette84 changed the title feat(bin): add the fleet usage ledger harvester and report reader feat(bin): harvest fleet task usage into a cost ledger Sep 3, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is waiting on the author.

Re-triage on tip 6d5d71f8fe9fbbf50e6e42b96aafdc9f1fd759b6 (no-mistakes: apply CI fixes) vs main 3d2a08b2097dd24f9ce03fdafe6501e39b79dbd0. Prior stamps: waiting-ci MATCH 25368798…, then waiting-author MISMATCH b9d3b1d5….

Contract-class: new-default (unchanged). bin/fm-teardown.sh harvests at three sites with || warn only — no config / FM_USAGE_* enable gate. Unconfigured teardowns always scan session logs and append data/usage-ledger.jsonl. Report reader is opt-in to read; the write path is always-on.

VISION:

  1. One captain, one interface — aligns (below-deck CLI).
  2. Authority explicit — does not align as default (always-on harvest without enable grant).
  3. Scripts own mechanics — aligns.
  4. Restart non-event — aligns (append-only home ledger).
  5. Delegation with a spine — aligns (observability only).
  6. Fleet outlives vendor — aligns (multi-harness parsers).
  7. Scope — aligns (command-layer cost ledger). Closing: cost visibility aligns; always-on consent assumption resists until captain picks default-on vs opt-in.

Security: clean. No .github/**. Local jq/stat/find; gitignored ledger; no network/exfil; spawn/launch files untouched.

Greptile: FAILURE — normal retirement of a local secondmate can permanently discard completed child-task usage rows (children write into the secondmate home ledger; normal retirement removes that home without transferring rows; only unfinished children on forced cleanup are harvested into the parent). Author must address or deliberately document that loss as out-of-scope.

Attestation: MATCH (body binds tip; review/test/document completed).

Workflow approval this pass (tip action_required):

  • CI 33735206374
  • Require no-mistakes 33735290339 (edited; SUCCESS after approve) and 33735206354 (synchronize; approved)

Land-eligible: NO (new-default; Greptile FAILURE; CI not green yet). Firstmate-flag: no — not otherwise-ready (Greptile + CI still open; even when green, flag only for the always-on vs opt-in decision, never auto-merge).

Please fix or explicitly bound the secondmate-ledger loss Greptile found, keep attestation MATCH on tip, and wait for green CI.

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