Skip to content

feat(bin): add a durable task-usage ledger that outlives task cleanup - #3470

Open
GodKimba wants to merge 32 commits into
kunchenguid:mainfrom
GodKimba:fm/firstmate-durable-task-usage-ledger
Open

feat(bin): add a durable task-usage ledger that outlives task cleanup#3470
GodKimba wants to merge 32 commits into
kunchenguid:mainfrom
GodKimba:fm/firstmate-durable-task-usage-ledger

Conversation

@GodKimba

@GodKimba GodKimba commented Sep 1, 2026

Copy link
Copy Markdown

Intent

Implement a durable, private task-usage ledger for Firstmate so model and workflow analysis survives ordinary task cleanup.

Background: a forensic audit found that 0 of 138 merged PRs could be joined to a recorded model because state/.meta is deleted during cleanup. Close that instrumentation gap without changing dispatch, model selection, merge authority, or project behavior.

Required behavior:

  • Define one versioned schema owner for a home-private append-only ledger under FM_HOME/data/; other scripts call that owner rather than duplicating serialization.
  • Preserve the implementation axes that already exist in authoritative task metadata: task id and incarnation, harness, model, effort, kind, project/repository, delivery mode, autonomy posture, spawn time, and backend only when analytically useful.
  • Preserve outcome linkage when it becomes authoritative: PR full URL and head identity, merge/landing identity where available, terminal outcome, final status class, and cleanup timestamp. Keep implementer and no-mistakes validator identity separate when both can be proven; use explicit unknown rather than inference.
  • Capture enough before volatile task state is deleted that ordinary successful cleanup cannot erase the record. Also capture the initial spawn axes so abandoned or indefinitely preserved work is not invisible forever. Repeated spawn, retry, PR registration, merge notification, and cleanup calls must be idempotent by stable event identity or safely append distinct versioned events.
  • Store no credentials, tokens, account identity, prompt or response text, captain text, PHI, free-form status payloads, temporary paths, or private remote payloads. Do not infer provider, model, outcome, or PR from names or prose.
  • Make writes concurrency-safe, atomic at the record boundary, mode 0600, and refuse symlink, non-regular, cross-device, or otherwise unsafe targets. A malformed existing ledger must stop safely without losing its bytes.
  • Keep history bounded by a documented retention or compaction rule that preserves useful 30-day and longer comparisons. Never rewrite history opportunistically during an unrelated task mutation.
  • Do not fabricate a historical backfill. New instrumentation starts with an explicit first-observed timestamp and records unknown fields honestly.
  • Add focused executable-interface regression tests for concurrent writers, retries/idempotency, spawn then teardown, cleanup refusal, PR/merge enrichment, malformed or unsafe targets, permissions, retention, missing fields, and secret/PHI exclusion.
  • Update the authoritative operator/configuration and maintainer architecture owners with the schema path, lifecycle points, privacy boundary, retention, and analysis limitations. Avoid adding conditional detail to AGENTS.md unless every session truly needs it.
  • Review every supported primary harness and runtime backend integration surface; the ledger must not silently omit one because it was not active in this fleet.
  • Subscription-quota history is explicitly OUT of scope; that is a separate private local adapter change.

This run validates one further accepted requirement on top of the already-validated work. An upstream P1 review finding reported that bin/fm-teardown.sh's cleanup_firstmate_home_children removed a nested child secondmate home and its task record before capturing that child's usage axes - the same class of gap as the parent-home finding already fixed earlier in this branch. The accepted fix, and the only code change in this run's new commit, captures each swept child's axes and final status class before that frame retires its status log and removes its task record, then appends the row past every refusal, immediately before the record is removed. Deliberate decisions a reviewer should not re-litigate: (1) the row is written to the home that SURVIVES the operation rather than the task's own home, because every home this sweep walks is deleted by the same operation and a row written into one would be destroyed with the ledger holding it - this is a deliberate, documented single exception to the call policy's own-home rule, recorded in bin/fm-usage-ledger-lib.sh and docs/architecture.md; (2) the outcome is 'discarded' because this sweep only runs under --force; (3) the append sits after every refusal so a child whose cleanup is refused leaves no row, matching the existing ordinary and remote-secondmate teardown paths; (4) the previously documented limitation in docs/configuration.md that a forced retirement leaves child tasks visible only as the parent's single retirement record is now removed, because it is no longer true.

Firstmate also instructed that a second reported CI failure, tests/fm-remote-job.test.sh failing with 'remote job worker did not report ready after startup', be fixed in this same run on the theory that a spawn-row change altered remote-job worker startup ordering. That theory was checked and disproved, so it is deliberately NOT changed here: this branch modifies no remote-job code and does not modify that test, and the portable-serial shard membership and ordering up to and including tests/fm-remote-job.test.sh are byte-identical on this branch and on upstream main. The CI log shows a pre-existing race inside that test itself - state/worker.lock existed at its assert_present, and the killed worker's restart supervisor removed it about 50ms later, so the following write to worker.lock/pid failed. Firstmate's standing constraint for this run was 'do not touch anything else', so that unrelated test is left untouched and the flake is expected to clear on this run's CI re-run. Do not propose editing tests/fm-remote-job.test.sh, bin/fm-remote-job-lib.sh, or bin/fm-remote-job-worker.sh in this run.

The branch's PR attestation is stale relative to the current head, so this run's push and PR steps are also expected to re-attest the PR.

What Changed

  • Adds bin/fm-usage-ledger.sh as the single owner of a home-private, append-only ledger at $FM_HOME/data/task-usage.jsonl — fixed-key v1 JSON records carrying task id/incarnation, harness, model, effort, kind, project name, delivery mode, autonomy posture, backend, PR URL/head, landing commit, outcome, final status class, and separate (currently unknown) validator identity — with record, list, verify, prune, path, status-class, and axes verbs, a ledger-open first-observed marker instead of any backfill, an allowlisted field set that stores no paths, prose, or credentials, locked mode-0600 writes that refuse symlink/non-regular/cross-device targets, idempotency by composed event identity, and retention only via the explicit prune verb (FM_USAGE_LEDGER_RETENTION_DAYS, default 400). bin/fm-usage-ledger-lib.sh owns the shared call policy: the effective home is passed explicitly and a failed write warns and returns 0 rather than gating the lifecycle step.
  • Wires call sites at each lifecycle point: fm-spawn.sh appends a spawn row immediately after each point that commits a task record beyond rollback (fresh dispatch, relaunch, remote-secondmate launch) and now mints spawn_gen earlier so the Orca abort record shares it, while remote-secondmate metadata records its own spawn_gen and default for unpinned model/effort; fm-pr-check.sh records PR identity after the poll is published; fm-merge-outcome-lib.sh and fm-merge-local.sh record merges and local landings; fm-teardown.sh captures status class and axes past every refusal and appends the cleanup row just before the task record is removed, on the ordinary, remote-secondmate, and forced-retirement child-sweep paths — the sweep writing each discarded child's row to the surviving retiring home. fm-wake-lib.sh gains FM_WAKE_LOCKS_ONLY so sourcing it for locks alone no longer recreates a removed state directory, and fm-secondmate-reconcile.sh comments are corrected for markerless-route identity.
  • Adds tests/fm-usage-ledger.test.sh plus ledger assertions in the backend-orca, control-relaunch, gotmp, pr-merge, remote-secondmate-lifecycle, and secondmate-reconcile suites; registers the new suite's family, weight hint, and change-to-family selection in bin/fm-test-run.sh and refreshes the shard table in docs/fm-test-portable-shards.md; and documents the schema path, lifecycle points, privacy boundary, retention, and analysis limitations in docs/architecture.md, docs/configuration.md, docs/scripts.md, docs/remote-secondmates.md, one AGENTS.md data-tree line, and a CONTRIBUTING.md note on re-pushing to rebind a stale attestation.

Risk Assessment

✅ Low: The only change since the reviewed head is a two-file documentation qualification that does exactly what the user's round-1 instruction asked and touches no code, and re-verification of the underlying sweep, the spawn-row invariant, and the shard table found the implementation and its numbers correct apart from one inaccurate doc clause.

Testing

