Skip to content

fix(outbox): an impossible claim field reads UNKNOWN, never pid reuse - #5830

Merged
sonichi merged 10 commits into
mainfrom
fix/outbox-claim-birth-token
Oct 24, 2026
Merged

sonichi merged 10 commits into
mainfrom
fix/outbox-claim-birth-token

Conversation

@sonichi

@sonichi sonichi commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Priority

Level: high
Reason: duplicate-delivery risk: a live owner's outbox claim can be reclaimed.

Closes

Fixes #5829

Summary

_read_claim_at coerced pid, start_usec and claimed_at with int()/float(). An impossible birth token (start_usec -1 or 10**400) next to a live pid made _pid_was_reused() report reuse, so may_reclaim_delivery(..., 0) returned True for a claim whose owner is alive. Huge claimed_at and 5001-digit pid values raised instead of reading UNKNOWN.

What changed (src/outbox.py, byte-identical in packages/ag2-sparrow/ag2_sparrow/outbox.py):

  • Claim fields are parsed without coercion. Accepted: pid a non-bool int with 0 < pid <= PID_MAX (231-1) via a new valid_pid; start_usec null or a non-bool int with 0 < start_usec < 2**63 (no writer emits 0); claimed_at a finite non-bool int or float within ±253.
  • Torn vs malformed. A claim is torn only when its text is empty or fails JSON decoding (what a crashed writer leaves, since the writer emits one complete object). Torn claims keep the existing SWAP_GRACE_S (30 s) sweep. Anything that decodes but has invalid fields, is not an object, or holds an impossible number reads UNKNOWN with malformed=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 other ValueError/OverflowError from json.loads still reads malformed, keeping the caller's item_id.
  • A malformed claim is never reclaimed sooner than a valid one. It keeps its parseable item_id, drainer_id, strictly valid pid and valid start_usec.
  • Evidence is asymmetric. Blocking a takeover is lenient: _candidate_owner_pid reads a pid from every form the pre-fix reader's int() accepted: any non-bool int, any finite float (truncated, as int() does), or any string in int()'s grammar at any length (so "0123", "+123", " 123 ", "1_23" and a pid padded to 5000 characters all count). _int_string_pid reads exactly that grammar in linear time, without calling int() on unbounded input. It strips only the characters int() strips (_INT_WHITESPACE, every c with int(c + "1" + c) == 1, which excludes U+001C..U+001F that str.strip() would remove). It allows one + and requires decimal digit groups joined by single _. It maps every digit of any script to its value with unicodedata.decimal, drops leading zeros, and parses only when at most 10 significant digits remain. More digits are past PID_MAX at any length, so non-blocking, within 0 < pid <= PID_MAX. The blocking set is therefore a superset of what main recognised. For every JSON value type the candidate is exactly valid_pid(int(value)), or None where int() raises: true is pid 1 (a real drainer in a container or PID namespace), false is 0 (non-blocking), and null, 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 strict valid_pid and a valid birth token, and release and identity use the strict pid.
  • A malformed claim is reclaimable only when (1) the liveness rules above allow it, and (2) the claim file's own mtime is at least max(TTL, SWAP_GRACE_S) old. claimed_at is never trusted for it. This applies on every path: may_reclaim_delivery, reclaim_delivery_claim, and DesignAClaimBackend.claim()/recover().
  • Its owner (matching drainer_id) may release it, so startup recover() retires a terminal item's malformed claim once the rules above allow.
  • _pid_was_reused treats 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: null already follows. Before this PR, start_usec=-1 on 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": true names 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:

  1. quiesce every drainer on the outbox;
  2. confirm no provider call is in flight;
  3. reconcile any indeterminate outcome;
  4. release the claim through a locked administrative transition.

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.

  • A health row that surfaces any claim held beyond N×TTL.
  • A locked administrative release command that performs steps 1 to 4 above.

Review first: _read_claim_at and _pid_was_reused in src/outbox.py, then contracts 24-25 in tests/sparrow-outbox-claim-protocol.test.py and tests/outbox-malformed-claim-backend.test.py.

