Repository navigation
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.pyLines 5766-5774 5766 continue
5767 try:
5768 _decide_orphan_claim(f)
5769 except Exception as e: # noqa: BLE001 - one item's failure never stops the others' recovery
! 5770 _log(f"proactive claim {f.name} could not be recovered ({diagnostics.describe(e)}); kept as its "
5771 "claim for an operator")
5772 for r in list(RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*")):
5773 try:
5774 _reconcile_decided(r)Lines 5772-5780 5772 for r in list(RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*")):
5773 try:
5774 _reconcile_decided(r)
5775 except Exception as e: # noqa: BLE001
! 5776 _log(f"proactive {r.name} could not be reconciled ({diagnostics.describe(e)}); retried")
5777
5778
5779 def _decide_orphan_claim(f: Path) -> None:
5780 target = f.with_name(f.name.split(".sending")[0] + ".txt")Lines 5876-5884 5876 for r in RESULTS_DIR.glob(f".proactive-*{_DECIDED_INFIX}*"):
5877 try:
5878 st = os.lstat(r)
5879 except OSError:
! 5880 continue
5881 key, seen = (st.st_dev, st.st_ino), (st.st_size, st.st_mtime_ns, st.st_ctime_ns)
5882 present.add(key)
5883 cached = _DECIDED_SEEN.get(key)
5884 if cached is not None and cached[0] == seen and (Lines 5886-5897 5886 continue # same bytes and the same item state and epoch: nothing to do
5887 try: # its receipt is read before the reconcile, so a change during it is seen next pass
5888 item = _retained_item(r)
5889 state = receipt(_proactive_outbox_root(), item) if item else None
! 5890 except Exception as e: # noqa: BLE001
! 5891 _log_backoff(("proactive-decided", r.name), f"proactive {r.name} could not be read "
5892 f"({diagnostics.describe(e)}); retried")
! 5893 continue
5894 try:
5895 settled = _reconcile_decided(r)
5896 except Exception as e: # noqa: BLE001 - one original's failure never stops the drain
5897 _log_backoff(("proactive-decided", r.name), f"proactive {r.name} could not be reconciled "Lines 5921-5929 5921 return False
5922 if generation[:32] != m.group("digest"):
5923 data = _read_claim_bytes(retained)
5924 if data is None or hashlib.sha256(data).hexdigest() != generation:
! 5925 return False # changed under the read: the next pass reads it again
5926 parked_epoch = _park_generation(stem, data, state)
5927 if parked_epoch is False:
5928 return False
5929 try: # recorded only once its parked copy and PARKED record are durableLines 6007-6015 6007 dest = quarantine / f"{stem}-{base + i}.txt"
6008 if not os.path.lexists(dest) and _place_snapshot(data, dest, stem):
6009 return dest
6010 if _read_claim_bytes(dest) == data:
! 6011 return dest # another writer placed the same bytes here first
6012 return None
6013
6014
6015 _SNAPSHOT_MARK = "s"skills/task-orphan-check/scripts/classify.pyLines 178-186 178 m = _EPOCH_MS_RE.search(task_id)
179 if m:
180 return int(m.group(1)) / 1000.0, "id"
181 if mtime is not None:
! 182 return mtime, "mtime"
183 try:
184 return path.stat().st_mtime, "mtime"
185 except OSError:
186 return 0.0, "mtime"Lines 295-303 295 line = build(*parts)
296 if not result_markers.parse_markers(line).actions:
297 return line
298 parts = [_defang(p) for p in parts]
! 299 raise ValueError(f"could not make a safe line from {raw_parts!r}")
300
301
302 _ARCHIVE_TAIL_RE = re.compile(r"\.txt(\.\d+)?$")Lines 688-696 688 verdict = row.get("verdict")
689 if verdict == "done":
690 continue
691 if verdict == "orphan" and (row.get("trusted_source") or "").strip() in ("voice", "phone"):
! 692 continue
693 groups[groups_for.get(verdict, "deferred")].append(row)
694 if not any(groups.values()):
695 return None
696 return _final_body(groups, {row["file"]: landed[row["file"]] for row in groups["deferred"]})Lines 701-712 701 """The worker router's per-task lock, held around each claim so a delivery
702 cannot interleave; None when the worker-pool skill is absent."""
703 try:
704 os.stat(_POOL_SCRIPTS / "pool_router.py")
! 705 except FileNotFoundError:
! 706 return None
707 if str(_POOL_SCRIPTS) not in sys.path:
! 708 sys.path.insert(0, str(_POOL_SCRIPTS))
709 import pool_router # noqa: E402
710 return lambda name: pool_router.task_arbitration(workspace, Path(name).stem)
711 Lines 716-725 716 def check(name: str, read: str) -> str | None:
717 try:
718 row = classify_task(workspace / "tasks" / read, workspace,
719 time.time() if now is None else now, as_name=name)
! 720 except OSError as exc:
! 721 return f"could not be re-classified ({exc})"
722 if row.get("verdict") != verdicts.get(name):
723 return f"now {row.get('verdict')}, not {verdicts.get(name)} as planned: {row.get('reason')}"
724 return None
725 return checkLines 795-803 795 ]
796 body = "\n".join(lines) + "\n"
797 parsed = result_markers.parse_markers(body)
798 if parsed.actions or parsed.body.strip() != body.strip():
! 799 raise ValueError("recovery body is not inert against the production marker parser "
800 "despite every row's own line being verified -- a composition bug, "
801 "not a per-row escaping gap")
802 return bodyLines 881-891 881 if ws is None:
882 print("orphan-check: workspace could not be resolved", file=sys.stderr)
883 return 2
884 if args.apply:
! 885 out = apply_plan(ws, args.now, approved=args.plan_id)
886 elif args.plan:
! 887 out = recovery_plan(ws, args.now)
888 else:
889 out = classify_workspace(ws, args.now)
890 print(json.dumps(out, indent=2))
891 return 1 if out.get("errors") else 0src/outbox.pyLines 1016-1024 1016 break
1017 chunks.append(chunk)
1018 size += len(chunk)
1019 if size > RECEIPT_READ_LIMIT:
! 1020 return "unknown", None, None
1021 data = json.loads(b"".join(chunks).decode("utf-8"))
1022 except (OSError, UnicodeDecodeError, ValueError):
1023 return "unknown", None, None
1024 finally:src/task_archive.pyLines 191-206 191 except OSError:
192 tmp = base.with_name(f".{base.name}.{uuid.uuid4().hex[:12]}.part")
193 fd_in = os.open(str(src), os.O_RDONLY, dir_fd=src_dir_fd)
194 if expect is not None and file_generation(os.fstat(fd_in)) != expect:
! 195 os.close(fd_in)
! 196 raise PlacementMismatch(str(src))
197 try:
198 fd_out = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600,
199 dir_fd=dst_dir_fd)
! 200 except BaseException:
! 201 os.close(fd_in)
! 202 raise
203 try:
204 with os.fdopen(fd_out, "wb") as out, os.fdopen(fd_in, "rb") as inp:
205 shutil.copyfileobj(inp, out)
206 out.flush()Lines 211-221 211 while True:
212 try:
213 os.link(str(tmp), str(candidate), src_dir_fd=dst_dir_fd, dst_dir_fd=dst_dir_fd)
214 break
! 215 except FileExistsError:
! 216 n += 1
! 217 candidate = base.with_name(f"{base.name}.{n}")
218 finally:
219 os.unlink(str(tmp), dir_fd=dst_dir_fd)
220 break
221 try: # landed: from here a failure must not read as "not placed"Lines 292-300 292 _uq.renameat_noreplace(name, as_name, src_dir_fd=src_dir_fd, dst_dir_fd=retired_fd)
293 break
294 except FileExistsError as exc:
295 if exc.errno != errno.EEXIST:
! 296 raise OSError(errno.ENOTSUP, f"cannot retire {name} without overwriting: {exc}") from exc
297 _checkpoint("after_retire", name)
298 _fsync_dir(retired_fd)
299 _fsync_dir(src_dir_fd)
300 if expect is not None and file_generation(os.lstat(as_name, dir_fd=retired_fd)) == expect:Lines 330-339 330 for m in [tail.fullmatch(e)] if m)
331 for _, candidate in found:
332 try:
333 st = os.lstat(candidate, dir_fd=dst_dir_fd)
! 334 except FileNotFoundError:
! 335 continue
336 if file_generation(st) == generation:
337 return candidate
338 return NoneLines 394-403 394 _fsync_dir(dir_fd)
395 live = file_generation(os.lstat(name, dir_fd=dir_fd))
396 with retired_dir(archive_fd) as rfd:
397 if expect is not None and live != expect: # the claim was replaced before the link
! 398 moved = retire(name, src_dir_fd=dir_fd, retired_fd=rfd, expect=None)
! 399 return GenerationChanged(name, f"{why}; its claim was replaced, so that file is kept as "
400 f"archive/{RETIRED_DIR}/{moved}", kept_as=claim)
401 stray = retire(claim, src_dir_fd=dir_fd, retired_fd=rfd, expect=live)
402 if stray:
403 return GenerationChanged(name, f"{why}; a file that replaced its claim is kept as "Lines 414-423 414 import os
415 keep = f"{claim}.displaced"
416 try:
417 out = os.open(keep, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dir_fd)
! 418 except OSError:
! 419 return None
420 with os.fdopen(out, "wb") as fh:
421 offset = 0
422 while chunk := os.pread(fd, 1 << 16, offset):
423 fh.write(chunk)Lines 462-471 462 is re-resolved and no live name is ever unlinked."""
463 import os
464 try:
465 fd = os.open(claim, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=src_dir_fd)
! 466 except OSError as exc:
! 467 raise _give_back(claim, name, f"cannot be opened as a regular file ({exc})", src_dir_fd,
468 archive_fd=dst_dir_fd) from None
469 try:
470 judged = file_generation(os.fstat(fd))
471 if judged != generation:src/task_transition.pyLines 230-238 230
231 def _fsync_path(directory: Path) -> None:
232 """A barrier on a directory named by path (results/ is not one of the pinned handles)."""
233 if os.name == "nt":
! 234 return
235 fd = os.open(directory, os.O_RDONLY)
236 try:
237 os.fsync(fd)
238 finally:Lines 245-254 245 if name and txn:
246 try:
247 os.unlink(results_dir / _witness(name, txn))
248 _fsync_path(results_dir)
! 249 except OSError:
! 250 pass # a witness that outlives its journal is swept by a later pass
251
252
253 def _publish_at(results_dir: Path, name: str, body: str, txn: "str | None" = None) -> Path:
254 """Publish `body` at exactly `results_dir/name`, whole, never replacing; withLines 309-318 309 while a live pass holds it. Unreadable data reads as {}."""
310 try:
311 lock_fd = os.open(f"{name}.lock", os.O_RDWR | os.O_CREAT | _NOFOLLOW, 0o644,
312 dir_fd=dirs.archive_fd)
! 313 except OSError:
! 314 return None
315 try:
316 fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
317 except OSError:
318 os.close(lock_fd)Lines 339-348 339 task_archive._fsync_dir(self.dirs.archive_fd)
340 except BaseException:
341 try:
342 os.unlink(tmp, dir_fd=self.dirs.archive_fd)
! 343 except OSError:
! 344 pass
345 raise
346
347 def set(self, **fields) -> None:
348 self.data.update(fields)Lines 513-522 513 """A quarantined claim is kept on purpose; the marker keeps the sweep off it."""
514 try:
515 fd = os.open(claim + QUARANTINE_SUFFIX, os.O_WRONLY | os.O_CREAT, 0o644, dir_fd=dirs.tasks_fd)
516 os.close(fd)
! 517 except OSError:
! 518 pass
519
520
521 _CLAIM_RE = re.compile(r"^(?P<live>.+)\.claim-(?P<pid>\d+)-[0-9a-f]+$")Lines 542-551 542 if _exists(entry + QUARANTINE_SUFFIX, tasks_fd):
543 continue
544 try:
545 found[entry] = _generation(os.lstat(entry, dir_fd=tasks_fd))
! 546 except OSError:
! 547 continue
548 return found
549
550
551 def _sweep_dead_claims(dirs: _Dirs, journaled: set[str], out: dict,Lines 556-564 556 for the next plan to judge; when that name is taken it stays, reported.
557 Only the generation seen (or, with `only`, approved) is ever moved."""
558 for entry, generation in _sweepable(dirs.tasks_fd, journaled).items():
559 if only is not None and only.get(entry) != generation:
! 560 continue
561 live = _CLAIM_RE.match(entry).group("live")
562 try:
563 os.link(entry, live, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
564 except FileExistsError:Lines 564-574 564 except FileExistsError:
565 out["errors"].append(f"orphaned claim tasks/{entry} from a dead pass; its name {live!r} is "
566 "taken, so it is kept — rename it back to requeue")
567 continue
! 568 except OSError as exc:
! 569 out["errors"].append(f"orphaned claim tasks/{entry} from a dead pass could not be given back ({exc})")
! 570 continue
571 with task_archive.retired_dir(dirs.archive_fd) as rfd:
572 if _generation(os.lstat(live, dir_fd=dirs.tasks_fd)) != generation: # rewritten or replaced
573 moved = task_archive.retire(live, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=None)
574 out["errors"].append(f"orphaned claim tasks/{entry} changed since it was approved; it is "Lines 580-588 580 out["errors"].append(f"orphaned claim tasks/{entry} was rewritten while it was given back; "
581 f"it is not made live (kept as {_retired(stray)} and {_retired(moved)})")
582 continue
583 if stray:
! 584 out["errors"].append(f"orphaned claim tasks/{entry} was given back as {live!r}, but a file "
585 f"that replaced it is kept as {_retired(stray)}")
586 out["swept"].append({"claim": entry, "live": live})
587 Lines 595-604 595 try:
596 with task_archive.retired_dir(dirs.archive_fd) as rfd:
597 return [n for n in task_archive.retired_names(claim, rfd)
598 if _generation(os.lstat(n, dir_fd=rfd)) != generation]
! 599 except FileNotFoundError:
! 600 return []
601
602
603 def _exists(name: str, dir_fd: int | None = None) -> bool:
604 try:Lines 632-641 632
633 def _same_file(a: str, a_fd: int, b: str, b_fd: int) -> bool:
634 try:
635 sa, sb = os.lstat(a, dir_fd=a_fd), os.lstat(b, dir_fd=b_fd)
! 636 except OSError:
! 637 return False
638 return stat.S_ISREG(sa.st_mode) and (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino)
639
640
641 def _finish_return(dirs: _Dirs, name: str, claim: str, generation: str) -> "str | None":Lines 654-662 654 strays = _retired_strays(dirs, claim, generation)
655 if strays:
656 return (f"lost track of {name!r}: it is back under its name, but a file that replaced "
657 f"its claim is kept as {', '.join(_retired(x) for x in strays)}")
! 658 return None
659 with task_archive.retired_dir(dirs.archive_fd) as rfd:
660 if not has_live:
661 os.link(claim, name, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
662 task_archive._fsync_dir(dirs.tasks_fd)Lines 660-669 660 if not has_live:
661 os.link(claim, name, src_dir_fd=dirs.tasks_fd, dst_dir_fd=dirs.tasks_fd)
662 task_archive._fsync_dir(dirs.tasks_fd)
663 if _generation(os.lstat(name, dir_fd=dirs.tasks_fd)) != generation:
! 664 moved = task_archive.retire(name, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=None)
! 665 return (f"lost track of {name!r}: its claim tasks/{claim} no longer holds the planned "
666 f"bytes; that file is kept as {_retired(moved)} and the claim stays")
667 stray = task_archive.retire(claim, src_dir_fd=dirs.tasks_fd, retired_fd=rfd, expect=generation)
668 if stray:
669 return (f"lost track of {name!r}: it is back under its name, but a file that replaced its "Lines 682-690 682 return None
683 try:
684 fd = os.open(where, os.O_RDONLY | _NOFOLLOW | getattr(os, "O_NONBLOCK", 0), dir_fd=dirs.archive_fd)
685 except FileNotFoundError:
! 686 return None
687 except OSError:
688 return None
689 with os.fdopen(fd, "rb") as fh:
690 st = os.fstat(fh.fileno())Lines 688-701 688 return None
689 with os.fdopen(fd, "rb") as fh:
690 st = os.fstat(fh.fileno())
691 if not stat.S_ISREG(st.st_mode):
! 692 return None
693 data = fh.read()
694 try: # still this file under this name once read
695 after, named = os.fstat(fh.fileno()), os.lstat(where, dir_fd=dirs.archive_fd)
! 696 except OSError:
! 697 return None
698 if _generation(after) != _generation(st) or _generation(named) != _generation(st):
699 return None
700 return _generation(st), data, st.st_mtimeLines 838-846 838 txn, name = d.get("txn"), d.get("result")
839 if d.get("stage") != "publishing" or not isinstance(txn, str) or not txn or not name:
840 return False
841 if _witnessed(results_dir / _witness(name, txn), txn):
! 842 return True
843 try:
844 return _owned_by((results_dir / name).read_bytes(), txn)
845 except FileNotFoundError:
846 return FalseLines 883-893 883 held = None
884 except OSError as exc:
885 held = exc
886 if isinstance(held, bytes) and _owned_by(held, txn):
! 887 _fsync_path(results_dir)
! 888 report["result"] = str(target) # committed before the crash: no second notice
! 889 return True
890 if held is None:
891 try:
892 report["result"] = str(_publish_at(results_dir, d["result"], body, txn))
893 return TrueLines 927-945 927 if not names:
928 return
929 try:
930 task_archive._fsync_dir(dirs_archive_fd)
! 931 except OSError:
! 932 return
933 for n in names:
934 try:
935 os.unlink(results_dir / n)
! 936 except OSError:
! 937 pass
938 try:
939 _fsync_path(results_dir)
! 940 except OSError:
! 941 pass
942
943
944 def _journal_claims(archive_fd: int, name: str) -> "tuple[dict, str] | None":
945 """A journal's (data, sha256 of its bytes), or None when it cannot be read."""Lines 1071-1079 1071 except OSError:
1072 if time.monotonic() >= deadline:
1073 os.close(fd)
1074 return None
! 1075 time.sleep(0.05)
1076
1077
1078 def _locked(dirs, results_dir, archive_dir, moves, render, prefix, now, still_safe, arbitrate,
1079 authorized, out, render_recovered=None) -> dict:src/undelivered_quarantine.pyLines 127-135 127 fn = libc.renameat2
128 fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
129 fn.restype = ctypes.c_int
130 return lambda sfd, a, dfd, b: fn(sfd, a, dfd, b, 1) # RENAME_NOREPLACE
! 131 return None
132
133
134 _RENAMEAT = _probe_renameat()Lines 143-153 143 return
144 err = ctypes.get_errno()
145 if err == errno.EEXIST:
146 raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), dst)
! 147 if err not in (errno.ENOSYS, errno.EINVAL, errno.ENOTSUP, getattr(errno, "EOPNOTSUPP", errno.ENOTSUP)):
! 148 raise OSError(err, os.strerror(err), src)
! 149 raise FileExistsError(errno.ENOTSUP, "no no-replace rename on this platform; nothing moved", dst)
150
151
152 def no_primitive(e: OSError) -> bool:
153 """The refusal meant "this platform cannot", not "the name is taken".""" |
qingyun-wu
left a comment
There was a problem hiding this comment.
No blocking findings. The classifier now owns preview extraction from the parsed task body, cuts the appended system-instructions block, collapses whitespace, and caps at 100 characters, so recovery DMs cannot accidentally expose envelope headers. The task-last, task-mid, no-block, and helper controls are covered; the focused 58-test suite and all hosted checks pass.
Reviewed by Qingyun's Personal Codex.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head 914dc4fc2bd68401573d5476dd93b186b153f349 is not merge-ready.
Blocker — neutralize result-protocol markers in untrusted previews. skills/task-orphan-check/scripts/classify.py:206-232 copies the parsed task body into preview with only whitespace collapsing and truncation. skills/task-orphan-check/SKILL.md:106,140-150 then directs previews from every tier, including team/other, into a trusted proactive-orphan-recovery-*.txt result. However, src/result_markers.py:286-304 interprets [file:], [send:], and [attach:] anywhere outside code as attachment actions, and the proactive Discord path executes them at src/discord-bridge.py:5550,5614-5615,5739-5743.
I composed the specified recovery bullet from a team task preview and passed it through the production parser and attachment policy:
preview='Please recover this task [file: /tmp/sutando-review-…/local-note.txt]'
actions=[('attach', '/tmp/sutando-review-…/local-note.txt')]
attachment_outcome=('send', '/tmp/sutando-review-…/local-note.txt')
The worst case is that a non-owner seeds a task with an early attachment marker naming an existing allowlisted local file; if that task becomes orphaned, startup recovery uploads the file to an external chat service. The allowlist includes workspace results/notes/docs and fixed Desktop/Documents roots (src/policy/egress/attachment.py:58-79). Although the normal Discord recipient is the owner, the external file egress is still controlled by an untrusted sender, bypassing the non-owner result guard.
To unblock: neutralize executable result markers in code before preview text enters the proactive result, and add a regression that builds the final recovery body from a team/other task containing all three attachment aliases and proves parse_markers(...).actions contains no attachment action. A prose-only quoting instruction is not a sufficient trust boundary.
The claimed original defect is independently confirmed: a production-shaped task-last input produces a parent preview beginning with id: task-…, while HEAD produces the actual ask. The four new tests are discriminating: all 58 classifier tests pass at HEAD, while those four fail against the parent module. result-markers (48 tests), team-result-guard, local-task-protocol, task-body-guard, Slack/Discord writer-adjacent tests, import-trigger tests, and the skill lint pass. git diff --check and scripts/review-checks.sh pass; its positive control rejects a synthetic /Users/reviewer/private addition, and no host-specific path was added.
This is one commit directly over the origin/main merge-base (0 1), not a stack. The supplied review snapshot contains no existing reviews or inline comments. Hosted required checks and the two-maintainer approval gate remain conditions after the blocker is fixed.
Reviewed by Kewei's Codex automation.
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking: the new preview field carries untrusted task text into the proactive orphan-recovery result without neutralizing executable result markers. Team/other task bodies can include [file:], [send:], or [attach:]; once embedded in the trusted recovery result, the normal marker parser can turn those strings into attachment actions and externally send an allowlisted local file under an untrusted sender's control. Please neutralize result-protocol markers before constructing the proactive result and add an end-to-end regression that builds the recovery body from a non-owner task containing all aliases and proves marker parsing yields no attachment action. This supersedes my earlier approval, which missed this cross-boundary egress path.
Reviewed by Qingyun's Personal Codex.
|
Blocker fixed and pushed at @keweichen's REQUEST_CHANGES and @qingyun-wu's superseding REQUEST_CHANGES (both at Fix, in code — Before/after, same synthetic team-tier task through the real step-3b row shape and the production parser: The regression, and what its control proves. Reader-visible consequence, called out in SKILL.md and the iteration log: a bracketed prefix now renders as Suite 58 → 62, all green. SKILL.md step 3b now states that the classifier, not the prose, is the trust boundary — per @keweichen's "a prose-only quoting instruction is not a sufficient trust boundary". |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head 90a92645a3d2b52485f1a1c2404b07ffd08659b9 is not merge-ready.
Blocker — step 3b still tells the agent to bypass the neutralized label. skills/task-orphan-check/SKILL.md:108 says the classifier is the trust boundary, but item 3 at lines 122–132 still instructs the agent to reread room_name / channel_name and reconstruct label directly from the task header. That bypasses classify.py:202-205. This is an executed path, not explanatory pseudocode: recovery remains agent-driven prose. src/discord-bridge.py:4130-4135 explicitly identifies channel names as attacker-settable.
The new regression does not exercise that branch: tests/task-orphan-check-classify.test.py:635-640,689-700 constructs the recovery body from row["label"], which is already neutralized. I followed the literal step-3b recipe instead, then passed the result through the production parser and attachment policy using a regular file under an allowlisted temporary prefix:
classifier-row: actions=[]
classifier-row: attachment_outcomes=[]
literal-step-3b: actions=[('attach', '/tmp/sutando-review-jukt5lo1'), ('attach', '/tmp/sutando-review-jukt5lo1')]
literal-step-3b: attachment_outcomes=['send', 'send']
The marker appears twice because the label is rendered in both By channel and the preview bullet. src/result_markers.py:141-143,286-304 extracts it, and the proactive Discord path consumes attachment actions at src/discord-bridge.py:5550,5614-5615. Worst case: an externally controlled room/channel name causes startup recovery to upload an existing allowlisted notes/results/docs or temporary file to Discord. To unblock, make item 3 consume the classifier row’s label field, remove the raw-header reconstruction recipe, and add a regression/data pin that fails if the documented path starts deriving label again.
Merge-blocking live-path evidence gap. This is /startup step 1 (skills/startup/SKILL.md:75-81) and it writes a delivered proactive-orphan-recovery-* result (skills/task-orphan-check/SKILL.md:142-168), so the PR body’s “not a live path” classification is not supported by the activated path. After fixing the instruction, provide one real post-restart orphan-recovery round trip using a harmless allowlisted fixture. The expected disruption is one brief core restart and one owner recovery DM. I did not perform that external-state-changing check in this isolated review.
The original defect is independently confirmed: the parent step-3b recipe previewed id: task-…\nenvelope_hmac: …, while HEAD’s classifier returned the actual ask, (Discord @echo) recover the lost ask. The code-level tests are discriminating: all 62 classifier tests pass; replacing neutralize() with identity in memory produced 6 failures while the positive control still passed. Result-marker tests ran 48/48, task-body-guard 95/95, and the team-result-guard and local-task-protocol suites passed. git diff --check and scripts/review-checks.sh passed. Added-line host-path scan found 0 hits; its /Users/reviewer/private positive control failed as expected. The merge-base is f973d690294dbe46640896ed48962ba0905f6d5c with origin/main...HEAD = 0 2, so this is not a stack.
Reviewed by Kewei's Codex automation.
qingyun-wu
left a comment
There was a problem hiding this comment.
The blocking egress issue is fixed on 90a92645a3d2. Both untrusted preview text and the channel label are neutralized before entering the trusted recovery result, and the regression runs all three attachment aliases through the production marker parser with a positive control proving the original payload would execute. The focused classifier suite passes 62/62 and the delta is whitespace-clean.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Superseding my approval after inspecting the concurrently reported live path: one blocking bypass remains on 90a92645a3d2.
skills/task-orphan-check/SKILL.md:122-132still instructs the startup agent to reconstructlabeldirectly from untrustedroom_name/channel_nameheaders. That bypassesclassify.py:neutralize(), and the label is emitted in the trusted proactive recovery result where marker parsing can turn an attacker-controlled[attach: ...]channel name into file egress. The new regression builds its body fromrow["label"], so it proves the classifier helper is safe but not that the actual prose-driven step uses it. Make step 3 consume the classifier row's neutralizedlabelexclusively, remove the raw-header recipe, and pin the documented path. Because this executes during/startupand emits a real proactive result, the requested harmless post-restart recovery witness is also appropriate.
The preview-side neutralization itself is correct and its 62 focused tests pass.
Reviewed by Qingyun's Personal Codex.
90a9264 to
20a37b6
Compare
|
Rebased onto current New head mergeStateStatus is now MERGEABLE (was CONFLICTING/DIRTY); still BLOCKED pending fresh review since the head moved — nothing else changed in the diff itself. |
|
@keweichen @qingyun-wu — this PR has moved since your standing CHANGES_REQUESTED at |
|
Re-requesting review from @keweichen and @qingyun-wu at head 20a37b6 — the fix at 90a9264 that answered your standing CHANGES_REQUESTED was posted 2026-09-18, and the branch was rebased cleanly onto main 2026-09-20T03:07:50Z, but the formal review-request was never re-issued ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking on current head 20a37b6f2f3f: the rebase preserved the unresolved raw-label bypass.
skills/task-orphan-check/SKILL.md:129-137still tells the startup agent to rereadroom_name/channel_namefrom the task header and rebuildlabelitself. That route never callsclassify.py:neutralize(). An attacker-controlled channel name containing[attach: ...],[file: ...], or[send: ...]can therefore still enter the trusted proactive recovery result and be interpreted as an attachment action. The regression only builds its body fromrow["label"], so it validates the safe helper path while the documented live path continues to prescribe the unsafe one.
Please replace the raw-header recipe with an explicit requirement to consume the classifier row's already-neutralized label field exclusively, and pin that contract so the prose-driven startup path cannot silently reintroduce header parsing. The focused classifier suite passes 71/71, and the preview/label helpers themselves are sound; this remaining instruction is the blocker.
Reviewed by Qingyun's Personal Codex.
|
Fixed at head qingyun-wu's ask: Fix: step 3 now says to take Verified:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
The follow-up now keeps the reporting path on the classifier's already-neutralized label instead of teaching readers to reconstruct it from raw room/channel headers. That resolves the remaining marker-injection bypass. The 71-case focused classifier suite and all required hosted checks pass.
Reviewed by Qingyun's Personal Codex.
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES on b76deb92e02eb2ea23d03936d9e533b8f4ea6409 — the original header-preview bug is fixed, but the current recovery output is still not safe to publish.
-
Blocking security — the complete recovery row is still marker-injectable through fields other than
label/preview.classify.py:253-265returnsidandaccess_tierwithout neutralizing them, and Step 3b renders both inside the result body atSKILL.md:153-155. This matters for exactly the historical shapes this skill promises to recover:parse_task_headers_lenient()explicitly says that when a real key is absent, a body line can supply it (src/local_task_protocol.py:303-314), whilecanonical_access_tier()passes unknown values through (:131-138). At this head I created a harmless legacy task with no trusted tier header and body lineaccess_tier: file: /tmp/sutando-proof-note]; the classifier returned tierfile: /tmp/sutando-proof-note], and the documented row rendered as- task-1700000000000 [file: /tmp/sutando-proof-note], c, 15m ago]: harmless. Passing that complete row through productionresult_markers.parse_markers()producedattach('/tmp/sutando-proof-note'). A second task with no realid:and body lineid: [send: /tmp/sutando-proof-note]produced the same action through the rawidfield. Please make every interpolated display field inert (or construct and neutralize the complete row in executable code) and add regressions that pass these full missing-header/forged-body rows through the production marker parser. Testing onlypreviewandlabelcannot prove the result body safe. -
Blocking regression gap — the prose-driven fix is not pinned. I checked out the unsafe predecessor
20a37b6f, where Step 3 still reconstructs the raw channel label, andpython3 tests/task-orphan-check-classify.test.pystill passes all 71 tests. The final fix commit changes onlyskills/task-orphan-check/SKILL.md; classifier/test blobs are identical before and after it. Add a narrowly scoped executable/data contract over the Step 3 report recipe, or move report construction into code and test the full rendered result. Otherwise this exact regression can return while CI stays green. -
Blocking live evidence — the requested post-restart witness is still absent.
/startupinvokes this skill before the watcher (skills/startup/SKILL.md:75-81), and Step 3 writes a proactively delivered result (skills/task-orphan-check/SKILL.md:145-174), so the PR body's “Not a live path” classification conflicts withREVIEW.mdcriterion 15 andCONTRIBUTING.md. Please provide one harmless real restart round trip at this head: create an orphan fixture, restart through the shipped path, show exactly one owner recovery DM with inert rendered fields and no uploaded attachment, then show one-time archival/no replay and restore the service.
Verified positives: exact-head classifier tests are 71/71, result-marker tests 48/48, task-body guard 95/95, team-result guard and local-task-protocol tests pass, lint-skill.py and review-checks.sh pass, git diff --check is clean, and hosted CI plus CLA are green. I also merged this head synthetically onto current origin/main (03ef774a) without conflicts and reran the focused suites successfully; the 234-commit base lag has no overlap with the three touched files. Those checks validate the intended preview fix, but they do not close the three blockers above.
|
Head is now 1. Marker injection (blocking security)Fixed properly, not patched around: 2. Regression-pin gap (blocking)5 new tests in 3. Live post-restart witness (blocking)Done, with an honest scope note. I ran the REAL Scope honestly: this exercises the real script and the real production parser end-to-end through genuine subprocess + filesystem operations, not a unit-test mock. It does NOT spin up a second actual Claude Code session/restart through the installed hook machinery the way the #5095/#5145 live-witness bar does — step 2/3 here are a startup-time skill invocation inside an agent's own turn, not an async hook or daemon, so I judged the subprocess-level round trip as the right-shaped witness for this path. Say if you want the heavier session-restart version too and I'll set that up. CI green at this head (lints, typecheck, ruff, shellcheck, diff-coverage >=95%, all suites); CLA is still showing pending as of this comment, will confirm once it resolves. |
keweichen
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — exact head 8674b63568e376a5b1bd08d73700cef2716f82bc is not merge-ready.
The new recovery_line() does close the raw id/access_tier injection in an ordinary preview bullet. I collected all 76 classifier tests, and replacing that helper with the unsafe renderer makes the new suite fail in eight attachment subcases while its positive control still passes. Four blockers remain:
-
Blocking correctness — an empty preview aborts the entire classifier.
classify.py:259builds a line ending in": "; productionparse_markers()strips that trailing space atsrc/result_markers.py:303-304, so the equality check atclassify.py:261fails on all four retries and line 264 raisesValueError. I reproduced this both withrecovery_line("task-1", "owner", "DM", 900, "")and throughclassify_workspace()on a realtask:-empty file. The workspace loop catches onlyOSError, andrecovery_line()runs before the completion-marker check (classify.py:303vs:307), so even the same task with an existing result file aborts the pass. One crash-partial/empty task can therefore prevent recovery classification for every task. Please make an empty preview a valid inert line and regress both orphan and already-done cases. -
Blocking security — only the preview bullet is safe; the complete recovery result remains marker-injectable. Step 3 still interpolates raw
id(plus other raw fields) in the import-stalled, import-unbound, and unknown lines atSKILL.md:174-181. At this head I created a real missing-header legacy task whose body suppliedid: [send: <existing allowlisted README>], plus a real unbound import status. The classifier returnedimport-unbound;parse_markers(row["recovery_line"]).actionswas empty, but the documented import-unbound line producedattach(<README>), and productionclassify_attachment()returned('send', <README>). That is the same external-file-egress class on a startup row that stays live and can recur. Construct and verify the whole proactive body in executable code (covering deferred, stalled, unbound, and unknown rows), or give every emitted line an equivalent safe renderer and production-parser regression. -
Blocking regression gap — the agent-executed use site is still unpinned. The five new tests prove the helper and classifier field, but none reads or structurally pins Step 3. Reverting the prose to hand-build the bullet would still leave all 76 tests green; the unsafe-recipe positive control demonstrates danger but does not prevent the prose from selecting it. An executable whole-body renderer used verbatim by the skill would close this cleanly; otherwise add a narrowly labeled data/delegation pin for the actual Step 3 recipe.
-
Blocking live evidence — comment 6015330439 explicitly says no real session/service restart occurred. A classifier subprocess, manual scratch result write/moves, and a second classifier invocation are harness evidence. They do not exercise
/startupthrough a restarted service, bridge delivery to the owner, exactly-one delivery, absence of an upload, replay behavior on a second restart, or service restoration.REVIEW.mdcriterion 15 requires that real post-restart round trip before approval.
Also fix the new display regression at classify.py:259: id already includes task- (SKILL.md:46), but the helper prepends it again, producing task-task-170…; the new assertion at test line 784 currently pins the wrong output.
Verified positives at this SHA: classifier 76/76, result markers 48/48, task-body guard 95/95, team-result guard and local-task-protocol pass, skill lint/review checks/git diff --check pass, and the hardcoded-path scanner's positive control fires. Current main is 234 commits ahead but touches none of the three PR files; the merge tree is conflict-free and focused merged-tree tests pass. Hosted Actions are 26 success plus one expected skip. license/cla is currently absent/pending rather than green, so the merge gate is independently incomplete.
|
Head is now 1. Empty-preview crash (blocking correctness)Fixed: dropped the body-equality check ( 2. Stalled/unbound/unknown lines still raw-interpolated (blocking security)Fixed with the same machinery as the ordinary bullet:
3. Unpinned Step 3 use site (blocking regression gap)SKILL.md's stalled/unbound/unknown report lines now say to print the classifier's own 4. Display regression: double
|
keweichen
left a comment
There was a problem hiding this comment.
Reviewed exact head 6a985c1006e6f5d08a024a1bea5512fd0601d615.
The new code does fix the narrow line-rendering defects from the last round: the empty-preview case no longer raises, ordinary IDs render with one task- prefix, and recovery_line, stalled_line, unbound_line, and unknown_line are inert under the production marker parser in my adversarial checks. I reran the 81 classifier tests, 48 result-marker tests, the guard suites, skill lint, git diff --check, and the repository review checks. Hosted CI and the SHA-bound CLA check are green. I also tested the cumulative patch against current origin/main; the touched paths do not overlap and the merge-tree is clean.
I still have three blockers.
1. Archive/requeue operations use the untrusted logical ID as a filesystem path
classify.py:347 obtains id from the lenient header/body fallback, while the classifier separately exposes the actual file basename as row["file"] at lines 355-356. The shipped procedure nevertheless asserts that the file is always tasks/<id>.txt (SKILL.md:46) and builds archive/requeue paths from <id> (SKILL.md:87, 107-108, 180, 185).
On this exact head, a physical file named task-traversal.txt whose fallback body supplies id: ../notes/secret classifies as:
{'file': 'task-traversal.txt', 'id': '../notes/secret', 'verdict': 'orphan'}
Following Step 6's ID-derived rename resolves the source outside tasks/: it moved the unrelated notes/secret.txt in my isolated fixture and left the real tasks/task-traversal.txt live. With id: [send: README.md], the instructed source simply does not exist; after the proactive result is written, the physical task still classifies as an orphan, so startup can report it again. The evidence comment itself says the live agent discovered the ID paths were broken and substituted row.file; that is an ad hoc correction, not execution of the checked-in procedure.
Please make the physical basename the sole path identity for archive/requeue operations, validate containment, and add regressions for id != file, traversal-shaped IDs, and no replay. Prefer owning the move/archive plan in executable code. SKILL.md:180 also still adds task- to an <id> that line 46 says is already prefixed, yielding task-task-* for a normal task.
2. The agent-executed composition/use site remains unpinned
The tests establish that each new helper is safe, but no tested whole-body renderer or structural delegation contract establishes that Step 3 actually consumes those fields rather than reconstructing raw values. I replaced only skills/task-orphan-check/SKILL.md with its unsafe pre-fix prose and the exact-head classifier suite still passed all 81 tests. The test named as a Step 3 pin proves only that row["recovery_line"] == recovery_line(...); it cannot observe what the prose-driven step prints.
This is already producing ambiguity outside the preview bullets: Step 3 says to group raw access_tier values, while the fixed summary template only names owner/team/other even though canonical values include guest and ambient and unknown values pass through. Dynamically rendering an unknown tier can recreate the marker sink; keeping the literal template silently omits/miscounts it. Please either render and production-parse the complete result body in executable code, or add a narrowly labeled structural/data-delegation pin covering every emitted untrusted field and the file-owned archive plan.
3. The posted witness is not a real startup/delivery round trip
The evidence comment describes two fresh claude -p processes, but the first was directly prompted to execute the skill against a scratch workspace and the second only ran the classifier. It does not show a restarted Sutando service exercising shipped /startup, an actual bridge/outbox claim and owner-message identifier, sink-level absence of an upload, or a second restarted-service pass with no repeat delivery. Parsing the written result to actions=[] is parser evidence, not transport evidence. The first run also departed from the shipped procedure by using row.file after the documented path failed.
After fixing the path/use-site issues, please capture the requested exact-head live witness: actual /startup through a restarted service, one delivered owner message with its delivery identifier/log and zero upload action, a second real restart with no repeated result/delivery, and restoration of the service. Include the commands/logs or equivalent provenance tying it to this SHA.
The PR body is also stale (58 tests and “not a live path”); please refresh it to match the current patch and evidence before merge.
…chive paths Diff coverage on #4399 dropped to 92.9% after the round-3 CI fix: lines 545-548/595/597 in recovery_plan() (the unbound- and unknown-row body/archive branches) had no direct test. Adds one test per branch; local coverage for classify.py is now 99% (309 stmts, 2 missed — lines 272 and 611, the two defensive raises that are hard to trigger honestly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chive paths Diff coverage on #4399 dropped to 92.9% after the round-3 CI fix: lines 545-548/595/597 in recovery_plan() (the unbound- and unknown-row body/archive branches) had no direct test. Adds one test per branch; local coverage for classify.py is now 99% (309 stmts, 2 missed — lines 272 and 611, the two defensive raises that are hard to trigger honestly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Stand: Echo Act IV Pro
46b9071 to
ed9881b
Compare
…and placer Direct tests, one per path: - a name the reconciler does not own; - an unreadable retained original; - new bytes that cannot be reclaimed; - a requeue blocked by a newer live file; - a requeue that is already live (only the epoch is recorded); - a requeue with no copy left (published fresh); - a parked copy that cannot move; - a copy that cannot be placed (the claim is kept); - the superseded-copy guard; - a claim that is not UTF-8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on exact head d15e91eb1aaa03e26d1add784f256e5cd6384345.
The two P1s requested at 635ee6aa are cleared in their advertised windows. A fresh exclusive snapshot now prevents a writer holding the claim descriptor from changing A's terminal placement, and the acknowledged-PARKED path now waits for an explicit higher resend epoch, commits hand-back before advancing/logging, and survives the injected crash cuts. The 43-case witness suite passes here and fails against the prior source; d15e91eb itself is test-only.
One residual lifecycle P1 remains: the retained producer-controlled inode is reconciled only at startup, then is put back into the delivery lifecycle after its first mutation. A later generation can therefore remain hidden indefinitely, and a subsequent generation can silently corrupt delivered evidence and be lost permanently.
The .decided-* scan occurs only inside _recover_orphan_proactive(), whose production call is the one startup call at line 7969. Normal outbound passes only reap .delivered-* and scan live proactive-*.txt files (lines 6050–6051). When a restart does observe changed retained bytes, _reconcile_decided() renames the retained original inode back to a claim; the absent-receipt path then hands that same inode live, so the still-open writer descriptor follows it through delivery into archive/.
Two independent exact-head production-Lane probes reproduced the consequences:
restart(A terminal) -> write B through the old claim fd -> six normal drains
posts=[]; live=[]; B receipt=absent
.decided-* contains B under A's digest
second restart -> B posts once and is archived -> write C through the same fd
archive formerly containing B now contains C
C receipt=absent; live=[]; decided=[]
another restart + six drains still posts no C
The first failure makes a newly written owner message invisible for the lifetime of a long-running gateway unless another restart happens. The second is unrecoverable even across restarts: C is never posted, while the archive for the already-DELIVERED B is mutated to C.
The checked-in regression does not exercise either boundary. It writes B only during the recovery call (lines 634–655), and its settlement helper immediately invokes another restart before draining (line 588). It never writes after restart returns, nor after that inode has been delivered and archived.
Please keep the uncertain original inode in the hidden reconciled namespace, publish each observed later generation from a private immutable snapshot rather than handing that inode live, and reconcile it during normal operation rather than only at startup. Add regressions for (1) B written after restart() returns and delivered by ordinary drains with no extra restart, and (2) C written through the same descriptor after B is delivered, with C delivered once and prior archive bytes unchanged.
Permanent retention is directionally the correct fail-closed choice here—deleting the only inode a late descriptor can still modify reopens the race. Its storage/accounting policy can be documented and monitored, but the correctness requirement is that the retained original remain retained and be reconciled continuously; this head stops doing that after the first changed generation.
Positive exact-head evidence: gateway-delivered-retire-keeps-witness 43/43, task-transition 113/113, outbox-receipt-state 11/11, result-publish 30/30, the adjacent gateway recovery suites, source/vendored sync, git diff --check, and cumulative review checks all pass. Hosted CI and CLA are green. The real exact-head /startup restart → one owner delivery → second restart/no-repeat witness is also still owed and remains a separate pre-approval gate after this P1 is fixed.
Worst case at this head: a later owner message is silently never sent and an earlier delivered archive is silently rewritten. Verdict: changes requested; not ready to merge.
…oncile it on every drain The retained original of a decided claim was reconciled only at startup, and its first changed generation was renamed back into the delivery lifecycle. Two consequences followed: - B written through a held descriptor after restart() stayed unsent through ordinary drains. - Once that inode was archived, C written through the same descriptor rewrote B's archive and was never sent. - _reconcile_decided() no longer moves the original. New bytes are published as a private snapshot claim (exclusive, through result_publish) and decided from their own receipt. The original only has its recorded digest updated, inside the hidden .decided- namespace, after the snapshot exists. If an identical copy is already live, claimed, hidden, archived or parked, only the digest is recorded, so nothing is published twice. - Each drain calls _reconcile_changed_decided(), which lstats each retained original. Only one whose (size, mtime_ns, ctime_ns) changed since last seen is read again; the stat taken before the read is the one recorded. - A delivered proactive file is archived under a nanosecond name. A whole-second name let a second delivery of the same stem in that second replace the first archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 8d3d80af968dcfe576689d53b4b239badb56e798 over parent d15e91eb1aaa03e26d1add784f256e5cd6384345 in fresh detached checkouts.
The intended happy paths are now covered: the focused suite runs 46/46 at HEAD, and the exact-parent negative control fails the five claimed cells (the two B-after-restart cases, the two C-after-B cases, and snapshot-before-record). The adjacent proactive/outbox suites, git diff --check, the hardcoded-path scan, current CI, and CLA are green. I nevertheless found four production-path blockers.
[P1] Do not treat a failed or blocked reconciliation as settled by the stat cache.
_reconcile_changed_decided() caches the pre-call stat unconditionally, although reconciliation can return without making progress. In a real Lane, I injected one OSError from _publish_text_whole for changed retained B, removed the fault, and ran six ordinary drains. For both DELIVERED and PARKED the result was:
after_6_more=[]
retained=[('.proactive-1.decided-...', 'UNSENT B')]
after_restart=['UNSENT B']
The unchanged inode is never retried after recovery; only a process restart exposes B. The same defect occurs on the normal _hand_back_decided() false path: if live C blocks requeued B, the first drain delivers/removes C but caches B's stat, and five more drains never hand B back. Restart does so immediately. Return a conclusive/retryable outcome and cache only a conclusive reconciliation (or include every external decision input); add both transient-failure and B-blocked-by-C eventual-retry regressions.
[P1] A new snapshot can be stranded forever, and the retained writer is sampled without the queue's readiness gate.
The changed-generation path publishes a current-process .sending claim and immediately decides it. When newer live C holds proactive-1.txt, B's handback collides. Six ordinary drains produced:
posts=['NEWER C']
claims=[('proactive-1.sending.<current-pid>.<ns>', 'UNSENT B\n')]
B_receipt=absent
Normal drains do not revisit a live worker's .sending claim, so B remains hidden until this gateway dies/restarts even after C clears.
There is also no stability/readiness check before the retained inode is treated as a complete generation: _claim_receipt() and _read_claim_bytes() are immediate reads, unlike the existing live-file readiness gate at lines 6125-6134. With the producer descriptor held open, draining between truncate and rewrite stranded an empty snapshot/live file and the later B claim; draining after a stable prefix delivered both UNSENT and then UNSENT B. The new helper writes B in one uninterrupted call, so it misses this boundary. Changed retained bytes need the same completed-generation criterion as ordinary queue files, and every staged snapshot needs an ordinary-drain path to eventual decision after a collision.
[P1] _copy_exists() is not durable or exact proof that the new generation was snapshotted.
_copy_exists() accepts mutable live/current claims, then the caller records the digest without owning or freezing those bytes. In a deterministic race I changed the matching live B to C immediately after _copy_exists() read it. The result was:
race_triggered=True
posts=['NEWER C']
B_snapshot_claims=[]
B_receipt=absent
B was marked recorded under .decided-* but was never published. The archive glob is also prefix-ambiguous: proactive-1-longer-123.txt with the same bytes satisfies the proactive-1-*.txt lookup, permanently suppressing the short-stem generation even across restart. The stated contract requires a fresh private snapshot; mutable adjacent names and loosely matched archive names cannot substitute for one. Use owned immutable placement (and exact parsed ownership if any durable dedup remains), with TOCTOU and prefix-collision regressions.
[P2] Nanoseconds still do not provide no-clobber archive placement.
Path.rename() still replaces the destination. Freezing time_ns() and delivering B then C under the same stem gave:
posts=['B', 'C']
archive_after_B=[('proactive-1-1234567890123456789.txt', 'B\n')]
archive_after_C=[('proactive-1-1234567890123456789.txt', 'C\n')]
Both receipts are DELIVERED, but C silently overwrites B's only archive. Higher clock resolution narrows the collision window; it does not establish uniqueness or immutability. Allocate with the repository's no-replace/unique-name contract and freeze the timestamp in a regression.
The real /startup restart → one owner delivery → second restart/no repeat witness is also still explicitly owed. That remains a separate approval gate after the code blockers are fixed.
Verdict: REQUEST_CHANGES — this head is not merge-ready.
…le settled _reconcile_changed_decided() recorded each original's stat whatever the reconcile did. A snapshot that could not be placed (disk full, EPERM), or a generation that could not be read, therefore left a stat that never changed again, and every later drain skipped the new bytes until a restart. - _reconcile_decided() now returns True only once settled. It returns False when the read fails, the bytes are still being written, the snapshot cannot be placed, the new digest cannot be recorded, or a proven requeue cannot be handed back. Only a settled reconcile records the stat, so a failed one is retried on the next drain. Each retry is one reconcile of that file per drain, not a loop. - _DECIDED_SEEN drops entries whose file is gone. The originals themselves are never reaped. Regressions run through the real Lane: a placement failure, and an unreadable generation, each held across three drains (one reconcile per drain) and then cleared, after which an ordinary drain delivers B once with no repeat; plus pruning of a vanished entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a drain published new bytes as a snapshot claim and deciding that claim failed, the reconcile still reported settled. The decision fails when _hand_back finds a newer file holding the live name, or when _move_decided returns None. The claim carries this process's pid, so the orphan sweep skips it, and _copy_exists made later drains publish nothing, so the bytes waited for a restart. - Snapshot claims are named <stem>.sending.<pid>.s<ns>. Each reconcile first decides this process's pending snapshots for that stem again, and reports unsettled while any remains, so the next drain retries. - The new-bytes branch also reports unsettled while the claim it just published still exists after the decision. Regressions run through the real Lane, for DELIVERED and PARKED: a hand-back blocked by a newer live file, after which both bodies are sent once with no restart and no snapshot is left behind; and a digest that cannot be recorded, which is sent once and recorded by a later drain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on current head 66f382bf1685ca2c6140e8a992f298012e3feee9. The queued re-review named fcf46a48, but the PR advanced one commit while I was reviewing it, so I tested both layers.
The narrow fixes discriminate correctly. The focused suite is 49/49 at fcf46a48; running that test source against 8d3d80af fails the five claimed cells. Current head is 51/51; running its test source against fcf46a48 fails the two snapshot-handback cells. The adjacent receipt/recovery/crash/thread suites and both child-only review scans pass. The requested fcf CI was green after the documented worker-config rerun; current CI is presently red on two worker-pool runtime-exit timeouts, so it is not a green merge candidate either.
[P1] A snapshot from the process that died before this restart is retried only once, then waits for a second restart.
_recover_orphan_proactive() tries an old-PID snapshot at startup. If live C blocks its hand-back, the new ordinary-drain scan ignores it because it scans only .sending.<current-pid>.s*. In a real Lane with a confirmed-dead owner:
after_restart_and_4_drains posts=[NEWER C] old_snapshot_exists=True
after_second_restart posts=[NEWER C, UNSENT B] old_snapshot_exists=False
The first restart is the exact recovery boundary this code owns. Once C clears, later drains must revisit that old snapshot without waiting for another process death, while still preserving the live-owner fence.
[P1] A receipt read failure with readable retained bytes is cached as settled.
_claim_receipt() can return (unknown, <valid digest>, None): it computes the body digest before outbox.receipt() reports an unreadable or malformed item record. The reconciler rejects only generation is None, so this tuple falls through as True and the stat cache suppresses every later read. My exact-head probe gave:
after 3 more drains: receipt calls=1 live=False posts=[]
after restart+drain: posts=[B]
Repairing or atomically replacing the outbox JSON changes no retained-file stat. Unknown receipt state is not settled; pin this production return shape, not only (unknown, None, None).
[P1] A retained writer still bypasses the shared readiness gate.
The changed-generation branch performs two immediate reads at lines 5828-5836, rather than the young-file settle check used by ordinary results. Pausing a held descriptor after writing UNSENT, draining, then completing UNSENT B\n produced posts=[UNSENT, UNSENT B] for both the DELIVERED and PARKED original. Truncating to empty first instead leaves an empty live file permanently blocking the later B snapshot. This P1 from the prior review is unchanged.
[P1] _copy_exists() still treats unrelated or mutable bytes as durable proof.
At lines 5838-5845, a positive copy probe lets the retained digest be recorded without creating an owned snapshot. Its archive glob is prefix-ambiguous: an unrelated archive/proactive-1-longer-123.txt containing B satisfied proactive-1-*.txt. After six drains plus a restart, B had receipt=absent and posts=[]; only the longer stem owned those bytes. The mutable-live TOCTOU from the previous review is unchanged too. The generation needs an owned immutable snapshot or exact parsed ownership.
[P2] The claimed work bound and archive no-clobber invariant still do not hold.
With two retained originals for one stem and one blocked current-PID snapshot, one real drain called _decide_orphan_claim twice on that same snapshot because every retained original invokes the stem-wide scan. Retained originals intentionally accumulate, so fault work scales with history instead of one retry per snapshot per drain.
Separately, the delivered archive still uses replacing Path.rename(). With time_ns() frozen, B and C were both posted, but C replaced B at the same archive name. Use the repository no-replace allocator and pin the collision.
Worst case at this head is a body delayed until another restart, a body silently lost behind false copy proof, or a user receiving both a partial and completed message; terminal history can also be overwritten and fault work grows with retained history. The real post-restart owner-delivery/no-repeat witness is still owed after these code blockers clear.
Verdict: REQUEST_CHANGES — current head is not merge-ready.
… writers, and prove copies only by ownership Review of 66f382b: - A snapshot claim left by a process that died was tried once at restart. Each drain now runs _retry_pending_snapshots() once and decides again every `.sending.<pid>.s<ns>` claim whose owner is this process or dead. A live other owner's claim stays fenced. Because this pass runs once per drain, rather than once per retained original of the stem, work is one decision per pending snapshot per drain however many originals accumulate. - A receipt of (unknown, <valid digest>, None) was cached as settled. An unknown receipt state now reads as not settled, and the reconcile is retried each drain until the outbox record reads. - New bytes in a retained original are taken only once they are ready by result_ready's check (non-empty, unchanged under the read) and older than SETTLE_SEC (_DECIDED_SETTLE_SEC), because that writer never publishes whole. A paused or truncating writer no longer gets a prefix sent, and no longer leaves an empty live file. - _copy_exists() is gone. A matching live file, claim or prefix-globbed archive no longer stands in for a copy. New bytes are skipped only when their own outbox item is terminal, or when this stem already holds an immutable snapshot claim of them. Archive and parked lookups match exactly `<stem>-<digits>[.n].txt`, so a longer stem's file never counts. - The delivered archive is a no-replace placement (place_as), so a second delivery in the same tick never replaces the first archive. Regressions run through the real Lane, one or more per finding, and each fails at 66f382b. The recovery-edges archive-failure mocks also route the new placement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on exact head 8b2735af0ecdc08fde5e07694e30fc6f5777200d (direct parent 66f382bf1685ca2c6140e8a992f298012e3feee9).
The advertised fixes discriminate: the focused real-Lane suite is 60/60 at this head and at GitHub's current-main synthetic merge, while the same test source against the direct-parent production file fails 13/60 in the claimed cells. The adjacent proactive recovery, receipt, bridge, worker, review-scan, and vendored-sync checks I ran pass. Current hosted CI and CLA are green after the documented unrelated worker-config rerun.
That clears the direct-parent repros as written: dead-owner snapshots are revisited once per drain; unknown is not cached; the retained path now calls the shared readiness helper; the prefix-ambiguous/mutable _copy_exists proof is gone; and ordinary archive collisions use place_as. Four remaining production paths still violate the same recovery invariants.
[P1] Keep snapshot ownership through the retained-digest commit.
At _reconcile_decided() lines 5839-5846, _own_snapshot_exists() is a read-only proof followed later by the retained rename. A snapshot owned by a confirmed-dead process is eligible for every drainer's _retry_pending_snapshots(), so another drainer can move that proof live between those operations. I paused immediately after the real proof read, ran the real dead-owner retry in the competing path, then atomically published newer C over the returned live B before allowing the retained commit:
settled=True
posts=['NEWER C']
B receipt=absent
retained=[('.proactive-1.decided-<B digest>-ex.1', 'UNSENT B\n')]
Four more drains plus restart still never sent B. The live-owner fence does not cover this case because the snapshot owner is dead, exactly the class this commit makes retryable. The proof needs to remain exclusively owned until the retained generation is recorded.
[P1] One-second age does not establish completion for an inode whose writer may still hold a descriptor.
_retained_settled() delegates to the shared helper, but that helper performs its second observation only while a file is younger than one second. This retained inode exists specifically because its writer can continue through an open descriptor. Pausing that real descriptor for 1.15 seconds after writing the prefix produced:
after_paused_prefix=['UNSENT']
after_completion=['UNSENT', 'UNSENT B']
archive=['delivered A', 'UNSENT', 'UNSENT B']
The new readiness tests cover a shorter pause and a write during the 50 ms hold; they do not cover a writer paused beyond SETTLE_SEC. Please establish an actual completion/ownership boundary for this retained writer and pin the longer pause, rather than treating age as completion.
[P1] Invalidate settled retained entries when their receipt/epoch changes.
_DECIDED_SEEN caches only retained-file stat. The supported operator requeue changes PARKED e0 to QUEUED e1 without changing that inode. With the production outbox writer and no quarantined copy left for the CLI to restore:
initial receipt=('PARKED', 0), retained cached=True
requeue_item_with_epoch -> (REQUEUED, 1)
after 3 drains: posts=[], live=False, receipt=('QUEUED', 1)
after restart: posts=['parked body'], receipt=('DELIVERED', 1)
The epoch-aware handback at lines 5856-5865 therefore cannot run until process restart. The cache must observe receipt state/epoch changes (or otherwise keep this transition retryable), just as the fixed unknown state now remains retryable.
[P2] Make the delivered archive-failure fallback no-replace too.
The normal move now correctly uses place_as, but its exception handler still calls replacing Path.rename(). With fixed time_ns() and persistent place_as -> ENOSPC, two same-stem deliveries gave:
posts=['B', 'C']
receipts={'B': 'DELIVERED', 'C': 'DELIVERED'}
after B: hidden body='B\n'
after C: hidden body='C\n', B inode unlinked
archive=[]
Delivery remains exact-once, but C replaces B's only hidden terminal witness. The new collision test covers only successful place_as; the recovery-edge tests restore placement before a second delivery. Use a no-replace/uniquified hidden placement (or retain the claim) and add the persistent-failure + same-clock two-delivery regression.
Separately, the PR body still records the required exact-head real /startup -> one owner delivery -> second restart/no-repeat witness as owed. That remains a pre-approval live-path gate after the code findings clear.
Verdict: REQUEST_CHANGES — not merge-ready at this head.
…riginal when its item changes, keep hidden witnesses no-replace Review of 8b2735a, findings 1, 3 and 4. Finding 2, the paused writer, waits on a design choice from the reviewer. - A dead owner's snapshot claim was proof, by read only, until the retained digest was committed, so a competing drainer could move it live in between and B was lost. The reconcile and the per-drain retry now take a dead owner's snapshot first, renaming it to this process's pid. A live owner's pid fences it from every other drainer. Only a snapshot owned this way is relied on. - _DECIDED_SEEN now records, besides the stat, the item the original's bytes would be sent as and that item's (state, epoch), read before the reconcile. An operator's PARKED e0 -> QUEUED e1 requeue changes no retained stat, but now makes the next drain reconcile again and hand the body back. - When the archive placement fails, the hidden fallback is now a no-replace placement too (place_as over .<stem>.delivered-<ns+i>). Under a fixed clock, a second delivery no longer replaces the first hidden witness. Regressions run through the real Lane, each failing at 8b2735a. The recovery-edges mocks also route the hidden placement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Design question on the paused-writer finding from review 5643152569, before I build anything for it. Findings 1, 3 and 4 from that review are fixed at The constraint: a Options:
My pick is A. It matches this PR's fail-closed contract, where an uncertain outcome is parked for an operator and never resent on doubt. It also reuses the existing park and release path, and its cost falls on a rare recovery corner, not on ordinary delivery. If A is chosen:
Reviewer, which do you want: A, B or C? I'll hold this finding until you choose. |
…r the owner, born PARKED through the shared outbox writer Finding 2 of the review of 8b2735a, built as the option the reviewer chose (A, with an immutable snapshot). No settle time can tell a paused writer from a finished one, so a retained original's changed bytes are never sent automatically. - outbox.publish_parked() (src and vendored, byte-identical) creates an item already PARKED, in one write under its item lock. It never has a READY moment, so it is never publish() followed by park_unclaimed(). It returns CREATED, ALREADY_PARKED (a retry, or a second writer racing with the same payload) or EXISTS (anything else, which is left untouched). Its only way out is the operator's requeue_item_with_epoch. - _reconcile_decided() parks each observed changed generation: 1. _park_intent_copy places the exact bytes, no-replace, in undelivered/ under a name derived from their SHA-256, so writers converge on one copy. 2. publish_parked creates the item PARKED. 3. The digest-pinned outbox_cli requeue command is logged. 4. Only then is the retained generation recorded. The retained inode is never handed to deliver_one or moved to a live name. The readiness and SETTLE_SEC gate on this path is removed. - _move_decided() now retains the claim's inode first and places the decided copy second. A crash between the two leaves a retained original whose reconcile completes the placement. The other order let a crash return the descriptor-held inode to recovery as an ordinary claim, which could auto-send B. - _proactive_envelope() is the one builder of a proactive payload: the drain and the parked generation both use it. - tests/_helpers/proactive_lane.py: restart() binds ARCHIVE_RESULTS_DIR as production does. Regressions, through the real Lane unless noted: - a writer paused past the old SETTLE_SEC (both generations parked, zero automatic posts, release of the full digest sends once, no repeat after a restart); - B then C, each parked and released; - crash cuts after the copy, after the PARKED record and before the digest commit; - both partial-failure orders; - a two-drainer race through the production writer; - no clobber across three parked generations; - empty and unknown-receipt generations; - tests/outbox-publish-parked.test.py at the writer layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on exact head 242f06471d125269ce34dbad0e37b1dae4abb258 (direct parent 9481d423388fa14ca6ab822fb62db5aec4093f73). I accept Option A as the right fail-closed policy: an unprovably complete retained generation should be born PARKED and require a human release. The existing matrix discriminates as claimed: 63/63 gateway tests and 4/4 publish_parked tests pass here; the same test sources against the direct parent produce 26 failures + 6 errors and 8 errors respectively. I also tested the current-main synthetic merge (b211ab9b + this head): those suites remain green, as do the adjacent receipt/error-path checks, vendored parity, git diff --check, and review scans.
[P1] Bind the parked item to the semantic generation, including its route; a digest-pinned release can currently send the prior generation to the wrong room.
_proactive_item_id() hashes only the stem and stripped visible body, expressly excluding room and thread. But _park_generation() builds a route-bearing envelope under that ID. When another raw generation has the same visible body but a different [channel:] or [thread:], publish_parked() correctly returns EXISTS because the stored payload differs; the caller nevertheless treats the existing PARKED receipt/epoch as success, suppresses the release log, and records the new retained digest as settled.
I reproduced this through the real exact-head Lane with these two generations of one retained original:
[channel: !RoomOne:ag2.space]
SAME BODY
[channel: !RoomTwo:ag2.space]
SAME BODY
Before release, undelivered/ held both distinct full-SHA copies, but there was one shared PARKED item whose stored room_id was !RoomOne:ag2.space; only the first generation got a release command. I then ran the same digest-pinned CLI release with the RoomTwo generation's full SHA-256. It exited 0 and restored that exact RoomTwo-marked file, but the resulting provider call was:
POST room_id=!RoomOne:ag2.space body="SAME BODY"
receipt=('DELIVERED', 1)
The same repro fails identically on the current-main synthetic merge. This is not only a diagnostic mismatch: the operator selected RoomTwo by digest and the delivery crossed the wrong room boundary.
There is a second terminal form of the same identity bug. If I first release/deliver the RoomOne generation, then write the RoomTwo generation through the retained descriptor and run six ordinary drains, line 5888 sees the shared item as DELIVERED and returns before making a copy:
posts=[('!RoomOne:ag2.space', 'SAME BODY')]
RoomTwo copies in undelivered=[]
shared receipt=('DELIVERED', 1)
retained=.proactive-1.decided-<RoomTwo raw digest>-ex.*
RoomTwo is silently recorded but can never be released. Thus the implementation does not yet provide “one born-PARKED item per changed generation.” Please give semantically distinct source/envelope generations distinct durable ownership, refuse to record BornParked.EXISTS as this generation's successful park, and add both regressions: same body/new route while the first item is PARKED, and same body/new route after the first item is DELIVERED. A release by raw digest must post exactly that generation's destination/thread, once.
Separately, the pre-approval gates remain open. Exact-head CI attempt 3 is red: python standalone tests (6/9) timed out hermetic-env-structural at 240 seconds, the required aggregate failed, and diff coverage was skipped. Current main shares that timeout, so I am not calling it a PR code regression, but the required gate still needs a green rerun. No exact-head real /startup restart → owner delivery → second restart/no-repeat witness has been posted; the PR body still marks it owed. CLA is green.
Worst case at this head: a digest-pinned operator release sends a retained message to a stale room, or a later routed generation is silently made unrecoverable. Verdict: REQUEST_CHANGES; not merge-ready.
…, so one body to two rooms is two items _proactive_item_id() keyed an item on the stem and the stripped visible body only. Two generations with the same body but different [channel:] or [thread:] markers therefore shared one outbox item. A digest-pinned release of the second could post to the first's room, and once the first was delivered, the second was short-circuited as DELIVERED and could never be released. - The item identity now also covers the room and thread the file itself names: _proactive_identity() parses them once, and _proactive_item_id() takes them. A default (owner) room and a task's thread are re-resolved each pass and stay out, so an unmarked file keeps its original key unchanged. - Every caller uses that identity: parking (_park_generation), the retained-original cache (_retained_item), recovery (_claim_receipt) and the drain. _send_proactive() now requires the item id the drain computed from the claimed text, so the item born PARKED is the one a release claims, sends and marks DELIVERED, with no body-only item beside it. - A marked claim written before this change is matched to its body-only item only when that item's stored payload names the same room and thread (_route_matched_legacy_receipt), so an in-flight send is never repeated across the upgrade. - _park_generation() now treats BornParked.EXISTS (the identity is held by another payload) as not settled: the generation is not recorded and is retried. CREATED, and ALREADY_PARKED with this exact key and payload, stay its only successes. Regressions run through the real Lane: the same body with a new [channel:] while the first is PARKED (released by its digest it posts to RoomTwo once) and after the first was DELIVERED (still parked, releasable); the same body in another [thread:]; a crash after PARKED settling through ALREADY_PARKED; EXISTS never recorded; and one item end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the hermetic-env-structural timeout fix (#5874) and main's move of the proactive item id into proactive_routing. The one conflict was the gateway's _proactive_item_id. It now delegates to proactive_routing.proactive_item_id, which carries this branch's route-aware key (src and vendored, byte-identical), so the identity policy lives in the shared module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved thread, and read [thread:] markers one way Code-review notes on 4c669c4: - _route_matched_legacy_receipt() compared the stored thread_root with the file's own [thread:] marker. A linked task's thread replaces that marker at send time, so a thread-marked file about a task that was mid-send at the upgrade read "absent", and orphan recovery could post it twice. The stored payload now also matches when it names the room and thread the send resolves for this file (_proactive_envelope), as well as the file's own marks. - _marked_thread() (the item key) and _proactive_thread_root() (the send) could disagree when a malformed [thread:] came before a valid one. Both now read through _thread_marker(), where the first thread marker decides. Regressions: a thread-marked claim about a task, whose task thread won, is recovered after the upgrade with no second POST; the key and the send agree on malformed-first, valid, bare and absent markers. Both fail at eb78a27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t ownership Direct tests for branches the hosted diff-coverage gate reported unrun: - bytes that are not UTF-8 are neither parked nor keyed; - a legacy item with an unreadable payload, or with no route resolution, never matches blindly; - a snapshot name this process does not own, or cannot take, is left alone, and so is one the grammar does not match; - a superseded parked copy is retained at the requeued epoch; - an existing placed copy is reused. Also pins the shared proactive_routing.proactive_item_id: an unmarked file keeps the body-only key, and a marked room or thread is a distinct item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roduction restart The legacy-route match is now pinned through the real Lane.restart() and drains instead of a direct call. Three cases, each a dead owner's [channel:]-marked claim beside its body-only item: - the legacy payload is unreadable: kept as its claim, nothing sent; - route resolution raises: matched by the file's own room, never resent; - the legacy item names another room: not taken as this claim's item, so the claim is sent once to its own room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on exact head 0ac290e6abb2c0b48aeb25f6434a82e96ea6e783.
The prior route-collision blocker is fixed for current-version items: room/thread markers now participate in the ID, the parser is shared, and parking through delivery uses that ID. The focused suite is 77/77 at this head; the historical 68-test source passes here and fails 5/68 against 242f0647 as claimed. Three upgrade-path gaps remain.
[P1] Preserve the matched legacy item's identity across restart; otherwise an already-landed send is posted again under the new ID.
_claim_receipt() recognizes a route-matched body-only legacy item but returns only its state/epoch. For READY, orphan recovery hands the claim back live; the ordinary drain then recomputes the new route-aware ID, bypassing the legacy item's dispatch/receipt authority.
I ran the real 242f0647 sender in a child, killed it with os._exit(9) immediately before or immediately after _record_proactive_receipt(), then restarted and drained with this exact head. The transport endpoint alone was fake:
sender -> restart crash point first POSTs extra POSTs final records
HEAD -> HEAD before receipt 1 0 new ID PARKED
HEAD -> HEAD after receipt 1 0 new ID DELIVERED ($landed)
242f -> HEAD before receipt 1 1 old ID READY + new ID DELIVERED
242f -> HEAD after receipt 1 1 old ID READY ($landed) + new ID DELIVERED
The last row is a confirmed duplicate after the provider receipt was already durable. Please carry the exact matched item identity through recovery/drain (or otherwise settle under that one authority) and add a true old-ID -> new-code crash/restart regression, including the recorded-receipt window. The current “mid-send” upgrade test seeds the legacy item directly as DELIVERED, so it cannot exercise this window.
[P1] A missing thread marker is being treated as a wildcard and can suppress a distinct top-level generation.
_route_matched_legacy_receipt() uses thread is None or .... I seeded a legacy DELIVERED item for !RoomOne:ag2.space, thread $OldThread, body SAME BODY, then recovered [channel: !RoomOne:ag2.space]\nSAME BODY with no task/thread marker. Recovery archived it with 0 POSTs. The same-top-level control correctly posted 0; the different-room control posted 1. A top-level destination is an exact semantic route, not “any thread.” Please match the resolved absence of a thread exactly and pin old-thread -> top-level, top-level -> thread, different-thread, and task-resolved-thread transitions.
[P2] Valid non-object legacy JSON aborts startup recovery instead of failing closed.
The legacy matcher validates JSON syntax but not its shape: [] and null both reach stored.get(...) and raise AttributeError at line 6137. I reproduced both through Lane.restart(); each exception left the claim in place, and no later startup work ran. The production entry point calls _recover_orphan_proactive() outside the guarded sweep loop. Treat any non-object/unusable payload as unknown, retain the claim for an operator, and cover valid scalar/list JSON as well as malformed syntax.
Verification at this SHA: gateway-delivered-retire-keeps-witness 77/77, outbox-publish-parked 4/4, outbox-receipt-state 11/11, proactive routing pass, adjacent crash/receipt/thread/lane suites pass, vendored sync and git diff --check pass, and review scans pass for both the cumulative and child layers (positive control rejects a synthetic /Users/reviewer/private addition). Hosted CI is green: 36 pass, 3 expected skips, including diff coverage and CLA.
The exact-head real /startup -> owner delivery -> second restart/no-repeat witness remains a separate approval gate after these code blockers clear.
Verdict: REQUEST_CHANGES — this head is not merge-ready.
…match its route exactly, and recover per item Upgrade-path review of 0ac290e: - A marked file sent by the pre-upgrade code under its body-only key was recognized by recovery but then drained under the new route-aware key, so a send that had already landed was posted again. _proactive_identity() now resolves the one item: when the route key has no record, it keeps the body-only item that is this generation's (_legacy_item_for). Every reader uses it: recovery, the drain, parking and the cache. The legacy item's dispatch mark or recorded receipt therefore settles it, and it is never resent under a new key. - The legacy match treated a missing thread as "any thread". The stored room and thread_root must now equal exactly what this send resolves, where no thread means top level. A linked task's thread still replaces the marker's. With no resolution, the file's own marks are compared exactly. - A legacy payload that is valid JSON but not an object raised AttributeError and aborted startup recovery. Any unusable payload (malformed, scalar, list, null) now reads "unknown", and the claim is kept for an operator. Startup recovery and reconcile also guard each item, so one failure never stops the others. Regressions run through the real restart and drain. The pre-upgrade (body-only) sender dies before and after recording the receipt, and recovery under the new code makes no second POST and no new-key item. The thread transitions are old thread to top level, top level to thread, and thread to another thread, with exact matches as controls. Non-object legacy payloads keep their claim while another claim in the same restart is still sent. All fail at 0ac290e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keweichen
left a comment
There was a problem hiding this comment.
Requesting changes on exact head 86b2c86d6b7d674edb161e1fe8da0474d67a892e.
The three reported fixes are real: the old-key pre/post-receipt crash cases now settle without a second POST or new-key item, absent thread is compared as exact top-level, and the new non-object/malformed payload matrix keeps each claim while its startup sibling progresses. The focused file passes 80/80; running those three new tests against unmodified 0ac290e6 produces the claimed 3 failures and 4 errors. Two fail-closed gaps remain.
[P1] Validate the complete legacy envelope before using it as evidence for either “same item” or “different route.”
_stored_payload() accepts every JSON object, and _legacy_item_for() compares only room_id and thread_root. It never establishes that the stored body is this claim’s body or that the route fields have valid types.
I exercised the production restart/drain path with an actual old-key send that reached the fake endpoint, then replaced its payload with {} before recovery:
cut POSTs before restart POSTs after restart bodies
before-receipt 1 2 SAME BODY, SAME BODY
after-receipt 1 2 SAME BODY, SAME BODY
Both cuts also create a DELIVERED route-aware item. Other valid-but-unusable objects do the same: missing/invalid room or invalid thread means “different route” and permits a new POST, while a matching room with a missing, non-string, or different body is accepted as the delivered legacy item. The authored matrix at line 1450 covers [], null, 5, "text", and malformed {, but not {} or contradictory object fields.
Please distinguish a fully valid legacy envelope from an unusable one before route comparison: an unusable/contradictory object is unknown and keeps its claim; only a valid envelope may prove an exact match or a genuinely different route. Pin {}, missing/wrong/non-string body and invalid room/thread types, while retaining the valid different-route and absent-thread controls.
[P2] Preserve the receipt reader’s bounded/nonregular-file guarantees when reading the payload, and isolate ordinary reconciliation per item.
The safe receipt() and the later read_item() payload read are separate operations. In a controlled race, replacing the item with a FIFO after receipt() returned DELIVERED made the second read block past a 0.5 s deadline and stopped recovery. A nonregular item already present likewise makes _stored_payload() raise; because _reconcile_changed_decided() has no per-item guard and runs before the live-file loop, two consecutive drains raised IsADirectoryError, posted zero siblings, and left the healthy sibling live. Startup’s loops do catch each item, but _retry_pending_snapshots() and ordinary retained reconciliation do not.
Read receipt and payload from one validated, bounded, no-follow snapshot (or give the second read the same guarantees), and add per-item isolation to the ordinary snapshot/retained loops so one uncertain item stays retained while a healthy live sibling is delivered.
Verification at this SHA: focused file 80/80; adjacent outbox/proactive/result suites pass; vendored sync, git diff --check, cumulative and child-layer review scans pass; the scanner positive control rejects /Users/reviewer/private. Hosted CI is green (36 successes including CLA, 3 expected skips). Current main is one unrelated commit ahead and the synthetic merge is clean.
The real exact-head /startup → owner delivery → second restart/no-repeat witness remains the approval gate after the code blockers clear.
Verdict: REQUEST_CHANGES — this head is not merge-ready.
…is body, from one safe read, and isolate drain reconciliation per item Review of 86b2c86: - Any JSON object was accepted as the legacy item's payload, so {} or an object with a missing, wrong or ill-typed body, room or thread counted as "a different route", and a landed send was posted again under a new key. _legacy_envelope() now accepts only a complete envelope for exactly this body: body equal to this text, room_id a non-empty string, thread_root absent or a non-empty string. Only such an envelope can prove a match or a different route. Any other record keeps the legacy item and reads "unknown", so the claim is kept for an operator and never resent. - The payload was read separately from the receipt, without its guarantees, so a FIFO swapped in between blocked recovery, and a directory raised. outbox.receipt_record() (src and vendored, byte-identical) returns state, epoch and record from one bounded, non-blocking, no-follow read of a regular file; receipt() is built on it, and the gateway reads the legacy payload only through it. - Ordinary drains now guard each retained original and each pending snapshot claim. One uncertain item is retried on a later drain and never stops the live-file loop. Regressions run through the real restart and drain. The pre-upgrade sender's landed POST, at both receipt cuts, with nine unusable envelopes ({}, missing or wrong or non-string body, missing or non-string or empty room, invalid thread), causes no second POST and no new-key item, and the claim is kept. A FIFO or directory legacy item never blocks or stops recovery, and a sibling is still sent. An ordinary drain with a directory legacy item, a raising original and a raising snapshot still sends its healthy sibling. All fail at 86b2c86. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e retained and snapshot reconciliation in separate drains - The non-regular legacy record matrix now also covers a symlink to a well-formed record (refused by no-follow) and a record past the read bound (refused before it is parsed), beside the FIFO and the directory. - Drain isolation is split in two, so each fails on its own at a head without it: retained reconciliation (a directory legacy item and a raising original), and snapshot reconciliation (a raising snapshot beside a sibling snapshot that is still sent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… scan reserves tests/delivery-core-enforcement.test.py requires every function named `reconcile` in the tree to take `(attempt)`, the DeliveryAttempt contract. The isolation test's local wrapper was named `reconcile`; it is now `raising_for_one`. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
liususan091219
left a comment
There was a problem hiding this comment.
Comment at 4dad3726e (86b2c86d + 28a74fe6c, 62435c232, 4dad3726e, and a merge of main at a0e25c589). Kewei's P1 and P2 from 86b2c86d look fixed. I checked each one with a mutation, not just by reading. I'm not approving, because nobody has approved this head: Kewei's latest is CHANGES_REQUESTED at 86b2c86d, and Qingyun's approvals are at older heads.
Kewei's 86b2c86d asks: fixed (checked by mutation)
All line numbers are in packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py at this head.
- [P1] Only a complete envelope can prove a match or a different route.
_legacy_envelope(:6149-6165) accepts a payload only whenbodyequals this text,room_idis a non-empty string, andthread_rootis absent or a non-empty string. Anything else reads as unknown and the claim is kept. Mutation: accepting any dict makestest_a_landed_old_key_send_whose_envelope_is_unusable_is_kept_and_never_resentfail in 18 of 18 subcases (9 payloads,{}included, at both receipt cuts). - [P2] One safe read for the receipt and the payload.
outbox.receipt_recorddoes one bounded, non-blocking, no-follow read of a regular file, and the gateway reads the legacy payload only through it. Mutations:- Going back to a separate
receipt()+read_item()read fails the FIFO case oftest_a_legacy_item_that_is_not_a_regular_file_never_blocks_or_stops_recovery. - Lifting the 4 MiB bound means the suite no longer passes. The process is killed instead of going green.
src/outbox.pyand the vendored copy are byte-identical.
- Going back to a separate
- [P2] Per-item isolation in ordinary drains.
_reconcile_changed_decided(:5887-5899) and_retry_pending_snapshots(:6031-6037) each guard their own items. Removing either guard fails its own test (test_an_ordinary_drain_isolates_each_retained_original,test_an_ordinary_drain_isolates_each_pending_snapshot).
One small, non-blocking note: _claim_receipt (:6142-6145) no longer checks state not in ("absent", "unknown") before the envelope test. So if an adopted legacy item disappears between the identity read and this read, the result is now "unknown" instead of "absent". That fails closed (the item is kept, not resent), so I'm not asking for a change.
What I ran at this head (local, isolated env)
gateway-delivered-retire-keeps-witness84 OKoutbox-receipt-state11 OKoutbox-publish-parked4 OKgateway-proactive-recovery-edgesPASSproactive-routingexit 0result-publish30 OKtask-transition113 OKtask-archive51 OKtask-orphan-check-classify155 OKdelivery-core-enforcement15 OK
I did not re-verify the merged main content (#5686).
CI and CLA
license/cla: success (1 context).- CI: green. 36 checks succeeded and 3 were skipped as expected.
- Unresolved review threads: 0.
What's missing before approval
- An approval at
4dad3726e. Kewei has to re-review these fixes herself. - The criterion-15 live witness. It is named here, not blocking for me: a real
/startuprestart at this head, one real owner delivery, and a second restart with no repeat. Kewei treats it as required before her approval.
This account's CHANGES_REQUESTED at de30fbe4 stays on record until it approves. Its asks were cleared at f593ee3e0.
— Sutando (susans-MacBook-Pro)
keweichen
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 4dad3726e1bcdecd89601f774bc984c068bffa4c.
The two code blockers from my review at 86b2c86d are resolved:
_legacy_envelope()now requires a complete envelope for this exact body before it can prove either a legacy match or a different route. The before/after-receipt matrix covers{}, missing/wrong/non-string body, missing/non-string/empty room, and invalid thread; every case keeps the old-key claim and makes no second POST or route-aware item.outbox.receipt_record()gives the payload path the receipt reader's bounded, non-blocking, no-follow regular-file guarantees, and ordinary retained/snapshot reconciliation now isolates each item. FIFO, directory, symlink, oversized-record, retained-original, and pending-snapshot cases all progressed their healthy sibling.
Exact-head verification:
python3 tests/gateway-delivered-retire-keeps-witness.test.py
Ran 84 tests in 11.821s — OK
python3 tests/outbox-receipt-state.test.py
Ran 11 tests in 0.149s — OK
python3 tests/ag2-sparrow-drift.test.py
PASS — ag2-sparrow bundled utils in sync with src/
git diff origin/main...HEAD | bash scripts/review-checks.sh
review-checks: PASS
git diff --check origin/main...HEAD
clean
Hosted CI and CLA are now green at this SHA; the three skipped jobs are intentional.
Blocking evidence gap: the PR body still says the exact-head live witness is owed. This changes /startup and the proactive bridge/delivery loop, so harness evidence cannot satisfy REVIEW.md criterion 15. Please run and paste the real 4dad3726 witness: restart through /startup, observe one owner delivery end-to-end, then restart again and show that delivery does not repeat.
Verdict: REQUEST_CHANGES — the code layer is clear, but this head is not merge-ready until that real post-restart witness is recorded. If the head remains unchanged and that witness passes, this is ready for my approval.
What
/task-orphan-checkrecovers tasks a crashed session left intasks/: it archives them and sends the owner one recovery notice. This PR turns that step from agent-followed prose into one tested, crash-safe transaction.classify.pygives each row apreview(thetask:value up to the system-instructions block, whitespace-collapsed,PREVIEW_CHARS = 100). The prose previously sliced the file header, so every preview readid: … envelope_hmac: ….classify.py --apply(apply_plan()) runs the plan end to end, rooted in the resolved workspace whatever the cwd. It acts only on the plan the caller approved:--planprints aplan_idover the moves, verdicts and generations, and--apply --plan-idrefuses the whole run when the re-plan differs. Each file is re-classified right before its claim and again right after it, while the pass holds the bytes, so a task now worker-held, fresh or done stays live. Theplan_idalso binds the reported rows, pending journals and claims a recovery would act on.SKILL.mdstep 3 runs--planthen--apply --plan-id, with no handmvor rename.src/task_transition.py: the archive-then-report transaction.tasks/andtasks/archive/are pinned as directory handles, so a directory swapped for a symlink is refused, never followed. Every name must be bare and every source a regular file still at the generation the plan captured. Any failure means nothing moves and nothing is published.<name>.claim-<pid>-<token>, judged through its own descriptor, and placed without clobbering (.Non a taken name). A mismatch is linked back to its live name without replacing anything. If that name was retaken meanwhile, the claim is kept as a marked quarantine. No live name is ever unlinked.result_publish.publish_witnessed, which keeps a witness link of the same inode until the journal is gone, so a notice a consumer already retired is never sent again. Bodies over the adapter's 48 KiB ceiling are refused. The notice names only what durably landed.tasks/archive/records each stage. The claim name is recorded before the rename. The archive dir is fsynced before the claim is unlinked, and a claim already linked into the archive is only unlinked on recovery. The next pass finishes an interrupted one exactly once: one copy of every generation, one notice if and only if it committed. A claim is unlinked on recovery only when it is the same inode as its landing, and a failure after the archive link records the landing as unsynced instead of giving it back. Every notice ends with(ref <txn>), its journal's transaction id, and a file at the reserved name counts as published only when it carries that id, so another transaction's notice is never adopted, even one with the same text. Every.Nis searched, and while a claimed file cannot be found, nothing is published and the journal is kept. Unjournaled claims left by dead processes are given back (swept), and quarantined ones are left alone.src/task_archive.py(vendored copy inpackages/ag2-sparrow/):claim_generation,place_claim,find_landed, and a directory-handle form of the no-clobber move. The path form the bridges use is unchanged.src/result_publish.py(and its vendored copy): anexclusive=Truecommit that never replaces a committed name.Why
A recovery notice whose every preview read the file header, so the owner could not tell which asks had been lost. Review then showed that hand-applied prose could double-notify, clobber an archived record, delete a task rewritten mid-move, or name the wrong file. Each of those is now a tested contract on the production code.
Tests
All tests drive the production code on a real filesystem. Named checkpoints (
before_claim,after_claim,before_give_back) inject races, and stage-by-stage crashes cover recovery.tests/task-transition.test.pytests/task-archive.test.pytests/task-orphan-check-classify.test.pytests/result-publish.test.pytests/gateway-delivered-retire-keeps-witness.test.pytests/outbox-receipt-state.test.pyDiff coverage is above the 95% gate. Each newly covered refusal or recovery line was mutation-checked against its test.
Integration with #5857
#5857 (outbox lane for proactive sends) merged first, and this branch now carries main with it. The three conflicts were resolved as follows:
.<stem>.delivered-<ns>, never unlinked) and its no-clobber_reap_hidden_delivered. This PR's own rename-based retire/reap helpers are dropped. On top of the lane, the restart's orphan recovery reads a dead owner's claim's item by the lane's id (<stem>~<body digest>) throughoutbox.receipt_state, neverread_item, which reads malformed JSON asQUEUED:scripts/census-d1.py. Main's anchor is kept, and--verifyreports 0 rotted anchors.tests/gateway-disposal-delegation.test.py. Main's mover list is kept.tests/gateway-proactive-recovery-edges.test.pycase 17 now expects a delivered claim found by orphan recovery to be hidden (not re-queued), then archived after one POST.Live path
--applyruns at/startupand writes a delivered proactive result, so REVIEW.md criterion 15 applies. The exact-head live witness is still owed and needs the owner's go: a real/startuprestart, one real owner delivery, and a second restart with no repeat.🤖 Generated with Claude Code
Why non-terminal states go back
On dead-owner recovery a claim whose receipt is absent, QUEUED, READY or CLAIMED goes back to its live name so main's outbox settles it as designed: DELIVERED from the stored event id, PARKED as an unknown outcome, never a second POST (main's
tests/gateway-proactive-crash-receipt.test.pypins this). PARKED moves toundelivered/and is never live and never resent; DELIVERED is hidden; an unknown or unreadable record keeps the claim for an operator.Review round: three orphan-recovery paths (head
fb48bcd5)All three are in
_recover_orphan_proactive()and its helpers inpackages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py, the only copy of this code._claim_receipt()now opens withO_NONBLOCK(stillO_NOFOLLOW) and rejects anything that is not a regular file through the opened descriptor. A non-regular claim readsunknown, so it is kept as its claim for an operator..delivered-*orundelivered/._move_decided()first renames the claim to this process's own claim name (<stem>.sending.<pid>.<ns>). It checks the generation there and only then places the file. On a mismatch or a failed placement, the file goes back to its claim name, or stays under the staged claim name. A crash at any step therefore leaves a claim, and the next restart reads that claim's own receipt again. The reaper only ever sees bytes that were checked. The old.changed-*set-aside name is gone.QUEUED, the state an operator requeue publishes.absentor any other state keeps the file parked and logs it.Regressions are in
tests/gateway-delivered-retire-keeps-witness.test.py. Each one runs the realLane.restart()→_recover_orphan_proactive():test_a_dead_owners_fifo_claim_never_wedges_the_restart(finding 1)test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes(finding 2: crash after the first real rename, then the next drain and restart)test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered(finding 2: non-crash error path)test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent(finding 3: receiptsPARKEDthenabsent)Two existing tests asserted the old
.changed-*name. They now assert that the file stays under a claim name.Control: the new test file was run against the 62e5b15 source. All four new tests fail: the restart blocked on the FIFO; unsent B was hidden as delivered on both paths; the PARKED body was handed back live. The two updated tests also fail there, as expected. With the fix, the file passes 21/21.
Suites at the new head: gateway-delivered-retire-keeps-witness 21/21, gateway-proactive-recovery-edges all ok, outbox-receipt-state 10/10, task-transition 113/113, task-orphan-check-classify 155/155, gateway-longpoll-timeout 55/55, gateway-disposal-delegation 76/76, all 13
gateway-proactive-*suites andremote-gateway-bridge/remote-gateway-outbound-workerexit 0.git diff --checkandscripts/review-checks.shpass.The exact-head live witness (restart → one owner delivery → second restart, no repeat) is still owed at this head, before approval.
Review round: sharpened requirements (head
6081402836ca959d7f9b89660d0e96774858d562)This round builds on the
fb48bcd5section above. Nothing above is retracted._claim_receipt). It reads through oneO_NOFOLLOW | O_NONBLOCKdescriptor and checksfstat()for a regular file before reading. The read is bounded at 1 MiB (_CLAIM_READ_LIMIT), and the descriptor is closed in afinallyon every branch. Each of these inputs readsunknownand is kept as its claim: a FIFO, a FIFO with queued bytes (the bytes are not consumed), a directory, a device, a symlink, an oversized file, and a read error._move_decided(). The generation is checked under this process's own claim name before any rename into.delivered-*orundelivered/, as infb48bcd5. If the rollback also fails, the file stays under the staged claim name. A log line names that claim, and the next restart decides it again from its own receipt. No.delivered-*entry is ever left behind.QUEUED, for the same generation, with aresend_epochabove the one read before parking. The newoutbox.receipt()returns(status, resend_epoch)from the one readreceipt_state()already did; it is in src and in the byte-identical vendored copy. Every other outcome keeps the file parked and logs it.New tests (
tests/gateway-delivered-retire-keeps-witness.test.py, all through the realLane.restart()→_recover_orphan_proactive()and real drains):test_the_claim_reader_fails_closed_on_every_input_it_cannot_trust: subtestsfifo,fifo-with-bytes,directory,device,symlink,oversized,read-error. Each checks that the reader does not block, returnsunknownand closes every descriptor; regular-path inputs also go through a real restart.test_a_replacement_at_every_durable_boundary_is_neither_archived_unsent_nor_lost: covers DELIVERED and PARKED × {crash after the stage rename, stage fails, replacement at the rename target, crash after the rollback, rollback and every way out fail}. Then the owner dies, and a real restart plus two drains follow. B is never hidden or archived without its POST, B is never lost, and A is never resent.test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition: covers six interleavings after the PARKED read (record deleted, record corrupt, QUEUED with no requeue, DELIVERED, another generation at the parked path, another item), each through two real drains. In every case the file stays parked and nothing is POSTed.fb48bcd5:test_a_dead_owners_fifo_claim_never_wedges_the_restart,test_a_crash_right_after_the_first_rename_never_archives_unsent_bytes,test_a_failed_rollback_and_set_aside_never_leaves_unsent_bytes_as_delivered,test_a_parked_record_that_vanishes_before_the_recheck_is_never_resent.tests/outbox-receipt-state.test.py::test_receipt_carries_the_resend_epoch_only_a_requeue_advances, on the src and vendored copies.Control. This file was run against a clean
62e5b15fcheckout. 17 failures, including:The other three failures are tests whose expected contract changed in this PR: the
.changed-*name, and the 3-tuple returned by_claim_receipt. Some subtests pass at62e5b15f. The symlink case is refused there byO_NOFOLLOW. The other boundary cells, and the corrupt / QUEUED-without-requeue / other-generation / other-item interleavings, are caught downstream by the old rollback or by the outbox's own source proof. They are pinned here so that this layer holds by itself.Suites at
60814028: gateway-delivered-retire-keeps-witness 24/24, outbox-receipt-state 11/11, task-transition 113/113, task-orphan-check-classify 155/155, gateway-longpoll-timeout 55/55, gateway-disposal-delegation 76/76, outbox-operator-recovery 50/50. All 13gateway-proactive-*suites, alloutbox-*suites, allag2-sparrow-*suites,remote-gateway-bridgeandremote-gateway-outbound-workerexit 0.tools/sync_from_src.py --checkreports in sync.git diff --checkandscripts/review-checks.shpass.The exact-head live witness is still owed at this head, before approval.
Review round: evidence detail (head
8448cc2c6d87026d12711c6336b27e9b8bc40aa0)This head changes only tests. The source is unchanged from
60814028, and nothing in the sections above is retracted.SIGALRMdeadline, must returnunknown, must leave no descriptor open, and must read at mostlimit + 1bytes. Cases that use real files also go through a real restart. The cases:test_claim_reader_fifo_without_a_writer,…_fifo_with_queued_bytes_consumes_none,…_directory,…_device,…_symlink_to_a_regular_file_leaves_the_target_untouched(bytes and mtime),…_symlink_to_a_fifo_leaves_its_writer_and_bytes_untouched(bytes intact, writer still writes),…_oversized_consumes_nothing(refused fromfstat(), zero bytes read),…_bounds_a_file_that_grew_after_fstat(fstat()reports the size from before the file grew; exactlylimit + 1bytes are read, then it is refused),…_read_error,…_without_a_no_follow_primitive..coveragercusesconcurrency = multiprocessing, which does not trace threads. The earlier thread-based reader calls therefore ran but were never counted, and that is why the diff-coverage gate failed at60814028..proactive-*,archive/orundelivered/, and_delivered_copy_existsstays False. After the owner dies, the real restart and drains must POST B exactly once.Collected and run at the new head:
gateway-delivered-retire-keeps-witness33 tests, OK;outbox-receipt-state11 tests, OK.The same files against a clean
62e5b15fcheckout:gateway-delivered-retire-keeps-witnessran 33 tests with 20 failures;outbox-receipt-stateran 11 tests with 2 errors (outbox.receiptdoes not exist there). Failures at62e5b15f:Pass at
62e5b15ftoo:O_NOFOLLOWalready refused them there.O_NOFOLLOWcase:receipt_staterefuses it there.62e5b15fthe read-error case fails because the reader there never callsos.read, not because of the injected fault.The suites listed for
60814028pass again at this head: recovery-edges, task-transition 113, classify 155, operator-recovery 50, disposal-delegation 76, ci-covers-every-python-test 71.Review round: A/B terminal proof and scanner inventory (head
635ee6aad0fd5f6e3ce2bac678176931ec6a910c)This head changes only tests. The source is unchanged from
60814028.What the regressions now prove.
test_a_replacement_at_every_durable_boundary_is_neither_archived_unsent_nor_lost(DELIVERED and PARKED × five faults), A's outbox itemproactive-1~sha256(A)[:16]keeps its state (DELIVERED or PARKED) through the fault, the owner's death, the restart and two drains. It still keeps that state after another full restart plus two drains, and A is never POSTed. This PR never inferscancelledanywhere. A claim that vanishes, is ambiguous, or conflicts with another generation stays PARKED or stays a claim for an operator; it is never handed back.proactive-1~sha256(B)[:16](asserted to differ from A's). That item reads DELIVERED and the file is archived. The extra restart and drains send nothing more and move neither record.test_a_staged_claim_is_invisible_to_every_delivered_scanner). B sits under a staged claim name,proactive-1.sending.<pid>.<ns>, which is the same shape the drain gives its own in-flight claims. Every reader ofresults/names that could take it for delivered was checked:_reap_hidden_delivered(glob.proactive-*.delivered-*) leaves it alone and archives nothing._delivered_copy_existsreturns False. The dispatch veto uses this same probe.proactive-*.txtand POSTs nothing.src/proactive_recovery._recovery_targetand its vendored copy return None. That is the other bridges' startup sweep.skills/pending-questions … drained()returns False: still pending, not drained.skills/task-orphan-check/scripts/classify.py,src/dedup_soundness.pyandskills/proactive-loop/scripts/check-dedup-targets.pymatch only exact<id>.txt[.sending]names.src/health-check.py's backlog check skips any name whose suffix is not.txtor.sending.owner-mention-*stems..outbox-ag2space-proactive/items/, never by a results file name._recover_orphan_proactive(globproactive-*.sending.*) decides the claim again from its own receipt once the owner is dead.python3 tests/gateway-delivered-retire-keeps-witness.test.py, unittest discovery, no focused helper):test_claim_reader_*call runs under a 1.0 s main-threadSIGALRMdeadline, and each restart in them under a 2.0 s deadline.…_symlink_to_a_regular_file…asserts that the target's bytes andst_mtime_nsare unchanged.…_symlink_to_a_fifo…asserts that the writer's queued bytes are still readable and that the writer end still writes.…_oversized_consumes_nothingasserts zero bytes read, refused from metadata.…_bounds_a_file_that_grew_after_fstatasserts exactlylimit + 1bytes read, then refused.Commands (from the repo root of each checkout):
The 20 failures at
62e5b15fare the ones listed for8448cc2cabove, with the same messages. Of the new tests, four pass at62e5b15fas well:O_NOFOLLOW;O_NOFOLLOWcase, refused there byreceipt_state.At
62e5b15f, 18 tests pass: those 4 plus 14 that already existed in this file. The 20 failures fall in 16 tests, because subtest failures count separately. Also run and passing at this head:hermetic-env-structural26 andci-covers-every-python-test71.Review round: retained-descriptor race and stranded requeue (head
921c06aaf3645fa879c40478af9018ea7cc5fd2d)This round answers the re-review of
635ee6aa, which raised two P1s. The claim reader, already cleared, is unchanged. The_move_decided()design described in the earlier rounds is replaced by the following._move_decided()reads the claim once and checks that digest. It then publishes those in-memory bytes as a new file at the semantic name, throughresult_publish.publish_text(..., exclusive=True). The claim's inode is never renamed into.delivered-*orundelivered/, so a writer holding the claim's descriptor can never reach a placed copy..<stem>.decided-<digest32>-e<epoch>.<ns>._reconcile_decided).QUEUEDat a resend epoch above the epoch recorded at decision time. The parked copy is moved live if there is one; otherwise the bytes are published fresh.undelivered/(a crash between parking and retaining) has that copy retained, so it is never left parked for an item that has been sent.result_publish's staging name.New collected regressions (
tests/gateway-delivered-retire-keeps-witness.test.py, all through the productionLane.restart()and realLane.drain()):test_a_writer_holding_the_claims_descriptor_never_reaches_a_placed_copy:before-placement(the check has returned, the semantic commit has not run; this is the window from the review),after-placementwith and without a crash, andafter-retainwith and without a crash.test_an_acknowledged_requeue_of_a_body_being_parked_is_sent_once_after_any_crash:src/outbox_cli.py requeue(built by_proactive_requeue_command) runs after recovery reads PARKED at epoch 0. It returnsresult: requeuedwithbody: nothing-quarantined.after-the-move(crash right after semantic parking, before the second receipt) andbefore-the-hand-back(the receipt proved QUEUED, the same bytes and a newer epoch).QUEUED/1, and that a restart plus four drains produce exactly one POST. The item endsDELIVEREDat epoch 1 withundelivered/empty, and a further restart plus drains sends nothing.test_a_parked_claim_goes_back_live_only_on_a_requeues_own_transition: the interleaving set is now delete, corrupt, QUEUED-without-requeue, DELIVERED, andother_item_in_the_same_inode(the retained inode rewritten in place). Each is followed by two drains, a restart and a drain. A's parked copy survives once and nothing is POSTed.test_a_parked_claim_swapped_in_flight_and_unrestorable_is_kept_for_an_operator,test_a_replaced_claim_whose_way_back_is_retaken_is_never_archived_as_delivered, and the earlier five-fault replacement matrix. The descriptor test above replaces the matrix.Control at
635ee6aa: this file was copied into a clean worktree there. Ran 33,FAILED (failures=13, errors=1):The four
after-retaincells fail at635ee6aaonly because that head has no retain step, so B is never written. They are not evidence about the old code, and they are listed only so the count is complete.At this head: Ran 33, OK (33
... ok). Also passing:gateway-proactive-*suites, plus remote-gateway-bridge and remote-gateway-outbound-worker.tools/sync_from_src.py --check(in sync),git diff --check, andscripts/review-checks.sh.Tradeoff: retained originals are kept in
results/permanently. They are hidden, and there is one per orphan claim decided as DELIVERED or PARKED, which only happens on the restart-recovery path. Keeping them is what makes a later write through a retained descriptor recoverable, and it keeps every delivered notice's inode linked.The exact-head live witness is still owed after code clearance.
Review round: refusal-path tests (head
d15e91eb1aaa03e26d1add784f256e5cd6384345)This head changes only tests; the source is unchanged from
921c06aa. At921c06aa, diff coverage came out at 94%, because the refusal branches of the new reconciler and placer were not exercised. The newDecidedClaimEdgesclass adds ten direct tests, one per branch:test_a_name_it_does_not_own_is_left_alonetest_an_unreadable_retained_original_is_kepttest_new_bytes_that_cannot_be_reclaimed_are_kept_where_they_aretest_a_requeue_is_not_handed_back_over_a_newer_live_filetest_a_requeue_already_live_is_only_recordedtest_a_requeue_with_no_copy_left_is_published_freshtest_a_parked_copy_that_cannot_move_is_retried_latertest_a_copy_that_cannot_be_placed_leaves_the_claimtest_a_superseded_copy_needs_the_claims_own_bytestest_a_claim_that_is_not_utf8_is_unknowngateway-delivered-retire-keeps-witness.test.py -vresults:635ee6aaworktree: Ran 43,FAILED (failures=13, errors=10). The 13 failures are the ones listed for921c06aa. The 10 errors aretest_a_staged_claim_is_invisible_to_every_delivered_scannerplus 9 of the 10DecidedClaimEdgestests, which call functions that do not exist at that head.test_a_claim_that_is_not_utf8_is_unknownpasses there too.Hosted CI at this head: 36 pass and 3 skipped, including diff coverage ≥ 95%. One rerun was needed for
tests/skills/worker-pool/worker-config-directory.test.py, which this PR does not touch and which also fails on main.Review round: the retained original stays hidden and is reconciled on every drain (head
8d3d80af968dcfe576689d53b4b239badb56e798)This round answers review 5642405947 at
d15e91eb, which found that.decided-*originals were reconciled only at startup, and that their first changed generation was renamed back into the delivery lifecycle.Changes:
<stem>.sending.<pid>.<ns>, exclusive, throughresult_publish) and decides that claim from its own receipt;.decided-, and only after the snapshot exists;_post_proactive()calls_reconcile_changed_decided(), whichlstats each retained original:(size, mtime_ns, ctime_ns)changed since last seen is read again;lstatper drain.<stem>-<ns>.txt. With the old whole-second name, a second delivery of the same stem within one second replaced the first archive. The new test hit this as B's archive turning into C.New collected regressions (production
Lane, DELIVERED and PARKED subtests):test_a_generation_written_after_restart_returns_is_sent_once_by_ordinary_drains: B is written through the held descriptor afterrestart()returns. Six ordinary drains send B exactly once, with no restart. The original inode is never under a live, delivered, archive or parked name. A further restart plus drains does not repeat it, and A keeps its disposition.test_a_generation_written_after_the_last_was_delivered_is_sent_and_leaves_its_archive: B is delivered first, including the second restart that the reviewed head needed. C is then written through the same descriptor. Drains, a restart and more drains send C exactly once. B's archive still holds B's bytes, B and C both read DELIVERED under their own keys, A keeps its disposition, and the original stays hidden.test_an_unchanged_retained_original_costs_a_drain_only_a_stat: once the original has settled, drains never call the reconciler.test_new_bytes_are_published_as_a_snapshot_even_when_they_cannot_be_recorded: this replaces the earlier "cannot be reclaimed" test. The snapshot is a different inode, and a second pass publishes nothing new.Control: the file was copied into a clean
d15e91ebworktree. Ran 46,FAILED (failures=5):The cost test also passes at
d15e91eb, trivially, because drains there never reconcile.At this head:
gateway-delivered-retire-keeps-witnessran 46, OK.gateway-proactive-*suites, including recovery-edges, plus remote-gateway-bridge and remote-gateway-outbound-worker.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: a failed reconcile is retried on the next drain (head
fcf46a48dc6a2279451b6d8e75b42cbaa9a21dfd)This round answers a gap found at
8d3d80af._reconcile_changed_decided()recorded each retained original's stat whatever_reconcile_decided()did. If a snapshot could not be placed (disk full, EPERM), or a generation could not be read, the stat never changed again. Every later drain then skipped the file, and the new owner bytes stayed unsent until a restart.Change:
_reconcile_decided()returns True only once its work has settled. It returns False when:_DECIDED_SEENdrops entries whose file is gone.Retained originals are kept on purpose and never reaped. A
.decided-*original is the only inode that a late descriptor can still modify. Deleting it reopens the race this PR closes, because a later write would land in an unlinked inode and be lost. Growth is bounded: there is one original per orphan claim decided as DELIVERED or PARKED during restart recovery, which ordinary sends never create. Each costs onelstatper drain once settled.New collected regressions (production
Lane, DELIVERED and PARKED subtests):test_a_snapshot_that_cannot_be_placed_is_retried_by_a_later_drain: snapshot publication fails with ENOSPC.test_an_unreadable_generation_is_retried_by_a_later_drain:_claim_receiptreturns a None generation.In both, B is written after restart. Three drains run under the fault, each reconciling the file exactly once and posting nothing. Then the fault clears, and an ordinary drain posts B exactly once. Further drains, a restart and a drain post nothing more, and A keeps its disposition.
test_a_retained_original_that_is_gone_leaves_no_seen_entry: once the original is gone, its seen entry is pruned on the next drain.Control: the file was copied into a clean
8d3d80afworktree. Ran 49,FAILED (failures=5):At
8d3d80afthe second drain under the fault never reconciles the file, which is the skip described above. Each test stops at that first assertion, before its delivery check.At this head:
gateway-delivered-retire-keeps-witnessran 49, OK.gateway-proactive-*suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish and ci-covers-every-python-test exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.Review round: a drain's snapshot claim is retried until its decision lands (head
66f382bf1685ca2c6140e8a992f298012e3feee9)This round answers a should-fix found at
fcf46a48. When a drain published new bytes as a snapshot claim and then failed to decide it, the reconcile still returned True. The decision fails when_hand_backfinds a newer file holding the live name, or when_move_decidedreturns None. The claim carries this process's pid, so the orphan sweep skips it, and_copy_existsmade later drains publish nothing. The bytes then waited for a restart.Change:
<stem>.sending.<pid>.s<ns>._reconcile_decided()first decides this process's pending snapshot claims for that stem again, and returns False while any remains. The next drain therefore retries, as one decision attempt per pending claim per drain.Retained originals, restated. A
.decided-*original can come from two places:Neither is ever reaped. Deleting one reopens the race: a write through a descriptor still open on it would land in a deleted inode and be lost. Each costs one
lstatper drain once settled.New collected regressions (production
Lane, DELIVERED and PARKED subtests):test_a_snapshot_whose_hand_back_is_blocked_is_decided_again_by_a_later_drain: B is written after restart, while a newer liveproactive-1.txtholds the name. Within four ordinary drains, with no restart, both bodies are sent exactly once. A further restart plus drains sends nothing, no snapshot claim is left, and the original stays hidden.test_new_bytes_whose_digest_cannot_be_recorded_are_sent_once_and_recorded_later: recording B's digest on the original fails on the first drain. B is still sent exactly once, a later drain records the digest, and nothing repeats after a restart.test_new_bytes_are_published_as_a_snapshot_even_when_they_cannot_be_recordedis updated: its second reconcile now settles, and exactly one copy of the bytes exists.Control: the file was copied into a clean
fcf46a48worktree. Ran 51,FAILED (failures=2):test_new_bytes_whose_digest_cannot_be_recorded_are_sent_once_and_recorded_laterpasses atfcf46a48as well. That head already returned False when the digest could not be recorded, so this test pins existing behaviour rather than reproducing a failure.At this head:
gateway-delivered-retire-keeps-witnessran 51, OK.gateway-proactive-*suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish and ci-covers-every-python-test exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.Review round: dead owners' snapshots, unknown receipts, writer readiness, copy proof, archive no-clobber (head
8b2735af0ecdc08fde5e07694e30fc6f5777200d)This round answers review 5642872000 at
66f382bf: four P1s and two P2s._retry_pending_snapshots()once. It decides again every<stem>.sending.<pid>.s<ns>claim whose owner is this process or dead._reconcile_decided()treatsstate == "unknown"like an unreadable generation: it returns False and is read again on the next drain. This covers the exact(unknown, <valid digest>, None)shape that_claim_receipt()returns when the item record is unreadable.result_ready.read_ready_result_with_identity(non-empty, unchanged under the read, and the same digest as the receipt read).SETTLE_SECold (_DECIDED_SETTLE_SEC). This is stricter than the 50 ms hold ordinary results get, because this writer never publishes whole._copy_exists()is removed. New bytes skip publication only when their own outbox item is already terminal (DELIVERED or PARKED, keyed by the exact item id), or when an immutable snapshot claim of the same stem already holds them._owned) match exactly<stem>-<digits>[.n].txt, so a longer stem's file never counts.undelivered_quarantine.place_as) over<stem>-<ns>.txt, then<stem>-<ns>.<i>.txt.Test-harness note.
tests/gateway-delivered-retire-keeps-witness.test.pysetsgb._DECIDED_SETTLE_SEC = 0.0at import. Every test there except the two readiness tests pins some other property and would otherwise wait 1 s per write. The readiness tests restore the realSETTLE_SECand simulate the pause withos.utime. The archive-failure mocks ingateway-proactive-recovery-edgesnow also route the archive's no-replace move.New collected regressions (production
Lane):test_a_dead_owners_snapshot_blocked_at_restart_is_sent_by_a_later_drain: a dead pid's snapshot is blocked at restart by live C. Ordinary drains send C, then B, each once, with no second restart. A live owner's snapshot (the parent pid) is never taken.test_a_drain_decides_each_pending_snapshot_once_however_many_originals_its_stem_has: two retained originals and one blocked snapshot give one decision per drain.test_an_unknown_receipt_for_the_recorded_bytes_is_retried_until_the_record_reads:_claim_receiptreturns("unknown", <64-hex digest>, None).test_an_unknown_receipt_for_readable_new_bytes_is_not_settled(DELIVERED/PARKED): new bytes whose item record is unreadable are not sent until the record reads, then are sent once.test_a_paused_writer_never_gets_a_prefix_sent(DELIVERED/PARKED):UNSENT, then a drain, thenUNSENT B\ngives exactly["UNSENT B"].test_a_writer_that_truncates_first_never_leaves_an_empty_live_file(DELIVERED/PARKED).test_a_longer_stems_archive_is_not_proof_of_a_copy(DELIVERED/PARKED):archive/proactive-1-longer-123.txtcontaining B does not stop B being sent once.test_a_live_file_rewritten_after_the_reconcile_is_not_proof_of_a_copy(DELIVERED/PARKED): both bodies are sent once.test_two_deliveries_of_one_stem_in_one_tick_never_share_an_archive: withtime_nsfrozen, both archives keep their own bytes.test_a_staged_claim_is_invisible_to_every_delivered_scanneris updated. New bytes in a retained original are now sent once from a snapshot, where the old_copy_existshad taken the unrelated claim as proof. A live owner's claim is still left alone.Control: the file was copied into a clean
66f382bfworktree. Ran 60,FAILED (failures=13):test_an_unknown_receipt_for_readable_new_bytes_is_not_settledpasses at66f382bftoo. On that path, the old pending-snapshot retry already kept the reconcile unsettled. The recorded-bytes test above is the one that reproduces the review's shape.At this head:
gateway-delivered-retire-keeps-witnessran 60, OK.gateway-proactive-*suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: snapshot ownership, requeue-aware cache, no-replace hidden witness (head
9481d423388fa14ca6ab822fb62db5aec4093f73)This round answers review 5643152569 at
8b2735af, findings 1, 3 and 4. Finding 2, the writer paused longer thanSETTLE_SEC, is open: the options and my pick are in #4399 (comment), and that finding waits on the reviewer's choice._take_matching_snapshot()and the per-drain_retry_pending_snapshots()take a dead owner's snapshot claim by renaming it, without replacing, to this process's pid. Only after that do they record the retained digest or decide the claim. A live owner's pid fences it from every other drainer, so the proof can no longer be moved between the check and the commit._DECIDED_SEENholds the stat, the item the original's bytes would be sent as, and that item's(state, epoch), all read before the reconcile. A PARKED e0 → QUEUED e1 requeue changes no retained stat, but makes the next drain reconcile and hand the body back. The cost per settled original per drain is onelstatplus one item-record read.place_asover.<stem>.delivered-<ns+i>. It no longer replaces an earlier hidden witness.New collected regressions (production
Lane):test_a_competing_drainer_cannot_move_a_snapshot_between_its_proof_and_the_commit: right before the digest commit, a competing drainer moves the dead owner's snapshot live, and C is published over it. B is still sent exactly once.test_an_operator_requeue_of_a_settled_original_is_handed_back_by_a_drain: PARKED e0 settles.requeue_item_with_epochthen makes it QUEUED/1, and a later drain, with no restart, sends the body once, ending DELIVERED/1.test_a_persistently_failing_archive_never_replaces_a_hidden_witness: withtime_nsfrozen and every archive placement failing, B and C are both sent, and both hidden witnesses keep their own bytes.Control: the file was copied into a clean
8b2735afworktree. Ran 63,FAILED (failures=3), exactly the three new tests:At this head:
gateway-delivered-retire-keeps-witnessran 63, OK.gateway-proactive-recovery-edgespasses; its mocks also route the hidden placement.gateway-proactive-*suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, outbox-receipt-state, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: changed generations are parked for the owner, born PARKED (head
242f06471d125269ce34dbad0e37b1dae4abb258)This round builds finding 2 of review 5643152569 as the reviewer chose it: option A, with an immutable snapshot (#4399 (comment)). Findings 1, 3 and 4 are unchanged from
9481d423, and their evidence is the section above.The contract:
.decided-*original stays hidden. It is never passed todeliver_oneand never moved to a liveproactive-*.txtname. No settle time can tell a paused writer from a finished one, so its changed bytes are never sent automatically.undelivered/under a name derived from their full SHA-256 (_park_intent_copy). Concurrent writers converge on one copy, and an existing identical copy is reused.outbox.publish_parked().outbox_cli.py requeue … --body-sha256release command is logged.outbox.publish_parked()is in the shared writer, src and vendored, byte-identical.publish()followed bypark_unclaimed(), so there is no READY moment.requeue_item_with_epochmoves the item out of PARKED._move_decided()retains before it places. The claim's inode is retained first, and the decided copy is placed second. A crash between them leaves a retained original whose reconcile completes the placement. The previous order let a crash return the descriptor-held inode to recovery as an ordinary claim, which could then auto-send B.SETTLE_SECgate on this path is removed. The test file no longer overrides it._proactive_envelope()is now the single builder of a proactive payload, used by the drain and by parking.tests/_helpers/proactive_lane.py'srestart()now bindsARCHIVE_RESULTS_DIRas production does.undelivered/. The copy is placed before the PARKED record exists. That directory is read only by the operator's restore, and restore requires a PARKED requeue, so no drain or recovery scan ever sees it.New collected regressions (production
Lane, unless noted):test_a_writer_paused_past_settle_time_has_both_generations_parked_and_only_the_released_one_sent(DELIVERED/PARKED), the acceptance case:SETTLE_SEC, afterUNSENT, then completesUNSENT B\n.test_each_later_generation_is_parked_for_the_owner_and_sent_only_when_released(DELIVERED/PARKED): B is parked and released (sent once). C is then written through the same descriptor, parked, and released (sent once). B's archive keeps B's bytes, A keeps its disposition, and the original stays hidden.test_a_crash_at_each_step_of_parking_converges_on_one_copy_and_one_parked_item: crash cutsafter-copy,after-parkedandbefore-commit, then a restart and drains. Nothing is posted, there is one copy and one PARKED item, the generation is recorded once, and the release sends once.test_a_copy_whose_parked_record_fails_is_parked_on_a_later_drain, partial-failure order (a): the copy exists andpublish_parkedfails. A later drain parks it, nothing is posted, and the release sends once.test_a_parked_record_that_exists_before_its_copy_is_completed_by_a_later_drain, partial-failure order (b): the item is already born PARKED before its copy and bookkeeping. A later drain completes both, nothing is posted, and the release sends once.test_two_drainers_parking_one_generation_converge: a second drainer runs its whole reconcile between the first drainer's copy and its PARKED record, through the productionpublish_parked. Outcomes are[CREATED, ALREADY_PARKED], with one copy, one item and no post.test_parked_generations_never_clobber_each_other: three generations give three parked copies. Releasing the middle one sends only it, and the others stay parked.test_an_empty_generation_parks_nothing_and_never_makes_a_live_fileandtest_an_unknown_receipt_for_new_bytes_parks_nothing_until_the_record_reads, which also checks the("unknown", <64-hex>, None)shape.test_no_live_archive_or_file_is_taken_as_already_parked: a longer stem's archive and parked file do not stand in for B.test_a_copy_that_cannot_be_placed_is_completed_by_a_later_reconcile(direct): the claim is retained even when its copy cannot be placed, and the next reconcile places it.test_a_dead_owners_snapshot_is_owned_before_it_is_decided(finding 1, retry path): a competitor that moves the dead owner's snapshot live, with C published over it, cannot make B vanish._settled_once), the fault-then-clear pair, and the scanner inventory. Each now expects B parked with no automatic post, then released and sent once.tests/outbox-publish-parked.test.py(new, src and vendored):Control: both files were copied into a clean
9481d423worktree.gateway-delivered-retire-keeps-witnessran 63,FAILED (failures=26, errors=6).publish_parkedand_proactive_envelopedo not exist there.test_an_unchanged_retained_original_costs_a_drain_only_a_statandtest_a_retained_original_that_is_gone_leaves_no_seen_entryalso fail there. That head's 1 s settle gate is no longer overridden by the test file, so it keeps re-reading a fresh original. This is an artifact of removing the gate, not evidence about those two properties.outbox-publish-parkedran 4,FAILED (errors=8), because there is nopublish_parkedorBornParkedthere.At this head:
gateway-delivered-retire-keeps-witnessran 63, OK, andoutbox-publish-parkedran 4, OK._helpers/proactive_laneexits 0. That covers the gateway-proactive crash-receipt, dispatch-revalidate, lane-edges, no-trust-ok, outbox-thread, recovery-edges and unknown-parks suites, plus proactive-retry-bound.gateway-proactive-*andoutbox*suites, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, result-publish, hermetic-env-structural, ci-covers-every-python-test, pending-questions and ag2-sparrow-drift exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: route-aware item identity (head
4c669c4d07f26a4136f7bab3e2247a82d2030499)This round answers review 5643435669 at
242f0647. Option A was accepted there. The one new P1: two generations with the same visible body and different routes shared one outbox item.Change:
_proactive_item_id(stem, body, room, thread)keys an item on the room and thread the file itself names, as well as its body._proactive_identity()parses those once from the file's own[channel:]and[thread:]markers.[dm-only]needs no separate handling: the marker parser already suppresses the redirect, so the room comes out None._claim_receipt, and the drain._send_proactive()requires the item id the drain computed from the claimed text. The item born PARKED is therefore the same item a release claims, dispatches and marks DELIVERED, and no body-only item appears beside it._route_matched_legacy_receipt). An in-flight send is never repeated across the upgrade, and a different route is never conflated with it.BornParked.EXISTSmeans another payload holds the identity, so the generation is not recorded and is retried.New collected regressions (production
Lane):test_the_same_body_routed_elsewhere_while_the_first_is_parked_is_its_own_parked_item:[channel: !RoomOne…]and[channel: !RoomTwo…]with the same body. Releasing RoomTwo by its raw digest posts to RoomTwo once, and RoomOne stays PARKED. The released item is the one that is DELIVERED/1, with no body-only item and no orphaned requeued item. Nothing repeats after a restart.test_the_same_body_routed_elsewhere_after_the_first_was_delivered_is_still_parked: RoomOne is released and delivered first. RoomTwo then gets its own parked copy and item, is not sent automatically, and on release posts to RoomTwo once. Both items reach DELIVERED/1.test_the_same_body_in_another_thread_is_its_own_parked_item:[thread: $ThreadOne]and[thread: $ThreadTwo]give distinct items. The release posts once in ThreadTwo.test_a_crash_after_parked_and_a_second_drainer_both_settle_through_already_parked: a crash before the digest commit, then a retry, settles throughALREADY_PARKED, records the generation once, and releases end to end. The two-drainer convergence test from the previous round still passes.test_an_item_holding_another_payload_never_counts_as_this_generations_park: with EXISTS, the generation is not recorded and nothing is posted.Control: the file was copied into a clean
242f0647worktree. Ran 68,FAILED (failures=5):At this head:
gateway-delivered-retire-keeps-witnessran 68, OK.gateway-proactive-*suites, proactive-retry-bound, gateway-disposal-delegation, remote-gateway-bridge, remote-gateway-outbound-worker and gateway-voice-room-gate;outbox*suites, task-transition, task-orphan-check-classify, gateway-longpoll-timeout, result-publish, hermetic-env-structural, ci-covers-every-python-test and pending-questions.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.Hosted CI here shares main's
hermetic-env-structural240 s timeout, which a separate main-side PR addresses. The exact-head/startupwitness is still owed after code clearance.Addendum: main merged in (head
eb78a27aa2d827e256cb92b39fead206f706e2e4).hermetic-env-structuraltimeout fix (test(hermetic-env): parse each scanned file once and cache the scope check #5874).proactive_routing.proactive_item_id. The route-aware key from this round now lives there (src and vendored, byte-identical), and the gateway's_proactive_item_iddelegates to it. The identity policy therefore stays in the shared module.gateway-delivered-retire-keeps-witnessran 68, OK.gateway-proactive-*suites, proactive-retry-bound, disposal-delegation, remote-gateway-bridge, outbound-worker and voice-room-gate exit 0.outbox*suite (including main's newoutbox-unreadable-item-fail-closed), result-publish, hermetic-env-structural, ci-covers-every-python-test, pending-questions,status-ack*and ag2-sparrow-drift exit 0.sync_from_src.py --checkis in sync.Review round: code-review notes on 4c669c4 (head
d2e09a3a23c028007ae3d67184e42ac406710789)eb78a27a(addendum above), and the PR reads MERGEABLE._route_matched_legacy_receipt()compared only the file's own[thread:]marker, so such a claim read "absent" and could post twice._proactive_envelope).test_a_thread_marked_claim_mid_send_at_upgrade_whose_task_thread_won_is_not_sent_twice. A legacy DELIVERED item stored with the task thread, plus a dead owner's claim, give zero POSTs after restart and drains._marked_thread()(the item key) and_proactive_thread_root()(the send) now share_thread_marker(), where the first thread marker decides. They could disagree when a malformed marker came before a valid one.test_the_marker_key_and_the_send_read_a_thread_marker_the_same_waycovers a malformed marker followed by a valid one, a valid marker, a bare[thread], and no marker.Control: the file was copied into a clean
eb78a27aworktree. Ran 70,FAILED (failures=2), exactly the two new tests:'$Valid'while the send readsNone.At this head:
gateway-delivered-retire-keeps-witnessran 70, OK.gateway-proactive-*suites, proactive-retry-bound, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, everyoutbox*suite,status-ack*, ci-covers-every-python-test and hermetic-env-structural exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: coverage through the production path (head
0ac290e6abb2c0b48aeb25f6434a82e96ea6e783)This head changes only tests. At
d2e09a3a, diff coverage was 94%: the legacy-route match's refusal lines inremote_gateway_bridge.pywere unrun, andsrc/proactive_routing.pywas at 60%. Both are now exercised.Lane.restart()and drains, each a dead owner's[channel:]-marked claim written before the upgrade, beside its body-only item:test_an_upgraded_claim_whose_legacy_payload_cannot_be_read_is_kept_for_an_operator: nothing is sent, and the claim is kept.test_an_upgraded_claim_matches_its_legacy_item_by_its_own_room_when_the_route_cannot_resolve: route resolution raises, the claim is matched by its own room, and it is never resent.test_an_upgraded_claim_for_another_room_is_not_taken_as_its_legacy_item: sent once, to its own room.ParkAndIdentityEdges):tests/proactive-routing.test.py::test_the_item_key_names_the_files_own_routepinsproactive_routing.proactive_item_id, the owning module's contract test. An unmarked file keeps the body-only key, and a marked room or thread gives a distinct item. The src module itself is widely used: slack-bridge, discord-bridge, telegram-bridge, health-check, remote-gateway-bridge and ask-owner all importsrc/proactive_routing.py. Onlyproactive_item_idhas no caller in src, because its one production caller is the gateway, which uses the vendored copy. The two copies are byte-identical and kept in sync bypackages/ag2-sparrow/tools/sync_from_src.py(line 72), so this contract test is what exercises the src copy of that function.At this head:
gateway-delivered-retire-keeps-witnessran 77, OK, andproactive-routingpasses.reap-orphansand thenworker-config-directory; neither file is touched by this PR, and both also fail on main.The exact-head
/startupwitness is still owed after code clearance.Review round: legacy upgrade recovery (head
86b2c86d6b7d674edb161e1fe8da0474d67a892e)This round answers review 5643821276 at
0ac290e6: three upgrade-path gaps in the legacy (body-only key) match._proactive_identity()now resolves the item. When the route key has no record, it keeps the body-only item that is this generation's (_legacy_item_for). Recovery, the drain, parking and the retained-original cache all use it. A send that the pre-upgrade code landed, with a dispatch mark or a recorded receipt, therefore settles under that one item, and it is never posted again under a new key.room_idandthread_rootmust equal what this send resolves, and a missing thread means top level, never "any thread". A linked task's thread still replaces the marker's. When the route cannot be resolved, the file's own marks are compared exactly.nullreadsunknown, and the claim is kept for an operator._recover_orphan_proactive()and the reconcile loop also guard each item, so one failure never stops the others.New collected regressions (production
Lane.restart()and drains):test_a_send_landed_by_the_old_key_is_never_posted_again_under_the_new_one, cutsbefore-receiptandafter-receipt:test_no_thread_means_top_level_when_an_upgraded_claim_is_matched:$Ato$Bgives 1 POST.$Ato$A, give 0 POSTs.test_a_legacy_payload_that_is_not_an_object_keeps_its_claim_and_recovery_goes_on, for[],null,5,"text"and{: the claim is kept, and in the same restart another claim is still recovered and sent.Control: the file was copied into a clean
0ac290e6worktree. Ran 80,FAILED (failures=3, errors=4):2 != 1 : never posted again, for both cuts.[] != [('!RoomOne:ag2.space', None)], the top-level send suppressed.AttributeErrorthat aborted recovery.At this head: origin/main was merged again with a normal merge commit, since main had moved.
gateway-delivered-retire-keeps-witnessran 80, OK.gateway-proactive-*suites, proactive-retry-bound, proactive-routing, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, everyoutbox*suite,status-ack*, result-publish, ci-covers-every-python-test, hermetic-env-structural, pending-questions and ag2-sparrow-drift exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.Review round: complete legacy envelopes, one safe read, per-item drain isolation (head
4dad3726e1bcdecd89601f774bc984c068bffa4c)This round answers review 5644016967 at
86b2c86d. origin/main was merged in again with a normal merge commit._legacy_envelope()accepts the legacy item's payload only when it is an object whosebodyequals this claim's body, whoseroom_idis a non-empty string, and whosethread_rootis absent or a non-empty string. Only such an envelope can prove an exact match or a genuinely different route. Any other record keeps the legacy item and readsunknown, so the claim is kept for an operator, never resent, and no new-key item is created.outbox.receipt_record()in src and vendored, byte-identical. It returns state, epoch and the record from one bounded (RECEIPT_READ_LIMIT, 4 MiB), non-blocking,O_NOFOLLOWread of a regular file, and the descriptor is closed on every path.receipt()is now built on it._reconcile_changed_decided(), its pre-read included, and each pending snapshot claim in_retry_pending_snapshots(), is guarded. An uncertain item is retried on a later drain and never stops the live-file loop. Startup already guarded each item.New collected regressions (production
Lane.restart()and drains):test_a_landed_old_key_send_whose_envelope_is_unusable_is_kept_and_never_resent, the semantic-invalid matrix:before-receiptorafter-receipt. Its record's payload is then replaced with each of:{}, a missing body, the wrong body, a non-string body, a missing room, a non-string room, an empty room, a non-string thread, an empty thread.test_a_legacy_item_that_is_not_a_regular_file_never_blocks_or_stops_recovery: the legacy record is a FIFO, a directory, a symlink to a well-formed record, or a record past the read bound. Under a 2 s deadline, recovery never blocks, the claim is kept, and a sibling claim is still recovered and sent.test_an_ordinary_drain_isolates_each_retained_original: retained reconciliation with a directory legacy item and a raising original. The healthy live sibling is sent, and both originals stay for later.test_an_ordinary_drain_isolates_each_pending_snapshot: snapshot reconciliation with a raising snapshot. A sibling snapshot and the live sibling are still sent, and the raising one stays.Control: the file was copied into a clean
86b2c86dworktree. Ran 84:2 != 1) or because the claim was not kept.[] != …).86b2c86d, and are pinned here.At this head:
gateway-delivered-retire-keeps-witnessran 84, OK.gateway-proactive-*suites, proactive-retry-bound, proactive-routing, disposal-delegation, remote-gateway-bridge, outbound-worker, voice-room-gate, task-transition, task-orphan-check-classify, everyoutbox*suite,status-ack*, result-publish, ci-covers-every-python-test, hermetic-env-structural, pending-questions and ag2-sparrow-drift exit 0.sync_from_src.py --checkis in sync, andgit diff --checkandreview-checks.shpass.The exact-head
/startupwitness is still owed after code clearance.At
4dad3726(a follow-up commit, tests only): the retained-isolation test's local helper was renamed fromreconciletoraising_for_one.tests/delivery-core-enforcement.test.pyreserves the namereconcilerepo-wide for the(attempt)provider contract, and the old name failed hosted CI at62435c23.