I exercised the accepted change through the product surface an operator actually uses rather than through unit assertions alone: a real fm-teardown.sh <id> --force on a secondmate home holding live work at two nesting depths, read back through the real fm-usage-ledger.sh list/verify on the home that survives. Against the pre-fix binary that ledger holds one cleanup row — the mate's — and the three discarded tasks are attributable to nothing; at head it holds four, each carrying its own harness, model, effort, kind and delivery mode plus the status class it was stopped on and outcome=discarded, with the mate's original row intact rather than replaced. I confirmed the same fix as a true regression by reverting only bin/fm-teardown.sh and watching the new test fail on exactly the missing child row, then restored the file. I also verified the deliberate ordering decision directly: a child whose cleanup is refused keeps its task record and home and leaves no row at all. Targeted suites tests/fm-usage-ledger.test.sh (38 ok) and tests/fm-teardown.test.sh (58 ok, the regression owner for the modified sweep) both pass. Nothing here is UI-facing — the change is a shell lifecycle path writing an append-only JSONL store — so the reviewer-visible evidence is the CLI transcript plus the persisted ledger file itself rather than a screenshot. I did not exercise the remote-host retirement leg: this run's only code change is the local sweep, the remote-leg change in ba227d4 is documentation-only, and that e2e suite is broad regression that remote CI owns. Per the intent I left tests/fm-remote-job.test.sh and the remote-job scripts untouched. The worktree is clean; the only files I created are under the evidence directory.

Evidence: Forced retirement — surviving ledger AFTER the fix (real fm-teardown.sh --force, read via fm-usage-ledger.sh list)

Source: Forced retirement — surviving ledger AFTER the fix (real fm-teardown.sh --force, read via fm-usage-ledger.sh list)

=== AFTER: every home the sweep walked is gone, with its own ledger === removed .../secondmate-home removed .../secondmate-home/nested-home removed .../secondmate-home/data/task-usage.jsonl removed .../secondmate-home/nested-home/data/task-usage.jsonl === The question the audit could not answer, answered per discarded task === task=swept-grandchild kind=scout harness=cursor model=composer effort=xhigh outcome=discarded last_status=done task=swept-child-sm kind=secondmate harness=pi model=sonnet effort=medium outcome=discarded last_status=none task=swept-child kind=ship harness=codex model=gpt-5 effort=low outcome=discarded last_status=blocked task=swept-demo-x1 kind=secondmate harness=claude model=opus effort=high outcome=discarded last_status=done === PRIVACY + INTEGRITY of the surviving store === no free-form status text reached the ledger no discarded home path reached the ledger mode: -rw------- verify: ok records=5 first_observed=1788365058


=== BEFORE: the mate's home holds live work at two nesting depths ===
retiring mate      swept-demo-x1      harness=claude  model=opus     effort=high   kind=secondmate
  child task       swept-child        harness=codex   model=gpt-5    effort=low    kind=ship        (blocked)
  nested mate      swept-child-sm     harness=pi      model=sonnet   effort=medium kind=secondmate
    grandchild     swept-grandchild   harness=cursor  model=composer effort=xhigh  kind=scout       (done)

each of those three tasks owns a ledger INSIDE a home this retirement deletes:
  /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home/data/task-usage.jsonl
  /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home/nested-home/data/task-usage.jsonl
the surviving parent ledger is: /tmp/fm-usage-ledger.jOMAnA/swept-demo/home/data/task-usage.jsonl

=== RUN: fm-teardown.sh swept-demo-x1 --force ===
  teardown swept-demo-x1 complete (window firstmate:fm-swept-demo-x1, worktree /tmp/fm-usage-ledger.jOMAnA/swept-demo/wt)
  exit=0

=== AFTER: every home the sweep walked is gone, with its own ledger ===
  removed        /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home
  removed        /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home/nested-home
  removed        /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home/data/task-usage.jsonl
  removed        /tmp/fm-usage-ledger.jOMAnA/swept-demo/secondmate-home/nested-home/data/task-usage.jsonl

=== OPERATOR READ SURFACE: fm-usage-ledger.sh list (surviving parent home) ===
{"v":1,"seq":1,"at":1788365058,"event":"ledger-open","id":"ledger-open","task":"","gen":"","kind":"","harness":"","model":"","effort":"","project":"","mode":"","yolo":"","backend":"","pr":"","pr_head":"","landing":"","outcome":"","status_class":"","validator_harness":"","validator_model":""}
{"v":1,"seq":2,"at":1788365058,"event":"cleanup","id":"cleanup:swept-grandchild:g-grandchild","task":"swept-grandchild","gen":"g-grandchild","kind":"scout","harness":"cursor","model":"composer","effort":"xhigh","project":"project","mode":"","yolo":"","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":3,"at":1788365058,"event":"cleanup","id":"cleanup:swept-child-sm:g-child-sm","task":"swept-child-sm","gen":"g-child-sm","kind":"secondmate","harness":"pi","model":"sonnet","effort":"medium","project":"project","mode":"secondmate","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"none","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":4,"at":1788365058,"event":"cleanup","id":"cleanup:swept-child:g-child","task":"swept-child","gen":"g-child","kind":"ship","harness":"codex","model":"gpt-5","effort":"low","project":"project","mode":"no-mistakes","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"blocked","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":5,"at":1788365058,"event":"cleanup","id":"cleanup:swept-demo-x1:g-mate","task":"swept-demo-x1","gen":"g-mate","kind":"secondmate","harness":"claude","model":"opus","effort":"high","project":"project","mode":"secondmate","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"}

=== The question the audit could not answer, answered per discarded task ===
  task=swept-grandchild kind=scout harness=cursor model=composer effort=xhigh outcome=discarded last_status=done
  task=swept-child-sm kind=secondmate harness=pi model=sonnet effort=medium outcome=discarded last_status=none
  task=swept-child kind=ship harness=codex model=gpt-5 effort=low outcome=discarded last_status=blocked
  task=swept-demo-x1 kind=secondmate harness=claude model=opus effort=high outcome=discarded last_status=done

=== PRIVACY + INTEGRITY of the surviving store ===
  no free-form status text reached the ledger
  no discarded home path reached the ledger
  mode: -rw-------
  verify: ok records=5 first_observed=1788365058

surviving ledger copied to /home/rafaelfadel/.no-mistakes/evidence/01M1HC92WKTERPAVF0SY2F6Z4Y/surviving-home-task-usage.jsonl
Evidence: Same run against the pre-fix teardown — only the mate is recorded, the 3 discarded tasks vanish

Source: Same run against the pre-fix teardown — only the mate is recorded, the 3 discarded tasks vanish

=== The question the audit could not answer, answered per discarded task === task=swept-demo-x1 kind=secondmate harness=claude model=opus effort=high outcome=discarded last_status=done === PRIVACY + INTEGRITY of the surviving store === verify: ok records=2 first_observed=1788365052


=== BEFORE: the mate's home holds live work at two nesting depths ===
retiring mate      swept-demo-x1      harness=claude  model=opus     effort=high   kind=secondmate
  child task       swept-child        harness=codex   model=gpt-5    effort=low    kind=ship        (blocked)
  nested mate      swept-child-sm     harness=pi      model=sonnet   effort=medium kind=secondmate
    grandchild     swept-grandchild   harness=cursor  model=composer effort=xhigh  kind=scout       (done)

each of those three tasks owns a ledger INSIDE a home this retirement deletes:
  /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home/data/task-usage.jsonl
  /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home/nested-home/data/task-usage.jsonl
the surviving parent ledger is: /tmp/fm-usage-ledger.yBEnYr/swept-demo/home/data/task-usage.jsonl

=== RUN: fm-teardown.sh swept-demo-x1 --force ===
  teardown swept-demo-x1 complete (window firstmate:fm-swept-demo-x1, worktree /tmp/fm-usage-ledger.yBEnYr/swept-demo/wt)
  exit=0

=== AFTER: every home the sweep walked is gone, with its own ledger ===
  removed        /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home
  removed        /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home/nested-home
  removed        /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home/data/task-usage.jsonl
  removed        /tmp/fm-usage-ledger.yBEnYr/swept-demo/secondmate-home/nested-home/data/task-usage.jsonl