Overlap with #5828: #5828 also rewrites _read_claim_at and adds PID_MAX and valid_pid. This PR uses the same names and the same valid_pid signature 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 outside valid_pid stays HELD and process_identity reads 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 least max(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_delivery on a fresh claim with one field rewritten (the pid is the live test process):

Before (origin/main 7940f25):

claimed_at=10**400             read: OverflowError | may_reclaim(0): OverflowError
claimed_at=-10**400            read: OverflowError | may_reclaim(0): OverflowError
pid=5001 digits                read: ValueError | may_reclaim(0): ValueError
start_usec=-1, live pid        read: 'HELD' | may_reclaim(0): True
start_usec=10**400, live pid   read: 'HELD' | may_reclaim(0): True

After (this branch):

claimed_at=10**400             read: 'UNKNOWN' | may_reclaim(0): False
claimed_at=-10**400            read: 'UNKNOWN' | may_reclaim(0): False
pid=5001 digits                read: 'UNKNOWN' | may_reclaim(0): False
start_usec=-1, live pid        read: 'UNKNOWN' | may_reclaim(0): False
start_usec=10**400, live pid   read: 'UNKNOWN' | may_reclaim(0): False

New contract 24, run against origin/main's src/outbox.py (each case reports separately, so every repro is shown failing):

  FAIL an out-of-range pid, birth token or claimed_at reads UNKNOWN and is never reclaimable
         claimed_at=10**400: raised OverflowError; claimed_at=-10**400: raised OverflowError; claimed_at=1e400: state=HELD may_reclaim(0)=False; claimed_at=true: state=HELD may_reclaim(0)=False; pid=5001 digits: raised ValueError; pid=0: state=HELD may_reclaim(0)=False; pid=2**31: state=HELD may_reclaim(0)=True; pid=true: state=HELD may_reclaim(0)=False; pid=1.0: state=HELD may_reclaim(0)=False; start_usec=-1, live pid: state=HELD may_reclaim(0)=True; start_usec=10**400, live pid: state=HELD may_reclaim(0)=True; start_usec=2**63, live pid: state=HELD may_reclaim(0)=True; start_usec=1.5, live pid: state=HELD may_reclaim(0)=True
  target: src/outbox.py  (repo wt)
--
  23 contracts: 22 pass, 1 FAIL, 0 ERROR, 0 not-implemented

Same contract on this branch:

  ok   an out-of-range pid, birth token or claimed_at reads UNKNOWN, not pid reuse

Contract 24 also pins start_usec=0 as invalid (UNKNOWN, may_reclaim(0)=False), and the edge values start_usec=1 and claimed_at=2**53 as 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 public reclaim_delivery_claim with TTL 300:

  • A live owner whose pid is unreadable (pid a string): refused at 31 s and at 299 s, taken at 300 s.
  • A live readable pid with start_usec=-1: refused at 31 s, at 299 s and at 30000 s.
  • A dead pid with start_usec=-1: refused at 31 s and at 299 s, taken at 300 s.
  • A torn write: refused at 29 s, taken at 31 s (the grace sweep still applies).

tests/outbox-malformed-claim-backend.test.py runs through DesignAClaimBackend:

  • The review's scenario (a live owner, start_usec=-1, mtime aged SWAP_GRACE_S + 1, TTL 1e9): claim(B) returns None and A still holds the claim.
  • A torn claim under the same conditions is still taken.
  • A terminal item's malformed claim (dead pid, start_usec=-1): recover() retires it at TTL age and the item can be republished. At TTL-1 s it is kept.

Controls:

--- previous head edcb838f, sparrow-outbox-claim-protocol
  FAIL an out-of-range pid, birth token or claimed_at reads UNKNOWN, not pid reuse
         start_usec=0, live pid: state=HELD may_reclaim(0)=True
  FAIL a malformed claim ages by file mtime to the full TTL, under the liveness rules; torn still sweeps
         unreadable pid at 31.0s: want taken=False; unreadable pid at 300.0s: want taken=True; live owner, start_usec=-1, taken at 31.0s; dead owner, start_usec=-1 at 31.0s: want taken=False; dead owner, start_usec=-1 at 300.0s: want taken=True
  24 contracts: 22 pass, 2 FAIL, 0 ERROR, 0 not-implemented
