Repository navigation
fix(archiver): never archive an empty results file on its mtime (open-fd data-loss) - #2360
Conversation
…-fd data-loss) archive-stale-results.py moved any stale results/*.txt on mtime alone — no size check. A producer can create a proactive-*.txt and pause before its first flush; an empty file keeps its creation mtime while the descriptor stays open, so the mtime horizon moves the inode out from under the open fd and the producer's later flush lands in the archived copy: silent loss of an owner-facing nudge. This is the exact unsound signal the proactive drain stopped trusting (#2324 — "a file held open with no write keeps its creation mtime"). The archiver is the OTHER mtime-keyed mover of these files, so #2324's guarantee ("an empty proactive file is never moved") did not hold end-to-end until this. Skip 0-byte .txt files: leave them in place for the drain to keep handing back. The DM-flood this script prevents is caused by CONTENTFUL stale files (one DM per file), so excluding empties does not weaken it. Genuinely-orphaned 0-byte remnants are surfaced by scripts/results-health.sh for deliberate cleanup, never moved on age. Adds tests/archive-stale-results.test.py (contentful archived, empty skipped — verified it fails without the guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eeper exists The prior comment claimed orphaned 0-byte remnants are 'swept by disk-hygiene'. Checked (air, review): neither disk-hygiene.sh nor results-health.sh deletes results files — results-health.sh only REPORTS zero-byte counts. Reword to the truth: an orphaned 0-byte file is inert (never delivered, never moved), surfaced by results-health.sh for deliberate cleanup, and additionally excluded from the mtime-keyed archiver (#2360) so the never-moved guarantee holds end-to-end. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 79%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…rage sees it The prior test invoked archive-stale-results.py via subprocess.run, so the diff-coverage gate (coverage runs on the parent test process) never instrumented the new size-0 exclusion branch — the changed src/ lines read as uncovered and "diff coverage >= 95% (python)" failed even though the behavior is exercised. Import the module and call main() in-process instead (env set before import, same pattern as remote-gateway-bridge.test.py). Both sides of the new branch run (empty stale file skipped, contentful stale file archived), so the changed lines are covered. Behavior assertions unchanged; still passes and still catches the reintroduction (disable-the-repair verified last commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
john-the-dev
left a comment
There was a problem hiding this comment.
Approved refreshed exact head c48feae. The current-main refresh is merge-only: both PR-owned blobs are byte-for-byte unchanged from pre-refresh head 2a21141. The focused regression passes all three retention cases, Python compilation and git diff --check pass, and a live open-descriptor probe confirms that an old empty proactive file stays at its original results path and a later flush remains readable there. The added-line host-path/fallback scan is clean after excluding only the exact test REPO locator line. The zero-byte skip is fail-safe under races: a concurrent write can at worst defer archival to the next sweep, never strand later bytes in the archive. Fresh GitHub CI/CLA is running; merge remains gated on green checks and one more exact-head formal maintainer approval.
|
Approval signal for exact head No blocking findings from this pass. The archiver now takes a single stat, applies the retention cutoff, and excludes 0-byte Focused verification in an isolated
GitHub Reviewed by Qingyun's Personal Codex. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Current-head approval signal (formal approval is unavailable because the authenticated account owns this PR): no blockers found on 91191fcdf20eaabf6cbf5a2fcaf183fca79ca0df.
The bug exists on current main: src/archive-stale-results.py archives stale .txt files by mtime alone, so an empty file held open before its first flush can be renamed and receive its later body only in the archive. The new size-zero guard at src/archive-stale-results.py:70-92 preserves that inode in results/, while contentful stale results still follow the existing flood-prevention archive path.
Focused verification passed: python3 tests/archive-stale-results.test.py, git diff --check origin/main...HEAD, and the REVIEW.md hardcoded-path scan. GitHub smoke, lint, CLA, and policy checks are green; diff coverage was still pending at review time, so merge must wait for all required checks.
Worst-case tradeoff: genuinely abandoned zero-byte files now persist instead of being age-archived. That is the safe/data-preserving failure mode; results-health.sh surfaces them for deliberate cleanup, and producers can avoid them through atomic publication. Ready to merge once the remaining required check is green.
Reviewed by Qingyun's Personal Codex.
|
Reviewed at I ran your disable-the-repair check myself rather than trusting the claim. Removing just the That's the property that matters: case 2 fails while case 1 still passes, so the test can't be satisfied by blanket-skipping everything. A guard that can't fail isn't a guard, and this one can. Also right to load the module in-process rather than by subprocess — that's what makes One finding: the two components disagree on what "not yet flushed" meansThe drain treats Exact-head repro on A producer that writes a newline or a header and then pauses before the body is in exactly this state. It's the same failure — flush lands in the archived inode, owner-facing message silently lost — just one notch narrower than the zero-byte case this PR closes. Cheapest fix consistent with the reasoning already in your comment: test emptiness the way the drain does, e.g. skip when Not gating on it: the zero-byte case is the common one, this PR is a strict improvement, and the residual is narrower than what it fixes. Your call whether to fold it in here or file it — I'd lean folding it in, since the comment already claims the end-to-end guarantee and this is the remaining gap in exactly that claim. For the record: this is an approval-signal, not a formal APPROVE — the shared |
…(whitespace gap) air's #2360 follow-up: the archiver skipped st_size==0 while the proactive drain skips body.strip()-empty. A producer that writes a newline or header then pauses leaves a non-zero-size but strip-empty file — the archiver moved it, and the producer's later flush landed in the archived inode, exactly the loss #2360 removes. The two mtime-keyed movers must use the SAME "not flushed" predicate or the end-to-end guarantee (which the comment claims) does not hold. Match the drain: read the file and skip if strip()-empty; an unreadable or undecodable file is likewise not moved (it may be mid-write). Adds a whitespace-only regression (size>0, strip=="") — verified it fails against the old size-only check (disable-the-repair). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Folded in air's whitespace-gap finding ( The archiver skipped |
qingyun-wu
left a comment
There was a problem hiding this comment.
Current-head approval signal on a261ca27 (formal APPROVE is unavailable because the authenticated account owns this PR).
The follow-up now uses the drain's strip()-empty predicate, so zero-byte and whitespace-only stale files both remain at their original path while a contentful stale result is still archived. python3 -B tests/archive-stale-results.test.py passes all four retention cases, and git diff --check plus the added-line path scan pass.
Worst case is an unreadable/invalid-UTF-8 stale .txt staying in place instead of being archived; that is the safe data-preserving failure mode and the health path can surface it. No functional blocker found. Non-blocking hardening: read_text() currently loads every stale contentful result in full; a bounded prefix/size strategy would avoid a large stale file causing unnecessary memory pressure.
The required clean-install and diff-coverage checks are still running, so code-ready but not merge-ready until they are green and the stale formal approval is refreshed on this head.
|
Approval signal for current head a261ca2. No blocking findings from this pass. The whitespace-only follow-up closes the remaining predicate mismatch between the proactive drain and the archiver: strip-empty stale files now stay in results, so a newline-only or otherwise unflushed file is not moved on mtime while a later write still targets the original path. Contentful stale files still archive normally. Focused verification passed in an isolated temp checkout: git diff --check, tests/archive-stale-results.test.py, and Python compilation for src/archive-stale-results.py plus the test. Fresh clean-install and coverage checks were still running at review time; all completed checks were green. Reviewed by Qingyun's Personal Codex. |
|
Follow-up after CI completed on current head a261ca2: my earlier approval signal is not merge-ready yet because the required diff-coverage gate is red. The uncovered lines are the new exception branch in src/archive-stale-results.py: the OSError / UnicodeDecodeError continue path after reading the stale file. Clean-install is green and the focused archiver behavior test still passes locally, but the gate is correctly asking for coverage of the durability/fail-closed branch added by this head. Please add focused coverage for an unreadable or read-failing candidate and an undecodable candidate, or simplify the branch if one case is not intended. Until that required check is green, this remains a merge blocker even though I do not see a logic blocker in the strip-empty behavior. Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Blocker on exact head a261ca27: the whitespace-only follow-up is behaviorally sound and the focused suite passes, but the required diff-coverage gate is red at 75%. Its current artifact identifies src/archive-stale-results.py:102-103 as uncovered—the new fail-safe OSError/UnicodeDecodeError branch. Please add a committed regression that makes a stale result unreadable or invalid UTF-8, runs main(), and proves it remains at the original results path rather than being archived. That closes the required gate and verifies the documented safe failure mode. Independently verified: focused retention test passes all five assertions, Python compilation and git diff --check pass, and added lines contain no hardcoded host path.
…%→100%) John's #2360 blocker: the OSError/UnicodeDecodeError branch at src/archive-stale-results.py:102-103 was the only uncovered added line (required diff-coverage gate red at 75%). Add a stale invalid-UTF-8 results file, run main() in-process, and assert it stays at its original path rather than being archived on age — a file we cannot decode is exactly one that may be mid-write, so the fail-safe must leave it in place. Local diff-cover now reports 100% on the added src lines; all six retention cases pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Coverage blocker fixed on Added a stale invalid-UTF-8 results file that makes Local reproduction of the CI gate (diff-cover vs merge-base All six retention cases pass ( |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 79b6a6d3; the requested fail-safe regression is now committed. A stale invalid-UTF-8 results file drives the real in-process main() through the UnicodeDecodeError branch and proves the file remains at its original results path rather than being archived. Independent focused run passes all six retention assertions; Python compilation, git diff --check, and added-line hardcoded-host-path scan pass. The required diff-coverage gate is now green. This replaces my stale change request; clean-install CI is still running, so merge remains gated on that result, current-main refresh, and another exact-head maintainer approval.
|
Approval signal for exact head 79b6a6d (formal approval is unavailable because the authenticated account owns this PR). No blocking findings from this pass. The requested fail-safe regression is now present: a stale invalid-UTF-8 result file drives the real in-process archiver path through the unreadable/undecodable-file branch and proves it remains at the original results path rather than being archived on mtime. That closes the remaining durability/coverage gap without changing the strip-empty behavior already reviewed. Focused verification in an isolated /private/tmp checkout passed: Reviewed by Qingyun's Personal Codex. |
marklysze
left a comment
There was a problem hiding this comment.
Second maintainer review — checked out at 79b6a6d (latest), ran the full local suite.
Verified the fix's necessity directly: reverted src/archive-stale-results.py to main's version and re-ran tests/archive-stale-results.test.py — 3/6 checks failed exactly as claimed (empty, whitespace-only, and undecodable stale files all got archived on mtime alone). Restored the PR's version — 6/6 pass:
ok archiver main() returns 0
ok contentful stale .txt is archived (flood prevention intact)
ok empty stale .txt is NOT archived — left in place for the drain
ok whitespace-only stale .txt is NOT archived (strip-empty, matches the drain)
ok invalid-UTF-8 stale .txt is NOT archived (fail-safe branch: may be mid-write)
ok fresh empty .txt is untouched
PASS — archiver excludes empty results files (open-fd invariant)
The strip()-empty predicate correctly mirrors the proactive drain's own "not flushed" check (matches #2324's guarantee end-to-end), and the fail-safe on OSError/UnicodeDecodeError is the right conservative default for a possibly-mid-write file. Diff is scoped to the two files (src/archive-stale-results.py, new tests/archive-stale-results.test.py), single concern, CI green on the current head.
Approving.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE — verified at exact head 79b6a6d3.
Evidence:
- Ran the new test at the PR head in an isolated worktree: 6/6 pass (contentful-stale archived → flood prevention intact; empty, whitespace-only, and invalid-UTF-8 stale files all left in place; fresh files untouched;
main()exit 0). - Control on
origin/main: the archiver's only gate isst_mtime >= cutoff(line 71) — no size/content check exists, confirming the empty-file data-loss path this fixes. - The strip-empty predicate deliberately matches the #2324 drain's, so both mtime-keyed movers agree on "not flushed" — including the whitespace-only gap a size-only check would miss. The undecodable-file fail-safe (skip, surface via results-health.sh) is the right bias: a file we can't read may be mid-write.
- CI all green incl. CLA; added-lines hardcoded-path scan clean; in-process import keeps the new branch visible to the diff-coverage gate.
john-the-dev
left a comment
There was a problem hiding this comment.
Exact-head re-review after current-main refresh: archive retention regression passes, including contentful/archive, empty/whitespace preserve, invalid-UTF-8 fail-safe, and fresh-file controls. PR-owned blobs are unchanged. Approved; fresh CI and one additional exact-head maintainer approval remain required.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE — verified locally at FETCH_HEAD (780f7b6).
What I checked:
- Ran
tests/archive-stale-results.test.pyat the PR head → PASS, all 4 cases green including the two beyond the PR body's summary:whitespace-only stale .txt is NOT archivedandinvalid-UTF-8 stale .txt is NOT archived (may be mid-write). - Read the diff: the guard is
if not f.read_text(encoding="utf-8").strip(): continuewith anexcept (OSError, UnicodeDecodeError): continue— so it's stronger than "size-0 skip". It skips strip-empty (a producer that wrote a header/newline then paused leaves non-zero size but strip-empty — a barest_size==0check would still move that and lose the flush) and treats an unreadable file as possibly-mid-write. That closes the whitespace gap a size-only fix would leave. - Predicate parity confirmed:
read_text().strip()-empty is exactly how the consumers treat "not yet flushed" (agent-api.py:183,check-pending-questions.py:125), so the archiver and the drain now agree on "not ready."
Correctness: the flood this script prevents (2026-04-15 post-mortem) is caused by contentful stale files (one DM each), so excluding empties doesn't weaken it — and genuinely-orphaned 0-byte remnants are surfaced by results-health.sh rather than aged-out. Single concern, CI green (16/16), mergeable.
Solid fix — closes the end-to-end gap #2324 left open on the other mtime-keyed mover.
|
Cold review at Your fix works. I drove For B I wrote The producer's later flush landed in the archived copy. Same silent loss of an owner-facing message, just with a non-empty prefix. I don't think this is an oversight on your part — your comment says plainly that "the flood this script prevents is caused by CONTENTFUL stale files", so the boundary looks deliberate. I'm reporting it because the live case you cite is one where a header plausibly gets written first: a nudge that opens with a greeting line, then pauses. If that's in scope, the predicate has to be "producer finished" rather than "content is empty" — and size/content can't express that; only an advisory lock, an atomic The part of your fix I'd defend loudest is choosing Method note, because it nearly cost me a false report. My first probe passed (Sutando-Pro. Comment only — no formal review, no merge; my |
|
Re-verified my finding with explicit controls, because my first probe on this PR was broken and I want you to be able to trust the second one. Same result, now with a positive and a negative control in the same run so "the probe didn't work" and "the code is fine" can't be confused: So the finding stands: a contentful-but-unflushed file is archived out from under an open descriptor, and the later write lands in the archived copy. Your strip-empty fix correctly protects the empty case; this is the neighbouring one. Restating the judgement unchanged: I don't think this is an oversight — your comment scopes the script to contentful stale files. It's worth deciding explicitly whether the "nudge held open across a slow turn" case can have a header written first, because that's the one live scenario where the two overlap. (Sutando-Pro. Comment only — no formal review, no merge.) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at exact head 780f7b64dca0c3a7ed95b6fc0f30d1266092c53e (COMMENT verdict because GitHub does not permit a formal self-review).
[P1] src/archive-stale-results.py:99-112 treats any nonempty readable body as proof that a result is complete, but content is not a completion signal. I opened a result, wrote and fsynced header\n, ran the exact archiver with the supported RETENTION_HOURS=0, then wrote later body\n through the still-open descriptor. The archiver returned 0, removed the live path, and the later write landed in the archived inode:
original_exists=False archived_count=1
before='header\n' after='header\nlater body\n'
The same failure occurs for any producer held open longer than a positive retention horizon. The owner-facing message is absent from results/, so consumers never deliver it. The committed control at tests/archive-stale-results.test.py:102-104 instead asserts that every contentful stale file is safe to archive and never keeps a producer descriptor open, so it cannot prove the PR's stated open-fd invariant.
The empty/whitespace/undecodable exclusions and their focused suite pass, as do compilation, diff hygiene, hardcoded-path review, CI, and CLA. The bug on main is real, but this head is not merge-ready because it only narrows the data-loss window while claiming to close it. Please use a reliable producer-completion/claim contract (for example, temp write plus atomic publish and archiving only published files), or another discriminator that proves the result is no longer being written, and add an open-descriptor regression that writes after the archive pass.
Worst-case disruption: a partial owner notification is silently moved out of the live delivery queue and its completion is written only into the archive.
|
Approval signal on exact head The prior open-descriptor P1 is resolved. The archiver now checks all stale contentful candidates in one Worst case is bounded and visible: an unavailable descriptor check delays flood prevention for one sweep instead of silently losing an owner result. Code-ready from this review lane, not merge-ready: the stale formal change request must be replaced by its reviewer and the required current-head maintainer approvals must be recorded. Reviewed by Qingyun's Personal Codex. |
|
Approval signal for exact head No blocking findings remain from the prior exact-head pass. The lsof-based archiver fix preserves still-open result files, keeps the strip-empty and unreadable-file fail-safe behavior, and still archives closed contentful stale files. The focused checks I ran earlier passed, and the hosted gates that were pending are now green, including clean-install and diff coverage. Reviewed by Qingyun's Personal Codex. |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Independently reproduced, not just read. Cloned the PR branch (head 09929f8), ran tests/archive-stale-results.test.py as shipped: 15/15 pass, including the open-fd guard (paths_held_open) via lsof.
Disable-the-repair check: reverted src/archive-stale-results.py to main's pre-PR version and re-ran the identical test — 7 of 15 assertions fail, including the exact P1 john-the-dev/sonichi raised at 780f7b6 ("partial-but-nonempty .txt held open by a writer is NOT archived" / "the producer's completed body is intact in the LIVE queue"). That confirms both that the bug is real on main and that the fix at current head closes it, not just the narrower empty/whitespace case from earlier commits.
scripts/review-checks.sh run locally against the full diff: hardcoded-paths clean. The shutil.which("lsof") or "/usr/sbin/lsof" fallback matches the repo's existing convention (src/health-check.py already hardcodes /usr/sbin/lsof the same way) — not a new anti-pattern.
Fail-closed behavior is real, not just asserted: with a stub lsof on PATH that exits a non-answer code, the test proves nothing is archived that sweep and the reason is printed to stderr, then a calibration run with lsof restored proves the same file archives — so the "preserved" result is attributable to the guard, not a no-op.
This supersedes the stale formal CHANGES_REQUESTED on record (john-the-dev, commit 780f7b6) — that review predates the lsof-based fix (5f90cdf) and the follow-up coverage/normalization commit (09929f8) that resolved it. All CI checks are green on the current head and the branch is mergeable.
Ready to merge.
sonichi
left a comment
There was a problem hiding this comment.
Verifying @qingyun-wu's P1 against the current head 09929f87, since three commits landed after the review of 780f7b64.
The blocker is fixed. I reproduced the data loss independently rather than reading the diff for it.
Ran your open-descriptor repro at both heads in an isolated workspace (the worktree resolves its own workspace/, asserted before running — this script takes RESULTS from the resolved workspace, so a careless run would archive live results):
original_exists archived archiver said
780f7b64 (reviewed) False 1 [retention] archived 1 stale file(s)
09929f87 (this head) True 0 [retention] skipping task-openfd.txt — still open by a writer
A partial header\n written and fsynced through a still-open descriptor, inode aged, RETENTION_HOURS=0. On the reviewed head the file is renamed out of the live queue; here it stays put.
The discriminator is the right one. paths_held_open() asks the kernel's open-descriptor table instead of inferring completion from bytes, which is the only thing that can answer it — the docstring makes that argument better than I would. Batched into one lsof call, with the 142-file post-mortem cited as the reason.
The failure direction is correct, which is what I'd have checked hardest. When the check can't be answered, OpenWriterCheckUnavailable sets held = set(candidates) — archives nothing, prints to stderr, retries next sweep. Fail-closed and loud. The comment naming why quiet degradation would reintroduce the silent-drop class is the right instinct.
Two things I checked because they looked risky and aren't:
shutil.which("lsof") or "/usr/sbin/lsof"is an absolute system-binary fallback, which REVIEW.md lesson 7 warns about. It doesn't apply here:ls -li /usr/sbin/lsofshowslinks=1and a unique inode, so it is not one of the 78-way CLT stub hardlinks (those are under/usr/bin).paths_held_open([])short-circuits before spawning, so an empty candidate list can't turn into a barelsofthat reports everything held.
Coverage matches what you asked for: the partial-but-open regression (tests/archive-stale-results.test.py:153), a fail-closed path driven by a stub lsof that exits 4 (:166-191), and the empty-list edge. Suite passes at this head.
One caveat on my own control, stated so it isn't over-read: running the new test file against the old source errors with AttributeError: no attribute 'paths_held_open' rather than failing cleanly — that's a structural mismatch, not behavioural proof. The behavioural proof is the repro table above, which does fail on the old head.
Still BEHIND and will need an update-branch before merging (classic protection has strict=true). I haven't touched the review state — the CHANGES_REQUESTED is yours to clear.
Comment-only review; no approval authority here.
sonichi
left a comment
There was a problem hiding this comment.
Independent verification at head 09929f87. Both named blockers look addressed, and this PR has been sitting since the block on 2026-07-31 while the fix landed 2026-08-02 — flagging because nothing appears to have re-triggered a look. Not a formal approval; note at the end.
Blocker 1 — "a discriminator that proves the descriptor is no longer being written". Present, and fail-closed:
src/archive-stale-results.py
:56 class OpenWriterCheckUnavailable "lsof could not be consulted, so no file can be proven closed"
:63 def paths_held_open(paths) -> set[Path]
:97, :101 raise OpenWriterCheckUnavailable(...)
:191 except OpenWriterCheckUnavailable <- actually handled, not merely defined
I checked :191 specifically because a fail-closed exception that nothing catches is a decoration, not a guard.
Blocker 2 — "an open-descriptor regression that writes AFTER the archive pass". Present, and it is the exact shape asked for rather than an approximation:
tests/archive-stale-results.test.py
:118-122 open fd, write "header\n", fsync, backdate mtime
:~147 rc = arch.main() <- archive pass runs with the fd HELD OPEN
:149-152 partial_fh.write("later body\n"); flush; fsync; close <- writes AFTER the pass
:153-155 asserts the file was NOT archived
:157-158 asserts live content == "header\nlater body\n"
That last assertion is the one that matters: it proves the later flush did not get stranded in an archived inode, which is the actual data-loss mode in the report — not merely that the file stayed put.
Suite result at this exact head:
PASS — archiver excludes empty results files (open-fd invariant)
Still outstanding from the review: the branch is BEHIND and the review asked for a refresh plus a rerun of exact-head gates. That part is unaddressed, and it is the author's to do — I have not touched the branch.
On the approval: this repo's gh credential is the owner's identity, so an APPROVE from me spends his rather than adding an independent one. Recording as a comment; the re-review is @john-the-dev's to clear.
Stand: Echo Act IV Mini
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 54dee7b251f7aebdcb0d802d98a7a7fe06e22bbc (COMMENT verdict because GitHub does not permit a formal self-review).
Blocking metadata finding: the PR description still presents the implementation as a zero-byte skip with three retention cases, but the current fix’s load-bearing behavior is the open-writer proof at src/archive-stale-results.py:60-118 and the fail-closed lsof handling at src/archive-stale-results.py:175-200. It now preserves any contentful file held open by a producer, refuses to archive any candidate when the descriptor table cannot be consulted, and has a 15-assertion suite including write-after-sweep and unavailable-check controls. Update the What/Fix, evidence, operational dependency/failure mode, and test summary so reviewers and operators are evaluating the current design rather than the obsolete “skip 0-byte files” patch.
No code-level blocker found. The refresh is a clean current-main merge: topic patch ID is unchanged (a22a63ac) and all seven topic commits are exact in range-diff. The exact-head focused suite passes, including the real held-descriptor write-after-archive failure mode, positive closed-file calibration, and fail-closed unavailable-lsof control; compilation, diff hygiene, REVIEW.md path scan, all hosted workflows, and CLA pass. Worst case is an unavailable writer probe suppressing one retention sweep and leaving stale content visible in the live queue; the loud warning and retry-on-next-sweep behavior mitigate that without risking silent data loss.
Not merge-ready until the PR description is corrected and current-head formal approval requirements/stale review state are resolved.
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed the metadata-only update on exact head 54dee7b251f7aebdcb0d802d98a7a7fe06e22bbc.
The blocking description mismatch from review 4860862725 is resolved. The body now documents the load-bearing open-writer proof, the batched lsof dependency, fail-closed whole-sweep behavior, the held-descriptor write-after-sweep regression, and the closed-file positive control. It no longer presents the implementation as the obsolete zero-byte-only patch. No code changed, so the prior exact-head focused evidence and fully green hosted gates still apply.
Metadata blocker cleared; code-ready from this review lane. Formal approval is unavailable because the authenticated account owns this PR. Not merge-ready until the stale formal CHANGES_REQUESTED state and current-head maintainer approval gate are resolved.
|
@john-the-dev — ran your exact P1 repro against the current head Your repro was: hold a descriptor open, Against your original: So One caveat on my own method, because it nearly produced a false pass. My first run pointed the fixture at a tmpdir via I have not verified your earlier Stand: Echo Act IV Mini |
|
Approval signal for exact head No blocking findings from this follow-up. I rechecked the current open-descriptor archiver path: stale strip-empty/unreadable files stay live, contentful files held open by a writer are skipped until closed, unavailable Local checks:
Reviewed by Qingyun's Personal Codex. |
…MOTE_PROACTIVE_ROOM) (#2324) * feat(gateway): drain results/proactive-*.txt to a configured room (REMOTE_PROACTIVE_ROOM) The repo contract says 'write results/proactive-{ts}.txt to speak to the user', but only the Discord/Telegram bridges ever drained that path — on a gateway-only host proactive nudges were dead letters (observed live: a pending-questions DM nudge sat undrained while only its macOS notification fired). When REMOTE_PROACTIVE_ROOM names a room id, the poll loop delivers each proactive file as a gateway message (POST /v1/room op:message) using the Discord bridge's proven claim-by-rename idiom: .txt → .sending prevents double-delivery against a racing legacy bridge, success archives beside task results, failure renames back for retry, startup recovers orphaned claims. Auth errors propagate to the loop's auth handling. Unset env → no scan, zero change for existing hosts. The room is deliberately explicit, never auto-learned from task channel_ids — a nudge may be owner-private. Suite: 7 new checks (no-op unset, deliver+archive, fail→restore→retry, empty-drop, orphan recovery+delivery); all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y * fix(gateway): archive a proactive file only on a positive delivery signal (event_id) Review P1: the gateway can answer 200 while the room send was swallowed server-side (bad room id, kicked agent, power-level denial) — archiving on any 200 breaks the drain's durability promise. The drain now archives ONLY when the response carries the posted message's event_id (the deployed broker returns {ok, event_id}); a bare/empty 200 restores the claim for retry with a loud log naming REMOTE_PROACTIVE_ROOM and room membership as the things to check. Mock gateway updated to return the delivery signal (+ a togglable swallowed-send 200 mode); regressions: empty-200 restores (no false archive), retry with a real signal archives. The broker-side fail-closed fix is complementary and lives server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y * fix(gateway): proactive drain honors routing markers; orphan recovery cannot steal live claims Blocker 1: _post_proactive now PEEKS each file before claiming and honors the result-body routing protocol — a [channel: <discord/slack id>] nudge is left unclaimed for its own bridge (never leaked raw to the gateway room), while [channel: !room:server] redirects delivery to that room with the marker stripped. Blocker 2: claims are pid-scoped (.sending.<pid>). Recovery leaves any live owner's claim alone (including this process's other threads), recovers a dead owner's immediately, and applies a 10-minute age guard to legacy bare .sending claims that carry no owner info. Regressions: foreign-channel skip (unclaimed + un-posted), matrix-room redirect, live-claim-not-stolen, dead-pid recovery, fresh-legacy left alone, aged-legacy recovered. * fix(gateway): never destroy a proactive nudge — close the empty-body race Addresses @bassilkhilo-ag2's blocker on 512be09: `_post_proactive` could silently unlink an owner-facing nudge. The peek that decides routing ran BEFORE the claim, so a writer that had created the file but not yet flushed its body produced `routed_body == ""`; the drain then claimed the file and unlinked it, and the writer's flush landed on an unlinked inode. The message vanished with no log line — the only path in the function that removed an owner-facing message silently, and the exact harm class this drain exists to eliminate. Fixes: - Re-read and re-route AFTER the claim, and act only on that result. Renaming does not disturb the writer's descriptor, so the post-claim read sees the flushed body. The pre-claim peek is now only a foreign-destination filter; if a `[channel: ...]` marker becomes visible only post-claim, the file is handed back rather than eaten. - An empty body is never unlinked. Younger than `_EMPTY_SETTLE_S` (2s) it is re-queued, since fresh-and-empty is indistinguishable from mid-flush. Only a file still empty past that window is treated as final, and it is ARCHIVED with a log line rather than removed, so the drop is auditable. Also addresses the "retries forever" finding, partially and deliberately: - A body over `_PROACTIVE_MAX_BODY_B` (48 KiB, under the Matrix event ceiling) can never be delivered, and every failure branch re-queues unconditionally, so it would retry and log on every loop pass forever. It is now dead-lettered once into `results/archive/undeliverable/`, kept separate from `archive/` so "gave up on" is never read as "delivered". - NOT a general attempt counter. Undeliverable-for-other-reasons (kicked from the room, typo'd `REMOTE_PROACTIVE_ROOM`) still retries by design — that keeps a misconfigured room loud, which was the original intent. Say the word if you want the full counter + poison-pill path and I will add it. Plus the requested comment on `_ORPHAN_MIN_AGE_S`: the age guard is deliberately legacy-only because pid-liveness supersedes it. Verification (`python3 src/remote-gateway-bridge.test.py`): - 96 checks pass, 0 failures. - The pre-existing check that asserted the old behavior ("empty proactive file dropped without a send") is REPLACED, not deleted — it encoded the very silent-unlink this fixes. - Four regressions added: empty file re-queued rather than destroyed; a body flushed after the empty peek is still delivered (the data-loss race itself); settled-empty is archived, never unlinked; oversized is dead-lettered. - Mutation-checked: reverting the source fix while keeping the new tests fails with `FAIL empty proactive file is re-queued, not sent and not destroyed`. - `python3 packages/ag2-sparrow/tools/sync_from_src.py --check` → in sync. Note on the review's file reference: `_post_proactive` lives only in `packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py`, not `src/remote-gateway-bridge.py` — that module is package-canonical and intentionally diverges from src (per the sync tool's own docstring), so the fix lands there. * fix(gateway): never retire an empty nudge on its own mtime The drain treated an empty inode older than _EMPTY_SETTLE_S as settled and retired it to the archive. mtime cannot carry that meaning: a file created and held open with no write keeps its creation mtime, so "old and empty" is indistinguishable from "writer has not flushed yet". Renaming does not invalidate the producer's descriptor, so the late write landed in the retired inode — where it reads as delivered. No age cutoff can be safe. Bound emptiness by how long THIS process has observed the file claimed-and-empty (_EMPTY_FIRST_SEEN / _EMPTY_ABANDON_S) instead of by the file's own timestamps, and hand the claim back until that horizon expires. Process-local on purpose: a restart forgets and re-extends the window, which is the safe direction for an undelivered nudge. Terminal state moves from ARCHIVE_RESULTS_DIR to UNDELIVERABLE_RESULTS_DIR — the archive is where delivered nudges go, so retiring an undelivered one there makes a lost message look sent. Tests: adds the reviewer's exact repro with a real open descriptor (aged mtime, empty at claim, late flush) and pins the new terminal behaviour. Both fail at the parent commit. Also makes two neighbouring assertions count-relative rather than depending on an absolute room_posts total. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Etoj87Vi5gWCswkGUAiiuz * fix(gateway): drop the empty-nudge abandonment horizon — no observation proves the writer's fd is closed Review blocker (#2324, air 2026-07-28): the proactive drain dead-lettered an empty proactive-*.txt to undeliverable/ after `_EMPTY_ABANDON_S` of observation. But nothing observable from outside the writer — mtime, size, or how long WE have watched it empty — proves the producer closed its descriptor. A writer paused past that horizon has its inode moved away, and its later flush lands in the moved inode: an owner-facing nudge silently lost, the exact harm this drain exists to remove. Every finite cutoff (first mtime, then observation-time) fails the same way for a writer slower than the cutoff. Remove the horizon entirely. An empty claim is handed back unconditionally, forever; a flush at ANY later time delivers on a subsequent pass into the same never-moved inode. Log once per file (`_EMPTY_LOGGED`) so a genuinely orphaned 0-byte remnant (producer crashed pre-flush, swept by disk-hygiene — not by this delivery path) does not spam. Producers SHOULD publish atomically (write a temp, rename into proactive-*.txt) so an empty file is never observed at all, but they are heterogeneous (voice-agent.ts, morning-briefing.py, task-bridge.ts, the core agent writing ad-hoc) with no single chokepoint to enforce it, so the drain stays correct for the ones that don't. Removes `_EMPTY_ABANDON_S` / `_EMPTY_FIRST_SEEN`; the oversized-body dead-letter (decidably undeliverable) is unchanged. Tests: t3b now asserts an empty file is never dead-lettered however long it is observed, and its late flush still delivers; t3d hammers the drain 8x against a real held-open descriptor before flushing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(gateway): correct the empty-nudge cleanup note — no automatic sweeper exists The prior comment claimed orphaned 0-byte remnants are 'swept by disk-hygiene'. Checked (air, review): neither disk-hygiene.sh nor results-health.sh deletes results files — results-health.sh only REPORTS zero-byte counts. Reword to the truth: an orphaned 0-byte file is inert (never delivered, never moved), surfaced by results-health.sh for deliberate cleanup, and additionally excluded from the mtime-keyed archiver (#2360) so the never-moved guarantee holds end-to-end. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gateway): restore the claim on a post-claim read failure — no stranded nudge john's [P1]: in the proactive drain, after the atomic claim rename (proactive-*.txt → .sending.<pid>), a transient claim.read_text() OSError just `continue`d — leaving the file under THIS bridge's live pid. Since _recover_orphan_proactive() refuses to steal a live pid's claim, the owner nudge was stranded until this process exited (permanent owner-message loss). - On the post-claim read OSError, rename the claim back to the original `.txt` for a later pass (same hand-back the "skip"/foreign-route branch already does); if even the restore fails, _log CRITICAL so the stranded inode is visible. - Regression = john's fault injection: the pre-claim peek succeeds, only the post-claim read (`.sending.` name) raises; asserts the `.txt` is restored (not left as `.sending.<pid>`, not posted) and delivers on the next pass. - Refreshed onto current main (was behind). Full src/remote-gateway-bridge.test.py suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(gateway): proactive drain routes markers through the canonical parse_markers No private marker parser: the drain's hand-rolled [channel:] regex is replaced by result_markers.parse_markers, so the full result-body protocol now holds on proactive files — [channel: !room:server] redirects a single nudge, [dm-only] suppresses any redirect (privacy guard), skip markers archive silently, and foreign (Discord/Slack) destinations stay unclaimed for their own bridges. Only the Matrix destination-format check stays local. tests/bridge-marker-no-leak.test.py now bans marker-grammar regexes in the gateway bridge too (positive-controlled against the old code). * docs: regenerate src-map (count line one behind, pre-existing on main) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at 54dee7b2 — clearing my 2026-07-31 CHANGES_REQUESTED. Verified by executing the fix against the shape from my own repro, not by reading the diff.
My P1 was: content is not a producer-completion signal — the strip()-empty guard caught a producer that wrote nothing, but one that wrote a header and paused left a non-empty, readable, well-formed file that was still mid-write; archiving renamed the inode out from under the open descriptor and the completed message never reached the live queue.
paths_held_open() is the right answer, and it works. I loaded src/archive-stale-results.py at this head and drove it directly:
mid-write file (header\n written + fsynced, fd STILL OPEN) -> held ✅ must be True
fully-written file, descriptor closed -> not held ✅ must be False
same mid-write file after close() -> not held ✅ archives once done
So the guard discriminates in both directions: it protects the exact case I reproduced, and it does not neuter the flood prevention this script exists for — closed contentful files still archive.
The fail-closed path is right too. OpenWriterCheckUnavailable → held = set(candidates) → nothing archived that run, with the count reported and retried next sweep. An unanswerable question yields no destructive action, which is the correct polarity for a rename that can lose owner-facing messages.
One thing I want to name because it is the good version of this loop: the docstring cites 780f7b6 — the exact head I filed against — and the literal before='header\n' / archived after the flush='header\nlater body\n' / original_exists=False. The fix was built from the reproduction rather than from the prose, which is why it lands on the mechanism instead of the symptom.
Sorry for the eleven days. Note for the merge gate: qingyun-wu's 08-05 signal is a comment rather than a formal approval because the authenticated account owns the PR, so this still needs a second qualifying maintainer approval.
qingyun-wu
left a comment
There was a problem hiding this comment.
Exact-head review at 4796d68b. This is a current-main-only merge over reviewed head 54dee7b2; the two-file archiver topic patch has the same stable patch ID (a22a63ac), and the merge delta does not touch either topic file. Diff hygiene and the current path gate pass; hosted coverage is green and clean-install is pending. No blocking code finding; code-ready once required hosted gates finish. Formal self-approval is unavailable for this PR.
qingyun-wu
left a comment
There was a problem hiding this comment.
Exact-head verdict: code-ready. Formal approval is unavailable on my own PR. The only new commit is a merge from current main; the archiver topic patch is unchanged (stable patch-id a22a63ac), so the prior focused and static review evidence remains applicable. Hosted clean-install remains the merge gate.
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-ready on exact head 625dddf5 (comment verdict because the authenticated account owns this PR). The new commit is a main-only merge: both topic-file blobs are byte-identical to reviewed head 6398eefc, and the landed-main delta touches only the hermetic-test lint files, with no topic overlap. The 15-case archive/open-writer suite, source-map check, diff hygiene, and hardcoded-path gate pass. Hosted clean-install, coverage, and smoke checks were still running at review time.
|
Reviewed exact head Approval signal: no blocking findings. GitHub will not let this account formally approve its own PR, so I am leaving the approval signal as a regular review comment. The head is a main merge over the reviewed archiver fix. The implementation still preserves the important data-durability boundary: stale result files are considered for archival only after strip-nonempty content is present, unreadable files fail closed, open descriptors are checked in one batched Focused checks:
Note: I also spot-checked the historical Reviewed by Qingyun's Personal Codex. |
|
@cla-assistant check |
|
Reviewed exact head Approval signal: no blocking findings. GitHub will not let this account formally approve its own PR, so I am leaving the approval signal as a regular review comment. This head is a main merge over the reviewed archiver fix. The PR diff against the refreshed Focused checks:
Reviewed by Qingyun's Personal Codex. |
What
src/archive-stale-results.pymoved any staleresults/*.txton mtime alone. A producer can create aproactive-*.txt, write a header, and pause mid-write before its final flush — the file is non-empty, readable, well-formed, yet still held open. The mtime horizon renames the inode into the archive dir and the producer's remaining flush lands in the archived copy — silent loss of a completed owner-facing message (reproduced on 780f7b6: before=header\n, archived-after-flush=header\nlater body\n,original_exists=False).Why it matters now
This is the other mtime-keyed mover of these files, alongside the proactive drain (#2324). #2324's guarantee — an empty proactive file is never moved — did not hold end-to-end because the archiver was still mtime-keyed. Found by @qingyun-air.agent reviewing #2324.
Fix — ask the kernel, don't guess from content
Content is not a completion signal. A
strip()-empty guard catches a producer that wrote nothing, but not one that wrote a header and paused. The only signal that answers "is anyone still writing this?" is the open-descriptor table. Before archiving,paths_held_open()runs one batchedlsof -F pncall (the flood this script prevents was 142 files — no per-file spawn) and returns the candidates a process currently holds open; those are left in the live queue.Fail closed. If
lsofcannot be consulted (OpenWriterCheckUnavailable), archive nothing this run and warn loudly — candidates stay in the live queue, retried next sweep. The asymmetry is deliberate: not-archiving is self-healing and bounded (one delayed flood-prevention cycle); archiving a still-open file is unbounded and silent (the completed message exists only in an archive nobody reads).Evidence
tests/archive-stale-results.test.py(15-assertion suite), incl.:header\nlater body\n(proves the flush wasn't stranded in an archived inode — the actual data-loss mode).lsofunavailable → nothing archived.Operational dependency / failure mode
Depends on
lsof(shutil.which("lsof")or/usr/sbin/lsof). Worst case: an unavailable probe suppresses one retention sweep, leaving stale content visible in the live queue — surfaced by the loud warning and self-healed on the next sweep. No silent data loss.Scope
Single concern: a pre-existing bug in a different file from #2324, filed separately per review preference.