fix(custody): reclaim a hold whose holder PROCESS IS DEAD, without waiting out the TTL (model_performance-oy4) - #76
Conversation
…iting out the TTL A custody record has carried `pid` and `host` since it was designed and nothing has ever read them for a decision. Liveness was inferred entirely from silence, so a lane whose process died sat unclaimable for the remainder of its 900s TTL plus up to a 300s sweep interval -- and its relaunched successor could do nothing at all: it could not work_claim (held by a dead agent), could not work_release (it does not hold it), and could not work_file (filing requires holding an item). FORENSICS (committed, docs/lanes/oy4-dead-holder-reclaim/evidence/): the measured incident on model_performance-h6v was NOT a broken TTL, a wrong field, or a dead sweep -- the item's three filed candidates are all falsified. Its holder renewed on a metronome-regular 120s cadence through 07:41:36Z and then stopped; all four refused successor claims (07:47/07:50/07:51/07:56Z) landed INSIDE the 900s TTL, the last by 45 seconds, and were refused correctly. The 'held_stale: 0' reading at 07:52Z was likewise correct. The defect is that the one fact which settled the matter -- the holder's process was gone -- sat unread. THE FIX. A third path to reclaim-eligible that OBSERVES the holder instead of inferring from its silence, fenced so it can only ever ACCELERATE the TTL and never take work from a live agent: the record must name this host, a real pid, and custody must already have been silent for two renewal intervals before any pid probe is consulted at all. Every unknowable case resolves to NOT eligible. Two further silent misses in the reaper, found while root-causing this: - reap_project read bd's DEFAULT 50-item list page, so a held item outside it was invisible to the reaper permanently, with nothing reporting the skip. - one item whose release raised aborted the reap of every remaining hold in that project, deterministically and forever, while the sweep still recorded itself completed. And the instrument that would have made this visible: doctor gains `sweeps.reclaiming`, which tells 'the reap loop is turning' apart from 'the reap loop is doing anything'. reap_loop discarded reap_sweep's per-project error results before stamping a completed heartbeat, so `sweeps.alive` (and work_tracker_status's running_healthy) read identically whether every project succeeded or every project failed.
…lation; doctor 37->38 (MEASURED); lane note + evidence _FakeBeads' list() now mirrors A.Beads.list's real signature (status/limit) -- a double that silently ignored either would let a caller pass a filter that never took effect and still look correct. Two new unit pins on the call SHAPE (the outcome looks identical on any project small enough to fit inside bd's default page) and on per-item release isolation. AGENTS.md's two doctor-count sites go 37 -> 38, read off the tool on this branch, not computed -- the run is committed under the lane's evidence dir.
Manager verification — the refutation HOLDS. I filed this item on a wrong premise, and this lane proved it. Merging.Head I re-derived the timeline myself from the live events table (read-only), and it falsifies my itemAll four refused successor claims were refused correctly, the last one by well under a minute, and the The real defect, confirmed at file:line on main
The cross-host safety check — the thing I most wanted to see, and it is right
if c.pid <= 0: return False, ""
this_host = host if host is not None else local_host()
if not c.host or c.host != this_host:
# A pid on another machine says nothing to this one. Never guessed.
return False, ""
if silence < min_silence: return False, "" # corroboration window
if is_pid_alive(c.pid): return False, ""So a foreign Gates
Two judgement calls I want to endorse explicitly: the successor-claim test deliberately does not use Squash + |
… fence, and pin it CCV1-023's seeded note said the fixture that model_performance-c0e was filed for 'asserts the TOOL-layer refusal (which passes via the session latch)'. That sentence was removed wholesale by PR #71 when the row flipped CONFORMS, but was never replaced by the right answer -- so the ledger has been silent on WHY its own flagship custody fixture passes. Measured, one mechanism at a time, against real bd + an isolated dolt server: * delete the adapter's custody-identity fence (PR #68, b3fac1e) and test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim fails at 'assert resolved.success is False' -- the close LANDS. The session latch is what lets it through. * delete the tool layer's 'except A.FencedError' latch-clearing (PR #3, 69b717e) and the refusal still happens, but the fixture fails at 'assert session._held is None'. So two mechanisms at two layers, and the session latch is neither. Pinned executably rather than left as prose: test_post_reap_refusal_originates_below_the_session_latch asserts the latch still names the item at the moment of the refused call, that the ADAPTER refuses with no session in the picture, that the surfaced wording is the fence's and not the latch's, and that an integrator's close of the same unheld item still succeeds. Its own discrimination measured three ways (fence removed -> DID NOT RAISE; refusal re-sourced to the latch -> wording red; fence re-keyed on 'unheld' instead of identity -> integrator half red). test_row_ccv1_009 gained a part 4 so the ledger notices if that pin is deleted. CCV1-009 re-assessed: stays CONFORMS, both halves RUN this time rather than probed for existence. assertion.kind deliberately unchanged. STALE-NOTE AUDIT of every CCV1 row (the item asked for it explicitly) found four more, all corrected here, none a disposition change: * CCV1-002 said reclaim_eligible has 'exactly two routes'; PR #76 added a third, and the source docstring has said 'three, and only three' since. Two path-2 cites added so the row covers the mechanism it describes. * CCV1-003, CCV1-004, CCV1-010 each defer their indexed upgrade on 'that suite runs in nothing (CCV1-022)'. CCV1-022 went green in PR #68. * CCV1-017 said Fixture 4 'does not exist in ANY suite'; CCV1-023 flipped CONFORMS on the file that contains it. ledger/reconcile-report.md carries the same claims and is deliberately NOT touched: it is a dated, self-declared superseded snapshot.
… fence, and pin it CCV1-023's seeded note said the fixture that model_performance-c0e was filed for 'asserts the TOOL-layer refusal (which passes via the session latch)'. That sentence was removed wholesale by PR #71 when the row flipped CONFORMS, but was never replaced by the right answer -- so the ledger has been silent on WHY its own flagship custody fixture passes. Measured, one mechanism at a time, against real bd + an isolated dolt server: * delete the adapter's custody-identity fence (PR #68, b3fac1e) and test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim fails at 'assert resolved.success is False' -- the close LANDS. The session latch is what lets it through. * delete the tool layer's 'except A.FencedError' latch-clearing (PR #3, 69b717e) and the refusal still happens, but the fixture fails at 'assert session._held is None'. So two mechanisms at two layers, and the session latch is neither. Pinned executably rather than left as prose: test_post_reap_refusal_originates_below_the_session_latch asserts the latch still names the item at the moment of the refused call, that the ADAPTER refuses with no session in the picture, that the surfaced wording is the fence's and not the latch's, and that an integrator's close of the same unheld item still succeeds. Its own discrimination measured three ways (fence removed -> DID NOT RAISE; refusal re-sourced to the latch -> wording red; fence re-keyed on 'unheld' instead of identity -> integrator half red). test_row_ccv1_009 gained a part 4 so the ledger notices if that pin is deleted. CCV1-009 re-assessed: stays CONFORMS, both halves RUN this time rather than probed for existence. assertion.kind deliberately unchanged. STALE-NOTE AUDIT of every CCV1 row (the item asked for it explicitly) found four more, all corrected here, none a disposition change: * CCV1-002 said reclaim_eligible has 'exactly two routes'; PR #76 added a third, and the source docstring has said 'three, and only three' since. Two path-2 cites added so the row covers the mechanism it describes. * CCV1-003, CCV1-004, CCV1-010 each defer their indexed upgrade on 'that suite runs in nothing (CCV1-022)'. CCV1-022 went green in PR #68. * CCV1-017 said Fixture 4 'does not exist in ANY suite'; CCV1-023 flipped CONFORMS on the file that contains it. ledger/reconcile-report.md carries the same claims and is deliberately NOT touched: it is a dated, self-declared superseded snapshot.
… fence, and pin it CCV1-023's seeded note said the fixture that model_performance-c0e was filed for 'asserts the TOOL-layer refusal (which passes via the session latch)'. That sentence was removed wholesale by PR #71 when the row flipped CONFORMS, but was never replaced by the right answer -- so the ledger has been silent on WHY its own flagship custody fixture passes. Measured, one mechanism at a time, against real bd + an isolated dolt server: * delete the adapter's custody-identity fence (PR #68, b3fac1e) and test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim fails at 'assert resolved.success is False' -- the close LANDS. The session latch is what lets it through. * delete the tool layer's 'except A.FencedError' latch-clearing (PR #3, 69b717e) and the refusal still happens, but the fixture fails at 'assert session._held is None'. So two mechanisms at two layers, and the session latch is neither. Pinned executably rather than left as prose: test_post_reap_refusal_originates_below_the_session_latch asserts the latch still names the item at the moment of the refused call, that the ADAPTER refuses with no session in the picture, that the surfaced wording is the fence's and not the latch's, and that an integrator's close of the same unheld item still succeeds. Its own discrimination measured three ways (fence removed -> DID NOT RAISE; refusal re-sourced to the latch -> wording red; fence re-keyed on 'unheld' instead of identity -> integrator half red). test_row_ccv1_009 gained a part 4 so the ledger notices if that pin is deleted. CCV1-009 re-assessed: stays CONFORMS, both halves RUN this time rather than probed for existence. assertion.kind deliberately unchanged. STALE-NOTE AUDIT of every CCV1 row (the item asked for it explicitly) found four more, all corrected here, none a disposition change: * CCV1-002 said reclaim_eligible has 'exactly two routes'; PR #76 added a third, and the source docstring has said 'three, and only three' since. Two path-2 cites added so the row covers the mechanism it describes. * CCV1-003, CCV1-004, CCV1-010 each defer their indexed upgrade on 'that suite runs in nothing (CCV1-022)'. CCV1-022 went green in PR #68. * CCV1-017 said Fixture 4 'does not exist in ANY suite'; CCV1-023 flipped CONFORMS on the file that contains it. ledger/reconcile-report.md carries the same claims and is deliberately NOT touched: it is a dated, self-declared superseded snapshot.
… fence, and pin it (c0e) (#87) * ledger(CCV1-023/-009): attribute the post-reap refusal to the ADAPTER fence, and pin it CCV1-023's seeded note said the fixture that model_performance-c0e was filed for 'asserts the TOOL-layer refusal (which passes via the session latch)'. That sentence was removed wholesale by PR #71 when the row flipped CONFORMS, but was never replaced by the right answer -- so the ledger has been silent on WHY its own flagship custody fixture passes. Measured, one mechanism at a time, against real bd + an isolated dolt server: * delete the adapter's custody-identity fence (PR #68, b3fac1e) and test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim fails at 'assert resolved.success is False' -- the close LANDS. The session latch is what lets it through. * delete the tool layer's 'except A.FencedError' latch-clearing (PR #3, 69b717e) and the refusal still happens, but the fixture fails at 'assert session._held is None'. So two mechanisms at two layers, and the session latch is neither. Pinned executably rather than left as prose: test_post_reap_refusal_originates_below_the_session_latch asserts the latch still names the item at the moment of the refused call, that the ADAPTER refuses with no session in the picture, that the surfaced wording is the fence's and not the latch's, and that an integrator's close of the same unheld item still succeeds. Its own discrimination measured three ways (fence removed -> DID NOT RAISE; refusal re-sourced to the latch -> wording red; fence re-keyed on 'unheld' instead of identity -> integrator half red). test_row_ccv1_009 gained a part 4 so the ledger notices if that pin is deleted. CCV1-009 re-assessed: stays CONFORMS, both halves RUN this time rather than probed for existence. assertion.kind deliberately unchanged. STALE-NOTE AUDIT of every CCV1 row (the item asked for it explicitly) found four more, all corrected here, none a disposition change: * CCV1-002 said reclaim_eligible has 'exactly two routes'; PR #76 added a third, and the source docstring has said 'three, and only three' since. Two path-2 cites added so the row covers the mechanism it describes. * CCV1-003, CCV1-004, CCV1-010 each defer their indexed upgrade on 'that suite runs in nothing (CCV1-022)'. CCV1-022 went green in PR #68. * CCV1-017 said Fixture 4 'does not exist in ANY suite'; CCV1-023 flipped CONFORMS on the file that contains it. ledger/reconcile-report.md carries the same claims and is deliberately NOT touched: it is a dated, self-declared superseded snapshot. * c0e: tier evidence logs (all five tiers rc=0) Recovered by the manager after the lane was killed mid-flight by an external tmux-server restart (model_performance-ye80). The lane had already finished: tiers-summary.txt records cli rc=0, ledger rc=0, module rc=0, ALLDONE. Only the push and the DONE.json marker were lost. --------- Co-authored-by: lane c0e <lane-c0e@localhost>
The item's three candidates are all falsified — and its arithmetic is built on a wrong death time
model_performance-oy4asks whether the reap sweep is (a) not running,(b) computing staleness from the wrong field, or (c) using a TTL far
larger than the documented 900s. The answer, from the live
eventstable formodel_performance-h6v(committed atdocs/lanes/oy4-dead-holder-reclaim/evidence/h6v-forensic-timeline.txt), isnone of them:
agent-spark-1-563997claims, takes custodywork_claimrefused ×4work_statsreportsheld_stale: 0,oldest: nullunclaimreleases itLast renewal 07:41:36Z + 900s ⇒ first reclaim-eligible at 07:56:36Z.
So all four refusals were correct — the last landed 45 seconds inside
the TTL — and
held_stale: 0was correct (623s of silence against 900s).The holder actually died ~10 minutes later than the item's "~07:33Z" estimate;
the 19- and 23-minute figures are measured from that wrong start. The sweep was
running, the field was right, the TTL was 900s.
The real defect: the one fact that settled the matter — the holder's
process was gone — sat unread.
Custodyhas carriedpidandhostsince itwas designed (
custody.py:89-96) and nothing has ever read them for adecision. Liveness was inferred from silence, never observed from the holder,
so a lane whose process dies is stranded for the rest of its 900s TTL plus up
to a 300s sweep interval — up to 20 minutes — on a fact knowable in
microseconds on the same host. In that window a relaunched successor can do
nothing at all: it cannot
work_claim(held), cannotwork_release(it doesnot hold it), cannot
work_file(filing requires holding an item). That deadend forced a hand-run
unclaimtwice in one day (h6v,2nx).The fix
A third path to reclaim-eligible that observes the holder instead of
inferring from its silence, fenced so it can only ever accelerate the TTL and
never take work from a live agent:
hostmust equal this host — a pid on another machine isunknowable, never guessed;
pidmust be a real positive pid;DEAD_HOLDER_MIN_SILENCE_SECONDS(default
2 × RENEW_INTERVAL_SECONDS= 240s) before any pid probe isconsulted at all.
Every unknowable case resolves to not eligible. Fence 3 is what protects an
agent whose pid is simply not addressable from here (a container in its own pid
namespace reporting the same hostname): it keeps renewing, so it never enters
the window where the probe runs. PID reuse is imprecise only in the safe
direction — a recycled pid answers "alive", falling back to the TTL. The probe
is injectable, mirroring
heartbeat.evaluate_freshness, so every branch istestable with no real processes and no real sleeps.
Reclaim latency for a dead holder: up to 1200s → up to 540s.
Fail-before / pass-after
Identical probe on both trees (
git stash push -- src/), reproducing theincident signature verbatim — real dead pid, 400s silence, 900s TTL, default
reap_project:Full text:
docs/lanes/oy4-dead-holder-reclaim/evidence/fail-before-pass-after.txt.Two further silent misses in the reaper, found while root-causing
Neither caused this incident; both are live bugs in the same function.
reap_projectread bd's DEFAULT list page.bd.list(include_resolved=False)with no
limitappliesLIST_DEFAULT_LIMIT(50), orderedpriority ASC, created_at DESC, id ASC— so a held item outside that first page wasinvisible to the reaper permanently, with nothing reporting the skip. Now
bd.list(status="held", limit=0). Ruled out as this incident's cause bymeasurement:
model_performanceheld 20–22 non-closed items across thewindow, and
h6vranked 2nd–4th. Pinned on the call shape, because theoutcome looks identical on any project small enough to fit.
first
releasethat raised propagated out,reap_sweepcaught it perproject, and every remaining stale hold went unreaped — deterministically, on
every sweep after it, while the sweep still recorded itself completed. Now
isolated per item and reported in
failed.doctornow tells "the loop is turning" from "the loop is doing anything"reap_sweepcatches every per-project exception into its return value (correct— one broken project must not abort the sweep), and
reap_loopdiscarded thatvalue before stamping a completed heartbeat. A sweep that failed on every
project recorded the same heartbeat as a healthy one, so
sweeps.alive— andwork_tracker_status'srunning_healthyon top of it — read identically eitherway. This is the gap
oy4names, and it is real even though it is not what bith6v.The reap heartbeat now carries the sweep's outcome (
projects,reclaimed,failed_projects),reap_looplogs failed projects by name, anddoctorgainssweeps.reclaiming(heartbeat.evaluate_reclaiming, pure and fullyunit-tested), which FAILS naming every project the last sweep failed on.
Deliberate honesty note: a heartbeat from a supervisor older than this change
carries no
failed_projectskey, and that case reportsunknown, never0 failed. Claiming "nothing failed" from a record that never carried the fieldwould invent exactly the reassurance this check exists to stop being invented.
That is what the live box reports today; it clears on the next service restart.
Verdict on
model_performance-c0e: genuinely distinct, and already fixedoy4c0ecustody.reclaim_eligibleBeads.resolve's custody fenceif current.status == "held")They rhyme — both read custody state from a proxy rather than from the custody
record — but they are different proxies at different call sites failing in
opposite directions, and neither fix touches the other's code. Two defects;
this PR fixes only
oy4. Andc0e's named failing test passes on thistree: the modules suite is 119 passed / 0 failed, including
test_reap_recovery.py::test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim.c0ewas filed 03:53Z; PR #68 (the post-reclaim fence, re-keyed on custodyidentity) landed afterwards and closed it.
c0e's second half — a ledger-notecorrection on CCV1-023 — is a different subject and is untouched here.
Tiers, by name
pytest tests/unitpytest -m integration tests/integrationpytest -m cli tests/clitest_doctor_quick_succeeds_against_the_real_installed_bd, which ismodel_performance-jyg, not this branchpytest ledger/checkspython -m ledger.checks.mutation_harnesspytest modules/tool-work-tracker/tests(tier 5)ruff check ./ruff format --check .pyright src testsdoctorThe one cli failure was verified rather than assumed: the doctor output inside
it shows a single
[FAIL], onsweeps.alive— "no heartbeat ever recorded forthe reap sweep loop", the isolated test root having none, exactly as
jygdescribes. The new check appears one line below as
[PASS] sweeps.reclaiming skipped -- no reap heartbeat recorded yet, correctly declining to pile a secondred line on a root cause already reported. Captured at
docs/lanes/oy4-dead-holder-reclaim/evidence/cli-tier-jyg-failure.txt.doctor37 → 38, read off the tool on this branch, never computed;AGENTS.md's two sites are updated and the run is committed atdocs/lanes/oy4-dead-holder-reclaim/evidence/doctor-measured.txt.New tests
tests/unit/test_custody_dead_holder.py— 19: the acceleration, all threefences, live-holder safety, pid-reuse direction, probe ordering.
tests/unit/test_sweeps_reclaiming.py— 17:sweep_failures,evaluate_reclaiming(including the older-supervisorunknowncase), and thedoctor wiring, plus a case where a heartbeat is FRESH and the sweep is still
proven not to be reclaiming.
tests/integration/test_dead_holder_reclaim.py— 7: realbd, real dolt, areal dead pid (a subprocess started and reaped), plus the two reaper
silent-miss regressions.
modules/tool-work-tracker/tests/test_dead_holder_successor_claim.py— 4: theagent seam end to end. Deliberately not
ttl_seconds=0, the shortcut everyother reap test in that suite uses — with ttl 0 every hold is stale and a
dead-holder bug hides completely.
Spend: $0.00 against a $0.00 authority. Lane note:
docs/lanes/oy4-dead-holder-reclaim/DONE-NOTE.md.