--- origin/main 7940f25f, sparrow-outbox-claim-protocol
  FAIL an out-of-range pid, birth token or claimed_at reads UNKNOWN, not pid reuse   (all 14 cases listed)
  ERR  a malformed claim ages by file mtime ...   OverflowError: int too large to convert to float
  24 contracts: 22 pass, 1 FAIL, 1 ERROR, 0 not-implemented
--- this head 251c8261
  24 contracts: 24 pass, 0 FAIL, 0 ERROR, 0 not-implemented

--- previous head edcb838f, outbox-malformed-claim-backend
FAIL: test_live_owner_bad_token_not_taken_after_grace
FAIL: test_terminal_malformed_claim_retired_at_ttl
FAILED (failures=2)
--- origin/main 7940f25f, outbox-malformed-claim-backend
FAIL: test_terminal_malformed_claim_retired_at_ttl     (main ages it by the untrusted claimed_at)
FAILED (failures=1)
--- this head 251c8261
Ran 4 tests ... OK

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:

  • Public path: a live owner's claim with a valid pid and birth token and only claimed_at oversized is not taken by reclaim_delivery_claim at 301 s or at 30000 s (TTL 300).
  • DeliveryCore path: drainer A is blocked inside the provider. B runs deliver_one with the claim aged 301 s and then 30000 s. Both return NOT_CLAIMED, and the provider is called exactly once.
  • Terminal retirement: a terminal claim with an oversized claimed_at and an oversized extra field keeps item_id, drainer_id, pid and birth token, and recover() retires it once the pid is dead and the mtime reaches the TTL.

Control at the previous head 251c826 (all three fail):

FAIL: test_live_owner_oversize_claimed_at_never_reclaimed
AssertionError: True is not false : a live owner's claim was reclaimed at 301.0s
FAIL: test_live_owner_oversize_claimed_at_one_provider_call
AssertionError: 2 != 1 : provider called 2 times
FAIL: test_terminal_claim_with_oversize_int_keeps_identity_and_retires
AssertionError: Tuples differ: ('UNKNOWN', True, '', '', -1, None) != ('UNKNOWN', True, 'room-evt-malformed', 'A', 3874, 123456789)
Ran 7 tests ... FAILED (failures=3)

At 4127343: Ran 7 tests ... OK.

Pid type after the third review (head 2623173)

Contract 26 (tests/sparrow-outbox-claim-protocol.test.py, public may_reclaim_delivery and reclaim_delivery_claim) and test_pid_forms_block_or_release_by_plausibility (tests/outbox-malformed-claim-backend.test.py, through DeliveryCore and recover()) 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_at is spoiled so every row is malformed.

  • Plausible form and live owner: never reclaimed at 301 s or 30000 s, and exactly 1 provider call while A is blocked inside the provider. recover() does not retire it.
  • Dead owner or implausible form: not reclaimed at 299 s, reclaimed at 300 s (2 provider calls through DeliveryCore), and retired by recover() at 300 s but not at 299 s.

Control at the previous head 4127343:

FAIL a plausible live owner pid in any form blocks a takeover; only a strict one authorizes
         digit string alive=True at 301.0s: may/reclaim=(True, True), want False; digit string alive=True at 30000.0s: may/reclaim=(True, True), want False; integral float alive=True at 301.0s: may/reclaim=(True, True), want False; integral float alive=True at 30000.0s: may/reclaim=(True, True), want False
--
  25 contracts: 24 pass, 1 FAIL, 0 ERROR, 0 not-implemented
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='digit string', alive=True)
AssertionError: 2 != 1
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='integral float', alive=True)
AssertionError: 2 != 1
Ran 8 tests in 0.161s
FAILED (failures=2)

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/OverflowError read malformed and keep the caller's id; a malformed claim whose file has vanished has no mtime and is never reclaimable.

