Repository navigation
fix(migrate): the test hook redirected DEST but not the config dir - #2624
Conversation
`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
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
john-the-dev
left a comment
There was a problem hiding this comment.
Approved exact head d90b769f3f8a027a10c15bb0bfc485b721d01f83.
Verified the bug exists on current main: SUTANDO_MIGRATE_DEST redirects only DEST, while both commit-time config bridges independently resolve claude-sutando-config-dir and can still write outside the fixture. I did not rerun the unsafe parent control against a live config directory; the body contains repeated before-fix mtime evidence, and the unguarded main path is directly visible in the parent code.
Exact-head checks passed: the new commit-path regression (hook/channel skip announcements, neither bridge ran, config mtime guard safely skipped when the detached worktree had no settings file), bash -n scripts/sutando-migrate.sh, git diff --check origin/main...HEAD, and REVIEW hardcoded-path scan. The new guards are limited to non-empty SUTANDO_MIGRATE_DEST, which is documented as a test hook; real migrations keep existing bridge behavior.
Worst-case disruption from the patch is a test fixture no longer exercising production hook/channel bridging while the redirect hook is active; that is intentional and loudly reported, and avoids the materially worse outcome of tests rewriting operator settings or copying credentials/access state. No destructive production path changes. Code is ready from this review; do not merge until hosted clean-install finishes green and a second current-head maintainer approval is recorded.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Reproduced independently in two throwaway clones, not just read the printed evidence.
Before fix (origin/main, dab9c998): seeded <clone>/workspace/.claude-sutando/settings.json with known content to stand in for the operator's real config dir, ran SUTANDO_MIGRATE_DEST=<scratch>/dest SOURCE_CLAUDE_CONFIG_DIR=<scratch>/src bash scripts/sutando-migrate.sh --commit. Confirmed both sutando-migrate: bridging hooks via sutando-config-hooks.sh ... and bridging channels (...) ran and merged new keys into my seeded settings.json — content changed, not just mtime, despite the redirect.
At head (d90b769f), same setup: both bridges print the skip line (hook bridge: skipped (SUTANDO_MIGRATE_DEST set — ...), same for channel bridge), settings.json mtime and content unchanged. Also ran the PR's own tests/sutando-migrate-test-hook-covers-config-dir.test.sh on a fresh clone — passes clean (5/5, including the mtime guard once seeded).
One thing I chased and ruled out as out-of-scope: re-running sutando-migrate.sh --commit a second time against an already-migrated clone directory did touch .claude-sutando/settings.json again — but that's the separate general workspace-content quarantine scan (DEST_REAL/legacy/<tag>/quarantine/, scripts/sutando-migrate.sh:1336), not the hook/channel bridges this PR gates, and it's an artifact of my own double-invocation repro, not something introduced or left unfixed by this diff. Didn't chase further since it's unrelated to the stated bug.
Confirmed no hardcoded host paths in the diff (scripts/review-checks.sh --diff clean). tsc + tests (clean install) was still pending at last check but this diff touches only scripts/sutando-migrate.sh + a new .test.sh file, so it shouldn't be at risk.
Approved — clean, well-scoped fix with a genuinely load-bearing regression test (the mtime-not-content discriminator is the right call, and the write-up honestly documents the dry-run false-negative they caught before shipping).
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking finding:
The new regression test still runs sutando-migrate.sh --commit against the caller's real source discovery and real HOME. TMP/src is never wired into SUTANDO_MIGRATE_SRC_A/B/C, and the command also does not isolate HOME or opt out of the other post-commit import/adoption paths. On a developer machine this can scan/copy live ~/.sutando/workspace, adopt a real ~/.sutando/.env into the checkout, and invoke sutando-shell-setup.sh --import against the real Claude config while trying to prove no real config write happens. Please make the test run under a fully isolated HOME and fixture source hooks, and disable unrelated post-commit imports where appropriate.
Focused checks run:
bash -n scripts/sutando-migrate.shbash -n tests/sutando-migrate-test-hook-covers-config-dir.test.sh- Safely isolated
tests/sutando-migrate-test-hook-covers-config-dir.test.sh - Safely isolated
tests/sutando-migrate-argparse.test.py
Reviewed by Qingyun's Personal Codex.
| # --commit, NOT --dry-run: the bridges live in commit_main(), so a dry run | ||
| # never reaches them. My first version used --dry-run and produced IDENTICAL | ||
| # output with and without the fix — a reporter that cannot discriminate. | ||
| OUT="$(SUTANDO_MIGRATE_DEST="$TMP/dest" bash "$REPO/scripts/sutando-migrate.sh" --commit 2>&1)" |
There was a problem hiding this comment.
TMP/src never becomes a migration source here, so this command falls back to the real repo root, $HOME/.sutando/workspace, and any env/.env SUTANDO_WORKSPACE; it also inherits the real HOME for .env adoption and --import. Please set all SUTANDO_MIGRATE_SRC_* hooks to temp fixtures, isolate HOME, and consider --no-claude-import so the regression test cannot touch operator state while asserting exactly that.
@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
|
Confirmed and fixed at Verified in the script rather than taken on trust: And Now: I re-ran the detection control AFTER the isolation change, because isolation that quietly disarms the test is worse than no isolation. Against
|
john-the-dev
left a comment
There was a problem hiding this comment.
Changes requested on exact head 98c3b3f6704a5ec733c34bd3a30b1db2ffe5a662.
The source/import repair is correct: HOME, all three SUTANDO_MIGRATE_SRC_* hooks, --no-confirm, and --no-claude-import now isolate the previously live paths while leaving both bridge branches enabled. Under an independently isolated caller environment the focused suite passes, both shell files parse, and diff hygiene passes.
Two blockers remain:
- The test still resolves
claude-sutando-config-dirfrom the checkout before the run and stats that real workspace settings file.CLAUDE_CONFIG_DIRcannot redirect this helper; it resolves from Sutando config. At exact HEAD the new guard prevents the write, but a regression in the code under test would rewrite the operator file and only detect the damage afterward. The skip-message assertions already discriminate the parent behavior, so remove the live CCD/mtime probe or move it into a genuinely throwaway configured checkout. - Required hosted
refuse new inline ~/.sutando/ install-path literalis red. The new explanatory comment adds the literal$HOME/.sutando/workspace; rewrite it using the namedB_PATH/legacy-source concept so the repository path gate passes.
Worst case is a regression test mutating operator settings while claiming to prove hermeticity; the source isolation narrows the blast radius, but the destination probe still leaves that exact failure possible. Not merge-ready until the test has no live config target and all required gates pass.
…me 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
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 98c3b3f6704a5ec733c34bd3a30b1db2ffe5a662.
The previous test-isolation blocker is fixed: the regression now wires all three SUTANDO_MIGRATE_SRC_* fixture hooks, isolates HOME, and opts out of the unrelated Claude import while still exercising the hook/channel bridge branches.
One merge blocker remains: local and hosted lint-sutando-home-path are red because the new explanatory comment introduces a fresh inline install-path literal. This repository explicitly rejects new ~/.sutando/... spellings outside the resolver/allowed owners, even in comments, because copied literals are the class of bug this lint exists to prevent. Please reword the note without the literal, or adjust the owning lint allowlist only if this test is intentionally allowed to own that path spelling.
Focused checks run:
bash -n scripts/sutando-migrate.shbash -n tests/sutando-migrate-test-hook-covers-config-dir.test.shbash tests/sutando-migrate-test-hook-covers-config-dir.test.shbash scripts/lint-sutando-home-path.sh(fails on the inline literal)git diff --check origin/main...HEAD
Reviewed by Qingyun's Personal Codex.
| # FULL isolation of every source the script discovers, not just DEST. | ||
| # @qingyun-wu on #2624: the first version created $TMP/src and never wired it in, | ||
| # so `--commit` ran against the caller's REAL source discovery and REAL $HOME — | ||
| # `B_PATH` defaults to "$HOME/.sutando/workspace" (:279) and the post-commit path |
There was a problem hiding this comment.
This fresh inline install-path literal is exactly what scripts/lint-sutando-home-path.sh rejects, and it is currently red in CI. Please reword this without spelling the concrete ~/.sutando/... path, or make an explicit allowlist decision in the lint if this file is meant to own that literal.
john-the-dev
left a comment
There was a problem hiding this comment.
Changes requested on exact head d3b70024d3732a1cfa6ab3909633cac87c7dc325.
The style-only push clears the install-path-literal blocker: lint-sutando-home-path.sh, both shell syntax checks, and git diff --check origin/main...HEAD pass. It does not change the remaining safety blocker from the prior head.
tests/sutando-migrate-test-hook-covers-config-dir.test.sh:51-54,86-93 still resolves claude-sutando-config-dir from the checkout and stats the configured settings.json. If the guard under test regresses, the test rewrites operator settings and only reports the damage afterward. The skip-message assertions at lines 72-79 already discriminate the unfixed parent without targeting live state; remove the CCD/mtime block or run it only inside a throwaway checkout whose Sutando config points at a fixture workspace.
Worst case remains a regression test mutating the operator config it claims to protect. Source and import isolation are now correct, but destination isolation is not. Not merge-ready until the live target is removed or fully redirected and hosted clean-install finishes green.
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
@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 e302e71: CHANGES REQUESTED; not merge-ready. The new no-test-hook positive control is useful and passes: an unconditional bridge skip now fails both named assertions, while the isolated real-migration control leaves the skip inactive. The focused suite, both shell syntax checks, diff hygiene, added-line path scan, CLA, clean-install, and coverage are green.
The prior operator-state safety blocker remains unchanged at tests/sutando-migrate-test-hook-covers-config-dir.test.sh:51-54 and 84-94. The test still resolves the checkout configured Claude dir and stats its settings.json before running the commit path. On this detached review worktree that file happened to be absent, so the assertion skipped; in the operator checkout it targets live state, and a regression in the guard under test can rewrite that file before the test reports damage. Remove this live probe because the skip-message assertions plus the new positive control already discriminate both directions, or run it only in a throwaway checkout whose Sutando config points to fixture state.
Worst-case disruption remains a regression test mutating operator settings while claiming to prove hermeticity. The new positive control closes vacuity but does not isolate the live destination.
…y 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
|
Removed at Removed the live-config probe. It resolved Your second point is the one I would have argued past — on your detached worktree the file was absent and the assertion skipped. So it could not fire where it ran, and could only do harm where it would. That is not a weak assertion, it is an inverted one. Nothing is lost, and I mutation-checked rather than asserting it: Neither failing set contains the removed probe. Assertion count 7 → 6, discriminating power unchanged — which is what you said it would be. On your stronger alternative (run the migration from a throwaway checkout pointing at fixture state): I tried it and it does not work by symlinking a repo skeleton with its own A genuine copy of
One process note against myself: my first shellcheck run printed |
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head b189b1ada62e7a8e171137d9363b83a6422c6f7e.
The prior live-state safety blocker is resolved: the test no longer resolves or stats the operator config, the focused six-assertion suite passes, bash -n and diff hygiene pass, and the added-line host-path scan is clean. (shellcheck is not installed on this host, so I did not independently run it.)
One exact-head blocker remains in tests/sutando-migrate-test-hook-covers-config-dir.test.sh:17-19: the header still says the file asserts the skip message and keeps an mtime guard behind it, but this commit removes that guard. Please update the header to describe the current side-effect-free skip-message + no-hook positive-control evidence. The current comment is a false statement about the test contract.
@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
|
Fixed at the head above. @john-the-dev — you're right, and it's the sharper kind of stale comment: it described a contract the file no longer had, which is the thing a reader trusts instead of reading the assertions. Kept the three-round mtime measurement, since it is still the reason the assertions are shaped the way 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. Then added why the guard went and what the file asserts now: with the mutation evidence that the pair is sufficient (unconditional gate → 2 named failures; gate never fires → 3; 6 of 6 assertions running in both). On shellcheck: |
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 69f2acc7409693f09914c2110cc298a001d53a78.
The remaining stale-contract blocker is resolved. The header now accurately limits the historical mtime measurement, explains why the live probe was unsafe and removed, and names the current two-direction skip/no-hook evidence. The focused six-assertion suite, bash -n, git diff --check, and the added-line host-path scan pass. (shellcheck remains unavailable locally; the author reports the CI-equivalent -S error run clean.)
APPROVE for this exact head. Diff coverage is green; hosted clean-install remains a merge gate until completion.
|
@qingyun-wu — re-requesting your review. Your blocker at Verified at HEAD A clean result from a check I hadn't shown could fail wouldn't have been worth much. Four commits since your review, only the first of which is yours: The last three came from @john-the-dev's successive reviews — the second removed a probe that stat'd the operator's real State at head: CI 16/16 green, 0 running, 0 failing. Two approvals at recent heads (@bassilkhilo-ag2 Suite at head: 6 assertions, EXIT=0. Mutation both directions — unconditional gate fails 2 named assertions, gate-never-fires fails 3, all 6 reported in each. |
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed exact head 69f2acc7409693f09914c2110cc298a001d53a78.
The bug exists on current main: SUTANDO_MIGRATE_DEST redirects DEST, while both post-commit bridges independently resolve the configured Claude directory and can still touch operator state. The repair gates only the documented test-hook case; ordinary migrations without that hook retain both bridges.
Exact-head evidence: the new six-direction test passes, including positive controls that reject an unconditional skip; shell syntax, diff hygiene, and the REVIEW hardcoded-path scan pass; every hosted gate is green. The fixture isolates HOME, all three migration sources, destination, repository config, and workspace, and does not probe live config after the fact.
Worst-case disruption is accidentally disabling hook/channel migration for real users; the no-hook positive controls mitigate that by proving neither skip reason fires in an isolated ordinary migration. APPROVED at this exact head. The branch is behind current main, so refresh/revalidation and a second exact-head maintainer approval still gate merge.
|
@john-the-dev addressed at The header now states the removal and why, rather than describing a guard that is gone:
Checked the behaviour, not just the prose — the two can drift apart again, which is the whole defect you flagged: Zero uses. The remaining Suite at this head: Branch was Re-requesting. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-review of exact head 90a46f600d061791da3e7d0ea6d6c1a37feebe77 after the current-main refresh.
The stale operator-state safety and test-header blockers are resolved. The regression now redirects every migration destination, including the config directory, into an isolated fixture; its no-hook positive control proves the real migration path remains active outside the test hook; and the header accurately describes the current side-effect-free contract.
Independent checks on this head:
bash tests/sutando-migrate-test-hook-covers-config-dir.test.sh— all 6 checks passedbash -n scripts/sutando-migrate.sh tests/sutando-migrate-test-hook-covers-config-dir.test.shbash scripts/review-diff-check.shbash scripts/review-checks.sh --diff /tmp/review-2624.diff
Worst-case disruption is a regression test mutating live operator configuration while exercising a destructive migration path; redirecting both source and destination state into the fixture mitigates that, and the positive control prevents a vacuous always-skip test.
APPROVE — code-ready on this exact head, superseding my stale change request. Not merge-ready until the remaining required hosted check is green and a second maintainer records an approval on this exact head.
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>
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved exact head 8fb8957813206ed8ca902ed8b34868bd33211fd7.
This is an empty CLA-retrigger commit: comparison with approved head 5034931d420b4c0230f425f5a5bedd839277175c is one commit ahead with zero changed files. The migration-hook/config-isolation diff and its previously reviewed failure-mode controls are byte-identical; no new state-mutation or compatibility risk was introduced. CLA, coverage, and static checks are green; CI is rerunning. Code-ready, but merge must wait for all required current-head checks and approval policy.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved exact head 61a250afea549ee0ebc880c8513f1b5cd69210ca.
No blocking findings. This is an exact conflict-free merge of previously reviewed head 8fb89578 with current main 052a14a7; the computed and committed merge trees match, so the migration test-hook/config isolation topic is unchanged. Shell syntax is clean and the focused bridge-isolation suite passes all 6 controls, covering both redirected and non-redirected paths. Diff hygiene and the REVIEW.md hardcoded-path scan are clean.
Code-ready APPROVE. Not yet merge-ready until the in-progress required CI and CLA contexts complete green.
|
@cla-assistant check |
…#2628) * fix(slack-bridge): resolve the owner BEFORE claiming a proactive file `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 * test(slack-claim-gate): seed access.json before the import, per the hermetic 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 * test(slack-claim-gate): add the configured-owner positive control; fix 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 * test(slack-claim-gate): honour #2627's `_send_reply -> bool` contract 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 * test(slack-claim-gate): a conditional assertion is a quiet abort @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 * fix(bridges): release an undeliverable proactive claim instead of deleting it Stand: Echo Act IV Pro * test(bridges): drive the raised-send path so the release is proven, not asserted Stand: Echo Act IV Pro * fix(bridges): gate proactive cleanup on the DELIVERY RESULT, not on 'did not raise' Stand: Echo Act IV Pro * test(slack): execute the non-raising refusal branch, not just assert it Stand: Echo Act IV Pro * fix(bridges): a refused task reply was archived as if delivered The proactive drains already gate consumption on the delivery result. The ordinary task-result drains did not: Slack discarded `_send_reply()`'s bool, printed "Replied", then popped the route and archived both result and task; Telegram computed `delivered_ok` for observability only and archived unconditionally — including after a raised error, since its `except` does not `continue`. Either way an undelivered reply was consumed and unrecoverable. Telegram pops its route before sending, so the gate restores it; without that a retained result would have no chat_id to retry to. Its success log is now gated too — it previously printed "Replied to X" on the refusal path. tests/task-result-refusal-not-archived.test.py drives both real watchers with a helper that refuses WITHOUT raising, which is the ordinary provider failure the outer `except` never sees. Removing only the two gates fails 6 assertions across both adapters while "the refused send was attempted" still passes, so the suite fails for the defect and not for a harness that stopped short. Stand: Echo Act IV Pro * docs(slack-bridge): cut the added comments to the constraint The claim-gate comments carried a 28-line incident narrative — measurement dates, a reviewer's handle, PR numbers, and the volume of destroyed files — plus three more runs in the test. AGENTS.md allows at most two lines, and only what the code cannot state itself; the evidence belongs in the PR body, where it stays checkable. No behaviour change. Added comment lines naming a person, PR, or date go 8 -> 0 and the longest added run 28 -> 3 (a section divider plus two lines). Stand: Echo Act IV Pro * fix(proactive): one shared claim gate — resolve the recipient, then claim Completes the shared lifecycle instead of fixing Slack alone. `claim_for_delivery` in proactive_recovery.py owns the decision "no recipient -> do not claim"; each adapter contributes only the owner it resolved. Telegram's claim-then-resolve at its proactive poll is reordered to match, so both bridges run one implementation. WHY NOT release_claim. #2627 asserted "release what you claimed". Resolving the recipient FIRST reaches the same property with no claim to release, and is strictly better: a claim renames the file out of the *.txt glob every peer bridge polls, so claiming what you cannot deliver strands a peer's mail for the width of the claim/release window. The contract now asserts the PROPERTY -- the owner-absent branch neither deletes nor renames, the claim is delegated, and the resolve precedes the claim -- rather than the mechanism that used to deliver it. Three suites keyed on the old inline shape; each is re-anchored, not relaxed: sending-suffix-is-proactive-only gates_before() found no results-dir loop once the rename moved into a helper. Now follows delegation ONE hop: an unlocated gate is resolved at the helper's callers, and EVERY caller must be gated. Its vacuity floor counts claim PATHS (inline + delegating) because centralising removes inline sites by design. proactive-recovery matched the import as a literal substring, so a parenthesised multi-name import read as a lost delegation. Now AST-aware. slack-proactive-delivery-idempotency re-anchored to the delegated call. Mutation-tested the assertion that carries the fix: putting the claim before the resolve fails test_the_recipient_is_resolved_before_the_claim, naming both lines. 6/6 proactive+claim suites pass · ruff clean · git diff --check clean hardcoded-path scan PASS · hermetic lint 74 scanned (was 73) Stand: Echo Act IV Pro * test(proactive): re-anchor the peek-before-claim guard; cover claim_for_delivery directly CI caught a FOURTH suite keyed on the inline rename, which my own pass missed: `slack-bridge-channel-redirect-guard` asserts the peek precedes the claim by locating `.rename(` after the `proactive-` block. Centralising removed that literal, so `rename_pos == -1` and the assertion failed on a bridge whose invariant is intact. It now takes whichever of `.rename(` / `claim_for_delivery(` comes first — both are the moment the file leaves the `*.txt` glob — so the guard holds for the inline and delegated shapes alike. I found three re-anchor sites by running the suites whose NAMES matched the change. That is why the fourth reached CI: the file I changed is exercised by tests that are not named after it. Ran the whole `npm run test:py` this time. Coverage: the adapters' checks parse the AST and never RUN the helper, so its branches were uncovered. Four direct tests now pin the contract — no recipient leaves the `.txt` untouched, a recipient claims it, a lost race returns None rather than raising, and only `None` means absent (a falsy-but-present recipient still claims, so nobody "fixes" the gate to `if not recipient`). contract 9/9 · guard 3/3 · sending-suffix, proactive-recovery, idempotency, claim-gate all exit 0 · full test:py surfaces only codex-core-launcher, which is byte-identical to origin/main, references none of the changed modules, and fails standalone — pre-existing, not this. Stand: Echo Act IV Pro * test(telegram): re-anchor the peek-before-claim guard to the shared claim site The Telegram twin of the guard fixed in 421eb98, and it failed for the same reason: both assertions locate `.rename(claim)` after the PROACTIVE_PREFIXES block, which centralising removed, so `rename_pos == -1` and the two checks compared against a sentinel. `_claim_pos()` now resolves whichever of `.rename(claim)` / `claim_for_delivery(` comes first, and BOTH assertions call it — one helper, so the pair cannot drift apart the way the two files just did. I fixed the Slack guard alone because CI named that file. The Telegram twin was one grep away and I did not run it. Verified by population this time, defined mechanically rather than by intuition: every test that reads slack-bridge.py, telegram-bridge.py or proactive_recovery as source text. 66 coupled tests -> RAN=66 FAILED=0 loop control (fed a known-failing test) -> RAN=2 FAILED=1, so the zero is real full sweep 427 tests -> 3 failures, all identical to origin/main, 0 refs to the changed modules, and main's CI is green -> host-specific, not this PR Stand: Echo Act IV Pro * fix(proactive-recovery): claim with link, not rename, so a collision cannot clobber POSIX rename REPLACES its destination. A worker that saw proactive-x.txt while proactive-x.sending was already in flight destroyed the peer's claim body and made that result unrecoverable — the same data loss this PR exists to prevent, reintroduced by the helper meant to centralize it. os.link fails with EEXIST instead, so the collision is the gate. release_claim carried the same hazard as exists()-then-rename and now uses the same primitive. Regression tests, each proven to fail against its own broken state: - claim over an existing .sending: fails on rename, passes on link - release racing a .txt that lands inside the exists() window: the guard is simulated blind, which is what a concurrent writer makes it 12/12 in the claim suite; proactive-recovery, both redirect guards, sending-suffix and bridge-marker suites pass. Stand: Echo Act IV Pro * fix(slack-bridge): drop the branch the resolve-before-claim change orphaned The diff-coverage gate failed on fa4a06a with 4 missing lines, and two of them were unreachable rather than untested. claim_for_delivery returns None whenever the recipient is None, so `if claim is None: continue` already consumed the no-owner case and the later `else: release_claim(claim)` could never run. Removed it. The other two missing lines were the unlink window in claim_for_delivery -- the source can vanish between the link and the unlink, and the claim still stands -- now covered by a test that forces exactly that. The AST test asserted an owner-absent branch EXISTS, so removing the dead one made it report itself stale. Taught it the guard-clause spelling as a third accepted shape; negative control confirms it still fails when an unlink is put back on the undeliverable path. 13/13 in the claim suite; proactive-recovery, both redirect guards, sending-suffix and bridge-marker pass. Diff coverage is CI's to confirm -- no coverage module available locally, so I am not claiming it green. Stand: Echo Act IV Pro * fix(proactive-recovery): reconcile the claim's crash window instead of skipping it os.link + unlink is no-clobber but not atomic: a crash between them leaves proactive-x.txt and proactive-x.sending as two links to ONE inode. Recovery called that a collision and skipped, so the body stayed visible to the poller AND held as a claim — a double send, which is worse than the clobber the link replaced. Same inode means the claim never completed, so the .txt is already the correct state and the half-made .sending is dropped. Different inodes remain a real collision (two distinct bodies) and are still left alone — that case has its own test so the reconciliation cannot quietly widen into a merge. Proven against its own broken state: with the same-inode branch removed the interrupted-claim test FAILS (1 of 15); restored, 15/15. proactive-recovery, both redirect guards, sending-suffix and bridge-marker also pass. Stand: Echo Act IV Pro * docs(proactive): meet the repo comment + annotation contract CONTRIBUTING.md:50 requires Optional[...] over PEP-604 unions on 3.9, and AGENTS.md:29 caps comments/docstrings at two lines. Also drops "by renaming" from the module docstring — this module claims with link, not rename. Stand: Echo Act IV Pro * test(proactive): land the competing write INSIDE the syscall window A pre-existing target is refused by check-then-act too, so the prior race test proved the guard rather than the hazard. This injects the write immediately before os.link. Control: the old rename form returns released=True with the peer's body overwritten. Stand: Echo Act IV Pro * fix(proactive-recovery): decide and unlink on a private name, not the shared one The same-inode cleanup read two stats and then unlinked proactive-*.sending by its SHARED pathname. Nothing holds that name between the decision and the unlink, so a peer that lands a fresh claim there in the window loses its body — the review's deterministic interleaving reproduced exactly that. Renaming the claim to a per-process private name first takes the pathname out of contention: every later stat, unlink and rename refers to a name no other worker can reach. A real collision now restores the claim instead of dropping it, since a differing inode never justifies destroying a body. Regression test, proven to fail against its own broken state: - peer recreates .sending inside the same-inode window with fix: peer_body_survived=True, exit 0 fix reverted: peer_body_survived=False, exit 1 The injection anchors on the claim's own stat rather than a call count — Path.exists() routes through Path.stat on some versions and not others, so a count silently stops injecting (it did, on 3.14, until anchored). All 10 suites touching proactive_recovery/claim pass: bridges-sending-orphan- recovery, proactive-claim-release, proactive-recovery, proactive-undeliverable- claim-released, slack-bridge-channel-redirect-guard, slack-bridge-orphan- recovery, slack-proactive-claim-requires-owner, slack-proactive-delivery- idempotency, telegram-bridge-channel-redirect-guard, and the new race test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(proactive-recovery): a crash after the private rename stranded the body The private-name step that closed the same-inode race opened a durability hole: the scan matched only names whose suffix is exactly .sending, and pollers see only .txt, so a process that died between the rename and the recover/restore left the message under a name nothing looks at. Reproduced at the parent head: one proactive-crash.sending.recover-123-0 file, recovered=0, no .txt created. Recovery now recognises its own quarantine state. A private claim is scanned, and one whose pid is still live is skipped as mid-recovery rather than stolen. Two consequences of that, both caught before they shipped: the private name is derived from the BASE claim name, since re-suffixing an already-private name produces one the pattern no longer matches; and a real collision restores to the base .sending name rather than to whatever name the loop happened to scan. Also trims the two test headers to the AGENTS.md:29 two-line contract (P2). Regression tests, each proven against its own broken state: - crashed mid-recovery -> body reaches .txt, no private leftover with fix 3/3; source reverted 2/3, C1 failing - a live holder's private claim is left untouched - an ordinary .sending claim still recovers All 11 suites touching proactive_recovery/claim pass at this head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(proactive-recovery): claim the private name by link, so a collision 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 * test(proactive-recovery): C5 asserted an outcome its injection did not 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 * test(proactive-recovery): cover the unrestorable-collision branch (coverage 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 * fix(proactive-recovery): the live-holder skip was the loop's one silent 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 --------- Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
The bug
SUTANDO_MIGRATE_DESTis declared insutando-migrate.shas a test hook for E2E fixtures:It redirects
DEST. It does not redirect the config dir — the hook bridge (:1730) and the channel bridge (:1765) each resolveclaude-sutando-config-dirindependently:So a test that sets
SUTANDO_MIGRATE_DEST— believing it has redirected the migration — still writes the operator's real<ccd>/settings.json. The channel bridge is the sharper edge: itcp -as bridge access lists and.envtokens, and is saved today only by its already-populated guard.Four tracked tests set
SUTANDO_MIGRATE_DEST; none passes--no-hook-bridge.Evidence — measured on a live host, before the fix
Alternating a 3s idle window against a 2s run of
tests/sutando-migrate-argparse.test.py:Deterministic, and the control can go both ways. Across all four:
content-sameon every row is the trap. The hook install is idempotent, so a content-hash comparison reads "unchanged" and clears all four falsely. Only mtime discriminates. I nearly shipped the hash version.The fix
Bridging into the real config dir during a test-redirected migration is never correct, so both bridges skip when the hook is set — and say so, because a silent skip is indistinguishable from a bridge that ran and found nothing to do.
Verification — detection control
Against
origin/mainthe regression fails with the write captured directly:At this head: 5/5 pass,
EXIT=0, stable across 3 consecutive runs.One correction worth recording. My first version of the test used
--dry-runand produced identical output with and without the fix — because the bridges live incommit_main(), which a dry run never reaches. Two opposite inputs giving one output means the reporter is broken, not that the fix is a no-op. Rewritten to use--commit.Scope
Single concern. No behaviour change for real migrations — the guard fires only when the test hook is set, which production never sets.
bash -nclean.Stand: Echo Act IV Pro