=== OPERATOR READ SURFACE: fm-usage-ledger.sh list (surviving parent home) ===
{"v":1,"seq":1,"at":1788365052,"event":"ledger-open","id":"ledger-open","task":"","gen":"","kind":"","harness":"","model":"","effort":"","project":"","mode":"","yolo":"","backend":"","pr":"","pr_head":"","landing":"","outcome":"","status_class":"","validator_harness":"","validator_model":""}
{"v":1,"seq":2,"at":1788365052,"event":"cleanup","id":"cleanup:swept-demo-x1:g-mate","task":"swept-demo-x1","gen":"g-mate","kind":"secondmate","harness":"claude","model":"opus","effort":"high","project":"project","mode":"secondmate","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"}

=== The question the audit could not answer, answered per discarded task ===
  task=swept-demo-x1 kind=secondmate harness=claude model=opus effort=high outcome=discarded last_status=done

=== PRIVACY + INTEGRITY of the surviving store ===
  no free-form status text reached the ledger
  no discarded home path reached the ledger
  mode: -rw-------
  verify: ok records=2 first_observed=1788365052
Evidence: Persisted ledger written by the forced retirement (surviving home's task-usage.jsonl)

Source: Persisted ledger written by the forced retirement (surviving home's task-usage.jsonl)

{"v":1,"seq":2,"at":1788365058,"event":"cleanup","id":"cleanup:swept-grandchild:g-grandchild","task":"swept-grandchild","gen":"g-grandchild","kind":"scout","harness":"cursor","model":"composer","effort":"xhigh","project":"project","mode":"","yolo":"","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"} {"v":1,"seq":4,"at":1788365058,"event":"cleanup","id":"cleanup:swept-child:g-child","task":"swept-child","gen":"g-child","kind":"ship","harness":"codex","model":"gpt-5","effort":"low","project":"project","mode":"no-mistakes","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"blocked","validator_harness":"unknown","validator_model":"unknown"}

{"v":1,"seq":1,"at":1788365058,"event":"ledger-open","id":"ledger-open","task":"","gen":"","kind":"","harness":"","model":"","effort":"","project":"","mode":"","yolo":"","backend":"","pr":"","pr_head":"","landing":"","outcome":"","status_class":"","validator_harness":"","validator_model":""}
{"v":1,"seq":2,"at":1788365058,"event":"cleanup","id":"cleanup:swept-grandchild:g-grandchild","task":"swept-grandchild","gen":"g-grandchild","kind":"scout","harness":"cursor","model":"composer","effort":"xhigh","project":"project","mode":"","yolo":"","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":3,"at":1788365058,"event":"cleanup","id":"cleanup:swept-child-sm:g-child-sm","task":"swept-child-sm","gen":"g-child-sm","kind":"secondmate","harness":"pi","model":"sonnet","effort":"medium","project":"project","mode":"secondmate","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"none","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":4,"at":1788365058,"event":"cleanup","id":"cleanup:swept-child:g-child","task":"swept-child","gen":"g-child","kind":"ship","harness":"codex","model":"gpt-5","effort":"low","project":"project","mode":"no-mistakes","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"blocked","validator_harness":"unknown","validator_model":"unknown"}
{"v":1,"seq":5,"at":1788365058,"event":"cleanup","id":"cleanup:swept-demo-x1:g-mate","task":"swept-demo-x1","gen":"g-mate","kind":"secondmate","harness":"claude","model":"opus","effort":"high","project":"project","mode":"secondmate","yolo":"off","backend":"tmux","pr":"","pr_head":"","landing":"","outcome":"discarded","status_class":"done","validator_harness":"unknown","validator_model":"unknown"}
Evidence: A refused child leaves no row and stays live (append sits past every refusal)

Source: A refused child leaves no row and stays live (append sits past every refusal)

=== RUN: fm-teardown.sh refuse-demo-x1 --force (child worktree is unsafe to remove) === REFUSED: unsafe child worktree removal target .../not-a-worktree is not a git worktree for .../project exit=1 === The refused child is still live: its task record and home survive === still present .../secondmate-home still present .../secondmate-home/state/refused-child.meta still present .../secondmate-home/state/refused-child.status === Surviving parent ledger: no cleanup row for a task that is still live === the ledger was never even created rows mentioning refused-child: 0 (expected 0)


=== RUN: fm-teardown.sh refuse-demo-x1 --force (child worktree is unsafe to remove) ===
  REFUSED: unsafe child worktree removal target /tmp/fm-usage-ledger.6UpDod/refuse-demo/not-a-worktree is not a git worktree for /tmp/fm-usage-ledger.6UpDod/refuse-demo/project
  exit=1

=== The refused child is still live: its task record and home survive ===
  still present  /tmp/fm-usage-ledger.6UpDod/refuse-demo/secondmate-home
  still present  /tmp/fm-usage-ledger.6UpDod/refuse-demo/secondmate-home/state/refused-child.meta
  still present  /tmp/fm-usage-ledger.6UpDod/refuse-demo/secondmate-home/state/refused-child.status

=== Surviving parent ledger: no cleanup row for a task that is still live ===
  the ledger was never even created

  rows mentioning refused-child: 0 (expected 0)
Evidence: Reproduction script for the end-to-end demo (drives the real scripts)

Source: Reproduction script for the end-to-end demo (drives the real scripts)

#!/usr/bin/env bash
# End-to-end demo of the accepted change: a forced secondmate retirement now
# records every task it discards, at every nesting depth, into the ledger of the
# home that survives the operation.
#
# It drives the REAL bin/fm-teardown.sh and reads the result back through the
# REAL bin/fm-usage-ledger.sh operator surface. The only fakes are the ones the
# repo's own lifecycle tests use for tmux/gh/treehouse.
#
# Usage: forced-retirement-ledger-demo.sh <path-to-firstmate-worktree>
set -u
ROOT_REPO=${1:?usage: $0 <firstmate-repo-root>}
ROOT_REPO=$(cd "$ROOT_REPO" && pwd)

# Reuse the suite's fixture helpers without running its assertions: everything
# above the trailing invocation list is helper definitions.
HELPERS=$(mktemp -d)/ledger-helpers.sh
mkdir -p "$(dirname "$HELPERS")"
sed -e '/^test_ledger_opens_with_an_explicit_first_observed_record$/,$d' \
  -e "s#\$(dirname \"\${BASH_SOURCE\[0\]}\")/lib.sh#$ROOT_REPO/tests/lib.sh#" \
  "$ROOT_REPO/tests/fm-usage-ledger.test.sh" > "$HELPERS"
# shellcheck disable=SC1090
. "$HELPERS"

rule() { printf '\n=== %s ===\n' "$1"; }

make_lifecycle_case swept-demo
id="swept-demo-x1"
home_path="$TMP_ROOT/swept-demo/secondmate-home"
ctl="$home_path/control-state"
nested="$home_path/nested-home"
mkdir -p "$home_path/state" "$home_path/data" "$home_path/config" \
  "$home_path/projects" "$ctl" \
  "$nested/state" "$nested/data" "$nested/config" "$nested/projects"
printf '%s\n' "$id" > "$home_path/.fm-secondmate-home"
printf '%s\n' swept-child-sm > "$nested/.fm-secondmate-home"
touch "$ctl/.last-watcher-beat"

fm_write_meta "$ctl/$id.meta" \
  "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$CASE_WT" \
  "project=$CASE_PROJ" "harness=claude" "kind=secondmate" "mode=secondmate" \
  "yolo=off" "model=opus" "effort=high" "home=$home_path" "spawn_gen=g-mate"
printf '%s\n' 'done: handed back' > "$ctl/$id.status"

git -C "$CASE_PROJ" worktree add -q -b fm/swept-child "$TMP_ROOT/swept-demo/child-wt"
fm_write_meta "$home_path/state/swept-child.meta" \
  "window=firstmate:fm-swept-child" "endpoint_task_id=swept-child" \
  "worktree=$TMP_ROOT/swept-demo/child-wt" "project=$CASE_PROJ" \
  "harness=codex" "kind=ship" "mode=no-mistakes" "yolo=off" \
  "model=gpt-5" "effort=low" "spawn_gen=g-child"
printf '%s\n' 'working: mid-flight' 'blocked: waiting, captain notes were sensitive' \
  > "$home_path/state/swept-child.status"

fm_write_meta "$home_path/state/swept-child-sm.meta" \
  "window=firstmate:fm-swept-child-sm" "endpoint_task_id=swept-child-sm" \
  "worktree=$nested" "project=$CASE_PROJ" "harness=pi" "kind=secondmate" \
  "mode=secondmate" "yolo=off" "model=sonnet" "effort=medium" \
  "home=$nested" "spawn_gen=g-child-sm"

git -C "$CASE_PROJ" worktree add -q -b fm/swept-grandchild "$TMP_ROOT/swept-demo/grandchild-wt"
fm_write_meta "$nested/state/swept-grandchild.meta" \
  "window=firstmate:fm-swept-grandchild" "endpoint_task_id=swept-grandchild" \
  "worktree=$TMP_ROOT/swept-demo/grandchild-wt" "project=$CASE_PROJ" \
  "harness=cursor" "kind=scout" "model=composer" "effort=xhigh" \
  "spawn_gen=g-grandchild"
printf '%s\n' 'done: reported' > "$nested/state/swept-grandchild.status"

rule "BEFORE: the mate's home holds live work at two nesting depths"
printf 'retiring mate      %-18s harness=claude  model=opus     effort=high   kind=secondmate\n' "$id"
printf '  child task       %-18s harness=codex   model=gpt-5    effort=low    kind=ship        (blocked)\n' swept-child
printf '  nested mate      %-18s harness=pi      model=sonnet   effort=medium kind=secondmate\n' swept-child-sm
printf '    grandchild     %-18s harness=cursor  model=composer effort=xhigh  kind=scout       (done)\n' swept-grandchild
printf '\neach of those three tasks owns a ledger INSIDE a home this retirement deletes:\n'
printf '  %s\n' "$home_path/data/task-usage.jsonl" "$nested/data/task-usage.jsonl"
printf 'the surviving parent ledger is: %s\n' "$CASE_HOME/data/task-usage.jsonl"

rule "RUN: fm-teardown.sh $id --force"
FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
  FM_STATE_OVERRIDE="$ctl" FM_DATA_OVERRIDE="$CASE_HOME/data" \
  FM_PROJECTS_OVERRIDE="$CASE_HOME/projects" FM_CONFIG_OVERRIDE="$CASE_HOME/config" \
  FM_TEARDOWN_GUARD_DONE=1 PATH="$CASE_FAKEBIN:$PATH" \
  "$ROOT_REPO/bin/fm-teardown.sh" "$id" --force 2>&1 | sed 's/^/  /'
teardown_rc=${PIPESTATUS[0]}
printf '  exit=%s\n' "$teardown_rc"

rule "AFTER: every home the sweep walked is gone, with its own ledger"
for p in "$home_path" "$nested" "$home_path/data/task-usage.jsonl" "$nested/data/task-usage.jsonl"; do
  if [ -e "$p" ]; then printf '  STILL PRESENT  %s\n' "$p"; else printf '  removed        %s\n' "$p"; fi
done

rule "OPERATOR READ SURFACE: fm-usage-ledger.sh list (surviving parent home)"
FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
  FM_STATE_OVERRIDE="$CASE_HOME/state" FM_DATA_OVERRIDE="$CASE_HOME/data" \
  "$ROOT_REPO/bin/fm-usage-ledger.sh" list

rule "The question the audit could not answer, answered per discarded task"
FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
  FM_STATE_OVERRIDE="$CASE_HOME/state" FM_DATA_OVERRIDE="$CASE_HOME/data" \
  "$ROOT_REPO/bin/fm-usage-ledger.sh" list \
  | grep '"event":"cleanup"' \
  | sed -n 's/.*"task":"\([^"]*\)".*"kind":"\([^"]*\)","harness":"\([^"]*\)","model":"\([^"]*\)","effort":"\([^"]*\)".*"outcome":"\([^"]*\)","status_class":"\([^"]*\)".*/  task=\1 kind=\2 harness=\3 model=\4 effort=\5 outcome=\6 last_status=\7/p'

rule "PRIVACY + INTEGRITY of the surviving store"
if grep -q "captain notes were sensitive" "$CASE_HOME/data/task-usage.jsonl"; then
  printf '  LEAK: a free-form status note reached the ledger\n'
else
  printf '  no free-form status text reached the ledger\n'
fi
if grep -qF "$home_path" "$CASE_HOME/data/task-usage.jsonl"; then
  printf '  LEAK: a discarded home path reached the ledger\n'
else
  printf '  no discarded home path reached the ledger\n'
fi
printf '  mode: %s\n' "$(ls -l "$CASE_HOME/data/task-usage.jsonl" | awk '{print $1}')"
printf '  verify: '
FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
  FM_STATE_OVERRIDE="$CASE_HOME/state" FM_DATA_OVERRIDE="$CASE_HOME/data" \
  "$ROOT_REPO/bin/fm-usage-ledger.sh" verify

if [ -n "${DEMO_COPY_LEDGER:-}" ]; then
  cp "$CASE_HOME/data/task-usage.jsonl" "$DEMO_COPY_LEDGER"
  printf '\nsurviving ledger copied to %s\n' "$DEMO_COPY_LEDGER"
fi
Evidence: Reproduction script for the refusal check

Source: Reproduction script for the refusal check

#!/usr/bin/env bash
# Companion to forced-retirement-ledger-demo.sh: proves the append sits PAST
# every refusal, so a child whose cleanup is refused leaves no row at all.
#
# Same scenario, one difference: the ship child's worktree is a plain directory
# that is not a registered git worktree of the project, so the sweep refuses
# that child before it can retire anything.
set -u
ROOT_REPO=${1:?usage: $0 <firstmate-repo-root>}
ROOT_REPO=$(cd "$ROOT_REPO" && pwd)
HELPERS=$(mktemp -d)/ledger-helpers.sh
sed -e '/^test_ledger_opens_with_an_explicit_first_observed_record$/,$d' \
  -e "s#\$(dirname \"\${BASH_SOURCE\[0\]}\")/lib.sh#$ROOT_REPO/tests/lib.sh#" \
  "$ROOT_REPO/tests/fm-usage-ledger.test.sh" > "$HELPERS"
# shellcheck disable=SC1090
. "$HELPERS"

rule() { printf '\n=== %s ===\n' "$1"; }

make_lifecycle_case refuse-demo
id="refuse-demo-x1"
home_path="$TMP_ROOT/refuse-demo/secondmate-home"
ctl="$home_path/control-state"
mkdir -p "$home_path/state" "$home_path/data" "$home_path/config" "$home_path/projects" "$ctl"
printf '%s\n' "$id" > "$home_path/.fm-secondmate-home"
touch "$ctl/.last-watcher-beat"
fm_write_meta "$ctl/$id.meta" \
  "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$CASE_WT" \
  "project=$CASE_PROJ" "harness=claude" "kind=secondmate" "mode=secondmate" \
  "yolo=off" "model=opus" "effort=high" "home=$home_path" "spawn_gen=g-mate"
printf '%s\n' 'done: handed back' > "$ctl/$id.status"

# The refusal: a worktree path that is NOT a git worktree of the project.
mkdir -p "$TMP_ROOT/refuse-demo/not-a-worktree"
fm_write_meta "$home_path/state/refused-child.meta" \
  "window=firstmate:fm-refused-child" "endpoint_task_id=refused-child" \
  "worktree=$TMP_ROOT/refuse-demo/not-a-worktree" "project=$CASE_PROJ" \
  "harness=codex" "kind=ship" "mode=no-mistakes" "yolo=off" \
  "model=gpt-5" "effort=low" "spawn_gen=g-refused"
printf '%s\n' 'working: still live' > "$home_path/state/refused-child.status"

rule "RUN: fm-teardown.sh $id --force (child worktree is unsafe to remove)"
FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
  FM_STATE_OVERRIDE="$ctl" FM_DATA_OVERRIDE="$CASE_HOME/data" \
  FM_PROJECTS_OVERRIDE="$CASE_HOME/projects" FM_CONFIG_OVERRIDE="$CASE_HOME/config" \
  FM_TEARDOWN_GUARD_DONE=1 PATH="$CASE_FAKEBIN:$PATH" \
  "$ROOT_REPO/bin/fm-teardown.sh" "$id" --force 2>&1 | sed 's/^/  /'
printf '  exit=%s\n' "${PIPESTATUS[0]}"

rule "The refused child is still live: its task record and home survive"
for p in "$home_path" "$home_path/state/refused-child.meta" "$home_path/state/refused-child.status"; do
  if [ -e "$p" ]; then printf '  still present  %s\n' "$p"; else printf '  REMOVED        %s\n' "$p"; fi
done

rule "Surviving parent ledger: no cleanup row for a task that is still live"
if [ -e "$CASE_HOME/data/task-usage.jsonl" ]; then
  FM_ROOT_OVERRIDE="$ROOT_REPO" FM_HOME="$CASE_HOME" \
    FM_STATE_OVERRIDE="$CASE_HOME/state" FM_DATA_OVERRIDE="$CASE_HOME/data" \
    "$ROOT_REPO/bin/fm-usage-ledger.sh" list
else
  printf '  the ledger was never even created\n'
fi
printf '\n  rows mentioning refused-child: %s (expected 0)\n' \
  "$(grep -c 'refused-child' "$CASE_HOME/data/task-usage.jsonl" 2>/dev/null || printf 0)"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-teardown.sh:2600 - The sweep's row goes to $DATA, which on the remote-host leg of a forced REMOTE secondmate retirement is itself inside the home being deleted, so the discarded children are still unattributable and docs/configuration.md:230's new unqualified claim is false there. Concrete path: parent runs fm-teardown.sh &lt;id&gt; --force on a remote mate; remote_secondmate_teardown (bin/fm-teardown.sh:700) hands off to fm-on.sh &lt;id&gt; fm-remote-secondmate-control.sh retire &lt;id&gt; --force; cmd_retire (bin/fm-remote-secondmate-control.sh:315-318) re-enters fm-teardown.sh &lt;id&gt; --force on the remote host with FM_DATA_OVERRIDE=$CONTROL_DATA, and CONTROL_DATA is defined at bin/fm-remote-secondmate-control.sh:42 as "$TARGET_HOME/data/.parent-route". That teardown has kind=secondmate and no remote_host=, so remote_secondmate_teardown_locked returns 3 and it falls through to cleanup_firstmate_home_children &#34;$HOME_PATH&#34; at :2669 with HOME_PATH=$TARGET_HOME. Every child row this line appends therefore lands in $TARGET_HOME/data/.parent-route/task-usage.jsonl, and remove_firstmate_home &#34;$HOME_PATH&#34; at :2923 reaches safe_rm_rf &#34;$abs_home_path&#34; (:2083) and deletes the whole home, ledger included. Unlike the local case there is no surviving home on that host, so decision (1)'s "the home that SURVIVES the operation" has no target there: the parent writes the mate's own spawn and cleanup rows into its own ledger (bin/fm-spawn.sh:710, bin/fm-teardown.sh:734), but nothing anywhere records the mate's child tasks. Note the sibling symptom that confirms the directory is gone: the remote leg's own usage_ledger_record_cleanup at :2956 runs after :2923, so ul_resolve_dir data &#34;$DATA&#34; already fails and it warns on every remote retirement (harmless, since the parent holds that row). The earliest supported boundary that would make the invariant hold for remote children is a parent-side capture before the remote retire call in remote_secondmate_teardown (a protocol change, larger than this run); the proportionate alternative is to scope the docs/configuration.md:230 sentence and the bin/fm-usage-ledger-lib.sh:11-15 exception note to local homes and restore a narrowed form of the limitation that was removed for the remote case. Flagging rather than fixing because intent decision (4) states that limitation "is now removed, because it is no longer true", and choosing between qualifying the claim and extending the protocol is the author's call.
  • ℹ️ bin/fm-teardown.sh:2599 - The if [ -n &#34;$axes&#34; ] ... --axes ... else ... --meta ... fi cleanup-record fallback is now written twice in this file with identical structure: once in usage_ledger_record_cleanup (:320-330) for the task's own record and once here for each swept child. Only the id/meta/axes/status/outcome inputs differ, so usage_ledger_record_cleanup could take those five as parameters and be called from both sites without moving either append. Not proposing it as a change in this run: the placement of both appends relative to their refusals is the property three prior rounds converged on, and a mechanical extraction here buys little against that risk.

🔧 Fix: qualify ledger sweep survival claim for remote retirement leg
1 info still open:

  • ℹ️ docs/configuration.md:232 - The parenthetical this fix round added is wrong on both of its claims. It says the retired mate's own cleanup record "reaches the same store and behaved that way before this ledger existed". (1) It does not reach the store. On the remote leg the mate's own record is attempted at bin/fm-teardown.sh:2956, after remove_firstmate_home at :2919-2929 has already deleted $TARGET_HOME - and $DATA there is $CONTROL_DATA=$TARGET_HOME/data/.parent-route (bin/fm-remote-secondmate-control.sh:42,315-318), so fm-usage-ledger.sh dies in ul_resolve_dir (bin/fm-usage-ledger.sh:322-328, reached at :655) before any append and the lib emits its warning instead. It is never written, rather than written and then destroyed with the home the way the child rows genuinely are. (2) It did not behave that way "before this ledger existed": the cleanup record was introduced by this branch's own first commit (48457ba). The user's round-1 instruction said the mate's own row "predates this change", meaning the child-sweep commit 1941ebb, not the ledger. The surrounding claims are accurate - the child rows really are written into $TARGET_HOME/data/.parent-route by the sweep and deleted with it, and the parent home really does hold the mate's own spawn (bin/fm-spawn.sh:712) and cleanup (bin/fm-teardown.sh:734) rows, so docs/configuration.md:246 and docs/architecture.md:325 are correct as written. Suggested replacement for the clause: note that the mate's own cleanup record on that leg is not written at all, because the store is already gone by the time it is attempted, and that this predates the child sweep rather than the ledger.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-usage-ledger.test.sh — 38/38 ok, including the new test_a_forced_retirement_records_every_task_it_discards
  • Regression proof: replaced bin/fm-teardown.sh with git show 1941ebb^:bin/fm-teardown.sh, re-ran the same suite, saw not ok - the discarded child task wrote no usage record, then restored the file (worktree back to clean)
  • bash tests/fm-teardown.test.sh — 58/58 ok, the regression owner for the modified cleanup_firstmate_home_children
  • Manual end-to-end: forced-retirement-ledger-demo.sh builds a secondmate home holding a ship child (codex/gpt-5/low, blocked), a nested mate (pi/sonnet/medium) and a grandchild (cursor/composer/xhigh, done), runs the real bin/fm-teardown.sh &lt;id&gt; --force, confirms both child homes and their own ledgers are gone, then reads the surviving parent home with bin/fm-usage-ledger.sh list and verify
  • Same demo run against the pre-fix bin/fm-teardown.sh to capture the before/after ledger contrast
  • Manual refusal check: forced-retirement-refusal-demo.sh gives a child an unregistered worktree so the sweep REFUSES mid-flight; asserted the child's task record and home survive and the parent ledger has zero rows mentioning it
  • Privacy assertions on the persisted store: the discarded child's free-form status note and the discarded home path are both absent; file mode is -rw-------
⚠️ **Document** - 1 info
  • ℹ️ docs/configuration.md:232 - Judgment call worth surfacing: the two sentences I corrected were authored by this run's review round (ba227d4) under the user's instruction to qualify the survival claim, so I edited user-directed wording rather than pre-existing prose. I kept the instructed content (the qualification, and that the mate's own row behaves the same way and predates this change) and changed only two factual slips: 'before this ledger existed' asserted behavior predating the ledger the branch itself introduces, and 'reaches the same store' / 'already did not [outlive it]' implied the mate's own remote-leg cleanup row is written and then deleted. It is never written: bin/fm-teardown.sh:2956 appends after remove_firstmate_home at :2923, and bin/fm-usage-ledger.sh's ul_resolve_dir (:325) never creates a missing data directory, so the call policy warns and returns 0. The operator-visible conclusion is unchanged - nothing that leg records survives it. Noting it in case the author wants the original phrasing restored.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR should not merge until nested-child cleanup records are redirected to a ledger that survives secondmate-home removal.

Forced nested-secondmate retirement appends the child’s cleanup event under the enclosing home and then deletes that home, leaving the original durable-attribution gap unresolved.

Files Needing Attention: bin/fm-teardown.sh

Reviews (15): Last reviewed commit: "no-mistakes(document): correct remote-le..." | Re-trigger Greptile

Comment thread bin/fm-teardown.sh
@kunchenguid

Copy link
Copy Markdown
Owner

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

Attestation MISMATCH (blocking). Body attestation binds b39ee89006cbee5e42bfd6cdc635a69951a1385e; HEAD is 1e77c734e165d275de2325c1a8dbda3636ee03c3 (gotmp fixture + secondmate axes-before-home-removal commits after the last attested push). Require no-mistakes run 33564877692 failed for exactly that reason. Please re-run git push no-mistakes so the attestation matches HEAD.

Contract-class: new-default. Unconfigured homes now always append a durable data/task-usage.jsonl at spawn / PR / merge / teardown via the always-return-0 call policy. Main never promised this ledger (the audit gap is new instrumentation, not a restore of a broken promise). This will need an explicit captain default-behavior decision once otherwise ready — it will not auto-merge.

What looks solid on review (via gh pr diff, no clone):

  • workflow-zero (no .github/workflows changes)
  • privacy boundary: allowlisted meta axes only; credentials / tokens / PHI / prompts / paths / status prose have no field; identity-bearing inputs refused rather than truncated; store mode 0600 with symlink/hardlink/cross-device refusal
  • teardown: capture status class + axes past refusals and before home/status removal; append still after refusals so a REFUSED teardown records nothing; Greptile P1 on secondmate attribution appears addressed by HEAD 1e77c734
  • fork CI approved this pass: CI 33564877693 (now in_progress), Require no-mistakes 33564877692 (also approved older action_required 33555588071 / 33555588093)

Please fix: re-attest HEAD only. No competing PR opened. After MATCH + green CI, this stays held for captain new-default decision (do not expect Firstmate auto-merge).

@GodKimba
GodKimba force-pushed the fm/firstmate-durable-task-usage-ledger branch from 7d80fd7 to 3b1f4c3 Compare September 2, 2026 05:21
@GodKimba GodKimba changed the title feat(bin): add a durable task-usage ledger that outlives teardown feat(bin): record durable task usage that outlives cleanup Sep 2, 2026
@GodKimba
GodKimba force-pushed the fm/firstmate-durable-task-usage-ledger branch from 3b1f4c3 to 337b659 Compare September 2, 2026 05:41
@kunchenguid

Copy link
Copy Markdown
Owner

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

Attestation MISMATCH (blocking). Body attestation binds 3b1f4c3635b1f373740f609e5ad0ad5539117662; HEAD is 625458ba6609e3017211822a0065bd4cdda699ff. Require no-mistakes run 33597126980 failed for exactly that reason after approval this pass. Please re-run git push no-mistakes so the attestation matches HEAD.

Contract-class: new-default. Unconfigured homes always append durable data/task-usage.jsonl at spawn / PR / merge / teardown (always-return-0 call policy). Main never promised this ledger — new instrumentation, not a restore. After MATCH + green CI this still needs an explicit captain default-behavior decision; Firstmate will not auto-merge it.

This pass: tip moved past prior stamp (1e77c734625458ba). workflow-zero; security clean on review (allowlisted axes, 0600, symlink/hardlink/cross-device refusal, no credentials/PHI/paths/prose fields); Greptile SUCCESS; author not in blocked-authors. Fork CI approved: CI 33597127052 (in_progress) / Require no-mistakes 33597126980 (FAILURE — attestation). MERGEABLE/UNSTABLE.

Please fix: re-attest HEAD only. No competing PR opened. After MATCH + green CI, expect a captain new-default hold (not Firstmate auto-merge).

@GodKimba
GodKimba force-pushed the fm/firstmate-durable-task-usage-ledger branch from 625458b to 1bb7981 Compare September 2, 2026 07:16
@GodKimba GodKimba changed the title feat(bin): record durable task usage that outlives cleanup feat(bin): record a durable task-usage ledger that outlives teardown Sep 2, 2026
@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@kunchenguid

Copy link
Copy Markdown
Owner

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

Attestation MISMATCH (blocking). Body attestation binds 1bb7981896286d36a2c4f90a96d5a8b957876295; HEAD is 6c117fafef3be72289f2faa5a19d8082af7fca33. Require no-mistakes run 33610618562 FAILED this pass after approval for exactly that reason (Pipeline attestation head_sha does not match the current PR head). Please re-run git push no-mistakes so the attestation matches HEAD.

Contract-class: new-default. Unconfigured homes always append durable data/task-usage.jsonl at spawn / PR / merge / teardown via fm_usage_ledger_record (always-return-0 instrumentation; no enable/config gate). Main has no bin/fm-usage-ledger.sh — new always-on instrumentation, not a restore. After MATCH + green CI this still needs an explicit captain default-behavior decision; Firstmate will not auto-merge it.

This pass: tip moved past prior stamp (625458ba6c117faf; body tip 3b1f4c361bb79818). workflow-zero (no .github/workflows/*); security clean on review (allowlisted axes, mode 0600, symlink/cross-device refusal, no credentials/PHI/paths/prose fields; call policy never gates lifecycle); Greptile SUCCESS; author not blocked. Fork CI approved this pass: CI 33610618540 (in_progress) / Require no-mistakes 33610618562 (FAILURE — attestation). MERGEABLE/UNSTABLE vs main (ahead 22 / behind 6).

Please fix: re-attest HEAD only. No competing durable-ledger PR opened. After MATCH + green CI, expect a captain new-default hold (not Firstmate auto-merge).

@GodKimba
GodKimba force-pushed the fm/firstmate-durable-task-usage-ledger branch from 6c117fa to 94c3ec8 Compare September 2, 2026 12:52
@GodKimba GodKimba changed the title feat(bin): record a durable task-usage ledger that outlives teardown feat(bin): add a durable task-usage ledger that outlives cleanup Sep 2, 2026
Comment thread bin/fm-teardown.sh
@kunchenguid

Copy link
Copy Markdown
Owner

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

Attestation MISMATCH (blocking). Body attestation binds 94c3ec880073f16e02bb6d83ef1d05de6f8a4e7f; HEAD is c9d657a250b8e2d19e9bcd10c06364209e9de581 (two later no-mistakes(ci) commits after the attested lint fix). Require no-mistakes run 33640434809 FAILED after approval this pass for exactly that reason (Pipeline attestation head_sha does not match the current PR head). Please re-run git push no-mistakes so the attestation matches HEAD.

Contract-class: new-default. Unconfigured homes always append durable data/task-usage.jsonl at spawn / PR / merge / teardown via unconditional fm_usage_ledger_record (always-return-0 instrumentation; no enable/config gate). Main has no bin/fm-usage-ledger.sh — new always-on instrumentation, not a restore. After MATCH + green CI this still needs an explicit captain default-behavior decision; Firstmate will not auto-merge it.

VISION: Durability / restart-is-non-event and private home records align with a forensic usage ledger that outlives teardown; Authority-is-explicit resists shipping new always-on home-local recording without captain consent (new-default, not opt-in); Scope keeps this as instrumentation (does not change dispatch, model selection, or merge authority).

This pass: tip moved past prior stamp (6c117fafc9d657a; body tip 1bb7981894c3ec88). workflow-zero (no .github/workflows/*); security clean on review (allowlisted axes, mode 0600, symlink/cross-device refusal, no credentials/PHI/paths/prose fields; call policy never gates lifecycle). Greptile FAILURE with open P1 on bin/fm-teardown.sh: cleanup_firstmate_home_children can remove a nested child home/state/<child>.meta without ledger capture/record (parent retirement axes capture alone does not cover that path). Author not blocked. Fork CI approved this pass: CI 33640434787 (in_progress after approval) / Require no-mistakes 33640434809 (FAILURE — attestation). MERGEABLE/UNSTABLE vs main (ahead 29 / behind 0).

Please fix: (1) re-attest HEAD; (2) address or rebut the Greptile nested-child cleanup P1 on this tip. No competing durable-ledger PR opened. After MATCH + green CI/NM, expect a captain new-default hold (not Firstmate auto-merge) — do not escalate while attestation/CI blockers remain.

GodKimba and others added 15 commits September 2, 2026 12:37
A task's implementation axes - harness, model, effort, kind, project,
delivery mode, autonomy posture, backend, incarnation - live only in
state/<id>.meta, and teardown removes that record as one atomic step with
closing the backlog item. Nothing durable survived, so no merged PR could be
joined back to the model that produced it.

Add bin/fm-usage-ledger.sh as the single owner of a home-private, append-only
ledger at data/task-usage.jsonl, and bin/fm-usage-ledger-lib.sh as the single
owner of how lifecycle scripts call it. Records are appended at the four points
where every supported harness and every spawn-capable backend converges: a
successful spawn or relaunch (plus the remote-secondmate launch that returns
earlier), a PR or merge request registration, a confirmed merge or approved
local landing, and the moment before cleanup removes the task record. A refused
teardown records nothing, because its task is still live.

The record format is fixed-key JSONL needing no JSON processor, versioned, and
forward compatible with a newer writer's rows. Every axis comes from a strict
allowlist read through the existing fm_meta_get and the documented "absent
backend= means tmux" contract; the final status class comes from the existing
verb reader, and only the verb is stored. Paths, trace carriers, relay
payloads, account identity, and status prose have no field to land in. An axis
that applies but cannot be proven is the literal "unknown" - including the
no-mistakes validator identity, which Firstmate cannot prove today and which is
kept in its own fields rather than inferred from the implementer.

Writes are serialized under the store's own lock, atomic at the record
boundary, mode 0600, and refuse a symlinked, hardlinked, non-regular, or
cross-device target without writing. A malformed record stops every verb and
leaves the file's bytes untouched. Idempotency is by stable event identity, so
a retried spawn, a re-armed poll, an at-least-once merge notification, and a
rerun teardown converge while a genuinely new incarnation, PR head, or PR
appends its own row.

Nothing is backfilled: the store opens with an explicit first-observed record
and an analysis must treat that timestamp as the start of coverage. History is
bounded only by the explicit prune verb (400-day default), so recording one
task never rewrites another's. Recording is instrumentation and never a gate: a
failed write warns loudly and leaves the completed launch, merge, or cleanup
successful.
…lsewhere

A compliance pass against the repo's knowledge-placement and one-owner rules
found the ledger's contract restated at several tiers instead of stated once.

docs/configuration.md had a second full copy of the stored-field allowlist and
of the lifecycle call-site list, both of which drift the moment only one copy is
edited. It now carries what an operator needs - where the file is, that nothing
depends on it, how to inspect and prune it, the retention default, and the two
limitations that are genuinely its own - and points at the script header for the
schema and privacy boundary and at architecture.md for the call sites.

AGENTS.md's entry is trimmed to an inventory line plus its owner, since no
session reads the ledger and the tree's first tier does not apply to it.

The "a failed record never gates the step" reinforcement appeared in three
script headers; it stays where forgetting it is costliest, in the merge outcome
emitter, and the other two become plain cross-references. fm-teardown.sh stated
its refusal behavior twice in one file, so the header keeps the contract and the
inline comment keeps only the code-local placement reason.

No behavior change: the same lint, doc-audience, and ledger regression checks
pass unchanged.
bin/fm-teardown.sh now sources bin/fm-usage-ledger-lib.sh before any fleet
mutation, and tests/fm-gotmp.test.sh is the one suite that hand-builds
teardown's sibling set instead of running it out of the real bin/. Both of its
fake roots were missing the two new siblings, so the real teardown aborted on
the missing source under set -e and every case failed at "teardown exited
non-zero". Symlink the call-policy library and the schema owner it runs as a
subprocess, the same way the fixture already carries every other sibling
teardown requires, so the suite exercises the actual cleanup record rather than
a warning path.
…oved

A secondmate retirement removes the very home its state directory - and so
state/<id>.meta - sits in, then reads that record to write the cleanup row.
The row therefore stored unknown harness, model, effort, kind, mode, and
project, and its identity collapsed to cleanup:<task>:unknown, colliding with
every other retirement of the same id. That is the one row proving a
retirement ever ran, and it is exactly the join the ledger exists for.

Capture the axes through the schema owner's new `axes` verb at the point the
final status class is already captured - past every refusal, before the home
is removed - and hand them back with record --axes. The append stays where it
was, so a refused teardown still records nothing.

The captured line is separated by the same quote no stored value may contain,
deliberately not whitespace: bash collapses runs of IFS whitespace, which
would merge two adjacent empty axes (a scout's mode and yolo) and shift every
axis after them onto the wrong field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t absent

ul_load_meta took the ledger's joinable project key with a bare ${project##*/}.
A recorded path carrying a trailing separator therefore reduced to the empty
string, and in this schema "" means not-applicable: the record stated the task
had no project at all, in the one column the ledger exists to join a merged PR
back to. That is the same defect class as a truncated PR URL - a silently
reduced value presented as fact - and it is reachable through the ordinary
lifecycle, because data/secondmates.md stores a remote route's root exactly as
it was written and fm-spawn copies it into project= verbatim.

Strip trailing separators before taking the name, and record "unknown" when a
project the record DOES carry cannot be named, keeping the empty string for the
absence it already means. The field's own contract in the schema owner's header
is updated with it; nothing else restates that rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GodKimba and others added 17 commits September 2, 2026 12:37
This branch added tests/fm-usage-ledger.test.sh to the portable serial lane
without a measured duration hint, so the lane grew from 139 to 140 scripts and
`bin/fm-test-run.sh --check-coverage` reported `serial_unhinted=1`: the one
script balanced on PORTABLE_SERIAL_DEFAULT_WEIGHT_MS's guess rather than on
evidence. docs/fm-test-portable-shards.md's own rule is to refresh the hints
whenever the serial lane gains scripts rather than waiting for the guard's
bound to trip, and its shard table still described the 139-script partition, so
the documented per-shard counts and durations no longer matched what
bin/fm-test-run.sh actually assigns.

Record the hint as the slowest of the script's two CI measurements (19928 ms of
runs 33555588071 and 33564877693), the same conservative rule the rest of the
table uses, and restate the shard table from the assignment that hint produces.
Coverage now reports serial_unhinted=0 and the partition stays a deterministic
disjoint cover at 27/28/27/29/29 scripts and 19 ms of imbalance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ft ragged

Accepted decision 7 reworded the persistent-remote-secondmate identity comment
in cmd_notify() but kept the old line breaks, leaving "Both are" orphaned on a
short line in the middle of the paragraph. Rewrap the sentence across the block
so it reads at the same width as its three sibling statements. Comment only; no
behavior change.
docs/fm-test-portable-shards.md states that the serial-lane balance hints are
the slowest measurement of each script across *green* CI runs, but the hint this
branch added for tests/fm-usage-ledger.test.sh cited run 33555588071, which
failed. That run was also the slower-looking of the pair only by accident: it
measured 15335 ms against 33564877693's 19928 ms, so the failed run contributed
nothing but a provenance claim the paragraph's own rule forbids.

Since then a third CI run measured the script green, 33597127052, at 29811 ms -
50% above the recorded hint. Under the documented conservative-maximum rule that
is now the hint, so the recorded 19928 understates the script by nearly ten
seconds and balances shard 3 on a stale figure.

Take the hint from the two green runs that have measured the script and drop the
failed one, then restate the shard table from the assignment that hint produces:
140 scripts at 27/28/28/29/28, 3839698 ms of weight, and the same 19 ms of
imbalance. Replaying that partition against the three source runs' real
per-script durations puts the worst shard at 12.65 min, 63% of the 20-minute cap.

Coverage guard still reports serial_unhinted=0; bin/fm-lint.sh and
bin/fm-doc-audience-check.sh pass; tests/fm-usage-ledger.test.sh passes 35/35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ints at

The `Require no-mistakes` check on this PR is red for one reason: its pipeline
attestation still binds 3b1f4c3 while the head is 5af8c8b. The head moved
because this branch's own no-mistakes CI-step fix commits landed after the PR
step had already written the attestation, and the check's error text sends the
contributor to CONTRIBUTING.md for the remedy.

CONTRIBUTING.md states the rule the check enforces - the attestation must bind
the current PR head, and a stale one fails - but never states what to do about
it, and never names the case that actually produces it: the gate committing its
own fix after attesting. Say both, in the sentence that already owns the rule.

Verification of the rest of this head, since CI itself has not executed on it
(both workflow runs sit at action_required awaiting fork approval): bin/fm-lint.sh,
bin/fm-doc-audience-check.sh, and --check-coverage pass; tests/fm-usage-ledger.test.sh
and the touched call-site suites pass, as do the same checks replayed on the
PR merge ref against current main; and the serial hint table plus every figure
in docs/fm-test-portable-shards.md replays exactly against the real timing
artifacts of the three green source runs it cites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… owner

bin/fm-usage-ledger.sh's WHY IT EXISTS paragraph says the ledger "is written at
the lifecycle points below", but this header has no such list: its sections are
STORE, RECORD, PRIVACY BOUNDARY, IDENTITY, FIRST OBSERVED, SAFETY, RETENTION,
and Usage. docs/architecture.md owns the call sites and why each was chosen,
which is exactly where docs/configuration.md already routes an operator.

Name that owner instead of a list this file deliberately does not restate, in
the same sentence, so the schema owner configuration.md tells operators to read
first no longer promises a section it does not have. Comment only; no verb,
schema, or call site changes.

Verification on this head: bin/fm-lint.sh, bin/fm-doc-audience-check.sh, and
--check-coverage (177 total, serial_unhinted=0) pass; tests/fm-usage-ledger.test.sh
passes 37/37; tests/fm-pr-merge.test.sh, tests/fm-teardown.test.sh,
tests/fm-backend-orca.test.sh, and tests/fm-gotmp.test.sh pass; the two new
relaunch ledger assertions in tests/fm-control-relaunch.test.sh pass in
isolation; and the docs/fm-test-portable-shards.md shard table replays exactly
against bin/fm-test-run.sh's real assignment (27/29/28/28/29 scripts at
770979/770981/770965/770974/770981 ms, 3854880 ms total, 16 ms imbalance).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…shows

The serial-shard section's headroom evidence still described the partition this
branch carried before the rebase onto main: "the previous partition ... 12.65
min against a 12.80 min hinted estimate", and then the delta to the table below
it. Neither 12.80 nor 12.65 refers to anything the document or bin/fm-test-run.sh
now contains - main's own refresh added tests/fm-wake-drain-outcome-backstop.test.sh
(15182 ms) under the branch, the table was repacked to 141 scripts at 770979 ms
(~12.85 min), and the only replay number a reader can act on was left describing
a 140-script packing that no longer exists.

Replay the partition that is actually in the tree and state that number instead.
Downloading fm-test-timing-portable-serial-* from the same three green runs the
hints come from (33558082172, 33523597838, 33463326167) and summing each current
shard's real per-script durations gives worst shards of 12.69, 12.09, and 12.63
min, so 12.69 min is the bound, still 63% of the 20-minute job cap. The two
scripts those runs never measured fall in shards 2 and 5, and neither is the
worst shard in any of the three runs, so the bound rests on measurement rather
than on a hint; that qualifier is now stated rather than left to be inferred
from a vanished table.

Documentation only: no verb, schema, lane composition, or hint value changes.

Verification on this head: bin/fm-lint.sh, bin/fm-doc-audience-check.sh
(surfaces=89, local_links=307), and bin/fm-test-run.sh --check-coverage
(total=177, serial=141, serial_unhinted=0) pass; tests/fm-usage-ledger.test.sh
passes 37/37; tests/fm-documentation-audiences.test.sh and
tests/fm-test-isolation-proof.test.sh pass; the table replays exactly against
bin/fm-test-run.sh's real assignment (27/29/28/28/29 scripts at
770979/770981/770965/770974/770981 ms, 3854880 ms total, 16 ms imbalance).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cleanup_firstmate_home_children killed each task inside a secondmate home
being force-retired, retired its status log, and removed its task record
without ever writing a usage row. Each of those tasks owns a ledger that
is deleted with the home holding it, so the work vanished with no harness,
model, or outcome attributable to it - the same gap the retirement of the
mate itself already closes, one level down and at every nesting depth the
sweep recurses through.

Capture each child's axes and final status class before that frame retires
them, and append the row past every refusal, immediately before the record
is removed. The row goes to the home that survives the operation, which is
the one deliberate exception to the call policy's own-home rule: a row
written into a home this sweep deletes would go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APnYDFegBnMUhTzwqxQoqE
@GodKimba
GodKimba force-pushed the fm/firstmate-durable-task-usage-ledger branch from c9d657a to 4eeedc2 Compare September 2, 2026 16:13
@GodKimba GodKimba changed the title feat(bin): add a durable task-usage ledger that outlives cleanup feat(bin): add a durable task-usage ledger that outlives task cleanup Sep 2, 2026
Comment thread bin/fm-teardown.sh
Comment on lines +2599 to +2605
if [ -n "$child_axes" ]; then
fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" cleanup "$child_id" \
--axes "$child_axes" --outcome discarded --status-class "$child_status_class"
else
fm_usage_ledger_record "$FM_HOME" "$STATE" "$DATA" cleanup "$child_id" \
--meta "$child_meta" --outcome discarded --status-class "$child_status_class"
fi

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 Nested cleanup record is deleted

When forced retirement processes a secondmate containing a nested child, this call writes the child's cleanup event through the current home’s ledger, then the outer teardown removes that enclosing home. The cleanup event is deleted with the home, so the child’s terminal outcome remains unavailable for durable model and workflow analysis.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This conflates two different homes. The row is written through the operating home, not the home being removed, and those are the same directory only on one path.

cleanup_firstmate_home_children records through $FM_HOME/$STATE/$DATA (bin/fm-teardown.sh:2600 and :2603), which are the teardown process's own home (:161-163). The recursion at :2558 does not rebind them, so a grandchild at any depth records to that same operating home rather than to the frame's $home. In a local forced retirement that operating home is the parent that invoked fm-teardown.sh <id> --force, while the home being deleted is $HOME_PATH (swept at :2669, removed later). $DATA is not inside $HOME_PATH, so the rows outlive the removal.

That is covered by test_a_forced_retirement_records_every_task_it_discards in tests/fm-usage-ledger.test.sh, which drives the real bin/fm-teardown.sh --force through a secondmate home holding a ship child, a nested child secondmate, and a task inside that nested home, then asserts each row is readable in the surviving home's ledger carrying its own harness, model, effort, kind, and incarnation. It fails without this change (the discarded child task wrote no usage record) and passes with it.

The one case where the two homes coincide is the remote-host leg: fm-remote-secondmate-control.sh cmd_retire re-enters teardown with FM_DATA_OVERRIDE=$TARGET_HOME/data/.parent-route, inside the home that leg then deletes. That is pre-existing retirement behavior rather than something this call introduced — the retired mate's own cleanup record already targeted that same store, and its append falls after the home removal, so it warns instead of writing. This PR documents it as a stated limitation in docs/configuration.md (the ledger section's survival wording and its limitations paragraph) and in docs/architecture.md, rather than changing where that leg writes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right—I conflated the operating home with the home being swept. On the local forced-retirement path, cleanup_firstmate_home_children retains the invoking teardown's $FM_HOME/$STATE/$DATA through recursion, so each child row is appended to the surviving parent ledger rather than the swept child home. The regression test also validates this across nested homes and confirms the child axes are preserved before removal.

My original finding does not apply to that path and should be withdrawn. The remote-host leg remains a separate documented limitation because its FM_DATA_OVERRIDE points into the target home that is removed before the post-cleanup append; that does not invalidate the local fix.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

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