Diff coverage (coverage 7.16 with .coveragerc, over every outbox test that imports src/; diff-cover is not installed here): 56 of 56 changed statements in src/outbox.py covered (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.

  • Plausible (blocking): int, "<pid>", <pid>.0, "0<pid>", "+<pid>", " <pid> ", "<p1>_<p2>", <pid>.5.
  • Implausible: "-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:

FAIL a plausible live owner pid in any form blocks a takeover; only a strict one authorizes
         leading-zero string alive=True at 301.0s: may/reclaim=(True, True), want False; leading-zero string alive=True at 30000.0s: may/reclaim=(True, True), want False; plus-sign string alive=True at 301.0s: may/reclaim=(True, True), want False; plus-sign string alive=True at 30000.0s: may/reclaim=(True, True), want False; padded string alive=True at 301.0s: may/reclaim=(True, True), want False; padded string alive=True at 30000.0s: may/reclaim=(True, True), want False; underscore string alive=True at 301.0s: may/reclaim=(True, True), want False; underscore string alive=True at 30000.0s: may/reclaim=(True, True), want False; fractional float alive=True at 301.0s: may/reclaim=(True, True), want False; fractional float alive=True at 30000.0s: may/reclaim=(True, True), want False
--
  26 contracts: 25 pass, 1 FAIL, 0 ERROR, 0 not-implemented
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='leading-zero string', alive=True)
AssertionError: 2 != 1
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='plus-sign string', alive=True)
AssertionError: 2 != 1
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='padded string', alive=True)
AssertionError: 2 != 1
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='underscore string', alive=True)
AssertionError: 2 != 1
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='fractional float', alive=True)
AssertionError: 2 != 1
Ran 8 tests in 0.131s
FAILED (failures=5)

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.py covered (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_delivery nor reclaim_delivery_claim may take it past the TTL. Grammar edge cases are pinned as well: -5, +-5, 1__2 and _12 are non-blocking; " +0_0012 " reads as 12; 40 zeros followed by 7 reads as 7. test_padded_live_pid_blocks_at_every_length sweeps the same lengths through DeliveryCore (exactly 1 provider call) and recover() (no retirement while the owner lives).

Control at the previous head 4c666d7:

FAIL a plausible live owner pid in any form blocks a takeover; only a strict one authorizes
         zero-padded len=33: candidate None; zero-padded len=33: live owner reclaimed past the TTL; space-padded len=33: candidate None; space-padded len=33: live owner reclaimed past the TTL; zero-padded len=64: candidate None; zero-padded len=64: live owner reclaimed past the TTL; space-padded len=64: candidate None; space-padded len=64: live owner reclaimed past the TTL; zero-padded len=5000: candidate None; zero-padded len=5000: live owner reclaimed past the TTL; space-padded len=5000: candidate None; space-padded len=5000: live owner reclaimed past the TTL; '00000000000000000000000000000000000000007': candidate None, want 7
--
  26 contracts: 25 pass, 1 FAIL, 0 ERROR, 0 not-implemented
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='zero', length=33)
AssertionError: 2 != 1
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='space', length=33)
AssertionError: 2 != 1
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='zero', length=64)
AssertionError: 2 != 1
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='space', length=64)
AssertionError: 2 != 1
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='zero', length=5000)
AssertionError: 2 != 1
FAIL: test_padded_live_pid_blocks_at_every_length (__main__.MalformedClaimBackend.test_padded_live_pid_blocks_at_every_length) (kind='space', length=5000)
AssertionError: 2 != 1
Ran 9 tests in 0.189s
FAILED (failures=6)

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 with valid_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.py covered (100%).

Exact int() grammar after the sixth review (head deae1ac)

