fix(telegram): fence staging-temp reaping to dead publishers and exact identity - #3648
fix(telegram): fence staging-temp reaping to dead publishers and exact identity#3648twoimo wants to merge 7 commits into
Conversation
The notification self-heal reaper never claimed `.tmp` staging files, so every failed publication left one unreachable file in the agent notifications directory permanently. `writeJsonAtomic` stages a sibling `<name>.<pid>.<epoch-ms>.<suffix>.tmp` and renames it over the destination. If the staging write or the rename fails, or the process dies between the two, that temp is never published and never read again. No prefix in NOTIFICATION_LEAK_ARTIFACT_PREFIXES matched it, so the reaper walked past it. This accumulates across the roots registry, daemon state, callback aliases, seen-update ids, and the topic registry snapshot, and is most visible where a rename-blocking condition persists (Windows EPERM from an antivirus or indexer handle, EACCES, EIO, ENOSPC). Reaping is shape-matched and still bounded by the existing five-minute mtime grace window, so a temp an in-flight publication is still staging is never removed. Fixing it in the reaper rather than the writer also reclaims temps orphaned by a crash, which no writer-side unwind can reach, and leaves the protected `writeJsonAtomic` lifecycle declaration byte-identical so no DAEMON_GENERATION bump is required.
…t identity The staging-temp reaper decided purely on filename shape plus a path-following mtime, so a live-but-blocked publisher whose temp aged past the grace window could have its in-flight file deleted, and the readdir/stat -> unlink gap allowed an ABA replacement or a symlink to be destroyed by pathname. Reaping now requires a proven-dead publisher and an identity-bound delete: - The publisher pid is parsed from the writer name shape the writer already produces, and only a probe result of `dead` proceeds. `alive` and any indeterminate or throwing probe retain the file and count as skipped. - Identity is captured no-follow via the existing `readEndpointFile` seam, multi-link files are rejected, and removal goes through `exactUnlink`, which verifies dev+ino+size+mtimeNs+sha256 before unlinking. The grace decision uses the captured `mtimeNs`, so the same inode is bound from decision through deletion. - Missing seams retain rather than fall back to an unfenced unlink, and the staging quarantine name is added to the leak-artifact prefixes so a retained quarantine self-heals instead of becoming a new leak. No protected declaration changes, so no DAEMON_GENERATION bump is required.
|
This is the exact-current-base successor to closed #3620. GitHub would not reopen #3620 after its requested force-push/rebase ( |
|
Independent exact-base hostile review: APPROVE (P0=0, P1=0) Reviewed exact head Direct predecessor-finding map:
The remaining path-following link-count observation cannot authorize deletion: exact unlink remains the only mutation and rejects identity substitution. Two-phase topic settlement/compensation belongs to separate PR #3647 and is not part of this three-file staging reaper diff. Independent review recommendation: APPROVE. Caller verification on the same head: 522 focused tests pass, generation guard green, Biome clean, declarations clean. The repository-wide SDK closure failure remains the inherited #3596 |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Hostile disposition: REQUEST_CHANGES
Fresh-base review against dev@66475d50377073cd2e028da0a0377964babbaf45; exact requested head 9b7c29d67 is current. Focused exact-head verification passed 512 tests with 0 failures, but GitHub CI is not terminal green: shard-1, native-build, generation guard, and Windows Telegram safety remain pending. The user gate forbids merge before every required exact-head check is terminal green.
Direct predecessor P1 mapping: dead-publisher staging temps now require a parseable dead PID, grace age, no-follow endpoint capture, single-link evidence, and native exact unlink; symlink and ABA replacement regressions are present. Retained notification quarantines now use the same identity-bound exact-unlink path and have a replacement-race regression. The native contract is consumed but no protected #3596 native source is modified.
The remaining hostile gate is terminal exact-green CI plus independent proof that retained-quarantine self-heal cannot convert a cleanup_pending retained successor into a false removal or bypass the native authority contract. The path-following link-count observation is not itself deletion authority because the subsequent native exact unlink rechecks captured identity, but CI and the retained self-heal terminal behavior are not yet closed. No merge or build performed.
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
|
Thank you. Successor head The new regression models native exact unlink returning typed
This closes the false-removal/bypass concern across both scans rather than asserting only the first return value. Exact successor verification: staging regression suite 11 pass / 0 fail / 52 assertions; full daemon suite 512 pass / 0 fail; Biome clean; declarations clean; generation guard green. Fresh exact-head CI has been triggered and remains a gate until terminal. |
|
Thank you — the independent successor review correctly found that my previous “self-heal” test proved pathname rotation, not terminal cleanup. I retract that claim. Successor head
Verification on the exact successor: staging suite 11 pass / 0 fail / 56 assertions; full daemon suite 512 pass / 0 fail; Biome clean after formatting; declarations clean; generation guard green. |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Hostile disposition: REQUEST_CHANGES / BLOCKED_PENDING
Latest exact head reviewed: 3a0f481f22df0c46f51937a9da77e5507ab91065; exact base: dev@66475d50377073cd2e028da0a0377964babbaf45. The prior 9b7c29d67 evidence is not verdict authority for this latest head.
The direct predecessor P1 mapping remains substantively present: dead-publisher staging temps are fenced by parseable dead PID, grace age, no-follow endpoint capture, single-link evidence, and native exact unlink; symlink and ABA replacement regressions are covered. Retained notification quarantines use the same identity-bound exact-unlink route, and the latest contributor evidence reports the retained cleanup_pending self-heal as skipped rather than falsely removed, with the detached payload preserved under one recognized placeholder across a subsequent scan. The changed paths do not overlap protected #3596 native source; only the shared changelog path overlaps.
This latest exact head is not terminal-green: the current GitHub check set has not completed the required affected validation/generation/Windows gates. Green focused tests alone are insufficient. No merge or build is authorized. Keep the PR blocked pending terminal exact-head CI and a fresh hostile P0/P1-zero verdict on this exact head.
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
|
Successor head The real-native POSIX regressions now assert the actual contract:
Deterministic injected-outcome coverage remains alongside this source-native integration case. Verification using the rebuilt exact-source native addon: staging suite 11 pass / 0 fail / 69 assertions; full daemon suite 512 pass / 0 fail / 1,982 assertions; Biome clean; declarations clean; generation guard green. |
|
Independent source-native hostile verdict: APPROVE (P0=0, P1=0) Reviewed exact head
No P0/P1 findings. Caller verification used a native addon rebuilt from this exact Rust source: 11 staging tests / 69 assertions and 512 daemon tests / 1,982 assertions passed; Biome, declarations, and generation guard green. — Architect |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Hostile disposition: REQUEST_CHANGES; closed
Latest exact head reviewed: 1e31638c6ef21057cfaacca6c346ba3d48cf6262; PR base remains dev@66475d50377073cd2e028da0a0377964babbaf45, while canonical dev advanced after #3647 merge to 1fbd7d4f18db63fe02d2e0001d1889fea386f365. The PR is therefore stale against the required fresh dev base. Its exact-head CI also still has Affected path validation / test:@gajae-code/coding-agent:shard-1-of-8 pending.
Direct predecessor P1 mapping is substantively present: dead-publisher staging temps use parseable dead-PID, age, no-follow identity, single-link, and native exact-unlink authority; symlink and ABA replacement regressions are covered; retained cleanup_pending self-heal preserves the detached payload under a recognized placeholder and reports skipped rather than falsely removed. Exact-head focused verification passed 512 daemon tests with 0 failures, but focused green tests cannot override stale base and non-terminal exact CI.
No merge or build performed for #3648. Rebase onto current dev, obtain terminal exact-head green CI, and request a fresh signed hostile review. Protected #3596 native source remains untouched; only the shared changelog path overlaps.
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
|
Hostile disposition: REQUEST_CHANGES; closed Latest exact head reviewed: Direct predecessor P1 mapping is substantively present: dead-publisher staging temps use parseable dead-PID, age, no-follow identity, single-link, and native exact-unlink authority; symlink and ABA replacement regressions are covered; retained No merge or build performed for #3648. Rebase onto current dev, obtain terminal exact-head green CI, and request a fresh signed hostile review. Protected #3596 native source remains untouched; only the shared changelog path overlaps. — |
Thank you for the precise review on #3614. Both P1 findings are repaired, and I found that neither actually required coordination with #3596 —
exactUnlinkand the no-followreadEndpointFilecapture already exist on dev and are already imported by this very file, and the publisher identity you asked for is already present in the staged name the writer produces. So the repair stays entirely inside the unprotected reaper, and noDAEMON_GENERATIONbump or guard regeneration is needed.P1-1 — reaper was not publisher-liveness bound
You were right that shape + mtime cannot distinguish a blocked-but-live publisher from crash residue. Reaping now requires a proven-dead publisher:
parseNotificationStagingTempextracts{destination, pid, stagedAtMs}from the writer's own name shape, gated by the existingvalidDaemonPid, so pid 0, overflow, and non-integer values yield no claim.classifyNotificationStagingPublisherreturnsalive | dead | unknown, and maps a throwing probe tounknown.deadproceeds.alive,unknown, and no-claim all retain and count asskipped.This is a different mechanism than the generation encoding you suggested, and I want to be explicit about why: encoding a generation into the staged name would change
writeJsonAtomic, which is a protected declaration, requiring theDAEMON_GENERATIONbump and manifest regeneration that #3596 owns. The pid is already in the name, so the safety property you asked for — reap only proven-dead claims — is reachable without touching the writer at all. If you would still prefer the generation-encoded form, I am glad to do it once #3596 lands.P1-2 — pathname-only stat then unlink
Removal is now identity-bound end to end:
readEndpointFileseam, which rejects symlinks and directories (Endpoint is not a regular file) and concurrent mutation (Endpoint changed while it was read). Both propagate into the existing best-effort catch, so the file is retained.nlinkon thestatseam, failing closed when the seam or the field is absent.exactUnlinkAcceptedWithRetainedEvidencewith a distinct.gjc-delete-notification-staging-temp-<uuid>.jsonquarantine name, so the native verifiesdev+ino+size+mtimeNs+sha256before unlinking.identity.mtimeNsrather than a second path-followingstat, so the decision and the delete bind the same inode. This also removes a fractional-mtime negative-age hazard.NOTIFICATION_LEAK_ARTIFACT_PREFIXES, so a retained quarantine self-heals on a later pass instead of becoming a new permanent leak.If either seam is missing, the temp is retained — never an unfenced unlink.
Verification
Nine tests, public surface only, no
as anyand no private access. On unmodified dev 8 of 9 fail; with the repair 9 of 9 pass. Ran the file 8 consecutive times with zero flakes.New coverage for exactly the races you named: live publisher never reaped however old, indeterminate liveness retained, unparseable claim retained, dead-past-grace reaped through the identity fence, ABA replacement between capture and delete refused, and a symlink shaped like a staging temp never followed or deleted.
I also isolated each fix and reverted only it, with the other in place, to prove the coverage is specific rather than incidental:
The file was verified byte-identical to baseline after each revert/restore cycle.
Gates: 585 pass / 0 fail across five telegram suites,
tsc --noEmitclean,biome checkclean, andtelegram-daemon-generation-guard --validate-current-treeexits 0 with no generation bump demanded. Rebased onto current dev `7f5167c86`.One honest gap
The startup self-heal call site lives inside the guard-protected
rundeclaration, so threading an injectablepidAlivethere tripped the manifest digest check. I reverted that one line; startup self-heal uses the production probe, which is behaviorally correct, but that specific path is not test-injectable. Both exported entry points are fully injectable and every fence branch is covered through them.