Summary
window_key in bin/fm-watch.sh is the single derivation of every per-window watcher marker key, and it is not injective: it folds :, /, and . all to _. Two distinct recorded endpoints can therefore share one marker key and silently overwrite each other's .hash-*, .count-*, .stale-*, .stale-since-*, .wedge-escalations-*, .paused-*, and .writing-* state.
window_key() { # <window>
local key=${1//:/_}
key=${key//\//_}
printf '%s' "${key//./_}"
}
s:a.b and s:a_b both derive s_a_b. So do s:a/b and s:a:b.
Why it matters
The markers this key names are the staleness backbone's entire memory. When two live endpoints share one key:
- Each poll overwrites the other's
.hash-*, so neither window ever registers two consecutive identical hashes and neither can become stale. A genuinely stopped or wedged worker is never detected as such, for either endpoint.
.stale-* and .stale-since-* are likewise shared, so a wedge timer started for one endpoint is read as belonging to the other.
This is a pre-existing latent defect, independent of any one consumer: it costs the wedge detector its liveness memory for both colliding endpoints.
How it surfaced
Found during review of #2374 (PR #2877), which added a bare-turn-end absorb that reads .hash-<key> as churn evidence. That work is not affected — it shipped a localized fail-closed guard that refuses to absorb when more than one recorded endpoint derives the same marker key, so a colliding pair simply keeps today's behavior. The reviewer's evidence there, verbatim:
Allowed task IDs can collide after window_key normalization: a.b and a_b produce windows whose hashes share one marker. A stopped a.b pane can differ from a_b's stored hash, be falsely absorbed as churn, and then never become stale because both windows overwrite the shared hash every poll.
and, on a later attempt to repair it with a versioned key:
Marker identity depends on current fleet membership. For s:a:b, adding colliding s:a_b selects v2 markers, but removing it switches the first endpoint back to legacy markers. A stopped turn can then differ from the resurrected .hash-* and be falsely absorbed as churn; if the resurrected .stale-* already matches the final pane, the staleness backbone can suppress it indefinitely as previously surfaced. Use a stable injective identity that never reverts, with one-way fail-closed legacy handling.
That second quote is the trap worth recording: a key whose version depends on which other tasks currently exist reverts when the colliding sibling is torn down, resurrecting stale markers and making things worse than the collision itself. A fix must be stable and membership-independent.
Reproduce
- Record two task metas whose endpoints differ only in a folded character, e.g.
window=s:a.b and window=s:a_b.
- Run the watcher and let both panes go quiet at distinct content.
- Observe
state/.hash-s_a_b alternating between the two panes' hashes every poll, state/.count-s_a_b never reaching 2, and neither endpoint ever classified stale.
Constraints for a fix
The comment on window_key already states the governing rule - the format lives in that one function, because a second copy silently orphans live homes' on-disk markers. Any fix therefore has to:
- Keep one owner for the derivation.
- Be injective and stable: an endpoint's key must not depend on which other endpoints currently exist, or on fleet membership changing over time.
- Handle existing on-disk markers one-way and fail-closed. Live homes hold markers under the current spelling; a transition must never resurrect an old marker as if it were current evidence, and ambiguous legacy state should be ignored rather than trusted.
- Avoid per-poll fleet-wide scans. A naive "does any other window share my key" check on every window every poll is quadratic in crew count.
Deliberately not proposing an encoding here - the tradeoff between preserving legacy spellings for non-colliding endpoints and giving every endpoint one unambiguous identity is the actual design decision.
Environment
- firstmate at
47617a1 (2026-08-23), bin/fm-watch.sh window_key
- backend: tmux; primary harness: claude
Summary
window_keyinbin/fm-watch.shis the single derivation of every per-window watcher marker key, and it is not injective: it folds:,/, and.all to_. Two distinct recorded endpoints can therefore share one marker key and silently overwrite each other's.hash-*,.count-*,.stale-*,.stale-since-*,.wedge-escalations-*,.paused-*, and.writing-*state.s:a.bands:a_bboth derives_a_b. So dos:a/bands:a:b.Why it matters
The markers this key names are the staleness backbone's entire memory. When two live endpoints share one key:
.hash-*, so neither window ever registers two consecutive identical hashes and neither can become stale. A genuinely stopped or wedged worker is never detected as such, for either endpoint..stale-*and.stale-since-*are likewise shared, so a wedge timer started for one endpoint is read as belonging to the other.This is a pre-existing latent defect, independent of any one consumer: it costs the wedge detector its liveness memory for both colliding endpoints.
How it surfaced
Found during review of #2374 (PR #2877), which added a bare-turn-end absorb that reads
.hash-<key>as churn evidence. That work is not affected — it shipped a localized fail-closed guard that refuses to absorb when more than one recorded endpoint derives the same marker key, so a colliding pair simply keeps today's behavior. The reviewer's evidence there, verbatim:and, on a later attempt to repair it with a versioned key:
That second quote is the trap worth recording: a key whose version depends on which other tasks currently exist reverts when the colliding sibling is torn down, resurrecting stale markers and making things worse than the collision itself. A fix must be stable and membership-independent.
Reproduce
window=s:a.bandwindow=s:a_b.state/.hash-s_a_balternating between the two panes' hashes every poll,state/.count-s_a_bnever reaching 2, and neither endpoint ever classified stale.Constraints for a fix
The comment on
window_keyalready states the governing rule - the format lives in that one function, because a second copy silently orphans live homes' on-disk markers. Any fix therefore has to:Deliberately not proposing an encoding here - the tradeoff between preserving legacy spellings for non-colliding endpoints and giving every endpoint one unambiguous identity is the actual design decision.
Environment
47617a1(2026-08-23),bin/fm-watch.shwindow_key