Contract 28: exhaustive single-character oracle. For every code point c from U+0000 to U+10FFFF (surrogates included), it builds five strings around a live pid P:

  • 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 where int() raises. That is 5,570,560 cases. It then rescans all code points and asserts that _INT_WHITESPACE equals {c : int(c+"1"+c) == 1}, which is 25 characters on CPython 3.14.

  • Scope: one non-pid character per string, in those five positions, around a single live pid. It does not enumerate multi-character combinations. Those are covered by the seeded fuzz and the fixed rows below.

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:

    • optional leading space, U+3000 or U+001C;
    • an optional + or -;
    • 0 to 40 zeros drawn from all 76 decimal zeros (every c with unicodedata.decimal(c) == 0);
    • 0 to 12 digits, each from a random script among all 760 decimal digit characters;
    • noise spliced in at 8%: space, tab, U+3000, U+001C, U+001F, +, -, _, a, U+00B2;
    • an optional trailing space or newline.

    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_blocks drives U+0660 and mixed-script zero padding at lengths 33 and 64 through DeliveryCore (exactly 1 provider call) and recover() (no retirement while the owner lives).

Control at the previous head 046b626 (contract 28 stops listing after 10 disagreements):

FAIL a plausible live owner pid in any form blocks a takeover; only a strict one authorizes
         ten U+0660: candidate None, int() reads 34654; mixed-script zeros len=33: candidate None, int() reads 34654; mixed-script zeros len=64: candidate None, int() reads 34654; fuzz '\u3000𖩠०𞗱𐵀߀𞋰𝟶০𞓰᮰0๐꤀꧐𑙐꧰০𑛐߀᱀୦𐴰꤀𞋰𑵐𑽐꩐𑃰𑇐𞅀𑛚𝟎௦𖭐2 ': candidate None, int() gives 2
--
  FAIL the pid string reader agrees with int() for every code point as padding, separator or digit
         U+001C as padding: reader 34654, int() None; U+001C as leading digit: reader 34654, int() None; U+001C as trailing digit: reader 34654, int() None; U+001C as ten as padding: reader 34654, int() None; U+001D as padding: reader 34654, int() None; U+001D as leading digit: reader 34654, int() None; U+001D as trailing digit: reader 34654, int() None; U+001D as ten as padding: reader 34654, int() None; U+001E as padding: reader 34654, int() None; U+001E as leading digit: reader 34654, int() None; U+001E as trailing digit: reader 34654, int() None; U+001E as ten as padding: reader 34654, int
--
  27 contracts: 25 pass, 2 FAIL, 0 ERROR, 0 not-implemented
FAIL: test_unicode_zero_padded_live_pid_blocks (__main__.MalformedClaimBackend.test_unicode_zero_padded_live_pid_blocks) (kind='U+0660', length=33)
AssertionError: 2 != 1
FAIL: test_unicode_zero_padded_live_pid_blocks (__main__.MalformedClaimBackend.test_unicode_zero_padded_live_pid_blocks) (kind='mixed-script', length=33)
AssertionError: 2 != 1
FAIL: test_unicode_zero_padded_live_pid_blocks (__main__.MalformedClaimBackend.test_unicode_zero_padded_live_pid_blocks) (kind='U+0660', length=64)
AssertionError: 2 != 1
FAIL: test_unicode_zero_padded_live_pid_blocks (__main__.MalformedClaimBackend.test_unicode_zero_padded_live_pid_blocks) (kind='mixed-script', length=64)
AssertionError: 2 != 1
Ran 10 tests in 0.556s
FAILED (failures=4)

At deae1ac: 27 contracts: 27 pass; Ran 10 tests ... OK.

Diff coverage: 68 of 68 changed statements in src/outbox.py covered (100%). This was measured just before _INT_WHITESPACE was re-laid out as range() spans (same characters). That re-layout keeps outbox-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_pid passes bool through int(). Before this, "pid": true gave 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 where int() raises TypeError, ValueError or OverflowError. The values:

  • true, false, null, [], {}, [pid], {"pid": pid}
  • ints: 0, 1, own pid, PID_MAX, PID_MAX+1, -1, 10**400, and a dead pid
  • floats: 0.0, -0.0, 1.0, 1.5, own pid, pid+0.9, PID_MAX, PID_MAX+1, ±1e308, ±inf, NaN, and the dead pid
  • strings: "", "0", "1", own and dead pid, " +pid ", "abc", "-5", U+0660-padded pid, "PID_MAX", "0PID_MAX"
  • the oversize sentinel

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) or 1 (dead-looking), and it is aged 100× the TTL. It must:

  • be held, with may_reclaim and reclaim both refused;
  • carry authorizing pid -1;
  • refuse release by another drainer.

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 and from 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.

FAIL a plausible but non-strict pid blocks a takeover and never proves pid reuse
         leading-zero string, reused-looking token: taken as proven reuse of a live owner; leading-zero string, dead-looking token: taken as proven reuse of a live owner; padded string, reused-looking token: taken as proven reuse of a live owner; padded string, dead-looking token: taken as proven reuse of a live owner
--
  28 contracts: 27 pass, 1 FAIL, 0 ERROR, 0 not-implemented

Ran 11 tests in 0.367s
OK

Backend. The pid-form tables gain true (blocking) and false (not blocking). test_owner_identity_axis crosses five pid forms with five owners:

  • own pid (alive)
  • pid 1, alive and dead
  • PID_MAX, alive and dead
  • a dead pid

pid 1 and PID_MAX cannot 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 through recover() at the TTL (no retirement when alive, retired when dead).

Control at the previous head deae1ac:

FAIL a plausible live owner pid in any form blocks a takeover; only a strict one authorizes
         true alive=True at 301.0s: may/reclaim=(True, True), want False; true alive=True at 30000.0s: may/reclaim=(True, True), want False
--
  FAIL the pid reader agrees with int() for every JSON value type, and every code point as padding, separator or digit
         domain True: candidate None, int() oracle 1
--
  28 contracts: 26 pass, 2 FAIL, 0 ERROR, 0 not-implemented
FAIL: test_pid_forms_block_or_release_by_plausibility (__main__.MalformedClaimBackend.test_pid_forms_block_or_release_by_plausibility) (form='true', alive=True)
AssertionError: 2 != 1
Ran 11 tests in 0.562s
FAILED (failures=1)

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.py covered (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-drift and outbox-adapter-contract were 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 tests test_hatch_adapter and test_wheel_contents.

CI at deae1ac: red only on tests/skills/process-hygiene/reap-orphans.test.py, in test_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-machine exited 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

…#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>
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 93%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/outbox.py (100%)

Summary

  • Total: 68 lines
  • Missing: 0 lines
  • Coverage: 100%

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/outbox.py Outdated
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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/outbox.py Outdated


def _valid_start_usec(value: object) -> bool:
return type(value) is int and 0 <= value < _START_USEC_LIMIT

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

sonichi added a commit that referenced this pull request Oct 23, 2026
…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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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) reports src/outbox.py at 89.6%, missing lines 489-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/cla is 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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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/cla is 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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@keweichen

Copy link
Copy Markdown
Collaborator

Secondary parser finding from the same exact-head sweep (not the basis of the P1 duplicate-delivery verdict): str.strip() at src/outbox.py:545 removes U+001C–U+001F, but Python int() rejects those four characters as surrounding whitespace. For example, _candidate_owner_pid("\x1c" + str(live_pid) + "\x1c") returns the live PID while int(...) raises ValueError.

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 int() contract while touching this helper, or document and test the deliberate conservative superset if indefinite retention is accepted.

…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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

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.

sonichi and others added 2 commits October 23, 2026 12:11
… 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 keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 90bf1d12 and 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 with start_usec=-1 returns may_reclaim(0)=True; this head reads it malformed/UNKNOWN and returns False.

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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 4ef93d56e4fcc562dd151856414be057ac24dde5. Verdict: comment. The code is right; the approval bar is not met yet.

What I checked myself:

  • src/outbox.py and packages/ag2-sparrow/ag2_sparrow/outbox.py are 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=-1 reads HELD and may_reclaim_delivery(..., 0) returns True; start_usec=10**400 the same; claimed_at=10**400 raises OverflowError.
    • 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:

  1. 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.
  2. license/cla is 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)

@sonichi

sonichi commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at 4ef93d5

I checked the head with git ls-remote against refs/pull/5830/head at start, before each step and before posting. It stayed at 4ef93d56e4fcc562dd151856414be057ac24dde5 throughout.

