Skip to content

fix(migrate): the test hook redirected DEST but not the config dir - #2624

Merged
john-the-dev merged 12 commits into
mainfrom
fix/migrate-test-hook-covers-config-dir
Aug 5, 2026
Merged

john-the-dev merged 12 commits into
mainfrom
fix/migrate-test-hook-covers-config-dir

Conversation

@sonichi

@sonichi sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The bug

SUTANDO_MIGRATE_DEST is declared in sutando-migrate.sh as a test hook for E2E fixtures:

# TEST hook: SUTANDO_MIGRATE_DEST overrides for E2E fixtures ...
if [ -n "${SUTANDO_MIGRATE_DEST:-}" ]; then
    DEST="$SUTANDO_MIGRATE_DEST"

It redirects DEST. It does not redirect the config dir — the hook bridge (:1730) and the channel bridge (:1765) each resolve claude-sutando-config-dir independently:

local _new_ccd; _new_ccd="$(bash "$(dirname "$0")/sutando-config.sh" claude-sutando-config-dir ...)"
bash "$_hook_helper" install "${_new_ccd}/settings.json" --with-catchup-hook

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: it cp -as bridge access lists and .env tokens, 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:

round 1  IDLE(3s)  mtime same        round 1  TEST(argparse)  MTIME CHANGED
round 2  IDLE(3s)  mtime same        round 2  TEST(argparse)  MTIME CHANGED
round 3  IDLE(3s)  mtime same        round 3  TEST(argparse)  MTIME CHANGED

Deterministic, and the control can go both ways. Across all four:

sutando-migrate-claude-import.test.sh    exit=0  dur=814s  MTIME-CHANGED  content-same
sutando-migrate.test.sh                  exit=0  dur= 11s  MTIME-CHANGED  content-same
sutando-migrate-backup-excludes.test.sh  exit=0  dur=  2s  mtime-same     content-same
sutando-migrate-argparse.test.py         exit=0  dur=  2s  MTIME-CHANGED  content-same

content-same on 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/main the regression fails with the write captured directly:

  FAIL hook bridge announces the test-redirect skip
  FAIL hook bridge did not run — the bridge announced an install under a redirected DEST
  FAIL operator settings.json mtime unchanged
       mtime 1782228303 -> 1785836777 (the bridge wrote the real config dir)
EXIT=1

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-run and produced identical output with and without the fix — because the bridges live in commit_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 -n clean.

Stand: Echo Act IV Pro

