Skip to content

window_key is not injective: colliding endpoints share watcher markers and neither ever goes stale #2878

Description

@karotkriss

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

  1. Record two task metas whose endpoints differ only in a folded character, e.g. window=s:a.b and window=s:a_b.
  2. Run the watcher and let both panes go quiet at distinct content.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-prTriage: real bug or VISION-aligned feature, open for a PR

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions