Skip to content

🐛 fix(kanban): route transition wakes to the origin thread, not Home - #51

Merged
cwest merged 1 commit into
cwest/integrationfrom
topic/wake-origin-thread-routing
Jul 6, 2026
Merged

🐛 fix(kanban): route transition wakes to the origin thread, not Home#51
cwest merged 1 commit into
cwest/integrationfrom
topic/wake-origin-thread-routing

Conversation

@cwest

@cwest cwest commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Problem

A kanban card born in a thread must report back to THAT thread, but every
transition wake was dispatching to the Home channel with thread=None. From the
watcher's seat the pipeline "went dark": work ran, cards moved, follow-ups were
commissioned — and none of it surfaced in the thread.

Verified against the live board: 43 correct thread subs vs 105
Home/thread-less subs; 15 cards carry BOTH a correct discord:<chat>:<thread>
sub and a later Home/thread-less sub. The transition-wake destination is read
verbatim off the notify subscription row, and the emit-wake loop fires one wake
per row — so a card with both rows double-fires: one to the origin thread
(correct) and one to Home/thread=None (dark).

Root cause

Two writers produce the thread-less Home row on top of a correct origin sub:
the notifier's Home-channel fallback and a review-stage defensive re-subscribe to
the bare Home channel. Once written, the extra row claims + fires its own wake.

Fix (both the write and the egress)

  • add_notify_sub skips a thread-less sub when the same (task, platform)
    already has a thread-bearing sub — the dark-routing duplicate is never written.
    A genuinely channel-born card (no thread sub anywhere) is unaffected.
  • dedupe_wake_subs collapses a card's subs to a single wake target per
    (task, platform), preferring the thread-bearing sub; the notifier gates the
    agent-wake claim on it, so even a legacy DB that already has both rows fires
    exactly one wake — to the origin thread. Distinct real threads are preserved;
    only the thread-less duplicate is dropped. The chat-ping path (separate cursor)
    is untouched.

Tests

  • Unit: dedupe_wake_subs (order-independent, keeps distinct cards/threads, keeps
    a lone channel sub) + the add_notify_sub guard (skips thread-less-over-thread,
    allows a legitimate channel sub).
  • Integration: a card with a thread sub AND a thread-less Home sub fires exactly
    ONE wake, to the thread. RED-verified — two wakes without the gate
    (origin_chat_id = both the thread and Home), one with it.
  • Full touched-area suites green: 224 passed / 1 skipped (gateway notifier +
    transition + thread-origin) and 279 passed / 1 skipped (kanban_tools +
    kanban core).

Notes

  • RESTART-GATED. Touches the gateway notifier/emit loop and kanban_db;
    verify against a restarted gateway, not a stale one. End-to-end acceptance
    is a real #research thread request confirming the commissioning ping +
    completion synopsis land in the origin thread (log-confirmed), not a code-read.
  • Filing-side residue is a separate, gated follow-up (different repo): the
    literal Report back to: origin sentinel resolution at filing and the
    review-stage bare-channel subscribe. This PR makes the wake robust regardless;
    the follow-up fixes the source.

Base: cwest/integration.

A card born in a thread must report back to THAT thread, but the wake was
dispatching to the Home channel with thread=None — so from the watcher's
seat the pipeline went dark: work ran, cards moved, follow-ups were
commissioned, and none of it surfaced.

Root cause: the transition-wake destination is read verbatim off the notify
subscription row, and a thread-born card ends up with TWO rows — the correct
origin sub (chat+thread) stamped at filing, plus a later thread-less channel
sub (e.g. a review-stage defensive re-subscribe to the Home channel). The
emit-wake loop fires one wake per row, so the card double-fires: one to the
origin thread (correct) and one to Home/thread=None (dark).

Fix at both the write and the egress:
- add_notify_sub skips a thread-less sub when the same (task, platform)
  already has a thread-bearing sub, so the dark-routing duplicate is never
  written. A genuinely channel-born card (no thread sub) is unaffected.
- dedupe_wake_subs collapses a card's subs to a single wake target per
  (task, platform), preferring the thread-bearing sub, and the notifier
  gates the agent-wake claim on it — so even a legacy DB that already has
  both rows fires exactly one wake, to the origin thread. Distinct real
  threads are preserved; only the thread-less duplicate is dropped. The
  chat-ping path (separate cursor) is untouched.

