✨ feat(kanban): inherit + reassign card origin across the spawn boundary - #55
Conversation
Autonomous-wake and terminal-state routing resolve a card's delivery surface from its kanban_notify_subs row. That row is stamped at card-create from the running process's own session identity, so a workstream that crosses a spawn boundary into a detached context (dispatched worker, delegated subagent, background process, nested create) loses the human origin and its wakes route to an inert surface. There is also no atomic way to re-point a card's origin to a new thread when a workstream forks. Spec an explicit, inheritable origin channel (HERMES_KANBAN_ORIGIN, deliberately outside _VAR_MAP so it is not subject to the session identity strip guards) that is captured at the root live session and propagated to children, plus a reassign primitive/tool to move a card's origin (optionally its descendants') to a new surface without replaying history. The already-merged active-origin delivery and progressive fallback are out of scope. Design gate — review before TDD.
A card's origin — the delivery surface its transition wakes and completion notifications route to — is its kanban_notify_subs row, stamped at create time from the running process's own HERMES_SESSION_*. That is correct inside a live gateway session but wrong the moment a workstream crosses a spawn boundary into a detached context (dispatched worker, delegate_task subagent, background process, or a nested create from any of those): the session identity then names the detached run, not the human origin, so a wake for that work has nowhere real to land. There was also no way to re-point a card's origin when a workstream forks into a new thread. Add an explicit, inheritable origin channel and a reassign primitive: - HERMES_KANBAN_ORIGIN: a standalone ContextVar + os.environ mirror (NOT a _VAR_MAP member, so it is exempt from the per-message reset and the subprocess-env engaged-strip that session-identity vars get — it must SURVIVE the spawn boundary, the opposite requirement). set/get/capture helpers plus a root-capture at session bind and a handler-entry reset that mirrors the existing cross-session leak guards. - _maybe_auto_subscribe prefers the inherited origin over the running process's own session, so a child card created in a detached worker subscribes the human origin. Falls back verbatim to prior behaviour when no origin is inherited, so live-session-created cards are byte-identical. - The dispatcher seeds HERMES_KANBAN_ORIGIN into the worker env from the card's origin notify-sub (worker_origin_env), so descendant cards re-inherit it. - reassign_task_origin: atomically re-point a card's origin for a platform (delete same-platform subs + insert, cursor-seeded to the latest event so no history replay; idempotent; optional descendant cascade). Exposed as the orchestrator-only kanban_reassign_origin tool, which also refreshes the caller's origin so subsequently-created child cards inherit the new surface. Reuses the existing owning-adapter / async-delivery path (no parallel delivery), preserves prompt caching + role alternation, and keeps all HERMES_SESSION_* identity leak guards intact. Behaviour-contract tests C1–C4 + D1–D3 and an E2E that drives the real seed → create → subscribe → wake-payload chain and asserts the wake targets the inherited (and reassigned) origin surface.
cwest
left a comment
There was a problem hiding this comment.
The design holds up and matches the approved spec. Origin rides its own channel outside _VAR_MAP, so the 2026-06-21 identity-leak guards still pass unchanged, and the root-capture/reset ordering at the handler entry is correct. The inheritance path is wired end to end and I exercised the reassign tool through its real handler, not just the DB primitive: it re-points the sub and refreshes the context origin so later child cards pick up the new surface. Tests are green here too (34 new, plus the kanban_db and leak-guard suites).
One thing to fix before this lands, noted inline. reassign_task_origin promises "exactly one row for that platform," but the idempotency early-return can leave a stale sibling sub behind, which is the two-wakes-one-dark case this feature exists to close. Details on the line.
| """, | ||
| (tid, platform, chat_id, thread_norm or ""), | ||
| ).fetchone() | ||
| if existing is not None: |
There was a problem hiding this comment.
The idempotency check returns as soon as it finds the exact target row, before the same-platform DELETE runs. When a card already carries two thread-bearing subs for one platform and you re-point to one of them, the other survives, so the card ends up with two origin subs for that platform. That contradicts the docstring invariant ("replaces the card's existing row(s) for that platform with exactly one new row") and reintroduces the exact dark-second-wake hazard the same-platform delete is there to prevent.
Reproduced against this head SHA:
add_notify_sub(discord, CHAN, tA)
add_notify_sub(discord, CHAN, tB)
reassign_task_origin(discord, CHAN, tA)
# -> subs still [(discord,CHAN,tA), (discord,CHAN,tB)] # tB should be gone
The no-op intent (don't rewind a live cursor when re-pointing to the surface already in place) is right, but it should only short-circuit when that target is the card's sole same-platform sub. When other same-platform rows exist, fall through to the delete+insert so the invariant holds; preserve the existing row's cursor for the retained surface so re-pointing to the current thread still doesn't rewind it. A test with two same-platform subs re-pointed to one of them would lock this down (the current D2 test only has a single sub, so it passes through the early return without exercising the multi-sub case).
…ary (#55) * 📝 docs(kanban): spec origin inheritance + reassignability Autonomous-wake and terminal-state routing resolve a card's delivery surface from its kanban_notify_subs row. That row is stamped at card-create from the running process's own session identity, so a workstream that crosses a spawn boundary into a detached context (dispatched worker, delegated subagent, background process, nested create) loses the human origin and its wakes route to an inert surface. There is also no atomic way to re-point a card's origin to a new thread when a workstream forks. Spec an explicit, inheritable origin channel (HERMES_KANBAN_ORIGIN, deliberately outside _VAR_MAP so it is not subject to the session identity strip guards) that is captured at the root live session and propagated to children, plus a reassign primitive/tool to move a card's origin (optionally its descendants') to a new surface without replaying history. The already-merged active-origin delivery and progressive fallback are out of scope. Design gate — review before TDD. * ✨ feat(kanban): inherit + reassign card origin across the spawn boundary A card's origin — the delivery surface its transition wakes and completion notifications route to — is its kanban_notify_subs row, stamped at create time from the running process's own HERMES_SESSION_*. That is correct inside a live gateway session but wrong the moment a workstream crosses a spawn boundary into a detached context (dispatched worker, delegate_task subagent, background process, or a nested create from any of those): the session identity then names the detached run, not the human origin, so a wake for that work has nowhere real to land. There was also no way to re-point a card's origin when a workstream forks into a new thread. Add an explicit, inheritable origin channel and a reassign primitive: - HERMES_KANBAN_ORIGIN: a standalone ContextVar + os.environ mirror (NOT a _VAR_MAP member, so it is exempt from the per-message reset and the subprocess-env engaged-strip that session-identity vars get — it must SURVIVE the spawn boundary, the opposite requirement). set/get/capture helpers plus a root-capture at session bind and a handler-entry reset that mirrors the existing cross-session leak guards. - _maybe_auto_subscribe prefers the inherited origin over the running process's own session, so a child card created in a detached worker subscribes the human origin. Falls back verbatim to prior behaviour when no origin is inherited, so live-session-created cards are byte-identical. - The dispatcher seeds HERMES_KANBAN_ORIGIN into the worker env from the card's origin notify-sub (worker_origin_env), so descendant cards re-inherit it. - reassign_task_origin: atomically re-point a card's origin for a platform (delete same-platform subs + insert, cursor-seeded to the latest event so no history replay; idempotent; optional descendant cascade). Exposed as the orchestrator-only kanban_reassign_origin tool, which also refreshes the caller's origin so subsequently-created child cards inherit the new surface. Reuses the existing owning-adapter / async-delivery path (no parallel delivery), preserves prompt caching + role alternation, and keeps all HERMES_SESSION_* identity leak guards intact. Behaviour-contract tests C1–C4 + D1–D3 and an E2E that drives the real seed → create → subscribe → wake-payload chain and asserts the wake targets the inherited (and reassigned) origin surface. (cherry picked from commit 53cff68)
What & why
A card's origin — the delivery surface its transition wakes and completion notifications route to — is its
kanban_notify_subsrow, stamped at create time from the running process's ownHERMES_SESSION_*. That's correct inside a live gateway session but wrong the moment a workstream crosses a spawn boundary into a detached context (dispatched worker,delegate_tasksubagent, background process, or a nestedkanban_createfrom any of those): the session identity then names the detached run, not the human origin, so a wake for that work has nowhere real to land. There was also no way to re-point a card's origin when a workstream forks into a new thread.The active-origin delivery + progressive fallback (owning-adapter dispatch, wake-precedence, thread→Home) is already merged; this PR builds the two structural properties that were still missing: origin inheritance and origin reassignability.
Design
Spec:
docs/specs/kanban-origin-inheritance-reassign.md.HERMES_KANBAN_ORIGIN— a standaloneContextVar+os.environmirror, deliberately NOT a_VAR_MAPmember. Membership would subject it to the per-messagereset_session_varsstrip and the_inject_session_context_envengaged-strip — both correct for session identity but wrong for an inheritable origin, which must survive the spawn boundary into a detached child that legitimately has an_UNSETsession. It rides the already-copiedos.environand is overwritten only by an explicitset_kanban_origin(root capture or reassign), never implicitly.capture_root_origin_if_absentin_set_session_env) + a handler-entry reset (reset_kanban_origin, symmetric withreset_session_vars) so a live turn rebinds its own origin and a concurrent sibling's value can't leak in._maybe_auto_subscribeprefers the inherited origin over the running process's own session; falls back verbatim when none is inherited (live-session cards stay byte-identical → no regression).worker_origin_envreads the card's origin sub and_default_spawnseedsHERMES_KANBAN_ORIGINinto the worker env, so descendant cards re-inherit the human origin.reassign_task_origin: atomically re-point a card's origin for a platform (delete same-platform subs + insert, cursor seeded to the latest event → no history replay; idempotent; optional descendant cascade). Exposed as the orchestrator-onlykanban_reassign_origintool, which also refreshes the caller's origin so subsequently-created child cards inherit the new surface.Guard rails honoured
Reuses the existing owning-adapter / async-delivery path (no parallel delivery); preserves prompt caching + strict role alternation; keeps every
HERMES_SESSION_*identity leak guard intact (origin rides a separate, intentionally-inherited channel); reassign is an explicit tool call (no passive behaviour change).Tests
Behaviour-contract, not snapshots. C1–C4 (root capture / inheritance across the boundary / no-origin fallback / no identity leak) + D1–D3 (reassign replaces & seeds cursor / idempotent / fork inherits after reassign), the subprocess-bridge carry contract, and an E2E that drives the real chain end to end (
worker_origin_envseed → realkanban_createin a detached worker session → real_maybe_auto_subscribe→ realbuild_transition_payload) and asserts the wake targets the inherited (and reassigned) origin surface.Blast-radius suites green:
kanban_db(270),kanban_tools(104), session-context / leak-guard / notifier / transition / wake-routing / toolsets / ACP / MCP + the 7 new test files. Fulltests/gatewayrun: 8683 passed; the 9 failures are pre-existing flakes/pollution in unrelated files (memory-monitor timer, telegram markdown-escaping) — verified failing on the clean base with none of the changed code in their paths.