Repository navigation
fix(outbox): an impossible claim field reads UNKNOWN, never pid reuse - #5830
Conversation
…#5829) The claim reader coerced pid, start_usec and claimed_at with int()/float(). An impossible birth token (start_usec -1 or 10**400) beside a live pid then made _pid_was_reused() report reuse, so may_reclaim_delivery(..., 0) let a second drainer take a live owner's delivery. Huge claimed_at and 5001-digit pid values raised OverflowError/ValueError instead of reading UNKNOWN. Parse without coercion: pid a non-bool int in (0, 2**31-1] via valid_pid, start_usec null or a non-bool int in [0, 2**63), claimed_at a finite int or float within +-2**53. Anything else, and any ValueError/OverflowError from json.loads, reads UNKNOWN, which is never reclaimable. _pid_was_reused treats an invalid token on either side as "cannot tell". Vendored copy kept identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 93%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
keweichen
left a comment
There was a problem hiding this comment.
Blocker — an invalid live-owner claim is still reclaimed after 30 seconds, and this bypasses the configured TTL.
Reviewed exact head edcb838fb0972428c6f276d2bb00018c9d1d309b.
_read_claim_at() maps invalid numeric fields to UNKNOWN, the same state used for a torn write. The actual reclaim path then treats every UNKNOWN as an abandoned torn claim and removes it solely once its file mtime passes SWAP_GRACE_S; DesignAClaimBackend.claim() routes UNKNOWN directly into that branch.
I created a real claim owned by the current ALIVE process, rewrote start_usec=-1, aged its mtime by SWAP_GRACE_S + 1, and used a deliberately huge reclaim TTL. HEAD produced:
owner_state ALIVE
read_state UNKNOWN
may_ttl_1e9 False
reclaim_ttl_1e9 True
holder B
A blocked-provider two-drainer control exercises the live DeliveryCore path: at HEAD both A and B reached the provider and returned ATTEMPTED; at merge base 7940f25f, only A reached the provider and B returned NOT_CLAIMED. With the normal 300-second backend TTL, this patch therefore turns the invalid-token case into a takeover after 30 seconds and can duplicate the external side effect sooner than the pre-fix path.
The added contract does not catch this because it stops at may_reclaim_delivery() (lines 798-805); its “never reclaimable” title is not true for reclaim_delivery_claim() or the backend claim path.
Please distinguish a semantically invalid record from a recoverable torn write, or otherwise prevent the torn-write sweep from taking this case. Pin both sides: an invalid live-owner claim remains held before and after grace through the public/backend reclaim path, while a genuinely torn write still ages out. Since this is active delivery-loop recovery behavior, the repository-required post-restart live-path witness is also still owed after the fix.
| pid, start_usec, claimed_at = d.get("pid"), d.get("start_usec"), d.get("claimed_at") | ||
| if not (valid_pid(pid) is not None and (start_usec is None or _valid_start_usec(start_usec)) | ||
| and _valid_claimed_at(claimed_at)): | ||
| return ClaimRecord(item_id, "", -1, None, 0.0, state="UNKNOWN") |
There was a problem hiding this comment.
Second recovery regression: this UNKNOWN sentinel discards the parseable on-disk item_id, so startup cannot retire a terminal claim left by a crash between terminal write and release.
Exact-head control: publish and claim as A, call record_delivered, rewrite start_usec=-1, then run DesignAClaimBackend.recover(). HEAD returns retired=[]; a B claim is refused, republish is false, and the claim remains forever. The merge base reads the same record as HELD/A, returns retired=["terminal"], removes the claim, and permits republish. The skip occurs because recover() calls _read_claim_at(p, "") and ignores records whose sentinel has an empty item_id.
Please preserve enough routing identity for safe terminal retirement (without making an invalid READY claim reclaimable), and cover the crash-recovery path alongside the READY-item control.
|
|
||
|
|
||
| def _valid_start_usec(value: object) -> bool: | ||
| return type(value) is int and 0 <= value < _START_USEC_LIMIT |
There was a problem hiding this comment.
Boundary blocker: 0 is accepted here and explicitly pinned as HELD by the new contract, but no production identity writer emits a zero birth token: unavailable is None, and Linux, Darwin, and Windows produce positive epoch microseconds. For the current live PID, rewriting only start_usec=0 produced state=HELD, may_reclaim(..., 0)=True, reclaim(..., 0)=True, and holder B. That is the same impossible-token-as-PID-reuse failure this PR is meant to close.
Please make the non-null lower bound strict (0 < start_usec) unless there is a real writer that can emit zero, and replace the positive edge control with a two-way zero-token regression.
…t the torn-write sweep Review of the previous head: invalid fields read UNKNOWN, the torn-write state, so _reclaim_locked swept a live owner's malformed claim after SWAP_GRACE_S (30 s), bypassing the reclaim TTL. Tell the two apart. Torn is what a crashed writer leaves: empty text or a JSON decode error. Anything that decodes is a complete write, so invalid fields (or a non-object, or an impossible number) read UNKNOWN with malformed=True. A malformed claim keeps its parseable item_id, drainer_id, valid pid and valid birth token, and is reclaimable only when the liveness rules allow it (a readable pid that is ALIVE and not provably reused, or UNKNOWN, holds it) and its file mtime is at least max(TTL, SWAP_GRACE_S) old. claimed_at is never trusted for it. The owner may release it, so startup recovery can retire a terminal item's malformed claim. start_usec must now be > 0: no writer emits 0, and a zero token beside a live pid read as reuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 251c82616a1eb2370c1df41be1ac9b2d05436050.
Requesting changes: the prior malformed-claim recovery blocker remains for the issue's 5,001-digit integer case.
P1 — a parse-limit error discards valid sibling fields, so a live claim can still be stolen and a terminal claim can still wedge. In src/outbox.py:485-494 (and the vendored copy), json.loads() raises ValueError when any integer exceeds CPython's digit limit; the handler sets d = None and calls _malformed_claim(..., {}). That loses an otherwise valid PID/birth token and also loses item_id/drainer_id.
Focused local reproduction with a valid current PID/birth token, a raw 5,001-digit claimed_at (same result for start_usec), claim-file age 301s, and TTL 300s:
actual_owner=ALIVE parsed_pid=-1 may_reclaim=True reclaim=True
second drainer=ATTEMPTED provider_calls=2 while first provider call remained blocked
The terminal form also reproduces the earlier recovery wedge when only pid is a raw 5,001-digit integer:
scan item_id='' state=UNKNOWN malformed=True
recover recovered=[] retired=[]
claim_exists=True republish=False
So the new full-TTL behavior delays the duplicate but does not prevent it, and DesignAClaimBackend.recover() still skips the terminal record because the scanner lost its item ID. The new tests cover a huge invalid PID and separately cover terminal malformed fields that still decode to a dict; they do not cover one parse-limit field alongside usable owner/item identity.
Please retain unaffected fields across this parse-error class and add public/backend tests for (1) a huge claimed_at or start_usec with a live valid owner and (2) terminal recovery with a huge numeric field. Preserve the current normal/torn positive controls. After the correctness fix, the required post-restart live-path witness is still needed before approval.
Verification at this head: the focused protocol suite passed 24/24, malformed-backend tests passed 4/4, delivery-core contract suites passed, package parity passed, git diff --check passed, and the repository review checks passed. Current CI has one red standalone-Python lane whose observed failure appears unrelated to this diff; it is not the basis of this review.
…confirmed from undelivered; outbox claim parsing left to #5830 - persisted_number.bounded_number(value, lo, hi): an int (never a bool) or a finite float, range-checked before any conversion. The lane ts, a hold marker's since and the gateway status ts all go through it, so 10**400, NaN or infinity reads as missing/down instead of raising. - A hold since more than SINCE_SKEW_S ahead is no hold, and open_tasks no longer clamps a hold time to now. - hold_line_outcome reads the drain's own state: delivered (receipt), undelivered (queued, claimed or parked in undelivered/), or unconfirmed (gone with no receipt). Health reports task-held-line-undelivered or task-held-line-unconfirmed after 10 minutes. - README states the published-lane requirement, its freshness bounds and the two new health reasons. - Both outbox.py copies are main plus #5830's PID_MAX and valid_pid, word for word; the claim-reader change, the process_identity pid gate and the Windows test expectation are reverted, since #5830 owns claim parsing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 5001-digit integer anywhere in a claim made json.loads raise ValueError (the int-string digit limit), and the reader then built the malformed record from an empty dict. That erased item_id, drainer_id, pid and birth token: a terminal claim could not be retired, and a live owner's claim with only claimed_at oversized became pid -1 and was reclaimed at the TTL while the owner was still sending (two provider calls). Decode integers through _bounded_int: more than 20 digits becomes a private sentinel that every field validator rejects, so the record still decodes and keeps its owner. The ValueError/OverflowError catch stays for anything else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 4127343b27bc5dfbcf7263f65e008b0cc71b3a89.
REQUEST_CHANGES — the 5,001-digit sibling-field blocker from review 5630062187 is fixed, but the malformed-PID policy can now steal a live owner's claim.
The new _bounded_int path does preserve a valid PID/birth token and terminal identity when only claimed_at or an extra field is oversized. The focused protocol suite passes 24/24, the backend suite passes 7/7, and the three new oversized-integer cases fail at 251c8261 and pass here.
Blocker: an invalid-but-recognizable PID bypasses liveness and duplicates delivery
At _malformed_claim(), a decimal-string or integral-float PID becomes -1. _record_is_reclaimable() then skips process_identity() entirely and authorizes takeover from file mtime at TTL. The added contract actually codifies this for a string PID at lines 852–856.
I exercised the real DeliveryCore path with drainer A blocked inside the provider, rewrote only its live PID from 3621 to "3621", aged the claim, then ran drainer B:
7940f25f age=300 actual_owner=ALIVE B=not_claimed provider_calls=1
7940f25f age=30000 actual_owner=ALIVE B=not_claimed provider_calls=1
4127343b age=300 actual_owner=ALIVE B=attempted provider_calls=2
4127343b age=30000 actual_owner=ALIVE B=attempted provider_calls=2
The integral-float form has the same base→HEAD regression. The normal writer emits integer PIDs, but this PR's concern is precisely malformed persisted claims; a five-minute provider call plus this one-field corruption now permits the duplicate external side effect the outbox exclusion contract forbids. Strict schema validation can remain strict, but an unusable PID must not be treated as proof that no live owner exists. Either retain a tightly bounded recognizable PID solely for the liveness check or fail closed on an unreadable owner, and pin the public/backend two-drainer path at TTL and well beyond it.
Remaining readiness gates
- Required CI genuinely ran and is red:
diff coverage >= 95% (python)reportssrc/outbox.pyat 89.6%, missing lines489-490, 521-522, 577. These are the parse fallback and missing-mtime fail-closed branches introduced by this PR. - The PR body still explicitly says the required post-restart live delivery round trip is owed. This delivery-loop change is not approvable on harness evidence alone.
license/clais currently absent at this SHA, so the complete merge gate is not green.
Other verified evidence: both outbox copies are byte-identical; adapter ownership/delegation checks, git diff --check, and repository review checks pass. A future-but-in-range claimed_at can strand a dead claim, but that behavior is identical on the merge base and is not the basis of this verdict.
A malformed claim collapsed any pid that was not a strict int to -1, so a pid of "12345" or 12345.0 named no owner and a second drainer took a live owner's claim at the TTL (two provider calls). Make the evidence asymmetric. _candidate_owner_pid reads a pid from any form that plausibly names a process (a non-bool int, an integral finite float, or a canonical decimal string), within valid_pid's bounds. Every reclaim decision (may_reclaim_delivery, reclaim_delivery_claim, and the backend's claim() and recover(), all via _record_is_reclaimable) refuses while that candidate is alive or unknown. Authorizing stays strict: pid reuse is proven only with a strict valid_pid and a valid birth token, and release and identity still use the strict pid. Tests: a table over nine pid forms crossed with a live or dead owner, at the public reclaim API and through DeliveryCore and recover(); plus branch coverage for the torn/malformed split, the oversize sentinel, the remaining parse errors and a vanished claim file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 2623173fc0d9942186ef000a15727ee4af7f0d67.
REQUEST_CHANGES — the canonical decimal-string/integral-float blocker from review 5630516794 is fixed, but adjacent PID strings that main safely recognized can still steal a live owner's claim.
The asymmetric shape is otherwise sound: candidate_pid is used only to refuse takeover; UNKNOWN/ALIVE candidates hold; PID-reuse authorization still requires the strict integer PID plus a valid birth token. Independent exact-head QA passed 225 ALIVE/DEAD/UNKNOWN PID/age combinations, eight concurrent DeliveryCore contenders at TTL and 100×TTL, protocol 26/26, backend 8/8, package parity, and the repository scanners. The new tests genuinely fail on 4127343b. Exact-head CI and 56/56 diff coverage are green.
Blocker: the safety-only parser is still narrower than the prior live-owner proof
_candidate_owner_pid() accepts a string only when it is digit-only and has no leading zero. main's prior int(...) read also recognized unambiguous positive strings with leading zeros, a leading +, or surrounding ASCII whitespace. At this head those forms become pid=-1, candidate_pid=None; once file mtime reaches TTL, _record_is_reclaimable() never probes the live owner and authorizes takeover.
I exercised the actual DeliveryCore path with A blocked inside the provider, changed only its live PID field, aged the claim to TTL+1 (301 s for TTL 300), and ran B:
form 7940f25f base 2623173f head
canonical B=not_claimed calls=1 B=not_claimed calls=1
leading-zero B=not_claimed calls=1 B=attempted calls=2
leading-plus B=not_claimed calls=1 B=attempted calls=2
ASCII-whitespace B=not_claimed calls=1 B=attempted calls=2
All other claim fields remained valid and the real owner stayed ALIVE with the same birth token. The new table currently codifies the leading-zero case as “implausible” and expects the second provider call at tests/outbox-malformed-claim-backend.test.py:34-44 and :199-217. That is a base→HEAD duplicate-delivery regression in the persisted-malformed-record class this PR owns.
Please let the lenient, blocking-only parser retain tightly bounded, unambiguous positive textual PID forms that the prior reader recognized, while keeping valid_pid and the birth token strict for every authorizing decision. Pin the one-field forms through both public reclaim and blocked-provider DeliveryCore paths at TTL and well beyond it.
Remaining readiness gates
- The PR body still explicitly says the required real post-restart delivery round trip is owed. This delivery-loop change remains unapprovable on harness evidence alone under REVIEW.md §15.
license/clais absent at this SHA. The CLA recheck workflow only posted the bot command; no required status appeared.
Non-blocking portability question: PID_MAX = 2**31 - 1 says it bounds every supported OS, while the Windows probe passes a DWORD and Win32 exposes process IDs as DWORD. I did not observe a high-bit PID, so this is not part of the blocker above, but the bound should be justified or covered by a Windows edge test.
The canonical-only candidate reader let a live owner be displaced when its pid was written as "0123", "+123", " 123 ", "1_23" or 123.5. The previous reader parsed the pid with int(), which accepts all of these, so it held those claims. _candidate_owner_pid now takes any finite float and any string of at most 32 characters that int() parses, and any non-bool int, bounded by valid_pid. bool stays non-blocking (int(True) is pid 1, never a drainer). Authorizing is unchanged: reuse proof and release use the strict pid. The pid-form table gains those forms with a live owner, plus a property check that every form int() turns into a valid pid is a candidate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 4c666d7bf6d45dd0b0631cb12434c7f622cde1a8.
REQUEST_CHANGES — the five newly added forms fix the prior review, but the string-length boundary still lets a live claim be stolen and delivered twice.
The declared bounded contract is otherwise sound. Independent QA passed 324 public/recovery cases, 324 DeliveryCore cases, 20 eight-contender scenarios, protocol 26/26, malformed backend 8/8, and strict reuse/incarnation/completion/release checks. Current CI is green (35 success, 3 intentional skips), package parity is green, and license/cla is present and successful.
P1 blocker: a 33-character int()-accepted PID bypasses the live-owner check
_candidate_owner_pid() calls int() for a string only when len(raw) <= 32. A live PID padded with leading zeros or surrounding whitespace to exactly 33 characters is still accepted by the former main reader and resolves to that live PID, but this head assigns candidate_pid=None. The reclaim rule then skips process liveness and authorizes takeover at the file-mtime TTL.
Independent production-path control with a real live owner, TTL 300, at both 300 s and 30,000 s:
7940f25f zeros33: state=HELD may=False reclaim=False
7940f25f DeliveryCore: B=not_claimed provider_calls=1
7940f25f terminal recovery: retired=[]
4c666d7b zeros33: state=UNKNOWN candidate=None may=True reclaim=True
4c666d7b DeliveryCore: B=attempted provider_calls=2
4c666d7b terminal recovery: retired=[qa-long-pid]
The 33-character whitespace-padded form produces the same result. Thus the cap is a base-to-HEAD regression in exactly the malformed persisted-record class this PR owns: B reaches the provider while A is still blocked inside its provider call.
The purported direct property check at contract 26 only loops over the finite 13-row table, so it never tests the 32/33 boundary. The backend table has the same omission.
Please preserve the old blocking evidence for every safely recognizable valid PID string, or fail closed when a bounded parser declines one; keep the current strict authorization path unchanged. Pin lengths 32 and 33 through public reclaim, concurrent DeliveryCore, and terminal recovery. The exact worst case is duplicate external delivery by two drainers.
The required real post-restart delivery witness is also still explicitly owed under REVIEW.md lesson 15. Fixing this code blocker, then attaching that witness on the final head, is what changes the verdict.
A live pid padded with leading zeros or whitespace past 32 characters was skipped by the length cap, so its owner was displaced at the TTL (two provider calls), though the pre-fix int() reader held it. _int_string_pid reads int()'s string grammar in linear time: strip surrounding whitespace, allow one leading '+', require decimal digit groups joined by single underscores, drop the underscores and leading zeros, and parse only when 10 or fewer significant digits remain. More is past PID_MAX at any length, so it is non-blocking, as int() would yield an out-of-range pid. '-' stays non-blocking. Contract 26 replaces the table property with a boundary sweep: zero- and whitespace-padded live pids at lengths 1, 10, 32, 33, 64 and 5000 must read as the pid and must not be reclaimed; the backend test sweeps the same lengths through DeliveryCore (one provider call) and recover() (no retirement while the owner lives). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 046b62603260d2d1cc5ef9449a7dacd591de8e2a.
REQUEST_CHANGES — the ASCII 32/33-length blocker is fixed, but the replacement parser drops valid Unicode-zero-padded live PIDs and still permits duplicate delivery.
P1 blocker: non-ASCII decimal zeros are counted as significant digits
_int_string_pid() accepts every Unicode decimal digit through isdecimal(), but line 551 removes only ASCII "0". A string such as ten Arabic-Indic zeros followed by the current live PID is valid Python integer syntax and resolves to that PID, yet its code-point length remains above ten, so this head returns candidate_pid=None.
Exact production-path control (the PID was alive throughout each run):
4c666d7b Arabic-zero padded: int(text)=<live pid> candidate=<live pid>
may=False reclaim=False; B=not_claimed provider_calls=1; retired=[]
046b6260 Arabic-zero padded: int(text)=<live pid> candidate=None
may=True reclaim=True; B=attempted provider_calls=2; retired=['qa-long-pid']
The same failure occurs with fullwidth zero and at both 300 s and 30,000 s claim ages. Base 7940f25f and prior head 4c666d7b both preserve the live claim. An exhaustive Unicode scan found this class for all 67 non-ASCII decimal-zero code points; an independent 39,273-case parser oracle recorded 1,107 false negatives. This does not depend on CPython's long-integer digit limit: the shortest reproducer here is only about 14 characters.
The added length sweep misses the defect because str.zfill() inserts only ASCII zeros. Through the real backend, B therefore enters the provider while A remains blocked in its provider call. The worst case is duplicate external delivery; the same miss also lets terminal recovery retire a claim still owned by a live process.
Please count zero-valued Unicode decimal digits as leading zeros (without passing unbounded significant input to int()), or otherwise fail closed when the blocking-only parser cannot resolve a candidate. Pin at least Arabic-Indic/fullwidth zero padding across the direct parser, public reclaim, blocked-provider DeliveryCore, and terminal recovery paths. Keep the current strict authorization/reuse path unchanged.
Verification at this head: protocol 26/26 and malformed-backend 9/9 pass but miss this case; package parity, git diff --check, and repository review checks pass. CLA is green; several CI lanes are still running. The required REVIEW.md §15 post-restart live delivery witness is also still explicitly owed after the correctness fix.
|
Secondary parser finding from the same exact-head sweep (not the basis of the P1 duplicate-delivery verdict): That direction is refusal-only, so it cannot authorize a duplicate. It can, however, retain a malformed delivery/terminal claim indefinitely whenever those digits name an ALIVE or UNKNOWN process, where the pre-fix reader did not recognize the string. Please make the whitespace grammar match the intended |
…its and whitespace included Two mismatches with int() remained. Leading zeros were stripped only as ASCII '0', so a live pid padded with another script's zeros (U+0660 and the like) kept its length, hit the 10-digit cap, and was displaced at the TTL (two provider calls, then retirement on recovery). And str.strip() removes U+001C..U+001F, which int() rejects, so the reader accepted strings int() does not. Map every decimal character to its value with unicodedata.decimal before stripping zeros and counting, and strip only _INT_WHITESPACE: exactly the characters c with int(c + "1" + c) == 1. Contract 28 checks the reader against int() for every code point as padding, separator, leading and trailing digit and a ten-character pad, then rescans all code points to pin _INT_WHITESPACE. Contract 26 adds U+0660 and mixed-script zero padding at lengths 33 and 64 and a seeded fuzz over mixed-script digit strings; the backend test drives the same padding through DeliveryCore and recover(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head deae1ac588f71f28117e410c837884f6d101259c. The Unicode-decimal and exact-whitespace fixes are sound: the focused protocol suite is 27/27, the backend suite is 10/10, and independent oracle testing found no parser mismatches. One cumulative safety blocker remains.
P1 — a live PID-1 drainer is now reclaimed when its malformed JSON pid is true.
The PR's compatibility rule is intentionally asymmetric: every legacy int()-coercible valid PID blocks takeover, while only a strict integer can authorize reuse/release. The implementation now exempts true because the PR says PID 1 is “never a drainer.” That premise does not hold for the shipped library:
write_delivery_claim()writesos.getpid()without excluding 1, andvalid_pid()accepts integer 1.- Sparrow documents the outbox as a library contract and explicitly supports container startup, where the drainer can be PID 1.
_candidate_owner_pid(True)falls through to strictvalid_pid()and becomesNone. The reclaim path then skips liveness and authorizes takeover from mtime alone after TTL (lines 598–615).
I ran the unmodified production modules at base and HEAD with controlled OS seams modeling a PID namespace (getpid() == 1, PID 1 ALIVE with birth token 111). Drainer A was held inside the provider; its valid claim's JSON pid was changed from 1 to true and aged past TTL; then drainer B and terminal recovery ran:
base 7940f25f
direct: may=False reclaim=False holder=A
blocked DeliveryCore: B=not_claimed provider_calls=1
recovery: retired=[] claim_exists=True
head deae1ac5
direct: may=True reclaim=True holder=B
blocked DeliveryCore: B=attempted provider_calls=2
recovery: retired=['terminal'] claim_exists=False
That is duplicate external delivery while the original drainer is still live, plus premature retirement of its terminal claim.
Please recognize raw True as candidate PID 1 only on the refusal/liveness path; keep valid_pid, reuse authorization, release, and False strict/invalid. Add public-path, blocked-DeliveryCore, and recovery coverage with a controlled live PID 1. Please also pin the authorization asymmetry with a malformed lenient PID plus a different live birth token (must still refuse) and a strict integer positive control (may prove reuse); deleting the strict guard at src/outbox.py:608 currently leaves both focused suites green.
Other exact-head gates: CI is currently red because python standalone tests (6/9) hit the unrelated-looking reap-orphans cwd race (the same 74-test file passed locally, 74/74); the aggregate job consequently failed and diff coverage skipped. license/cla is absent on this SHA. The required §15 real post-restart delivery witness is also still pending. These gates need to be green/current after the P1 fix.
… included A malformed claim with "pid": true read no candidate owner, so a live pid 1 (a real drainer in a container or PID namespace) was displaced at the TTL (two provider calls). The pre-fix reader's int(True) held it. _candidate_owner_pid now returns valid_pid(int(value)) for every JSON value the reader can see: bool and finite float go through int(), str through the int() grammar reader, and null, list, dict and the oversize sentinel give None, as int() raising does. Tests: - contract 28 adds a domain oracle over every JSON value type, with owners varied (own pid, pid 1, PID_MAX, a dead pid); - contract 29 pins the asymmetry: a plausible but non-strict pid with a mismatched or tiny birth token blocks a takeover, is not proven reuse, and is not releasable by another drainer; a strict pid with the same token is still proven reuse; - the pid-form tables add true and false, and the backend adds an owner axis (own pid, pid 1 and PID_MAX via a patched liveness probe, a dead pid) through DeliveryCore and recover(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed by hand Document the trade the blocking rule makes: a corrupt record that names a live pid (including "pid": true, which is pid 1) never ages out, and the remedy is deleting its claim file. Docstring only; no code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Re-review began on requested head 90bf1d1252c80b92560ddb16ea55050dceef58a2; the PR moved during verification, so this verdict covers current head 157285fb3e0b5585daf4908dfbb6e299acbbd4e6 and the intervening delta.
The executable PID fix is now sound. Exact-head protocol is 28/28, malformed-backend is 11/11, DeliveryCore ran 46 tests with 45 passing and one existing skip, and independent JSON-domain/string oracles and owner matrices passed. Restoring the deae1ac5 bool behavior fails contracts 26/28 plus the blocked-provider PID-1 case; deleting the strict reuse guard fails contract 29. Canonical and vendored copies are byte-identical.
P1 blocker — the new recovery instruction defeats the exclusion guarantee.
src/outbox.py:18-22 now tells an operator to delete a malformed live-owner claim file by hand. That raw unlink bypasses the item lock and claim-instance validation. The same module explicitly says even the locked force=True path is “ADMINISTRATIVE DESTRUCTION” that may remove a live peer, and names stale verify-then-unlink as a defect class.
Two exact-head production-path controls:
live-owner documented delete:
before delete: B=not_claimed, provider_calls=1
after delete: B=attempted, provider_calls=2
stale-observation documented delete:
observed=A; A released; successor B acquired
blind unlink removed B; C_acquired=true
In the first control, A remained blocked inside the provider, its malformed record read pid=-1, candidate_pid=1, and following the new instruction allowed B to call the provider concurrently. In the second, the operator's stale path deleted a newer live claim. Worst-case disruption is duplicate external delivery—including duplicate messages or other non-idempotent provider effects—precisely what this claim policy exists to prevent.
Please remove the raw-deletion remedy. The minimum safe documentation is that an ALIVE/UNKNOWN candidate must never be deleted or released and requires operator reconciliation. Any actionable procedure must first quiesce every drainer sharing the root, establish that no provider call remains in flight, and reconcile or park an indeterminate provider outcome before clearing the claim through a locked administrative transition. Quiescing alone is insufficient if the first call may already have crossed the provider boundary. No current CLI exposes that transaction, so omitting the remedy is safer than directing an unlocked unlink.
Non-blocking test hardening: raw true itself is not pinned as strict-invalid under a mismatched live birth token. A narrow mutation retaining True as strict PID 1 leaves both focused suites green (28/28 and 11/11) while changing may_reclaim from False to True. Adding raw true to contract 29 would directly preserve the “candidate-only, never authorization” boundary.
Current gates: exact-head CI is green, including diff coverage. license/cla is absent on this SHA, and the required §15 real post-restart delivery witness remains explicitly owed. Changes requested: fix the unsafe recovery guidance; approval also remains gated on the live witness and normal CLA/approval requirements.
…s yet Withdraw the "delete the claim file" remedy: a raw unlink bypasses the item lock and claim-instance checks, so on a live owner it duplicates the delivery and on a stale observation it can remove a successor's claim. State what clearing one actually needs instead. Docstring only; no code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 4ef93d56e4fcc562dd151856414be057ac24dde5.
The prior unsafe-remedy P1 is fixed. The 157285fb..4ef93d56 delta changes only the two outbox module docstrings: both now say no safe manual remedy exists, reject raw unlinking because it can duplicate a live delivery or remove a successor's claim, and name the prerequisites for a future locked administrative transition. The current PR body says the same and labels the health row/admin command as follow-up work, not a current capability.
Fresh exact-head evidence:
- Removing the leading module docstring yields identical ASTs at
90bf1d12and this head for both copies; canonical and vendored files are byte-identical. - Claim protocol: 28/28; malformed backend: 11/11; adapter: 10/10; operator recovery: 50/50; DeliveryCore: 45 pass, 1 expected skip.
- Merge-base review checks and the hardcoded-path scan pass.
- The original failure still discriminates: on
7940f25f, a live claim withstart_usec=-1returnsmay_reclaim(0)=True; this head reads it malformed/UNKNOWN and returnsFalse.
REQUEST_CHANGES remains, now solely for the required live-path witness. This changes delivery-loop recovery behavior, and the PR body still explicitly says the real post-restart delivery round trip is owed. Under REVIEW.md §15, harness proof cannot support approval here. Please run one real delivery through the restarted service on this exact code and attach the end-to-end evidence; that is the condition that changes this review to approval, assuming the head and normal gates remain current.
Current gate context, not additional code findings: python standalone tests (6/9) is red because untouched reap-orphans.test.py failed its own setup (change='exec': “the orphan was not a candidate; the case proves nothing”), so it appears unrelated but required CI still needs a green rerun. license/cla is absent on this SHA.
Non-blocking documentation precision: the module/PR prose says any plausibly named ALIVE PID blocks indefinitely, while _record_is_reclaimable() permits takeover when a strict PID plus valid birth tokens prove PID reuse. Adding “unless strict identity proves PID reuse” would make the trade-off exact. This overstatement is conservative and does not reintroduce the safety issue.
Worst-case disruption remains duplicate external delivery if exclusion fails; the fail-closed implementation and focused controls mitigate the code risk, while the restart witness is still needed to verify the shipped path. Verdict: changes requested; documentation blocker resolved, live-path evidence outstanding.
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed at 4ef93d56e4fcc562dd151856414be057ac24dde5. Verdict: comment. The code is right; the approval bar is not met yet.
What I checked myself:
src/outbox.pyandpackages/ag2-sparrow/ag2_sparrow/outbox.pyare byte-identical at this head.- I reproduced the bug and the fix with the production functions. A fresh claim held by a live pid, with one field rewritten:
main(7940f25):start_usec=-1reads HELD andmay_reclaim_delivery(..., 0)returns True;start_usec=10**400the same;claimed_at=10**400raisesOverflowError.- This head: all of those read UNKNOWN and
may_reclaim(0)returns False. A pid of 2**40 reads UNKNOWN / False on both.
tests/outbox-malformed-claim-backend.test.py: 11/11.tests/sparrow-outbox-claim-protocol.test.py: 28/28 contracts pass. Both drive the real module; no source-grep or in-file copy.- No hardcoded workspace path in the added lines.
- CI is now green at this head (35 success, 3 skipped), so the earlier red shard has cleared.
What is still missing before this can be approved:
- No approval yet. keweichen's latest review at this head blocks only on the post-restart delivery round trip. Under this account's review policy, a missing live round trip is named, not blocking, so it does not stop my approval. But the bar here also needs at least one expert approval, and there is none yet.
license/clais absent on this SHA. The commit status endpoint shows zero contexts, which means the check has not run, not that it passed.
Still owed, not blocking from my side: one real delivery through the restarted service on this code.
Non-blocking: #5828 rewrites _read_claim_at too. Settle the out-of-range-int pid difference described in the PR body when the second of the two rebases.
— Sutando (susans-MacBook-Pro)
REVIEW.md §15 live witness at 4ef93d5I checked the head with Setup. The head was staged by PR tests (scratch, stdlib only): at the head, (A) Real item after a restart — PASSI started the bridge, sent TERM to its verified PID, and started it again from the head. Then the owner posted one message in a test room ("reply PONG"). I wrote its result, and the restarted bridge's outbox claimed, sent and recorded it: The log has exactly one (B) Malformed claim with a live owner, held across a restart — PASSThe owner posted a second real message. Before writing its result, I planted a claim on that item. The claim has an impossible birth token ( On base, this live owner's claim counts as pid reuse and is stolen. Then I restarted the bridge (TERM by verified PID, start from head) and wrote the result: The test room has no copy of that result: the owner read the room, and the task-2 post is the newest message. Cleanup: I stopped the bridge by verified PID. I deleted the held item's result, outbox item and claim without sending them, then killed the sleep owner. The broker lease for task 2 was left open on purpose, and the owner retires it without sending (a No approve, no merge. |
|
@cla-assistant check |
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 4ef93d56e4fcc562dd151856414be057ac24dde5. APPROVE — the code and §15 live-path evidence are ready.
The sole condition in my latest review is now met by the later same-SHA live witness:
- After a verified bridge TERM/restart, one real result went claim → send →
DELIVERED, produced exactly one DeliveryCore delivered line and one room reply, then released its claim. - A one-hour-old
start_usec=-1claim owned by a verified-live process survived a second bridge restart and 40 drain passes unchanged, with zero accepted/delivered lines and zero room copies. The merge-base control reads the same live-owner claim as reclaimable.
Independent exact-head QA corroborated both branches without live credentials: the delivery case made one provider call, wrote DELIVERED, released the claim, and three later fresh-process passes stayed terminal; the live-owner case ran 40 fresh-process passes, all NOT_CLAIMED, with zero provider calls and byte/inode/mtime-identical claim state. The same scenario is actually reclaimed at merge-base 7940f25f.
Current exact-head checks:
- claim protocol 28/28 and malformed-backend 11/11;
- broader adapter, DeliveryCore, recovery, fault/error, lifecycle, bridge, and race suites passed (native Windows-only cases were explicitly skipped on macOS);
- canonical/vendored parity,
git diff --check, and repository review/hardcoded-path checks pass; - all current CI lanes are green (35 success, 3 intentional skips), and the branch is not behind
main.
The previously noted raw-true/mismatched-birth-token test-hardening gap remains non-blocking: the implementation keeps candidate PID 1 separate from strict authorization, and the current PID-1 backend coverage passes.
This approval replaces my now-resolved CHANGES_REQUESTED; it is not merge authorization yet. license/cla is still absent on this SHA, and this will be only one formal approval where the repo requires two. Merge only after CLA and the second maintainer approval are present and the head/checks remain current.
|
@cla-assistant check |
There was a problem hiding this comment.
Approving at 4ef93d56e4fcc562dd151856414be057ac24dde5. The approval bar is now met.
Why it is met now
- Expert approval: keweichen (collaborator; author of 18 merged PRs among the repo's last 100 closed; ten formal reviews on this PR) approved at this exact head, replacing the earlier change requests.
- Verification: the same-SHA live witness (issue comment 6376595932) covers both paths on a restarted bridge: one real result went claim, send,
DELIVERED, one room reply, claim released; and a one-hour-oldstart_usec=-1claim with a live owner survived a second restart and 40 drain passes with zero sends, while the merge-base reads the same claim as reclaimable. That was the only thing missing in my comment review at this head. - Code (my own check at this head, unchanged): the two
outbox.pycopies are byte-identical;tests/outbox-malformed-claim-backend.test.pypasses andtests/sparrow-outbox-claim-protocol.test.pyreports "the claim protocol holds" on this machine today; both drive the real module, no source-grep or in-file copy; no hardcoded workspace path in the added lines. The two unresolved threads are outdated and were superseded by keweichen's later approval.
Gates that still stand before merge (not review blockers)
- CI is red on the newest run only, and not because of this diff. Run 40048431645 (01:08Z) failed
python standalone tests (1/9)ontests/sparrowd-agent-bridge-rescan.test.py(test_a_removed_worker_stays_owned_until_it_has_exited, a process-exit timing assert). This PR does not touch that file or sparrowd, and the same head passed CI twice before (runs 40013263230 and 40040782784) on the samemain. Locally the file passes 5/5 at this head; this Mac's timing differs from the CI runner, so that only shows it is not deterministically broken. A re-run of the job should clear it. license/clais absent on this SHA: the commit status endpoint returns zero contexts. That is "has not run", not green.
Still owed, not blocking: nothing beyond the witness above.
— Sutando (susans-MacBook-Pro)
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at 4ef93d5: src/outbox.py and the vendored packages/ag2-sparrow/ag2_sparrow/outbox.py are identical, no second claim implementation appears, nothing kills. "Impossible" = a JSON record with an invalid field (pid outside 1..2^31-1, start_usec not null/positive int < 2^63, non-finite claimed_at, non-object, >20-digit int) → malformed; empty/undecodable stays torn. The bug on main is real (live pid + start_usec=-1 reads as pid reuse, reclaimable at TTL 0). A 200k-string differential of _candidate_owner_pid against valid_pid(int(s)) found 0 mismatches. Suites: claim-protocol 28/28, malformed-claim-backend 11 OK, adapter-contract 10/10, bench smoke OK, and the new tests fail against main's outbox (22 pass/3 FAIL/1 ERROR; 73 failures).
Approving, with should-fixes:
- This loosens main knowingly: a claim whose pid cannot be read was held forever; now it is taken after
max(TTL, 30 s)by mtime (outbox.py:608-624). The body states it; the risk is a claim file restored with its old mtime ageing out at once. backend_a.py:107still names the flagrecovered_tornthough it now covers malformed too, andoutbox_cli inspectprints(UNKNOWN)for both, so an operator cannot tell them apart.- #5828 edits both outbox copies and reads an out-of-range int pid as DEAD where this reads malformed; whichever merges second must settle that on purpose, not by a mechanical conflict resolution.
…ne and reports held, not stuck (#5828) * fix(ag2-sparrow): a Hatch task parked for the owner's go posts one line and reports held, not stuck When a Hatch worker parks a task on purpose because it waits for its owner's go, the room saw silence and the keepalive health row flagged the task as task-not-picked-up after 15 minutes, indistinguishable from a stuck task. - ag2_sparrow/task_hold.py: a generic, machine-readable hold marker (results/.task-holds/<tid>.json: reason slug + since), placed O_EXCL so only the first caller acts; pruned once the task is archived. - `ag2-sparrow-hatch hold <result_file>`: places the owner-go marker and queues exactly one notice ("Waiting for my owner's go on this.") as a proactive file routed to the task's room/thread through the existing drain; a second hold posts nothing; the task's result slot stays open for the real answer. A failed queue releases the marker so a retry still posts. - keepalive health row: held tasks no longer count as not picked up; with nothing else wrong the row is healthy with reason task-held-for-owner. A held task never masks an unheld stuck one beside it. - worker_note tells the worker to run hold once for an item waiting on the owner's go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): hold marker is bounded, crash-safe and fail-closed; hold is gated on terminal state Review fixes on the Hatch parked-task hold: - task_hold validates the task id with the shared live-task-id rule (local_task_protocol.valid_task_id, plus gateway_instance.INSTANCE_RE for `task-<inst>~<id>`), resolves the path and refuses any that leaves .task-holds/ (traversal, absolute, separators, NUL, symlink). - Two-phase marker: reserve() stages + fsyncs and links a PENDING record exclusively; commit() replaces it with HELD atomically. Only a whole HELD record is a hold: absent, pending, corrupt, partial or unreadable markers read as not held, so the task still reports task-not-picked-up. - hold routes the notice through the drain's own _proactive_route, so a room the drain would not deliver to is refused before anything is written. - hold refuses an item whose result exists or whose outbox entry is settled, re-checks after queueing (withdrawing the notice if the answer landed), and reply withdraws a still-queued hold notice before publishing the answer. - A fresh PENDING marker makes a concurrent hold report not confirmed (3); a stale or unreadable one is redone, reusing a notice still queued. - README documents `hold` and task-held-for-owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): one destination owner, a confined one-step hold, no requeue after delivery Review round 2 on the Hatch parked-task hold, simplified: - proactive_destination.py owns where the proactive drain posts: route(), deliverable_here() (the homeserver check), homeserver_of() and the receipt dir. The bridge's _proactive_route/_room_is_deliverable_here and receipt write delegate to it; hold asks drain_room() with the agent's homeserver, so a room on another homeserver is refused. hold no longer imports the bridge. - task_hold drops the pending/held two-phase protocol and stale recovery. A marker is written whole and exclusively in one step (temp + fsync + link) and carries a token; release(token) never erases a newer hold. Every operation goes through an O_NOFOLLOW fd on the literal RESULT_DIR/.task-holds (lstat-checked), so a symlinked hold dir, a symlinked marker or a same-dir alias is never written, read or pruned through. `since` must be a finite, bounded epoch on write and read. - hold queues the line first (exclusive link), skipping it when it is queued, being sent, or the drain's delivery receipt says delivered, then places the marker. A marker failure reports not confirmed (exit 3) and the task keeps reading stuck; a retry places the marker without requeueing. - reply releases the hold along with the queued line; keepalive ignores a hold on a task whose outbox already has its answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): no name collision in the loader, tombstone release, confined writes, fail-closed dedup, live identity Review round 3 on the Hatch parked-task hold: - Rename proactive_destination.py to delivery_destination.py and import it in the bridge as `_delivery_destination`: the sutando loader execs the package bridge into globals that already hold the routing function `proactive_destination`, and the module import replaced it, so the claim gate raised TypeError. tests/gateway-claim-gate-name-collision.test.py drives the gate for a verified room, a Discord-owned bare file and a `.to-discord` file. - task_hold.release(token) renames the marker to a private tombstone under the dir fd, checks the token there, and puts a mismatched marker back, so an old token never deletes a successor's marker. - Every marker write goes through the held dir fd; an absent or vanished hold dir raises instead of falling back to the CWD. Writes loop until the whole body is written. - hold records a durable `<tid>.notice` before queueing the line. Once it exists, the line is skipped while queued/sending or delivered per the drain receipt, and with no readable proof hold exits 3 and queues nothing. A failed queue clears the record so the retry queues it. - hold judges the homeserver from agent_identity.lane_identity, the same resolver the bridge uses, over the running Sparrow's environment when readable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): hold reads no process environment, holds only on a proven line, release never hides a successor Review round 4 on the Hatch parked-task hold: - Drop agent_identity.lane_identity and every read of the running Sparrow's environment: it handed a credential-source reader outside its owner. hold takes the homeserver from the identity Sparrow enrolled (agent_identity.enrolled, STATE_DIR/auth/ag2space.json); when that is unknown the pre-check passes and the drain's predicate decides at send. - The notice record carries its writer's token; only that caller queues the line and only it may clear the record. Every caller, owner or loser, records a hold only once the line is proven queued, being sent or delivered; otherwise exit 3 and no hold. - Once the marker's link succeeds it is published: a failed directory fsync afterwards no longer reports failure. - release(token) drops the tombstone: it reads the marker by fd, compares the token, and unlinks only if the name still points at that inode, so a crash or a mismatch never leaves a successor under a hidden name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): serialize hold create and release under a per-dir flock The inode comparison in release(token) could not tell an old marker from a successor that reused its inode number (Linux reuses them at once), so an old token could erase a newer hold. Every create (the exclusive link) and every owned unlink (release, clear_notice, prune) now runs under an exclusive flock on `.task-holds/.lock`, opened through the held dir fd with O_NOFOLLOW. Inside it, release is a plain read-compare-unlink; the inode check and its documented window are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): a failed hold release never fails an answered task's command Both hold releases that follow an answer (reply after publishing, and hold when the answer lands mid-hold) now go through _release_quietly: an OSError or ValueError is logged as one stderr line and the command keeps its own exit status. A hold left behind is harmless because health ignores holds on answered tasks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): hold proves a queued line by its content; post-link temp cleanup never fails a hold - `_line_state` counts a queued or claimed hold-line file as proof only when the drain's own readiness predicate reads it and its body is exactly the line this hold queues; an empty, blank or foreign file reads unknown, so hold exits 3 and installs no hold. - `task_hold._create_at`: once the marker link succeeds the result is success; temp cleanup is best-effort and the dir fd closes on every path. - Pin the stated limit: with no enrollment the homeserver pre-check passes and the drain is the final authority (the live identity sits in a credential env Hatch must not read). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): hold survives the drain's claim rename, keeps a linked marker, and fails closed without enrollment - _line_state reads candidates in the order the drain moves a line (queued .txt, then .sending.<pid> claims, then the delivered receipt) and rescans up to 3 times when a file vanishes mid-read, so a claim or hand-back during the check no longer reads as unconfirmed. - Claim naming has one owner, delivery_destination.claim_path/claim_pid; the drain's claim and orphan recovery use it, and hold accepts only .sending.<digits> claims, the ones the drain creates and recovers. - task_hold._create_at: once the final link succeeds, lock/dir-fd close, fsync and temp unlink are best-effort and cannot change the result; the temp name is drawn before the dir fd is opened. - hold exits 3 and records nothing when Sparrow's enrollment is missing or unreadable, instead of skipping the homeserver check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): judge the hold room by the drain's published lane identity; accept only canonical claim names claim_pid accepts only the spelling claim_path writes (positive, no leading zeros), so a `.sending.0<pid>` file is neither proof of a queued line nor a live claim that orphan recovery defers forever. The drain now publishes the identity its deliverability predicate uses to <state>/auth/lane-identity.json (agent id, timestamp, pid; one writer, agent_identity.publish_lane), on start of each proactive pass and whenever it changes. Hatch reads that record first, then enrollment, so an AGENT_MXID override that makes enrollment stale no longer lets `hold` record a hold the drain would refuse to deliver. Hatch still never reads the credential env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): hold needs a live, per-instance lane record; claims must be this notice's own - hold no longer falls back to enrollment: with no lane record from the live drain of its gateway instance it exits 3 and records nothing. - The lane record is per instance (lane-identity[.<instance>].json, the suffix the bridge's other per-instance files use) and carries its instance. Hatch treats it as missing when its pid has exited, when a live lock for that instance names a different pid, or when it names another instance. - The drain publishes at start (before the outbound worker) and on every proactive pass, rewriting whenever the record on disk is not its own; it retracts the record on a failed publish and at clean exit. - claim_base(name), beside claim_path/claim_pid, inverts claim_path. Hatch counts only claims whose base is the notice's stem, recovery renames a dead claim back to its own base, and the owner-mention queued check uses the same grammar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(hatch): pin the numeric adjacent-infix claim form in hold and recovery `<stem>.sending.5.sending.<pid>` parses a canonical pid just like the `.sending.x.` form; both are now covered, and the hold test checks that a matched name is exactly claim_path(base, pid). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): one pid domain for every owner check; a serialized, fail-closed lane record - outbox.valid_pid (0 < pid <= 2**31 - 1, an int, never a bool) is the one pid domain. claim_pid, the lane record's reader and writer, process_identity (so the drain's _pid_alive and claim_owner_may_be_alive) and Hatch's process_gone all use it: an out-of-domain pid is never a live owner. real_pid + 2**32 no longer aliases a live process through ctypes.c_int, and a lane record carrying it no longer crashes hold with OverflowError. The native probes also catch OverflowError/ArgumentError. src/outbox.py carries the same change. - Lane publish, retract and read hold a per-instance flock beside the record, opened O_NOFOLLOW through an O_DIRECTORY fd on auth/. Publish writes a fresh temp, fsyncs and replaces; on failure it invalidates the old record in the same locked step (unlink, else a retracted tombstone, else truncate in place). Retract compares pid, ts and instance under the lock. The drain retracts only the record it wrote at clean exit. - The longpoll test's os.replace fake forwards keyword arguments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): the lane record is a heartbeat; no dir-fd op with None; persisted pids never coerced - The drain refreshes its lane record at least every LANE_REFRESH_S (10 s) under the lane lock. A reader treats a record as missing when its ts is non-finite, older than LANE_FRESH_S (30 s) or more than LANE_SKEW_S (5 s) ahead, so a drain that cannot write (read-only auth/, all invalidation fallbacks failing) or a reused pid stops vouching for a lane and hold fails closed. A failing publish is logged once per episode. - _auth_dir(create=True) retries when auth/ vanishes after its mkdir and then raises OSError; no relative op ever runs with dir_fd=None, and every close is guarded. - outbox._read_claim_at takes pid, start_usec and claimed_at without coercion: a wrong JSON type (true, 1.9, "1", Infinity) is UNKNOWN, and an int outside valid_pid has no live owner. claim_path refuses such a pid. src/outbox.py matches. The Windows-native test expects DEAD for out-of-domain inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): an unrepresentable lane ts is no lane; health flags a hold whose line never arrives - _valid_lane accepts only a non-bool int within 2**53 (as float) or a float before isfinite, so a ts of 10**400 reads as a missing lane and hold exits 3 instead of raising OverflowError. - Keepalive health reports a hold standing over HELD_LINE_DELIVER_S (10 min) whose room line the drain's receipt does not show delivered as abnormal, reason task-held-line-undelivered, instead of a healthy deliberate hold. cli.hold_line_delivered reads the same receipt as _line_state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): one bounded reader for persisted numbers; health tells unconfirmed from undelivered; outbox claim parsing left to #5830 - persisted_number.bounded_number(value, lo, hi): an int (never a bool) or a finite float, range-checked before any conversion. The lane ts, a hold marker's since and the gateway status ts all go through it, so 10**400, NaN or infinity reads as missing/down instead of raising. - A hold since more than SINCE_SKEW_S ahead is no hold, and open_tasks no longer clamps a hold time to now. - hold_line_outcome reads the drain's own state: delivered (receipt), undelivered (queued, claimed or parked in undelivered/), or unconfirmed (gone with no receipt). Health reports task-held-line-undelivered or task-held-line-unconfirmed after 10 minutes. - README states the published-lane requirement, its freshness bounds and the two new health reasons. - Both outbox.py copies are main plus #5830's PID_MAX and valid_pid, word for word; the claim-reader change, the process_identity pid gate and the Windows test expectation are reverted, since #5830 owns claim parsing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): keepawake releases a hold whose room line never delivered A hold over HELD_LINE_DELIVER_S whose line reads undelivered or unconfirmed is released by the keepawake pass, token-checked under the hold lock through task_hold.release, with one released-hold line in the keepalive log. The task reads task-not-picked-up again, so its worker is re-woken and can hold through a valid lane or answer. A hold whose line was delivered is never released, and nothing is re-sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): release a hold only when its line provably never went out, and re-arm its wakes - keepawake releases a stuck hold through task_hold.release_if: under the hold lock, while the marker keeps its token, it withdraws a still-queued .txt line (the unlink decides against the drain's claim-by-rename) or finds it parked unsent; a claimed, delivered or unconfirmed line keeps the hold. The release removes the marker and the notice record and records the release time. - task_fields carries released_at, and the hook restarts that task's wake count once per release (recorded as rearmed in its own state), so a capped task is woken again. - hold_line_outcome finds a line parked under the failure resolver's own name (undelivered/<stem>.txt). - keepalive log writes are best effort (stderr on failure), so keepawake always reaches the Sparrow check. - check_sparrow reads a status dated more than 5 s ahead as down. - send-failure-delegation pins the bridge's park dir through the shared delivery_destination.UNDELIVERED_DIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): surface a false hold, never reconcile it; anchor the parked lookup - Remove the keepawake auto-release: release_stuck_holds, withdraw_undelivered_line, task_hold.release_if/released_at and the .released record, released_at in task_fields, and the hook's re-arm. Keepawake never touches a hold. The read-only health states task-held-line-undelivered/-unconfirmed stay. - _line_parked matches only undelivered/<stem>.txt, the exact name the failure resolver writes; the oversized-body <stem>-<epoch>.txt park never holds a hold line and can name another valid task. - check_sparrow compares the raw future delta, so the 5 s skew is exact. - README: the hold is surfaced, not reconciled, and the undelivered label is a queue location (a timed-out send is requeued under the same name). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a held task is re-woken with held: true and is never answered The hook built wake items from task_fields, which never read .task-holds, so a held task came back on its rewake looking open and REWAKE_NOTE told the worker to answer it. A fresh worker session could then act on an item that waits for the owner's go. task_fields now reads the hold marker through task_hold.read and sets held: true on the item; an unheld item is unchanged. The note gains a held branch: such an item is never answered, hold is not run again, and only its HANDOFF block is repeated. REWAKE_NOTE's "otherwise answer it now" now excludes a held item. Tests: a held task re-woken carries held: true with the held note, its unheld sibling is unchanged, and a released hold is woken for unflagged; task_fields flags only a whole, well-formed marker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(hatch): a parked task's room line is the served held_line `hold` posts runtime_text.held_line from the setup cache wake-context reads (the same revision and validity rules), when it is one plain printable line of at most 300 characters with no backtick and no leading "[" or space, so a served line can never add a routing marker or a command. Otherwise, as on every install until the served setup is fetched, it posts the built-in line. A re-park posts nothing, whatever is served by then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): an answer withdraws a hold line the drain claimed but has not sent `reply` unlinked only a still-queued hold line, so a line the proactive drain had already claimed could post after the answer. A producer can now withdraw a queued or claimed proactive file with a `<stem>.withdrawn` sentinel; the drain checks it just before the send and retires the claim unsent. `reply` and the answered-meanwhile path of `hold` write it when the line is claimed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(sparrow): ag2-sparrow 0.6.1.dev1, the first version with hatch hold The backend's served Muse list tells workers to run `hold`, so it names the release this ships in (0.6.1) as its min_package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a served held line carries no marker or mention anywhere; the park test checks behaviour held_line refused only a leading "[", but parse_markers finds [dm-only] and [attach:|file:|send:] anywhere in a body: a mid-line one dropped the task-room redirect or asked for an upload. The line is now refused unless result_markers.neutralize_markers leaves it unchanged, and refused if it holds an "@", so it can mention no one. send-failure-delegation's quarantine test now drives a permanent failure through the bridge's own binder and checks the file lands in results/undelivered/, instead of matching source text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(send-failure): isolate the environment at module top for the bridge park probe hermetic-env-structural requires a module-top isolate_os_environ() in any test file that spawns code reaching the gateway bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a hook registered before the held branch is never handed a held item install stages a new hook script beside the registered one and a pip upgrade rewrites neither, so a 0.6.0 hook can run against this package. That hook has no HELD_NOTE and its rewake note ends "otherwise answer it now", so a held item it received was re-woken as answerable. task_fields now lists held items only when the hook declares it explains them (HATCH_HOOK_HELD_AWARE=1, set by the new hook); to any other hook a held task is left out, so it wakes no worker for it. The test runs 0.6.0's shipped hook against this package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(hatch): pin the copied 0.6.0 hook fixture to its release and sha256 A sidecar records the fixture's source (tag sparrow-v0.6.0, commit ca6c542, the shipped hook path) and its sha256, and the old-hook test asserts the file still hashes to it, so the fixture cannot drift from the released hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): one lock serializes a proactive file's withdrawal with its send The drain stat'ed `<stem>.withdrawn` and then called _req separately, so a reply could commit its answer and the sentinel between the two, and the held line still posted after the answer. The drain now holds an exclusive flock (results/.proactive-send-lock/lock) from the sentinel check through the POST, and `reply` and hold's answered-meanwhile path withdraw under the same lock before the answer is queued: the line is sent before the answer or not at all. A lock that cannot be taken hands the claim back unsent. The regression injects the reply at `_req` entry, after the final check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): the drain settles a send under the lock; a losing reply reports the outcome Acquiring the send lock is the linearization point. The drain now holds it from its withdrawal check through the POST and the send's settlement (receipt, archive, retry or park), so a reply that loses the race sees the settled outcome and reports it ("already posted", or "withdrawn" when a failed send was handed back) instead of writing a sentinel for a line already sent. The lock is one fixed file that is never unlinked, so every process and restart locks the same inode; acquisition failures raise SendLockUnavailable and the drain hands the claim back unsent. Tests pin both lock orders and that every send outcome, including an auth error raised out of the drain, releases the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the drain's moved send code is a known mover; import fcntl where the lock test needs it _send_claimed holds the proactive send code that moved out of _post_proactive, so gateway-disposal-delegation lists it beside its caller; the hatch test imports fcntl inside its POSIX-only helper, which the hermetic structural gate's Python 3.9 stdlib fallback requires above the isolation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): hold, reply and the drain commit under one lock; outcomes stay truthful and bounded Review at 64d5974, four blockers: - Producer transaction: hold's settled check, line publication and marker, and reply's withdrawal plus answer commit, now run under the send lock the drain's send takes, so an answer can no longer land before a line is published and then be followed by it. - Durable outcome: a withdrawn line whose archive fails keeps its sentinel until the drain really retires it. Each begun send is counted on disk (.proactive-attempts), so the retry budget survives a restart, and a reply that withdraws a line whose earlier send has an unknown outcome says so instead of "withdrawn". - Progress bound: the lock is taken with a deadline (producer 30 s, drain 2 s, which then hands the line back), the POST carries a 15 s wall-clock deadline, and _read_by uses read1 so a dribbling body cannot outlive it. - Upgrade fence: the drain declares send_lock in its lane record; hold refuses for a live Sparrow without it, and keepawake restarts such a Sparrow (through the user service when one runs it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): settle every send, bound every phase, gate begun sends, keep reply atomic Review at 9b4b26e, five blockers, and two follow-ups: - A fixed-length 200 left its claim stranded: _read_by called settimeout on the socket the response had already closed. It now looks the socket up each pass, and _send_claimed settles any other error once a send has begun (OSError, HTTPException, a non-JSON body) as an unknown outcome. - The deadline missed status, headers and an HTTP error's body. A deadline-bound _req opens through its own opener, which records the request's socket; a timer shuts that socket down at the deadline, and an error's body is read inside it. - Before each POST the drain reads the durable count and parks the line, unsent, once the first send and its retries began or the count is unreadable; an unreadable count is never overwritten. A crash after the POST therefore counts. - reply claims, withdraws the line and publishes the answer as one step; a failure releases the claim and restores the line, so a retry finds the task as it was. - keepawake also replaces a live, connected Sparrow that has held its lock past LANE_GRACE_S with no lane record: the released main's drain. - A park moves the count to `<stem>.parked`, so a re-queued file gets a fresh budget while reports still count what began; the attempt-record-failure hand-back is tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): a deadline-bound error keeps its body through the gateway scrub gateway_call's scrub empties every bytes attribute of the exception it re-raises, so the error body read inside the deadline arrived empty and the room-upgrade 400 path never saw its replacement room. The body is now kept in a buffer, which the scrub carries like the response it came from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): the proactive send is bounded in every phase, since it runs under the send lock main's proactive sender calls _req with a 20 s per-socket timeout and no wall-clock bound: headers or a body arriving a byte at a time, or a slow error body, keep it going, and it now holds the send lock that hold and reply wait 30 s for. The proactive sender passes a 15 s deadline. A deadline-bound _req opens through its own opener, which records the request's socket; a timer shuts it down at the deadline, which ends any read in progress (status, headers, body, an error's body), and the error's body is read inside it, kept in a buffer the gateway scrub carries. _read_by looks the socket up on each pass and reads with read1, so a fixed-length body neither strands its claim nor waits for every byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(proactive-routing): fold the instance-id note into the two-line comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): bound resolve, connect and TLS by the send deadline; keep outcome-unknown across requeue; fence hold on one lane snapshot - _req: resolution runs under the remaining budget, each connect candidate gets only what is left, and the TLS-wrapped socket (handshake included) is recorded for the deadline timer. - outbox requeue keeps a sticky prior_outcome_unknown mark, so reply never says a hold line "will not post" when an earlier attempt may be visible. - an unreadable lane record is distinct from a missing one: keepawake does not replace a drain it cannot read the record of. - hold reads lane ownership and send-lock capability as one snapshot and re-checks it under the send lock before it publishes or marks. - _publish_once ignores a failed temp unlink after the final result is linked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(sparrow): cover the new connect, lane-record and hold-fence branches Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(sparrow): cover hold's under-lock re-read and reply's already-posted report Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sparrow): invalid lane state never replaces a drain; a refused hold takes back its line; connect tries every family - The lane record reads as fresh, stale, absent, invalid or unreadable (agent_identity.lane_state). keepawake replaces a drain only on a fresh record of the holder without the send-lock protocol, or on no record past the grace period; a corrupt, emptied, tombstoned, symlinked or FIFO record is invalid and leaves the drain running. - The lane and hold-marker readers use the shared bounded_file_read reader, now with dir_fd and follow=False: opened non-blocking, so a FIFO is refused instead of hanging hold or keepawake. - When hold's fence after publication fails, the line this run queued is renamed out of the queue (exact inode) and its notice mark cleared, so no successor drain posts it; a line a drain already claimed is reported as possibly posting. - _connect_by builds each candidate's socket inside its failure handling, so an unsupported first family falls through to the next address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a stale lane record of the live drain is judged by its protocol, never by the grace replace A drain whose record went stale over a few slow sends in one pass still declares the send lock, so keepawake no longer SIGTERMs it mid-send; a stale record of the holder that lacks the protocol is replaced as a fresh one is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a hold line is sent only once armed; lane evidence is bound to its process; a failed publication reads invalid - The drain claims a proactive file before it takes the send lock, so a refused hold cannot take its line back. Instead the line's generation is bound in the notice record: the drain's dispatch-time recheck, run under the send lock, sends a hold line only when it is the record's current generation, armed by a hold that stands. A refused hold leaves it unarmed and it is withdrawn at its send; the next hold queues a new generation under a new name, so the retired one never becomes sendable again and a failed advance, publication or arming stays retryable. SEND_LOCK_PROTOCOL is 3: hold refuses a drain without this recheck. - The lane record carries its writer's process start time; keepawake judges a fresh or stale record only when it is the live holder's own (start time, else written after the holder's lock), so a reused pid's residue is no evidence in either protocol direction. - A failed lane publication empties the record in place (or creates it empty), so it reads invalid rather than absent and keepawake replaces no drain over a persistent write failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(hatch): the drain beside hold runs in hold's environment, never a copied one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): whole notice and lane writes; a drain's lock declares its protocol; hold binds the lane to its process; a legacy notice needs its hold - task_hold's notice rewrite and agent_identity's record write loop over short writes, so a hold reported as standing always has a whole, armed notice record and its line posts. - The drain declares its send-lock protocol and process start in the gateway lock it takes (workspace_lock.acquire(declares=...), kept by heartbeats). keepawake judges a holder whose lock declares by that alone, so a lane record it could not write, or even create empty, is no evidence against it; a declaration under a reused pid is not acted on. - agent_identity.owned_by is the one process-ownership check for a lane record; hold's lane snapshot and keepawake both use it, so a fresh record another process left under this pid authorizes nothing. - A notice from before generations is sent only while its hold stands. - A deadline-bound HTTPError's response is closed once its body is buffered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): keepawake signals only a proven Sparrow of the lock it re-reads A released main's lock carries no start time, so a pid it names may have been reused. keepawake now replaces a holder only when it is proven to be the Sparrow that took the lock: a Sparrow command line, and a start time equal to the one the lock declares or, for a lock that declares none, no later than the lock was taken. _stop re-reads the lock and repeats that proof right before the signal, for every caller, and sends nothing without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a signal needs this workspace's own lock, read safely, and a strictly earlier start - cli._lock_record reads the lock without following a symlink, never blocking, as a bounded regular file (bounded_file_read); a FIFO, symlink or oversized lock reads as none. - cli.canonical_lock binds a record to this workspace: its role, its workspace path, the schema, a valid pid, and finite acquired/heartbeat times that are not ahead of now, acquired no later than the heartbeat. proven_sparrow requires it, at the candidate check and again at _stop, so keepawake and install both leave an ambiguous holder untouched. - For a lock that declares no start, the holder must have started a whole second before the lock was taken: start times have one-second resolution, so the same second, or any later one, proves nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): a legacy holder is proven when it started no later than its lock's second; the boundary matrix runs through keepawake and install - proven_sparrow accepts exactly began <= floor(acquired_at): both are bounded epochs, the same second passes (a main-built Sparrow takes its lock about 0.1 s after exec) and the next one is refused. - One matrix of locks that cannot prove their holder (a start the second after the lock, Infinity, a far-future or string time, acquired after its heartbeat, another or no workspace, role, schema, pid or declared start, malformed JSON, a symlink to another workspace's lock, a FIFO, an oversized file) runs through _stop, real keepawake and install: none signals and the holder is left running. Positive controls: the same generation, including a lock taken 0.1 s after start, is replaced. - unmanaged_health_problem is tested directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(hatch): keepawake restarts the unit only for the holder it runs, re-proves the generation at the action, and succeeds only on a connected successor - replace_old_drain, keepawake's one path for a legacy drain: the unit is restarted in place only when systemctl shows it active with the selected holder as its MainPID. Inactive or another MainPID means the holder runs outside it, so it is stopped through _stop's own proof before the unit starts. - Right before acting, drain_predates_send_lock is re-run; a lock that no longer names the selected holder (the generation rolled) is left untouched and nothing is restarted. - Success is reported only once a live Sparrow other than the old holder owns the lock and reports connected after the action; otherwise keepawake exits 1 with restarted=0. A hand-made, unreachable or unreadable unit fails closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): bind keepawake's legacy-drain restart to the generation it proved; success needs the successor's own status The in-place unit restart now takes the old pid and its lock record and, after its own final unit read and right before reset-failed/restart, requires MainPID == old (active) and the canonical lock still proving that same legacy drain. A mismatch resets, restarts, stops and spawns nothing; keepawake logs restarted=0 and exits 1. Success after a replacement now requires, from one status snapshot: a lock holder other than the old (pid, start) that proven_sparrow accepts (Sparrow cmdline and the start the lock declares), a status naming that same pid and start, connected, and written strictly after the action at sub-second precision. The gateway status writer (the single writer, in remote_gateway_bridge) records pid, start and written_at for this. Regressions through production keepawake(): the generation rolling at the action's final `show` (lock and unit / lock only / unit only) restarts nothing; a live non-Sparrow pid plus a same-second pre-action status is not success; a status naming another process, a reused pid's identity, a pre-action written_at, whole-second time only, or a disconnected successor is not success; a legitimate successor and the in-place restart of a holder the unit owns still succeed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ag2-sparrow): the legitimate-successor fixture runs Sparrow's command line under /proc too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ag2-sparrow): the successor regressions install once and wait 0.5s, so the suite stays inside the sentinel run's budget Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): keepawake signals only through a pre-proof pidfd, re-proves the successor's lock, and starts the unit only while still down - stop_holder opens the pidfd before re-reading and proving the lock, checks the record's precise proc_start where it names one, and signals through that handle; with no stable handle it signals nothing. - _successor requires the lock's proc_start to be the holder's and the status to name it, and re-reads the lock after the status: a lock that moved during the proof or the read certifies nothing. The status writer records proc_start. - After stopping an outside holder of an inactive unit, the unit is started only while it is still down with no live lock holder; a generation that came up meanwhile is left untouched and judged as the successor. - The in-place restart requires ActiveState exactly active; reloading fails closed. Regressions through production keepawake(), each failing at 1d4989f: the pidfd opened before the proof and used for the signal; no pidfd or a failed pidfd_open signals nothing; a lock naming another precise generation is not signalled; the lock rolling during the successor's process proof or status read is no success; a status of another generation is no success; a generation that came up during the stop wait is not reset or restarted and is verified as the successor; a reloading unit is not restarted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ag2-sparrow): stop_holder's positive control asserts the signal goes through its handle, as on Linux Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ag2-sparrow): successor regressions decide on the first poll, keeping the hatch suite inside the sentinel run's budget Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): a stopped unit is started, never restarted, and a successor's final lock must still be canonical - After the outside holder stops, restart_listener's "stopped" binding runs `systemctl --user start`, which leaves a unit that came up after the final check as it is; `restart` would have stopped that healthy generation. - _successor's final lock read must still pass canonical_lock (workspace, role, schema, times), as well as name the same generation. Regressions through production keepawake(), both failing at e777426: a healthy generation coming up during reset-failed, after the last unit read, is not restarted and is verified as the successor; a lock that keeps every generation field but changes workspace, role or schema during the status read is no success. The inactive-unit positive control now expects `start`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): after a stopped holder only `start` is issued, and a successor's final lock must be the same canonical file - restart_listener's "stopped" binding issues `systemctl --user start` alone: no reset-failed, no restart, so a generation that came up after the final check is never touched. A failed unit that needs reset-failed is left to the next pass's down-listener restart. - _successor reads the lock as a snapshot of one file (cli.lock_snapshot: the same inode before and after the read) and certifies only when the final snapshot is that same file, still canonical_lock, and the same generation. Regressions through production keepawake(): B coming up as the final show answers or during the start sees only nothing or a no-op start and is certified; a lock keeping every generation field while changing workspace, role or schema, or rewritten unchanged into a new file, during the status read is no success. The discriminating cases fail at e777426. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(gateway): the owner-DM privacy test's gateway fake takes the request deadline, as the other proactive fakes do Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): a hold line is never silently withdrawn, and hold recovers after a failed marker - B1: a drain whose read of the hold behind a hold line fails (task_hold. line_unchecked) hands the line back for a later pass instead of letting the dispatch veto withdraw it. A line withdrawn unsent while its hold still stands on an open task now reads task-held-line-undelivered in the health row, not healthy task-held. - B2: hold re-run after its marker write failed and the drain withdrew the armed line takes the arming back (task_hold.disarm_notice) under the send lock, so the retry queues the next generation and places the marker instead of exiting 3 forever. - tests: the seat-startup test records only the bridge's own sleeps, not subprocess's wait loop, which shares the time module. Regressions, each failing at 2f5af2e: a one-shot read failure at the drain is deferred and the line posts exactly once on the next pass; a withdrawn line on a standing hold reports undelivered; a failed marker, then a drain pass, then hold again gives rc 0, a marker and exactly one line in the room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(delivery-core): a revalidation can defer; an unreadable hold defers its line instead of withdrawing it The delivery core gains a third revalidate answer. `Defer(reason)` releases the claim unsent with no attempt recorded (ClaimBackend.release, on both backends, validating the incarnation like complete), and deliver_one reports DrainStatus.DEFERRED. The proactive drain's existing generic path hands the file back for the next pass. task_hold.line_veto answers Defer when the notice record or the marker cannot be read, so a read error at the send, even after an earlier check passed under the send lock, neither sends nor withdraws the line. The bridge-local pre-check from 89104a7 is removed; the outbox owns the outcome. Regressions: EMFILE inside the dispatch veto's own read gives no send and no withdrawal on that pass, then exactly one line on the next (fails at 35f6879). Contract suite, for both backends: a deferring revalidation makes no provider call, parks nothing and counts no attempt, then the next pass sends; a stale token's release changes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ag2-sparrow): an unreadable hold marker defers its line; the release contract runs on every backend - task_hold.read_checked: like read, but an unreadable hold dir or marker raises instead of reading as absent. line_veto uses it, so the EMFILE fault moved onto the marker read (file or dir) gives Defer, not a withdrawal. read itself is unchanged for its other callers. - The deferral and release contract tests move from CorePolicy (backend A only) into ContractCase, so they run on A and C. They also pin incarnation fencing: a stale token, from another worker or from the same worker's earlier claim, releases nothing and never a successor's claim. A no-op `release` fails both tests on either backend; A's release without its incarnation check fails the stale-token test. Regression (fails at c08cf74 for both cases): EMFILE on the marker file or the hold dir inside the dispatch veto gives no send and no withdrawal on that pass, then exactly one line on the next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Priority
Level: high
Reason: duplicate-delivery risk: a live owner's outbox claim can be reclaimed.
Closes
Fixes #5829
Summary
_read_claim_atcoercedpid,start_usecandclaimed_atwithint()/float(). An impossible birth token (start_usec-1 or 10**400) next to a live pid made_pid_was_reused()report reuse, somay_reclaim_delivery(..., 0)returned True for a claim whose owner is alive. Hugeclaimed_atand 5001-digitpidvalues raised instead of reading UNKNOWN.What changed (
src/outbox.py, byte-identical inpackages/ag2-sparrow/ag2_sparrow/outbox.py):pida non-bool int with0 < pid <= PID_MAX(231-1) via a newvalid_pid;start_usecnull or a non-bool int with0 < start_usec < 2**63(no writer emits 0);claimed_ata finite non-bool int or float within ±253.SWAP_GRACE_S(30 s) sweep. Anything that decodes but has invalid fields, is not an object, or holds an impossible number reads UNKNOWN withmalformed=True. Integers are decoded through_bounded_int: one with more than 20 digits becomes a private sentinel that every field validator rejects, so a single oversized value spoils only its own field and the record keeps its owner. Any otherValueError/OverflowErrorfromjson.loadsstill reads malformed, keeping the caller'sitem_id.item_id,drainer_id, strictly validpidand validstart_usec._candidate_owner_pidreads a pid from every form the pre-fix reader'sint()accepted: any non-bool int, any finite float (truncated, asint()does), or any string inint()'s grammar at any length (so"0123","+123"," 123 ","1_23"and a pid padded to 5000 characters all count)._int_string_pidreads exactly that grammar in linear time, without callingint()on unbounded input. It strips only the charactersint()strips (_INT_WHITESPACE, everycwithint(c + "1" + c) == 1, which excludes U+001C..U+001F thatstr.strip()would remove). It allows one+and requires decimal digit groups joined by single_. It maps every digit of any script to its value withunicodedata.decimal, drops leading zeros, and parses only when at most 10 significant digits remain. More digits are pastPID_MAXat any length, so non-blocking, within0 < pid <= PID_MAX. The blocking set is therefore a superset of what main recognised. For every JSON value type the candidate is exactlyvalid_pid(int(value)), or None whereint()raises:trueis pid 1 (a real drainer in a container or PID namespace),falseis 0 (non-blocking), andnull, lists, objects and the oversize sentinel give None. If that candidate is ALIVE, or its state is UNKNOWN, the claim is held, whatever the TTL. Authorizing is strict: pid reuse is proven only with a strictvalid_pidand a valid birth token, and release and identity use the strict pid.max(TTL, SWAP_GRACE_S)old.claimed_atis never trusted for it. This applies on every path:may_reclaim_delivery,reclaim_delivery_claim, andDesignAClaimBackend.claim()/recover().drainer_id) may release it, so startuprecover()retires a terminal item's malformed claim once the rules above allow._pid_was_reusedtreats an invalid token on either side as "cannot tell", so an impossible token is never evidence of reuse.Consequence to note: a malformed claim whose pid is readable and alive, with no valid birth token, is held for as long as that process lives, the same rule a valid claim with
start_usec: nullalready follows. Before this PR,start_usec=-1on a live pid was read as reuse, so the claim was reclaimed.Trade-off: a corrupt record naming a live pid never ages out. Blocking errs toward never displacing a possibly-live owner. Any pid a malformed claim plausibly names blocks a takeover while that pid reads ALIVE or UNKNOWN, whatever the claim's age. The cost is that such a record stays held. For example, a garbage
"pid": truenames pid 1, which reads ALIVE or UNKNOWN on most hosts, so it never ages out.No safe manual remedy exists today. Deleting the claim file is not one. A raw unlink bypasses the item lock and the claim-instance checks. Against a live owner it causes a duplicate delivery (1 → 2 provider calls), and acting on a stale observation it can remove a newer successor's claim. Clearing such a claim safely needs four steps, and no CLI provides them:
This is documented in
src/outbox.py's module docstring (docstring-only commit 4ef93d5; the code is identical to 90bf1d1, AST-compared).Follow-up (not in this PR): two pieces of tooling for this case.
Review first:
_read_claim_atand_pid_was_reusedinsrc/outbox.py, then contracts 24-25 intests/sparrow-outbox-claim-protocol.test.pyandtests/outbox-malformed-claim-backend.test.py.Overlap with #5828: #5828 also rewrites
_read_claim_atand addsPID_MAXandvalid_pid. This PR uses the same names and the samevalid_pidsignature and semantics (returns the pid or None) so the two reconcile cleanly. Whichever merges second rebases. One difference to settle at that rebase: in #5828 an int pid outsidevalid_pidstays HELD andprocess_identityreads it as DEAD, so the claim is reclaimable. Here it reads UNKNOWN (malformed): no owner can be read, so it is reclaimable only once the claim file's mtime is at leastmax(TTL, SWAP_GRACE_S)old.Docs: N/A. Internal claim-parsing hardening, no user-visible feature.
Before / after evidence
Production
read_delivery_claim/may_reclaim_deliveryon a fresh claim with one field rewritten (the pid is the live test process):Before (origin/main 7940f25):
After (this branch):
New contract 24, run against origin/main's
src/outbox.py(each case reports separately, so every repro is shown failing):Same contract on this branch:
Contract 24 also pins
start_usec=0as invalid (UNKNOWN,may_reclaim(0)=False), and the edge valuesstart_usec=1andclaimed_at=2**53as HELD.Malformed claims after review (head 251c826)
Contract 25 (
tests/sparrow-outbox-claim-protocol.test.py) ages the claim file's mtime and calls the publicreclaim_delivery_claimwith TTL 300:pida string): refused at 31 s and at 299 s, taken at 300 s.start_usec=-1: refused at 31 s, at 299 s and at 30000 s.start_usec=-1: refused at 31 s and at 299 s, taken at 300 s.tests/outbox-malformed-claim-backend.test.pyruns throughDesignAClaimBackend:start_usec=-1, mtime agedSWAP_GRACE_S + 1, TTL 1e9):claim(B)returns None and A still holds the claim.start_usec=-1):recover()retires it at TTL age and the item can be republished. At TTL-1 s it is kept.Controls:
Oversized integers after the second review (head 4127343)
Three new tests in
tests/outbox-malformed-claim-backend.test.py, each using a 5001-digit integer:claimed_atoversized is not taken byreclaim_delivery_claimat 301 s or at 30000 s (TTL 300).deliver_onewith the claim aged 301 s and then 30000 s. Both return NOT_CLAIMED, and the provider is called exactly once.claimed_atand an oversized extra field keepsitem_id,drainer_id, pid and birth token, andrecover()retires it once the pid is dead and the mtime reaches the TTL.Control at the previous head 251c826 (all three fail):
At 4127343:
Ran 7 tests ... OK.Pid type after the third review (head 2623173)
Contract 26 (
tests/sparrow-outbox-claim-protocol.test.py, publicmay_reclaim_deliveryandreclaim_delivery_claim) andtest_pid_forms_block_or_release_by_plausibility(tests/outbox-malformed-claim-backend.test.py, through DeliveryCore andrecover()) run one table. Nine pid forms (int,"<pid>",<pid>.0,"0<pid>","-1",<pid>.5,true, 5001 digits,2**31) are crossed with a live or dead owner.claimed_atis spoiled so every row is malformed.recover()does not retire it.recover()at 300 s but not at 299 s.Control at the previous head 4127343:
At 2623173:
26 contracts: 26 pass;Ran 8 tests ... OK.Contract 27 covers the remaining new branches: a decode error reads torn; an oversized pid spoils only the pid; other
ValueError/OverflowErrorread malformed and keep the caller's id; a malformed claim whose file has vanished has no mtime and is never reclaimable.Diff coverage (
coverage7.16 with.coveragerc, over every outbox test that importssrc/;diff-coveris not installed here): 56 of 56 changed statements insrc/outbox.pycovered (100%), up from 51/56 (91.1%) before contract 27. The package copy is omitted by.coveragerc, as for every vendored module.int()-accepted pid forms after the fourth review (head 4c666d7)
The pid-form table now has 13 forms, each crossed with a live or dead owner.
"<pid>",<pid>.0,"0<pid>","+<pid>"," <pid> ","<p1>_<p2>",<pid>.5."-1","abc",true, 5001 digits,2**31.Contract 26 also checks the property directly: every form that
int()turns into a valid pid is a blocking candidate. Contract 25's implausible row is now"abc".Control at the previous head 2623173:
At 4c666d7:
26 contracts: 26 pass;Ran 8 tests ... OK.Diff coverage at 4c666d7, measured the same way: 59 of 59 changed statements in
src/outbox.pycovered (100%).Padding length after the fifth review (head 046b626)
The length cap is gone. Contract 26's table property is replaced by a boundary sweep. A live pid is zero-padded and whitespace-padded to lengths 1, 10, 32, 33, 64 and 5000 (lengths 1 and 10 use the pid's own width, since a pid cannot be shorter than its digits). Each must read as the pid, and neither
may_reclaim_deliverynorreclaim_delivery_claimmay take it past the TTL. Grammar edge cases are pinned as well:-5,+-5,1__2and_12are non-blocking;" +0_0012 "reads as 12; 40 zeros followed by7reads as 7.test_padded_live_pid_blocks_at_every_lengthsweeps the same lengths through DeliveryCore (exactly 1 provider call) andrecover()(no retirement while the owner lives).Control at the previous head 4c666d7:
At 046b626:
26 contracts: 26 pass;Ran 9 tests ... OK. Correction: an earlier version of this body said a 300,000-string local fuzz agreed withvalid_pid(int(s))on every input. That claim was wrong. Its generator never produced runs of non-ASCII decimal zeros, so it missed the Unicode-zero false negatives and the U+001C..U+001F false positives fixed at deae1ac (see below). Both were found in review by @keweichen.Diff coverage at 046b626: 65 of 65 changed statements in
src/outbox.pycovered (100%).Exact int() grammar after the sixth review (head deae1ac)
Contract 28: exhaustive single-character oracle. For every code point
cfrom U+0000 to U+10FFFF (surrogates included), it builds five strings around a live pidP:c+P+c(padding)P[0]+c+P[1:](separator)c+P(leading digit)P+c(trailing digit)c*10+P(ten-character pad)It asserts
_int_string_pid(s) == valid_pid(int(s)), or None whereint()raises. That is 5,570,560 cases. It then rescans all code points and asserts that_INT_WHITESPACEequals{c : int(c+"1"+c) == 1}, which is 25 characters on CPython 3.14.Measured: at 046b626 the oracle found 75 false negatives (another script's zero, alone or padded ten deep) and 16 false positives (U+001C..U+001F as padding, leading and trailing digit, and ten-character pad). At deae1ac it finds 0 and 0.
Contract 26 additions:
A live pid padded by ten U+0660, and by mixed-script zeros to lengths 33 and 64.
A seeded fuzz of 20,000 strings checked against
int(). Its alphabet:+or-;cwithunicodedata.decimal(c) == 0);+,-,_,a, U+00B2;A separate local run of the same generator over 1,000,000 strings (seed 1; 376,333 of them name a valid pid) found 0 disagreements. That was measured before the whitespace fix, against a generator that had no U+001C/U+001F noise. Contract 28 is the check that covers those characters.
Backend:
test_unicode_zero_padded_live_pid_blocksdrives U+0660 and mixed-script zero padding at lengths 33 and 64 through DeliveryCore (exactly 1 provider call) andrecover()(no retirement while the owner lives).Control at the previous head 046b626 (contract 28 stops listing after 10 disagreements):
At deae1ac:
27 contracts: 27 pass;Ran 10 tests ... OK.Diff coverage: 68 of 68 changed statements in
src/outbox.pycovered (100%). This was measured just before_INT_WHITESPACEwas re-laid out asrange()spans (same characters). That re-layout keepsoutbox-adapter-contract's transport-leak scan from matching a substring of the literal 0x2000. The constant is a single statement executed at import.Every JSON value type, the owner axis, and the asymmetry pinned (head 90bf1d1)
Fix:
_candidate_owner_pidpassesboolthroughint(). Before this,"pid": truegave no candidate, so a live pid 1 was displaced at the TTL.Contract 28, domain oracle. For representative values of every JSON type, it asserts
_candidate_owner_pid(v) == valid_pid(int(v)), or None whereint()raisesTypeError,ValueErrororOverflowError. The values:true,false,null,[],{},[pid],{"pid": pid}PID_MAX,PID_MAX+1, -1, 10**400, and a dead pidPID_MAX,PID_MAX+1, ±1e308, ±inf, NaN, and the dead pid"","0","1", own and dead pid," +pid ","abc","-5", U+0660-padded pid,"PID_MAX","0PID_MAX"The owner varies across those values (own pid, pid 1,
PID_MAX, a dead pid), not only how the pid is written.Contract 29, asymmetry pin. A claim from the live owner has its pid rewritten as a plausible but non-strict string (
"0<pid>"or" <pid> "). Its birth token is either a mismatched valid one (reuse-looking) or1(dead-looking), and it is aged 100× the TTL. It must:may_reclaimandreclaimboth refused;The control is the same token on a strict int pid, which must still be proven reuse and be reclaimable.
Mutation result. I deleted the strict guard
valid_pid(rec.pid) is not None andfrom the ALIVE branch of_record_is_reclaimable, ran the suites, then restored it. Contract 29 fails, so it pins the asymmetry. The backend test stays green under that mutation; contract 29 is the check that catches it.Backend. The pid-form tables gain
true(blocking) andfalse(not blocking).test_owner_identity_axiscrosses five pid forms with five owners:PID_MAX, alive and deadpid 1 and
PID_MAXcannot be owned in a test, so their liveness probe is patched to the row's state, and the test says so. Each case runs through DeliveryCore (1 provider call when alive, 2 when dead) and throughrecover()at the TTL (no retirement when alive, retired when dead).Control at the previous head deae1ac:
Contract 29 and the owner-axis test pass at deae1ac as well. They pin behaviour that was already right; the mutation above is their failing control. At 90bf1d1:
28 contracts: 28 pass;Ran 11 tests ... OK.Diff coverage at 90bf1d1: 68 of 68 changed statements in
src/outbox.pycovered (100%).Tests run
Each suite ran alone with
nice -n 19, started only when the 1-minute load was under 4, at head 90bf1d1 (157285f and 4ef93d5 change only the module docstring;ag2-sparrow-driftandoutbox-adapter-contractwere rerun on 4ef93d5 and pass). All exited 0:sparrow-outbox-claim-protocol,outbox-malformed-claim-backend,ag2-sparrow-drift,proactive-recovery-process-probe,outbox-adapter-contract,outbox-claim-fault-injection,outbox-claim-regressions,outbox-disk-format,outbox-error-paths,outbox-log,outbox-operator-recovery,outbox-race,outbox-receipt-destination,outbox-recovery-paths,outbox-windows-policy,outbox-windows(18 Windows-only skips),proactive-outbox-classification,health-check-outbox-parked,discord-result-outbox-delegation,slack-bridge-reply-outbox,slack-result-outbox-delegation,delivery-core-contract,delivery-core-crash-between-terminal-and-release,delivery-core-enforcement,delivery-core-terminal-not-contended,delivery-primitives-single-owner,proactive-fence-terminal-claim-retired-on-restart,proactive-claim-fence,gateway-terminal-result-moved-once, and the ag2-sparrow package teststest_hatch_adapterandtest_wheel_contents.CI at deae1ac: red only on
tests/skills/process-hygiene/reap-orphans.test.py, intest_a_process_that_changes_between_scan_and_signal_is_left_alone (change='cwd')("the orphan was not a candidate"). This PR does not touch that code. The same assertion failed on main at 47ed231 and 5bcf615, and other main runs pass, so it is an intermittent flake on main.outbox-claim-machineexited 0 with SKIP: hypothesis not installed on this host, so it did not exercise the change here. CI runs it.Live path: still owed. This changes delivery-loop recovery behaviour, so the post-restart live witness the reviewer asked for has not been run yet. It needs the owner's go.
🤖 Generated with Claude Code