Restart-gated: touches gateway notifier/emit + kanban_db; verify against a
restarted gateway. Filing-side origin resolution (literal 'origin' sentinel,
review-stage bare-channel subscribe) lives in a separate repo and is tracked
as a gated follow-up.

Tests: new unit coverage for dedupe_wake_subs + the add_notify_sub guard,
and an integration test proving a card with dup subs fires ONE wake to the
thread (RED-verified: two wakes without the gate).
@cwest

cwest commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

No changes needed.

The root cause holds up against the board: the wake destination is read verbatim off the notify-sub row, and a thread-born card ends up with two rows — the correct origin sub plus a later thread-less channel sub — so the emit loop fires two wakes, one of them dark to Home/thread=None. The fix closes it at both the write (add_notify_sub skips a thread-less sub when the same task+platform already has a thread-bearing one) and the egress (dedupe_wake_subs collapses to one wake target, thread preferred, and the notifier gates the agent-wake claim on it, so a legacy DB with both rows still fires once).

Verified rather than assumed:

  • Read both changed functions at the head SHA; they match the diff, and list_notify_subs takes the task_id the new tests pass.
  • Ran the suite in a throwaway clone at b2ce94e: test_wake_origin_thread_routing.py + test_kanban_notify_all_transitions.py = 12 passed.
  • The gate has teeth — neutering dedupe_wake_subs to a passthrough makes the dup-wake integration test double-fire (['1523789197662748683', '1515879019269197885']) and the unit test yield two subs. That is the exact dark-to-Casey wake.
  • Distinct real threads on one card are preserved; only the thread-less duplicate is dropped. The chat-ping path is left alone.
  • One suite failure (test_scale_to_zero_watcher::test_bg_work_false_when_quiet) is pre-existing test-ordering pollution: it passes in isolation and fails identically on the base branch before this change. Not introduced here.

Two things carry forward, both already accounted for:

  • This is restart-gated (gateway notifier/emit + kanban_db). The end-to-end proof from a live thread against a restarted gateway can't be done from here; it belongs at the merge gate.
  • Face 2 (the literal origin sentinel and the bare-channel subscribe) lives in the config repo and is split to a gated follow-up. This change makes the wake robust regardless of that residue, which is the right call.

@cwest
cwest marked this pull request as ready for review July 6, 2026 21:41
@cwest
cwest merged commit 6b294f3 into cwest/integration Jul 6, 2026
31 checks passed
@cwest
cwest deleted the topic/wake-origin-thread-routing branch July 6, 2026 22:21
cwest added a commit that referenced this pull request Jul 26, 2026
…51)

A card born in a thread must report back to THAT thread, but the wake was
dispatching to the Home channel with thread=None — so from the watcher's
seat the pipeline went dark: work ran, cards moved, follow-ups were
commissioned, and none of it surfaced.

Root cause: the transition-wake destination is read verbatim off the notify
subscription row, and a thread-born card ends up with TWO rows — the correct
origin sub (chat+thread) stamped at filing, plus a later thread-less channel
sub (e.g. a review-stage defensive re-subscribe to the Home channel). The
emit-wake loop fires one wake per row, so the card double-fires: one to the
origin thread (correct) and one to Home/thread=None (dark).

Fix at both the write and the egress:
- add_notify_sub skips a thread-less sub when the same (task, platform)
  already has a thread-bearing sub, so the dark-routing duplicate is never
  written. A genuinely channel-born card (no thread sub) is unaffected.
- dedupe_wake_subs collapses a card's subs to a single wake target per
  (task, platform), preferring the thread-bearing sub, and the notifier
  gates the agent-wake claim on it — so even a legacy DB that already has
  both rows fires exactly one wake, to the origin thread. Distinct real
  threads are preserved; only the thread-less duplicate is dropped. The
  chat-ping path (separate cursor) is untouched.

Restart-gated: touches gateway notifier/emit + kanban_db; verify against a
restarted gateway. Filing-side origin resolution (literal 'origin' sentinel,
review-stage bare-channel subscribe) lives in a separate repo and is tracked
as a gated follow-up.

Tests: new unit coverage for dedupe_wake_subs + the add_notify_sub guard,
and an integration test proving a card with dup subs fires ONE wake to the
thread (RED-verified: two wakes without the gate).

(cherry picked from commit 6b294f3)
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.

1 participant