`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
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh
  • bash -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)"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Confirmed and fixed at 98c3b3f6. You're right, and the irony is the point: the test that proves the migration doesn't 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.

Verified in the script rather than taken on trust:

:274  A_PATH="${SUTANDO_MIGRATE_SRC_A:-$REPO_DIR}"
:279  B_PATH="${SUTANDO_MIGRATE_SRC_B:-$HOME/.sutando/workspace}"
:1650 bash "$_import_script" --import          # sutando-shell-setup.sh, post-commit

And ~/.sutando/workspace exists on this host (Jun 23), so this was live, not theoretical — my own earlier control runs scanned it. That is the same defect class this PR exists to fix, committed inside the fix for it.

Now: SUTANDO_MIGRATE_SRC_{A,B,C} wired to fixture dirs, HOME isolated to a tmpdir, --no-claude-import to opt out of the unrelated post-commit import. The two bridge opt-outs are deliberately not passed — those are the code paths under test, and disabling them would make every assertion vacuous.

I re-ran the detection control AFTER the isolation change, because isolation that quietly disarms the test is worse than no isolation. Against origin/main it still fails with the write captured directly:

  FAIL hook bridge announces the test-redirect skip
  FAIL hook bridge did not run — the bridge announced an install under a redirected DEST
  FAIL operator settings.json mtime unchanged
       mtime 1782228303 -> 1785837428 (the bridge wrote the real config dir)
EXIT(broken)=1      EXIT(restored)=0

bash -n clean on both files. Re-requesting review.

@sonichi
sonichi requested a review from qingyun-wu August 4, 2026 09:57

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The test still resolves claude-sutando-config-dir from the checkout before the run and stats that real workspace settings file. CLAUDE_CONFIG_DIR cannot 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.
  2. Required hosted refuse new inline ~/.sutando/ install-path literal is red. The new explanatory comment adds the literal $HOME/.sutando/workspace; rewrite it using the named B_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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh
  • bash -n tests/sutando-migrate-test-hook-covers-config-dir.test.sh
  • bash tests/sutando-migrate-test-hook-covers-config-dir.test.sh
  • bash 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
sonichi added a commit that referenced this pull request Aug 4, 2026
@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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Removed at b189b1ad. @john-the-dev — you were right on both halves, and the second one is the part I had not seen.

Removed the live-config probe. It resolved claude-sutando-config-dir and stat'd the operator's settings.json around the run. Your first point is decisive on its own: the assertion can only fire after the write, so it is a post-mortem rather than a guard, and obtaining it puts live operator state inside the blast radius of the exact regression under test.

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:

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 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 sutando.config.local.json. resolve_claude_sutando_config_dir() derives from the repo root that sutando-config.sh computes as cd "$(dirname "$0")/.." && pwd, which resolved straight back to the real checkout:

want:  $TMP/ws/.claude-sutando
got:   .../worktrees/migrate-hook-bridge/workspace/.claude-sutando

A genuine copy of src/ + scripts/ would do it, and that is a larger change than this PR's single concern. I put the attempt and its result in the test header so the next person does not re-derive it rather than leaving it in a comment thread.

shellcheck -S error (the CI gate) clean on both files; git diff --check origin/main...HEAD clean.

One process note against myself: my first shellcheck run printed exit=0 because $? after a pipe is tail's status, not shellcheck's. Re-run without the pipe, the test file is 0 findings and sutando-migrate.sh's 13 are pre-existing and all below the -S error gate.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

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 test hook set  -> both bridges announce the skip and do not run
with NO test hook       -> neither bridge is skipped for that reason

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: shellcheck -S error -f gcc — the exact CI gate — is clean on this file, run unpiped so the exit code is shellcheck's and not a pager's. bash -n clean, suite EXIT=0.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi
sonichi requested a review from qingyun-wu August 4, 2026 12:22
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — re-requesting your review. Your blocker at 98c3b3f6 was the inline ~/.sutando/... literal in an explanatory comment; that was fixed four commits ago at d3b70024 (reworded to cite the migrate line instead of spelling the path).

Verified at HEAD 69f2acc7, and I ran a positive control on the lint rather than just reporting a green:

lint-sutando-home-path.sh          EXIT=0   clean (mode=all, scanned 874 files)
  + a planted `~/.sutando/...`     EXIT=1   <- proves the lint can still go red here
  restored                         EXIT=0

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:

d3b70024  cite the migrate line instead of spelling the install-home literal   <- your blocker
e302e717  the skip-gate test could not see an unconditional skip
b189b1ad  drop the live-config probe from the hermeticity test
69f2acc7  the header still described the mtime guard this PR removed

The last three came from @john-the-dev's successive reviews — the second removed a probe that stat'd the operator's real settings.json, which could only fire after a write and silently skipped on a detached worktree.

State at head: CI 16/16 green, 0 running, 0 failing. Two approvals at recent heads (@bassilkhilo-ag2 d90b769f, @john-the-dev 69f2acc7). Your CHANGES_REQUESTED from 98c3b3f6 is the only thing holding reviewDecision, and I'm not clearing another reviewer's state with the owner's token — so this needs your re-look rather than a workaround.

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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonichi

sonichi commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev addressed at 69f2acc7, which landed after your b189b1ad review — docs(test): the header still described the mtime guard this PR removed.

The header now states the removal and why, rather than describing a guard that is gone:

This file no longer observes it. The mtime guard was removed at b189b1ad (@john-the-dev): it could only fire AFTER a write, so it was a post-mortem rather than a guard, and obtaining it put live operator state inside the blast radius of the regression under test.

Checked the behaviour, not just the prose — the two can drift apart again, which is the whole defect you flagged:

grep -c 'stat -f|%m|mtime='  tests/sutando-migrate-test-hook-covers-config-dir.test.sh  ->  0

Zero uses. The remaining mtime mentions are the retained measurement note (the three IDLE-vs-TEST rounds) plus the sentence recording its removal — kept deliberately, because the reason mtime was the only discriminator when observing the live file is the thing a future reader would otherwise rediscover by re-adding it.

Suite at this head:

ok   hook bridge did not run
ok   channel bridge did not run
ok   no test hook -> the hook bridge is NOT skipped for that reason
ok   no test hook -> the channel bridge is NOT skipped for that reason
All test-hook coverage checks passed.

Branch was BEHIND and is now updated; dismiss_stale_reviews=false on both gate surfaces, verified before the update, so standing reviews are intact.

Re-requesting.

@sonichi
sonichi requested a review from john-the-dev August 5, 2026 04:33

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 passed
  • bash -n scripts/sutando-migrate.sh tests/sutando-migrate-test-hook-covers-config-dir.test.sh
  • bash scripts/review-diff-check.sh
  • bash 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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 5, 2026 20:31
@john-the-dev
john-the-dev disabled auto-merge August 5, 2026 20:33
@john-the-dev
john-the-dev enabled auto-merge (squash) August 5, 2026 20:44
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 8b3774d into main Aug 5, 2026
16 checks passed
@john-the-dev
john-the-dev deleted the fix/migrate-test-hook-covers-config-dir branch August 5, 2026 20:45
john-the-dev added a commit that referenced this pull request Aug 13, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants