feat(bin): add a durable task-usage ledger that outlives task cleanup - #3470
feat(bin): add a durable task-usage ledger that outlives task cleanup#3470GodKimba wants to merge 32 commits into
Conversation
Confidence Score: 4/5The 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 |
|
Speaking as Kun's firstmate: waiting on the author. Attestation MISMATCH (blocking). Body attestation binds Contract-class: new-default. Unconfigured homes now always append a durable What looks solid on review (via
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). |
7d80fd7 to
3b1f4c3
Compare
3b1f4c3 to
337b659
Compare
|
Speaking as Kun's firstmate: waiting on the author. Attestation MISMATCH (blocking). Body attestation binds Contract-class: new-default. Unconfigured homes always append durable This pass: tip moved past prior stamp ( Please fix: re-attest HEAD only. No competing PR opened. After MATCH + green CI, expect a captain new-default hold (not Firstmate auto-merge). |
625458b to
1bb7981
Compare
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
|
Speaking as Kun's firstmate: waiting on the author. Attestation MISMATCH (blocking). Body attestation binds Contract-class: new-default. Unconfigured homes always append durable This pass: tip moved past prior stamp ( 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). |
6c117fa to
94c3ec8
Compare
|
Speaking as Kun's firstmate: waiting on the author. Attestation MISMATCH (blocking). Body attestation binds Contract-class: new-default. Unconfigured homes always append durable 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 ( 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. |
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.
…warning, and test selection
…ledger test selection
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>
…arry seq across prune
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
c9d657a to
4eeedc2
Compare
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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:
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
bin/fm-usage-ledger.shas 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 (currentlyunknown) validator identity — withrecord,list,verify,prune,path,status-class, andaxesverbs, aledger-openfirst-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 explicitpruneverb (FM_USAGE_LEDGER_RETENTION_DAYS, default 400).bin/fm-usage-ledger-lib.showns the shared call policy: the effective home is passed explicitly and a failed write warns and returns 0 rather than gating the lifecycle step.fm-spawn.shappends a spawn row immediately after each point that commits a task record beyond rollback (fresh dispatch, relaunch, remote-secondmate launch) and now mintsspawn_genearlier so the Orca abort record shares it, while remote-secondmate metadata records its ownspawn_genanddefaultfor unpinned model/effort;fm-pr-check.shrecords PR identity after the poll is published;fm-merge-outcome-lib.shandfm-merge-local.shrecord merges and local landings;fm-teardown.shcaptures 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.shgainsFM_WAKE_LOCKS_ONLYso sourcing it for locks alone no longer recreates a removed state directory, andfm-secondmate-reconcile.shcomments are corrected for markerless-route identity.tests/fm-usage-ledger.test.shplus 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 inbin/fm-test-run.shand refreshes the shard table indocs/fm-test-portable-shards.md; and documents the schema path, lifecycle points, privacy boundary, retention, and analysis limitations indocs/architecture.md,docs/configuration.md,docs/scripts.md,docs/remote-secondmates.md, oneAGENTS.mddata-tree line, and aCONTRIBUTING.mdnote 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 realfm-teardown.sh <id> --forceon a secondmate home holding live work at two nesting depths, read back through the realfm-usage-ledger.sh list/verifyon 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=1788365058Evidence: 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=1788365052Evidence: 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"}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)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)
Evidence: Reproduction script for the refusal check
Source: Reproduction script for the refusal check
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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 runsfm-teardown.sh <id> --forceon a remote mate;remote_secondmate_teardown(bin/fm-teardown.sh:700) hands off tofm-on.sh <id> fm-remote-secondmate-control.sh retire <id> --force;cmd_retire(bin/fm-remote-secondmate-control.sh:315-318) re-entersfm-teardown.sh <id> --forceon 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 tocleanup_firstmate_home_children "$HOME_PATH"at :2669 with HOME_PATH=$TARGET_HOME. Every child row this line appends therefore lands in $TARGET_HOME/data/.parent-route/task-usage.jsonl, andremove_firstmate_home "$HOME_PATH"at :2923 reachessafe_rm_rf "$abs_home_path"(: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 ownusage_ledger_record_cleanupat :2956 runs after :2923, soul_resolve_dir data "$DATA"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 inremote_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- Theif [ -n "$axes" ] ... --axes ... else ... --meta ... ficleanup-record fallback is now written twice in this file with identical structure: once inusage_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, sousage_ledger_record_cleanupcould 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 newtest_a_forced_retirement_records_every_task_it_discardsRegression proof: replacedbin/fm-teardown.shwithgit show 1941ebb^:bin/fm-teardown.sh, re-ran the same suite, sawnot 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 modifiedcleanup_firstmate_home_childrenManual end-to-end:forced-retirement-ledger-demo.shbuilds 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 realbin/fm-teardown.sh <id> --force, confirms both child homes and their own ledgers are gone, then reads the surviving parent home withbin/fm-usage-ledger.sh listandverifySame demo run against the pre-fixbin/fm-teardown.shto capture the before/after ledger contrastManual refusal check:forced-retirement-refusal-demo.shgives 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 itPrivacy 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-------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.