Setup. The head was staged by git archive into a scratch directory with a scratch venv. HOME, the task/result/state dirs and the workspace were all in scratch. The bridge ran as python3 -m ag2_sparrow.remote_gateway_bridge from the head's package, with env -i and nice -n 19, in a private tmux server. It used a test agent identity whose usual relay was paused for the run. REMOTE_PROACTIVE_ROOM was empty. The live core and live gateway were not touched.

PR tests (scratch, stdlib only): at the head, sparrow-outbox-claim-protocol.test.py passes 28/28 and outbox-malformed-claim-backend.test.py passes 11/11. The same protocol test run against the merge-base outbox.py (7940f25) fails 3, including:

start_usec=-1, live pid: state=HELD may_reclaim(0)=True

(A) Real item after a restart — PASS

I 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:

claim (captured in flight, <outbox>/.claims/<item>.claim):
{"claimed_at": 1792796974.869621, "drainer_id": "gateway-result-drain", "item_id": "task-e0d16f8f0779ffa7e0", "pid": <restarted bridge pid>, "start_usec": 1792796930051419}
log:
result task-e0d16f8f0779ffa7e0 delivered via DeliveryCore (provider=AG2SpaceResultProvider, backend=DesignAClaimBackend, worker=gateway-result-drain)
archived result for task-e0d16f8f0779ffa7e0
receipt (<outbox>/.items/<item>.json): "status": "DELIVERED", "provider": "AG2SpaceResultProvider"

The log has exactly one delivered line, and the claim was released afterwards. Checked in the room: exactly one reply.

(B) Malformed claim with a live owner, held across a restart — PASS

The owner posted a second real message. Before writing its result, I planted a claim on that item. The claim has an impossible birth token (start_usec: -1), is 1 hour old by both claimed_at and mtime, and is owned by a live sleep I started (PID verified):

{"claimed_at": <now-3600>, "drainer_id": "witness-live-owner", "item_id": "task-8edd409f6123c61728", "pid": <live sleep pid>, "start_usec": -1}
head:  state=UNKNOWN malformed=True candidate_pid=<sleep pid>  may_reclaim(ttl=300)=False
base (7940f25f, read-only on a copy, no send):  state=HELD start_usec=-1  may_reclaim(ttl=300)=True  (owner ALIVE)

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:

result task-8edd409f6123c61728: pending retry or delivery claim (attempts=0, next=None, deadline=None)   x40 passes
delivered/accepted lines for this item: 0
item: status READY, no provider recorded; claim file unchanged

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-send] close if it is redelivered). Nothing was left running, the test identity's relay was restored, and the scratch dir was removed.

No approve, no merge.

@sonichi

sonichi commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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=-1 claim 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.

@sonichi sonichi closed this Oct 23, 2026
@sonichi sonichi reopened this Oct 23, 2026
@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026
@sonichi

sonichi commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

@liususan091219 liususan091219 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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-old start_usec=-1 claim 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.py copies are byte-identical; tests/outbox-malformed-claim-backend.test.py passes and tests/sparrow-outbox-claim-protocol.test.py reports "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)

  1. CI is red on the newest run only, and not because of this diff. Run 40048431645 (01:08Z) failed python standalone tests (1/9) on tests/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 same main. 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.
  2. license/cla is 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)

@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026
@sonichi sonichi closed this Oct 24, 2026
@sonichi sonichi reopened this Oct 24, 2026

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. 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.
  2. backend_a.py:107 still names the flag recovered_torn though it now covers malformed too, and outbox_cli inspect prints (UNKNOWN) for both, so an operator cannot tell them apart.
  3. #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.

@sonichi
sonichi merged commit b76ce84 into main Oct 24, 2026
235 of 241 checks passed
@sonichi
sonichi deleted the fix/outbox-claim-birth-token branch October 24, 2026 16:21
sonichi added a commit that referenced this pull request Oct 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outbox claim reader: impossible start_usec lets a live owner's delivery be reclaimed (duplicate delivery)

4 participants