diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index 37f2d6179d11..512c90dd5970 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -102,6 +102,7 @@ "kanban_create", "kanban_unblock", "kanban_link", + "kanban_reassign_origin", ) diff --git a/docs/specs/kanban-origin-inheritance-reassign.md b/docs/specs/kanban-origin-inheritance-reassign.md new file mode 100644 index 000000000000..412fb85826a7 --- /dev/null +++ b/docs/specs/kanban-origin-inheritance-reassign.md @@ -0,0 +1,252 @@ +# Spec: Kanban origin inheritance + reassignability + +Status: implemented (design gate approved; TDD complete) +Base: `cwest/integration` @ `45a2a2aeb` +Card: t_81acbc3c +Scope: origin **inheritance** (C) + **reassignability** (D). The active-origin +delivery + progressive fallback (A/B) is already merged (#29/#32/#33/#34/#51/#52) +and is out of scope here — do not rebuild it. + +## 1. Ground truth (verified against HEAD) + +A kanban card's "origin" — the concrete delivery surface a transition wake or a +terminal-state notification routes to — is **the card's `kanban_notify_subs` +row(s)**. Nothing else. The wake emitter reads the destination verbatim off that +row: + +- `gateway/kanban_watchers.py:1011-1023` builds the transition payload with + `origin_platform / origin_chat_id / origin_thread_id` taken from `e_sub` + (a `kanban_notify_subs` row) and `origin_session_id` from the task's + `session_id`. +- `hermes_cli/kanban_db.py:9746 add_notify_sub(...)` is the sole writer of that + row. It is idempotent on `(task, platform, chat, thread)` and carries a + thread-less-MISROUTE guard: a thread-less sub is skipped when a thread-bearing + sub already exists for `(task, platform)` (two rows = two wakes, one dark). +- The row is stamped at card-creation time by + `tools/kanban_tools.py:953 _maybe_auto_subscribe(conn, task_id)`, which reads + the **currently running process's** session identity from + `HERMES_SESSION_PLATFORM / _CHAT_ID / _THREAD_ID / _USER_ID` via + `gateway.session_context.get_session_env`. + +Cron is the one emitter that already stamps an explicit origin +(`tools/cronjob_tools.py:285 _origin_from_env`), for the same reason: a cron run +is detached from any live session, so it must capture the origin at *schedule* +time and replay it at *fire* time. + +## 2. The two gaps + +### Gap C — inheritance across the spawn boundary + +`_maybe_auto_subscribe` reads the **running process's own** session env. That is +correct for a card created *inside a live gateway session* (an orchestrator in a +Discord thread fans out; the child card inherits that thread — works today). It +is **wrong** the moment the workstream crosses a spawn boundary into a detached +context: + +- a dispatched kanban worker (fresh subprocess, `default`/webhook session), +- a `delegate_task` subagent, +- a background process, +- any nested `kanban_create` issued from one of the above. + +In those contexts `HERMES_SESSION_*` reflects the *detached run's own* identity +(contextless / `webhook:` / empty), not the **human-origin session** that +started the whole workstream. Result: the child card is stamped with an inert +origin, so its wakes have nowhere real to land — "a detached run speaks into the +void" (the card's own framing). + +**Hard constraint — do NOT overload `HERMES_SESSION_*` to carry origin.** Those +vars are deliberately session-scoped and reset per message +(`reset_session_vars` at the top of `_handle_message`) precisely to stop one +session's identity leaking into a sibling's subprocess (regression tests: +`tests/gateway/test_session_context_inheritance.py`, +`tests/tools/test_local_env_session_leak.py`; production incident 2026-06-21). +Origin inheritance must therefore ride a **distinct, explicit channel** that is +*intended* to propagate to children — never the general session-identity vars. + +### Gap D — reassignability + +When a genuinely new workstream forks, an orchestrator wants to mint a new thread +in the right channel and designate it the origin for all future work on that +fork, so subsequent wakes land in the new thread, not the old one. Today the only +levers are `add_notify_sub` (blocked by the thread-less guard in the common case) +and `remove_notify_sub`. There is no atomic "re-point this card's origin to +`(platform, chat, thread)`" operation, and no way to do it for a *set* of related +cards. + +## 3. Design + +### 3.1 Origin as an explicit, inheritable value — `HERMES_KANBAN_ORIGIN` + +Introduce a single explicit origin channel, distinct from session identity: + +- A new ContextVar `_KANBAN_ORIGIN` (env name `HERMES_KANBAN_ORIGIN`) holding a + JSON blob `{"platform","chat_id","thread_id","user_id","session_id"}`. + + **It is deliberately NOT added to `_VAR_MAP`.** `_VAR_MAP` membership subjects a + var to two behaviours that are correct for session *identity* but wrong for an + *inheritable* origin: (1) the per-message `reset_session_vars` strip-to-`_UNSET` + (`_handle_message`), and (2) the `_inject_session_context_env` engaged-strip + rule (`tools/environments/local.py:322-330`) that DROPS the var from a child env + whenever THIS task's ContextVar is `_UNSET`. A detached child legitimately has + an `_UNSET` *session* but MUST keep the *inherited* origin — so origin cannot + obey the strip rule. + + Instead: + - `_KANBAN_ORIGIN` lives as a standalone ContextVar with an `os.environ` mirror + (like `set_current_session_id`), so it crosses the process boundary via the + already-copied `os.environ` in `_make_run_env` without the strip. + - `reset_session_vars` does NOT touch it (it is not in `_VAR_MAP`), so it + survives sibling-message resets. It is overwritten only by an explicit + `set_kanban_origin` (a new root capture or a reassign) — never implicitly. + - The one leak risk this reintroduces (a stale `os.environ` origin inherited by + an unrelated later turn) is bounded because origin is only ever *read* by + `_maybe_auto_subscribe` at card-create and by the reassign tool — both of + which run inside a bound turn that has already set its own origin at session + bind (root capture, §3.1 point 1). A turn that never bound an origin + (pure-CLI one-shot) has no live surface to leak *to* anyway. + +- Helpers in `session_context`: + - `set_kanban_origin(platform, chat_id, thread_id=None, user_id=None, session_id=None)` + — sets the var (and mirrors to `os.environ` for the subprocess bridge, like + `set_current_session_id`). + - `get_kanban_origin() -> dict | None` — parse the var; `None` when unset. + - `capture_kanban_origin_from_session() -> dict | None` — if + `HERMES_KANBAN_ORIGIN` is already set, return it verbatim (INHERIT); else, if + a live `HERMES_SESSION_PLATFORM`+`_CHAT_ID` exists, snapshot it as the origin + (this is the ROOT capture at the top of the chain). Detached contexts with + neither return `None`. + +**Binding points (where the origin is captured/propagated):** + +1. **Root capture — live gateway session.** At session bind + (`set_session_vars` in `_handle_message`), also + `set_kanban_origin(...)` from the just-bound session identity **iff no origin + is already inherited**. This is the top of every human-initiated workstream. +2. **Inheritance — dispatched kanban worker.** The dispatcher already passes the + card's context into the worker subprocess. When a card carries an origin + notify-sub, the dispatcher seeds `HERMES_KANBAN_ORIGIN` in the worker's env + from that sub, so anything the worker creates re-inherits the human origin. +3. **Inheritance — delegate_task / background process.** These build a child run + env; add `HERMES_KANBAN_ORIGIN` to the inherited set so the subagent/bg + process carries the origin (the subprocess-env bridge already carries + `HERMES_SESSION_*`; we add this one var to the carried set). + +### 3.2 `_maybe_auto_subscribe` prefers the inherited origin + +`_maybe_auto_subscribe` (`tools/kanban_tools.py:953`) changes its source of +truth to: + + origin = get_kanban_origin() or capture_kanban_origin_from_session() + +- If `origin` is present → stamp the notify-sub from it + (`platform/chat_id/thread_id/user_id`) via the existing `add_notify_sub`. + This is the inheritance fix: a child card created in a detached worker now + subscribes the **human origin**, not the detached run's inert identity. +- If `origin` is `None` → fall back to the existing behaviour verbatim + (current-session env → TUI key → configured `report_back_target` → no sub). + No regression for today's live-session-created cards (their inherited origin == + their own session identity, so the stamped row is byte-identical). + +The thread-less MISROUTE guard in `add_notify_sub` is unchanged and still +protects against a second dark sub. + +### 3.3 Reassignability — `reassign_task_origin` + +Add a DB primitive + a worker tool: + +- `hermes_cli/kanban_db.py: reassign_task_origin(conn, *, task_id, platform, + chat_id, thread_id=None, user_id=None, notifier_profile=None)` — inside a + single `write_txn`: DELETE the task's existing `kanban_notify_subs` rows **for + that platform**, then INSERT the new `(platform, chat_id, thread_id)` row, + seeding `last_event_id` to the latest already-existing notifiable event id (so + the re-point does NOT replay history — reuse the same cursor-seed logic + `add_notify_sub` uses for the lazy fallback). Idempotent: re-pointing to the + same surface is a no-op. Returns the new row. + - Deleting only the same-platform rows preserves multi-platform fan-out subs + while atomically moving the origin for the platform being repointed, and + sidesteps the thread-less guard (guard only blocks *adds*, not this + delete+insert). +- Optional cascade: `reassign_task_origin(..., include_descendants=True)` walks + the card's child links and repoints each, for "move the whole fork to the new + thread." (Ship single-card first; cascade behind the same function's flag.) +- Worker tool `kanban_reassign_origin` (in `tools/kanban_tools.py`) exposing the + primitive, so an orchestrator can mint a thread and designate it the origin. + Also refresh `HERMES_KANBAN_ORIGIN` in the caller's context to the new surface + so *subsequently* created child cards inherit the reassigned origin too. + +## 4. Behaviour contract (what the tests assert) + +Behaviour-contract tests, not snapshots. Resolution ORDER and invariants: + +- **C1 (root capture):** a live-session card-create stamps a notify-sub whose + `(platform, chat_id, thread_id)` == the live session's surface. (No regression — + identical to today.) +- **C2 (inheritance across boundary):** with `HERMES_KANBAN_ORIGIN` set to a + human origin and `HERMES_SESSION_*` set to a *detached/foreign* identity, a + card-create stamps the sub from the **inherited origin**, NOT the detached + identity. This is the core fix. +- **C3 (no-origin fallback):** with neither origin nor a live session, + `_maybe_auto_subscribe` behaves exactly as today (report_back_target or no sub). +- **C4 (no identity leak):** binding a kanban origin does NOT mutate any + `HERMES_SESSION_*` var; the existing inheritance/leak guards still pass + unchanged. +- **D1 (reassign):** `reassign_task_origin` to a new thread replaces the origin + sub for that platform; a subsequent transition wake resolves to the new + `(chat, thread)`; no historical-event replay (cursor seeded to latest). +- **D2 (reassign idempotent):** repointing to the current surface is a no-op + (row unchanged, cursor not rewound). +- **D3 (fork inheritance after reassign):** after `kanban_reassign_origin`, a + newly created child card inherits the reassigned surface. +- **E2E:** temp `HERMES_HOME` exercising the real chain end to end — + ``worker_origin_env`` (dispatcher seed) → real ``kanban_create`` in a detached + worker session (foreign ``HERMES_SESSION_*``) → real ``_maybe_auto_subscribe`` + stamps the inherited origin → real ``build_transition_payload`` reads that sub + → assert the transition-wake body routes to the inherited origin thread; and a + reassigned card's wake body routes to the new thread. (Asserts the wake's + resolved delivery target, which is the observable contract; the owning-adapter + turn-loop dispatch that consumes this body is the already-merged A/B path.) + +## 5. Guard rails honoured + +- Reuse the existing async-delivery + owning-adapter dispatch + wake-precedence + path (A/B); no parallel delivery path invented. +- Prompt caching + strict role alternation preserved — no synthetic mid-loop user + message; delivery is unchanged (this card only fixes *where the origin points*, + not *how* the wake is delivered). +- Wake-banner retained as grep/ID key. +- `HERMES_SESSION_*` identity semantics + leak guards untouched; origin rides a + separate, intentionally-inherited var. +- Config-flagged consistent with the existing gate: inheritance is gated by the + same `kanban.auto_subscribe_on_create` that already governs stamping; reassign + is an explicit tool call (no passive behaviour change). + +## 6. Files touched (planned) + +- `gateway/session_context.py` — standalone `_KANBAN_ORIGIN` ContextVar + its + `os.environ` mirror (NOT a `_VAR_MAP` member — see §3.1) + + `set_kanban_origin` / `get_kanban_origin` / `capture_kanban_origin_from_session` + + `capture_root_origin_if_absent` (root capture) + `reset_kanban_origin` + (handler-entry leak guard). +- `tools/kanban_tools.py` — `_maybe_auto_subscribe` prefers inherited origin; + new `kanban_reassign_origin` tool (handler + schema + registration). +- `hermes_cli/kanban_db.py` — `reassign_task_origin` primitive + + `worker_origin_env` (dispatcher origin seed) + `_default_spawn` seeds + `HERMES_KANBAN_ORIGIN` into the worker env. +- `gateway/run.py` — `reset_kanban_origin()` at handler entry (leak guard, + symmetric with `reset_session_vars`) + `capture_root_origin_if_absent()` in + `_set_session_env` (root capture at session bind). +- `toolsets.py` + `agent/transports/hermes_tools_mcp_server.py` — expose + `kanban_reassign_origin` in the kanban / hermes-cli toolsets and the MCP + orchestrator allowlist. +- Tests: `tests/gateway/test_kanban_origin_context.py`, + `tests/gateway/test_kanban_origin_reassign.py`, + `tests/gateway/test_kanban_worker_origin_seed.py`, + `tests/gateway/test_kanban_origin_e2e.py`, + `tests/tools/test_kanban_origin_bridge.py`, + `tests/tools/test_kanban_origin_inheritance.py`, + `tests/tools/test_kanban_reassign_origin_tool.py`. + +Note: the subprocess-env bridge (`tools/environments/local.py`) needs NO change — +because `HERMES_KANBAN_ORIGIN` is deliberately outside `_VAR_MAP`, it rides the +already-copied `os.environ` through every spawn surface untouched (locked in by +`tests/tools/test_kanban_origin_bridge.py`). diff --git a/gateway/run.py b/gateway/run.py index 6e0206d99d75..a4d896f90bdb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8424,6 +8424,16 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: reset_session_vars() except Exception: logger.debug("reset_session_vars failed at handler entry", exc_info=True) + # Symmetric leak guard for the kanban origin channel: drop any origin + # inherited into this task's ContextVar from a concurrent sibling (the + # same copy_context window as above). The live turn rebinds its own + # origin in _set_session_env; a legitimately-inherited worker origin + # rides the os.environ mirror (untouched here) across the spawn boundary. + try: + from gateway.session_context import reset_kanban_origin + reset_kanban_origin() + except Exception: + logger.debug("reset_kanban_origin failed at handler entry", exc_info=True) if ( getattr(self, "_startup_restore_in_progress", False) @@ -14170,7 +14180,7 @@ def _set_session_env(self, context: SessionContext) -> list: _adapters = getattr(self, "adapters", None) or {} _adapter = _adapters.get(context.source.platform) _async_delivery = getattr(_adapter, "supports_async_delivery", True) - return set_session_vars( + tokens = set_session_vars( platform=context.source.platform.value, chat_id=context.source.chat_id, chat_name=context.source.chat_name or "", @@ -14181,6 +14191,19 @@ def _set_session_env(self, context: SessionContext) -> list: message_id=str(context.source.message_id) if context.source.message_id else "", async_delivery=_async_delivery, ) + # ROOT capture of the kanban origin: a live gateway turn is authoritative + # for the origin any card it (or a subprocess it spawns) creates. This + # rebinds the origin from the just-bound live session, overriding a + # sibling value that leaked into this task's ContextVar (already reset to + # _UNSET at handler entry). It is what lets a wake for work spawned by + # this turn route back to the exact surface the work came from. See + # gateway/session_context.capture_root_origin_if_absent. + try: + from gateway.session_context import capture_root_origin_if_absent + capture_root_origin_if_absent() + except Exception: + logger.debug("capture_root_origin_if_absent failed at session bind", exc_info=True) + return tokens def _clear_session_env(self, tokens: list) -> None: """Restore session context variables to their pre-handler values.""" diff --git a/gateway/session_context.py b/gateway/session_context.py index a61ceb6c39c3..4fb70f5b7f06 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -105,6 +105,33 @@ def session_context_engaged() -> bool: # propagates that into this contextvar at session-bind time. _SESSION_ASYNC_DELIVERY: ContextVar = ContextVar("HERMES_SESSION_ASYNC_DELIVERY", default=_UNSET) +# --------------------------------------------------------------------------- +# Kanban origin — the inheritable delivery surface of a workstream +# --------------------------------------------------------------------------- +# +# A kanban card's "origin" is the concrete delivery surface a transition wake +# (or terminal-state notification) routes to. It is stamped onto the card's +# ``kanban_notify_subs`` row at card-create time. For a card created INSIDE a +# live gateway session that is simply the session's own identity. But the moment +# a workstream crosses a spawn boundary into a DETACHED context — a dispatched +# kanban worker, a delegate_task subagent, a background process, or a nested +# ``kanban_create`` from any of those — the running process's ``HERMES_SESSION_*`` +# no longer names the human-origin session; it names the detached run itself. +# A card stamped from that inert identity has a wake with nowhere real to land. +# +# 🟢 Why this rides a SEPARATE channel, NOT _VAR_MAP. The session-identity vars +# are deliberately reset per message (reset_session_vars) and stripped from a +# child env when _UNSET (the _inject_session_context_env engaged-strip) — both +# guards exist to stop one session's identity leaking into a sibling's +# subprocess (production incident 2026-06-21). Origin has the OPPOSITE +# requirement: it must SURVIVE the spawn boundary into a detached child that +# legitimately has an _UNSET session. So it cannot obey the identity-strip rule. +# It rides its own ContextVar with an os.environ mirror (like +# ``set_current_session_id``), crossing the process boundary via the +# already-copied os.environ, and is overwritten only by an explicit +# ``set_kanban_origin`` (a root capture or a reassign) — never implicitly. +_KANBAN_ORIGIN: ContextVar = ContextVar("HERMES_KANBAN_ORIGIN", default=_UNSET) + # Cron auto-delivery vars — set per-job in run_job() so concurrent jobs # don't clobber each other's delivery targets. _CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_PLATFORM", default=_UNSET) @@ -143,6 +170,156 @@ def set_current_session_id(session_id: str) -> None: _SESSION_ID.set(session_id) +# --------------------------------------------------------------------------- +# Kanban origin helpers +# --------------------------------------------------------------------------- + +_KANBAN_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" +_KANBAN_ORIGIN_FIELDS = ("platform", "chat_id", "thread_id", "user_id", "session_id") + + +def set_kanban_origin( + platform: str, + chat_id: str, + thread_id: str | None = None, + user_id: str | None = None, + session_id: str | None = None, +) -> None: + """Bind the kanban origin for this context and mirror it to ``os.environ``. + + The origin is the concrete delivery surface a transition wake for any card + created under this context should route to. Unlike the session-identity + vars, this is *intended* to propagate to child processes/subagents — the + ``os.environ`` mirror is what crosses the spawn boundary (the child inherits + the copied environ; its own ContextVar is ``_UNSET`` so it reads the mirror). + + Overwrites any previously-bound origin — call it only for a genuine ROOT + capture (top of a human-initiated workstream) or a deliberate reassign, + never implicitly per message. + """ + import json + import os + + blob = { + "platform": platform, + "chat_id": chat_id, + "thread_id": thread_id, + "user_id": user_id, + "session_id": session_id, + } + encoded = json.dumps(blob) + _KANBAN_ORIGIN.set(encoded) + os.environ[_KANBAN_ORIGIN_ENV] = encoded + + +def get_kanban_origin() -> dict | None: + """Return the bound kanban origin as a dict, or ``None`` when unset. + + Resolution order mirrors :func:`get_session_env`: the ContextVar wins when + set in this context; otherwise the ``os.environ`` mirror (the value a child + process inherits across the spawn boundary). ``None`` when neither is set or + the stored blob can't be parsed. + """ + import json + import os + + raw: Any = _KANBAN_ORIGIN.get() + if raw is _UNSET: + raw = os.environ.get(_KANBAN_ORIGIN_ENV) + if not raw: + return None + try: + parsed = json.loads(raw) + except Exception: + return None + if not isinstance(parsed, dict): + return None + return {k: parsed.get(k) for k in _KANBAN_ORIGIN_FIELDS} + + +def capture_kanban_origin_from_session() -> dict | None: + """Resolve the origin to stamp on a card being created in this context. + + Resolution order (design §3.1): + 1. **INHERIT** — an origin is already bound (ContextVar or os.environ + mirror): return it verbatim. This is the spawn-boundary case — a + detached worker/subagent whose *session* is foreign but which carries + the human origin it inherited. + 2. **ROOT capture** — no inherited origin but a live session + (``HERMES_SESSION_PLATFORM`` + ``_CHAT_ID`` bound): snapshot the live + session identity as the origin. This is the top of a human-initiated + workstream. + 3. Neither → ``None`` (a truly detached CLI/cron/test context with no + live surface to route to). + """ + inherited = get_kanban_origin() + if inherited is not None: + return inherited + platform = get_session_env("HERMES_SESSION_PLATFORM", "") + chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "") + if not platform or not chat_id: + return None + return { + "platform": platform, + "chat_id": chat_id, + "thread_id": get_session_env("HERMES_SESSION_THREAD_ID", "") or None, + "user_id": get_session_env("HERMES_SESSION_USER_ID", "") or None, + "session_id": get_session_env("HERMES_SESSION_ID", "") or None, + } + + +def capture_root_origin_if_absent() -> dict | None: + """Bind the ROOT kanban origin from the live session. + + Called at session bind (the top of a gateway turn). It is the point where a + workstream's origin is captured: the live session's surface becomes the + origin every card created under this turn (and every child process it spawns) + routes its wakes back to. + + A live gateway turn is ALWAYS authoritative for its own origin, so when a + live session (``HERMES_SESSION_PLATFORM`` + ``_CHAT_ID``) is present this + (re)binds the origin from it — overwriting any value inherited into this + task's ContextVar from a concurrent sibling (the copy_context leak window). + The handler-entry reset (``reset_kanban_origin``) drops the sibling value to + ``_UNSET`` first; this then stamps THIS turn's surface. Returns the bound + origin dict, or ``None`` when there is no live session to bind (a detached + context, which keeps whatever legitimately-inherited origin it carries). + + Detached workers never reach this path — they are subprocesses, not gateway + turns; their origin arrives via the seeded ``HERMES_KANBAN_ORIGIN`` env and + is read directly by the card-create subscribe. + """ + platform = get_session_env("HERMES_SESSION_PLATFORM", "") + chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "") + if not platform or not chat_id: + return None + set_kanban_origin( + platform=platform, + chat_id=chat_id, + thread_id=get_session_env("HERMES_SESSION_THREAD_ID", "") or None, + user_id=get_session_env("HERMES_SESSION_USER_ID", "") or None, + session_id=get_session_env("HERMES_SESSION_ID", "") or None, + ) + return get_kanban_origin() + + +def reset_kanban_origin() -> None: + """Drop this task's inherited origin ContextVar (handler-entry leak guard). + + Symmetric with :func:`reset_session_vars`: a per-message task spawned via + ``create_task`` inherits the spawning context's ContextVars, including a + concurrent sibling's kanban origin. Reset it to ``_UNSET`` at handler entry + so a live gateway turn rebinds its OWN origin (via + :func:`capture_root_origin_if_absent`) rather than acting on the sibling's. + + Only the task-local ContextVar is reset — the ``os.environ`` mirror is + process-global and left intact (a live turn overwrites it when it rebinds; + a legitimately-inherited worker origin rides the mirror across the spawn + boundary and must survive). + """ + _KANBAN_ORIGIN.set(_UNSET) + + def set_session_vars( platform: str = "", source: str = "", diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index ba8b6bc25cd1..a899bfe09a7c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -9225,6 +9225,25 @@ def _default_spawn( env["GIT_COMMITTER_NAME"] = _git_name env["GIT_COMMITTER_EMAIL"] = _git_email + # Seed the kanban ORIGIN so any card the worker creates re-inherits the human + # origin of this workstream, not the detached worker's own (contextless) + # session. The worker is a fresh subprocess; without this, a nested + # kanban_create would stamp an inert origin and its wakes would have nowhere + # to land. Read the card's origin notify-sub and pass it through the + # inheritable HERMES_KANBAN_ORIGIN channel (see gateway/session_context and + # kanban_db.worker_origin_env). Best-effort: a card with no routable origin + # sub leaves the worker as a plain detached context. + try: + _conn = connect(board=board) + try: + _origin_blob = worker_origin_env(_conn, task.id) + finally: + _conn.close() + if _origin_blob: + env["HERMES_KANBAN_ORIGIN"] = _origin_blob + except Exception: + _log.debug("worker origin seed failed for %s", task.id, exc_info=True) + cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, @@ -9743,6 +9762,42 @@ def notifier_delivery_profile() -> str: return "default" +def worker_origin_env(conn: sqlite3.Connection, task_id: str) -> Optional[str]: + """Return the ``HERMES_KANBAN_ORIGIN`` seed for a dispatched worker. + + The dispatcher spawns a worker as a detached subprocess whose own session is + contextless. So any card the worker creates would, without help, stamp an + inert origin. This reads the card's origin ``kanban_notify_subs`` row and + encodes it as the JSON blob the worker inherits via ``HERMES_KANBAN_ORIGIN``, + so descendant cards re-inherit the human origin (design §3.1 point 2). + + Selection: prefer a thread-bearing sub over a bare-channel one (a thread is + the most specific delivery surface); skip ``tui`` subs (a local single-UI + channel, not a routable chat surface an autonomous wake can address). Returns + ``None`` when the card has no routable origin sub — the worker then behaves as + a plain detached context (no origin to inherit). + """ + rows = [dict(r) for r in list_notify_subs(conn, task_id)] + routable = [ + r for r in rows + if (r.get("platform") or "") and (r.get("platform") != "tui") + and (r.get("chat_id") or "") + ] + if not routable: + return None + # Prefer a thread-bearing origin (most specific surface). + routable.sort(key=lambda r: 0 if (r.get("thread_id") or "").strip() else 1) + best = routable[0] + blob = { + "platform": best.get("platform"), + "chat_id": best.get("chat_id"), + "thread_id": (best.get("thread_id") or "").strip() or None, + "user_id": best.get("user_id") or None, + "session_id": None, + } + return json.dumps(blob) + + def add_notify_sub( conn: sqlite3.Connection, *, @@ -9842,6 +9897,117 @@ def remove_notify_sub( return cur.rowcount > 0 +def reassign_task_origin( + conn: sqlite3.Connection, + *, + task_id: str, + platform: str, + chat_id: str, + thread_id: Optional[str] = None, + user_id: Optional[str] = None, + notifier_profile: Optional[str] = None, + include_descendants: bool = False, +) -> dict: + """Atomically re-point a card's origin for ``platform`` to a new surface. + + When a workstream forks, an orchestrator mints a new thread and designates it + the origin for future wakes. This replaces the card's existing + ``kanban_notify_subs`` row(s) **for that platform** with exactly one new + ``(platform, chat_id, thread_id)`` row. + + Two properties that make this correct: + + - **Same-platform only.** Deleting only the rows for ``platform`` preserves a + multi-platform fan-out (e.g. a telegram sub survives a discord re-point) and + sidesteps the thread-less MISROUTE guard in :func:`add_notify_sub` (that + guard only blocks *adds* against an existing thread-bearing row; the + delete+insert here has no such conflict). + - **No history replay.** The new row's ``last_event_id`` is seeded to the + task's latest existing event id, so a transition wake *after* the re-point + fires only on strictly-future events — a re-point never floods the new + thread with the card's back-history. + + Idempotent: re-pointing to the surface the card already has is a no-op — the + existing row (and its live cursor) is left untouched, never rewound. + + ``include_descendants`` optionally walks the card's child links and repoints + each the same way ("move the whole fork to the new thread"). Ships behind this + flag; default single-card. + + Returns the new (or unchanged) origin row as a dict. + """ + thread_norm = (thread_id or "").strip() or None + + def _reassign_one(tid: str) -> dict: + with write_txn(conn): + # D2 idempotency: if the exact target row already exists, no-op. + # Do NOT delete+reinsert — that would rewind the live cursor and + # replay history the existing sub has already delivered. + existing = conn.execute( + """ + SELECT * FROM kanban_notify_subs + WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? + """, + (tid, platform, chat_id, thread_norm or ""), + ).fetchone() + if existing is not None: + return dict(existing) + + # Seed the new cursor to the task's latest event id so the re-point + # never replays back-history onto the new surface. + row = conn.execute( + "SELECT MAX(id) AS max_id FROM task_events WHERE task_id = ?", + (tid,), + ).fetchone() + seed = int(row["max_id"]) if row and row["max_id"] is not None else 0 + + # Drop every existing same-platform sub (the old origin thread[s]). + conn.execute( + "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ?", + (tid, platform), + ) + now = int(time.time()) + conn.execute( + """ + INSERT INTO kanban_notify_subs + (task_id, platform, chat_id, thread_id, user_id, notifier_profile, last_event_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (tid, platform, chat_id, thread_norm or "", user_id, + notifier_profile, seed, now), + ) + new_row = conn.execute( + """ + SELECT * FROM kanban_notify_subs + WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? + """, + (tid, platform, chat_id, thread_norm or ""), + ).fetchone() + return dict(new_row) if new_row is not None else {} + + result = _reassign_one(task_id) + + if include_descendants: + # Walk child links breadth-first and repoint each descendant. + seen: set[str] = {task_id} + frontier = [task_id] + while frontier: + parent = frontier.pop() + child_rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ?", + (parent,), + ).fetchall() + for cr in child_rows: + child = cr["child_id"] + if child in seen: + continue + seen.add(child) + frontier.append(child) + _reassign_one(child) + + return result + + def unseen_events_for_sub( conn: sqlite3.Connection, *, diff --git a/tests/gateway/test_kanban_origin_context.py b/tests/gateway/test_kanban_origin_context.py new file mode 100644 index 000000000000..b0140ec2362e --- /dev/null +++ b/tests/gateway/test_kanban_origin_context.py @@ -0,0 +1,267 @@ +"""Origin-channel ContextVar behaviour for kanban inheritance. + +The kanban "origin" (the concrete delivery surface a transition wake routes to) +must survive the spawn boundary into detached contexts (dispatched worker, +delegate_task, background process) WITHOUT riding the session-identity vars, +which are deliberately reset/stripped per message to stop cross-session leaks. + +These tests pin the contract of the standalone ``HERMES_KANBAN_ORIGIN`` channel: + +- it is NOT a member of ``_VAR_MAP`` (so ``reset_session_vars`` and the + ``_inject_session_context_env`` engaged-strip never touch it); +- ``set_kanban_origin`` mirrors to ``os.environ`` (like ``set_current_session_id``) + so it crosses the process boundary via the already-copied environ; +- ``capture_kanban_origin_from_session`` INHERITS an already-set origin verbatim, + else snapshots the live ``HERMES_SESSION_*`` as the root capture, else ``None``; +- binding an origin does NOT mutate any ``HERMES_SESSION_*`` var (C4 invariant). +""" + +import json +import os + +import pytest + +import gateway.session_context as sc +from gateway.session_context import ( + _VAR_MAP, + capture_kanban_origin_from_session, + clear_session_vars, + get_kanban_origin, + reset_session_vars, + set_kanban_origin, + set_session_vars, +) + +_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" +SESSION_VARS = list(_VAR_MAP.keys()) + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + """Clean ContextVar + os.environ + engaged-latch + origin slate per test.""" + saved_env = {k: os.environ.get(k) for k in SESSION_VARS} + saved_origin = os.environ.get(_ORIGIN_ENV) + saved_ctx = {name: var.get() for name, var in _VAR_MAP.items()} + saved_engaged = sc._session_context_engaged + saved_origin_ctx = sc._KANBAN_ORIGIN.get() + for var in _VAR_MAP.values(): + var.set(sc._UNSET) + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ.pop(_ORIGIN_ENV, None) + sc._session_context_engaged = False + try: + yield + finally: + for var, val in zip(_VAR_MAP.values(), saved_ctx.values()): + var.set(val) + sc._KANBAN_ORIGIN.set(saved_origin_ctx) + sc._session_context_engaged = saved_engaged + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + if saved_origin is None: + os.environ.pop(_ORIGIN_ENV, None) + else: + os.environ[_ORIGIN_ENV] = saved_origin + + +# --------------------------------------------------------------------------- # +# The channel is deliberately OUTSIDE _VAR_MAP. +# --------------------------------------------------------------------------- # + +def test_origin_var_not_in_var_map(): + """The origin var must NOT be a _VAR_MAP member (design §3.1). + + Membership would subject it to reset_session_vars strip-to-_UNSET and the + _inject_session_context_env engaged-strip, both of which would drop the + inherited origin in a detached child — exactly what must NOT happen. + """ + assert _ORIGIN_ENV not in _VAR_MAP + + +# --------------------------------------------------------------------------- # +# set / get round-trip + os.environ mirror +# --------------------------------------------------------------------------- # + +def test_set_get_round_trip(): + set_kanban_origin( + platform="discord", chat_id="C1", thread_id="T1", + user_id="U1", session_id="S1", + ) + got = get_kanban_origin() + assert got == { + "platform": "discord", "chat_id": "C1", "thread_id": "T1", + "user_id": "U1", "session_id": "S1", + } + + +def test_set_mirrors_to_os_environ_for_subprocess_bridge(): + """Origin must mirror to os.environ so it crosses the process boundary.""" + set_kanban_origin(platform="discord", chat_id="C1", thread_id="T1") + blob = os.environ.get(_ORIGIN_ENV) + assert blob, "origin was not mirrored into os.environ" + parsed = json.loads(blob) + assert parsed["platform"] == "discord" + assert parsed["chat_id"] == "C1" + assert parsed["thread_id"] == "T1" + + +def test_get_reads_os_environ_when_contextvar_unset(): + """A child process inherits origin only via os.environ (ContextVar _UNSET).""" + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ[_ORIGIN_ENV] = json.dumps( + {"platform": "discord", "chat_id": "C9", "thread_id": "T9"} + ) + got = get_kanban_origin() + assert got["platform"] == "discord" + assert got["chat_id"] == "C9" + assert got["thread_id"] == "T9" + + +def test_get_returns_none_when_unset_everywhere(): + assert get_kanban_origin() is None + + +# --------------------------------------------------------------------------- # +# capture: INHERIT existing > snapshot live session > None +# --------------------------------------------------------------------------- # + +def test_capture_inherits_existing_origin_verbatim(): + """When an origin is already set, capture returns it verbatim (INHERIT). + + Even if HERMES_SESSION_* names a DIFFERENT (detached) identity, the inherited + origin wins — this is the whole point of crossing the spawn boundary. + """ + set_kanban_origin(platform="discord", chat_id="HUMAN", thread_id="HT") + tokens = set_session_vars( + platform="webhook", chat_id="DETACHED", thread_id="", + ) + try: + got = capture_kanban_origin_from_session() + finally: + clear_session_vars(tokens) + assert got["platform"] == "discord" + assert got["chat_id"] == "HUMAN" + assert got["thread_id"] == "HT" + + +def test_capture_snapshots_live_session_as_root(): + """No inherited origin but a live session → snapshot it as the ROOT origin.""" + tokens = set_session_vars( + platform="discord", chat_id="ROOTCHAT", thread_id="ROOTTHREAD", + user_id="ROOTUSER", session_id="ROOTSESS", + ) + try: + got = capture_kanban_origin_from_session() + finally: + clear_session_vars(tokens) + assert got["platform"] == "discord" + assert got["chat_id"] == "ROOTCHAT" + assert got["thread_id"] == "ROOTTHREAD" + + +def test_capture_returns_none_in_detached_context(): + """No inherited origin AND no live session (platform/chat) → None.""" + assert capture_kanban_origin_from_session() is None + + +# --------------------------------------------------------------------------- # +# C4 — binding an origin does NOT mutate session identity. +# --------------------------------------------------------------------------- # + +def test_set_origin_does_not_mutate_session_vars(): + """C4: origin binding leaves every HERMES_SESSION_* var untouched.""" + before = {name: var.get() for name, var in _VAR_MAP.items()} + set_kanban_origin(platform="discord", chat_id="C", thread_id="T") + after = {name: var.get() for name, var in _VAR_MAP.items()} + assert before == after + + +def test_reset_session_vars_does_not_clear_origin(): + """C2 foundation: reset_session_vars (per-message) must NOT drop the origin. + + A freshly-spawned task resets its session identity at the top of the handler, + but the inherited kanban origin must survive that reset. + """ + set_kanban_origin(platform="discord", chat_id="KEEP", thread_id="KT") + reset_session_vars() + got = get_kanban_origin() + assert got is not None + assert got["chat_id"] == "KEEP" + + +# --------------------------------------------------------------------------- # +# Root capture at session bind (does not clobber an inherited origin). +# --------------------------------------------------------------------------- # + +def test_root_capture_binds_live_session_when_no_origin(): + """A live session with no inherited origin becomes the ROOT origin.""" + from gateway.session_context import capture_root_origin_if_absent + tokens = set_session_vars( + platform="discord", chat_id="ROOT", thread_id="RT", user_id="RU", + ) + try: + captured = capture_root_origin_if_absent() + finally: + clear_session_vars(tokens) + assert captured is not None + got = get_kanban_origin() + assert got["platform"] == "discord" + assert got["chat_id"] == "ROOT" + assert got["thread_id"] == "RT" + + +def test_root_capture_live_session_is_authoritative(): + """A live gateway turn (re)binds its OWN origin, overriding a leaked one. + + The handler-entry reset_kanban_origin drops a sibling's inherited origin to + _UNSET; the live turn then stamps its own surface. Simulate that sequence. + """ + from gateway.session_context import ( + capture_root_origin_if_absent, + reset_kanban_origin, + ) + # A concurrent sibling had leaked its origin into this task's ContextVar. + set_kanban_origin(platform="discord", chat_id="SIBLING_LEAK", thread_id="X") + # Handler entry resets it; the live turn binds its own. + reset_kanban_origin() + tokens = set_session_vars( + platform="discord", chat_id="MY_LIVE", thread_id="MT", + ) + try: + capture_root_origin_if_absent() + finally: + clear_session_vars(tokens) + got = get_kanban_origin() + assert got["chat_id"] == "MY_LIVE", got + + +def test_detached_context_preserves_inherited_worker_origin(): + """No live session (a worker subprocess) → inherited worker origin survives. + + A dispatched worker carries the human origin via the os.environ mirror and + never binds a live gateway session; capture must NOT wipe it. + """ + from gateway.session_context import capture_root_origin_if_absent + import os + # Simulate the seeded worker env: origin in the mirror, ContextVar unset. + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ[_ORIGIN_ENV] = json.dumps( + {"platform": "discord", "chat_id": "WORKER_HUMAN", "thread_id": "WT"} + ) + # No live session bound. + captured = capture_root_origin_if_absent() + assert captured is None # nothing to bind (no live session) + got = get_kanban_origin() + assert got["chat_id"] == "WORKER_HUMAN", got + + +def test_root_capture_noop_without_live_session(): + """No live session (platform/chat) and no inherited origin → binds nothing.""" + from gateway.session_context import capture_root_origin_if_absent + captured = capture_root_origin_if_absent() + assert captured is None + assert get_kanban_origin() is None + diff --git a/tests/gateway/test_kanban_origin_e2e.py b/tests/gateway/test_kanban_origin_e2e.py new file mode 100644 index 000000000000..d23516e0ebfc --- /dev/null +++ b/tests/gateway/test_kanban_origin_e2e.py @@ -0,0 +1,150 @@ +"""E2E: an inherited-origin (and a reassigned) card's wake targets the right surface. + +Exercises the real chain end to end against a temp HERMES_HOME — no mocks of the +units under test: + + dispatcher seed (worker_origin_env → HERMES_KANBAN_ORIGIN) + → real kanban_create in a DETACHED worker session (foreign HERMES_SESSION_*) + → real _maybe_auto_subscribe stamps the INHERITED origin as the child's sub + → real build_transition_payload reads that sub + → assert the transition-wake body routes to the inherited origin thread. + +Then the reassign path: kanban_reassign_origin re-points the card, and a wake +built from the re-pointed sub targets the NEW thread. + +This is the actual defect the card targets: a wake for work spawned across the +spawn boundary must land on the human origin surface, not a detached/void one. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +import gateway.session_context as sc +from gateway.kanban_transition_emit import build_transition_payload +from gateway.session_context import _VAR_MAP + +SESSION_VARS = list(_VAR_MAP.keys()) +_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / ".hermes" + h.mkdir() + monkeypatch.setenv("HERMES_HOME", str(h)) + monkeypatch.setenv("HERMES_PROFILE", "test-worker") + from pathlib import Path as _Path + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + # Clean origin + session slate. + saved_ctx = sc._KANBAN_ORIGIN.get() + sc._KANBAN_ORIGIN.set(sc._UNSET) + for v in SESSION_VARS + [_ORIGIN_ENV]: + monkeypatch.delenv(v, raising=False) + try: + yield h + finally: + sc._KANBAN_ORIGIN.set(saved_ctx) + + +def _subs(task_id): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return [dict(r) for r in kb.list_notify_subs(conn, task_id)] + finally: + conn.close() + + +def _origin_sub(task_id, platform="discord"): + subs = [s for s in _subs(task_id) if s["platform"] == platform] + assert len(subs) == 1, subs + return subs[0] + + +def test_e2e_inherited_origin_wake_targets_human_thread(home, monkeypatch): + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + # 1) A human-origin card exists with its origin notify-sub (the workstream root). + conn = kb.connect() + try: + root_tid = kb.create_task(conn, title="root", assignee="peer") + kb.add_notify_sub( + conn, task_id=root_tid, platform="discord", chat_id="HUMAN_CHAN", + thread_id="HUMAN_THREAD", user_id="HUMAN_USER", notifier_profile="p", + ) + # 2) The dispatcher computes the origin seed for the worker it spawns. + seed = kb.worker_origin_env(conn, root_tid) + finally: + conn.close() + assert seed is not None + + # 3) The worker runs DETACHED: its own session is foreign/contextless, but it + # carries the inherited origin via HERMES_KANBAN_ORIGIN (as _default_spawn + # seeds it). Simulate that env exactly. + monkeypatch.setenv(_ORIGIN_ENV, seed) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "webhook") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "DETACHED_WORKER") + + # 4) The worker creates a follow-up child card through the REAL tool path. + out = kt._handle_create({"title": "child-of-fork", "assignee": "peer"}) + d = json.loads(out) + assert d["ok"] is True and d["subscribed"] is True, d + child_tid = d["task_id"] + + # 5) The child's origin sub is the INHERITED human surface, not the worker's. + child_sub = _origin_sub(child_tid) + assert child_sub["chat_id"] == "HUMAN_CHAN" + assert child_sub["thread_id"] == "HUMAN_THREAD" + + # 6) The notifier builds a transition wake from that sub → body routes to the + # human origin thread (the actual delivery target), NOT a void/webhook one. + body = build_transition_payload( + task_id=child_tid, board="default", kind="blocked", reason=None, + event_id=42, title="child-of-fork", + origin_platform=child_sub["platform"], + origin_chat_id=child_sub["chat_id"], + origin_thread_id=child_sub["thread_id"], + ) + assert body["origin_platform"] == "discord" + assert body["origin_chat_id"] == "HUMAN_CHAN" + assert body["origin_thread_id"] == "HUMAN_THREAD" + + +def test_e2e_reassigned_origin_wake_targets_new_thread(home, monkeypatch): + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="fork-me", assignee="peer") + kb.add_notify_sub( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="OLD_THREAD", notifier_profile="p", + ) + finally: + conn.close() + + # Reassign the origin to a freshly-minted thread via the REAL tool. + out = kt._handle_reassign_origin({ + "task_id": tid, "platform": "discord", + "chat_id": "CHAN", "thread_id": "NEW_THREAD", + }) + assert json.loads(out)["ok"] is True + + sub = _origin_sub(tid) + assert sub["thread_id"] == "NEW_THREAD" + + body = build_transition_payload( + task_id=tid, board="default", kind="status_changed", reason=None, + event_id=7, + origin_platform=sub["platform"], + origin_chat_id=sub["chat_id"], + origin_thread_id=sub["thread_id"], + ) + assert body["origin_thread_id"] == "NEW_THREAD" + assert body.get("origin_thread_id") != "OLD_THREAD" diff --git a/tests/gateway/test_kanban_origin_reassign.py b/tests/gateway/test_kanban_origin_reassign.py new file mode 100644 index 000000000000..e6970af2ae34 --- /dev/null +++ b/tests/gateway/test_kanban_origin_reassign.py @@ -0,0 +1,119 @@ +"""D1/D2: reassign_task_origin — atomically re-point a card's origin. + +When a workstream forks, an orchestrator mints a new thread and designates it the +origin for future wakes. The DB primitive re-points the card's notify-sub(s) for +a platform to a new ``(chat_id, thread_id)``: + +- D1 (reassign): existing same-platform sub(s) are replaced by exactly the new + surface; the new sub's cursor is seeded to the latest existing event id so a + transition wake after the re-point resolves to the NEW surface and NO + historical event is replayed. +- D2 (idempotent): re-pointing to the current surface leaves the row (and its + live cursor) unchanged — no rewind, no history flood. + +Deleting only the SAME-platform rows preserves multi-platform fan-out subs and +sidesteps the thread-less MISROUTE guard (which only blocks *adds*). +""" + +from __future__ import annotations + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def conn(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + c = kb.connect() + try: + yield c + finally: + c.close() + + +def _subs(conn, task_id): + return [dict(r) for r in kb.list_notify_subs(conn, task_id)] + + +def _make_task_with_events(conn, n_events=3): + tid = kb.create_task(conn, title="fork me", assignee="peer") + # Generate some history so we can assert the cursor seed suppresses replay. + for i in range(n_events): + kb.add_comment(conn, tid, author="tester", body=f"event {i}") + return tid + + +def test_d1_reassign_replaces_same_platform_sub_and_seeds_cursor(conn): + tid = _make_task_with_events(conn) + # Original origin: an old thread. + kb.add_notify_sub( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="OLD_THREAD", user_id="U", notifier_profile="p", + ) + # A different-platform sub that must be PRESERVED. + kb.add_notify_sub( + conn, task_id=tid, platform="telegram", chat_id="TG", thread_id="TG_T", + ) + + latest_before = max(e.id for e in kb.list_events(conn, tid)) + + row = kb.reassign_task_origin( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="NEW_THREAD", user_id="U", notifier_profile="p", + ) + + subs = _subs(conn, tid) + discord = [s for s in subs if s["platform"] == "discord"] + telegram = [s for s in subs if s["platform"] == "telegram"] + + # Exactly one discord sub, pointing at the NEW thread; old thread gone. + assert len(discord) == 1, subs + assert discord[0]["thread_id"] == "NEW_THREAD" + assert all(s["thread_id"] != "OLD_THREAD" for s in discord) + # Multi-platform fan-out preserved. + assert len(telegram) == 1 and telegram[0]["thread_id"] == "TG_T" + # Cursor seeded to the latest existing event → no history replay. + assert discord[0]["last_event_id"] >= latest_before + # Return value describes the new row. + assert row["platform"] == "discord" and row["thread_id"] == "NEW_THREAD" + + +def test_d2_reassign_to_current_surface_is_noop(conn): + tid = _make_task_with_events(conn) + kb.add_notify_sub( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="THREAD", notifier_profile="p", + ) + # Advance the cursor to a live value to prove it is not rewound. + with kb.write_txn(conn): + conn.execute( + "UPDATE kanban_notify_subs SET last_event_id = 999 " + "WHERE task_id = ? AND platform = 'discord'", + (tid,), + ) + + kb.reassign_task_origin( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="THREAD", notifier_profile="p", + ) + + subs = [s for s in _subs(conn, tid) if s["platform"] == "discord"] + assert len(subs) == 1 + assert subs[0]["thread_id"] == "THREAD" + # Idempotent: the live cursor must not be rewound. + assert subs[0]["last_event_id"] == 999 + + +def test_d1_reassign_when_no_prior_sub_creates_one(conn): + """Re-pointing a card that had no origin sub yet just creates the new one.""" + tid = _make_task_with_events(conn) + row = kb.reassign_task_origin( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="NEW", notifier_profile="p", + ) + subs = [s for s in _subs(conn, tid) if s["platform"] == "discord"] + assert len(subs) == 1 + assert subs[0]["thread_id"] == "NEW" + assert row["thread_id"] == "NEW" diff --git a/tests/gateway/test_kanban_worker_origin_seed.py b/tests/gateway/test_kanban_worker_origin_seed.py new file mode 100644 index 000000000000..a33686afedd4 --- /dev/null +++ b/tests/gateway/test_kanban_worker_origin_seed.py @@ -0,0 +1,66 @@ +"""Dispatcher seeds HERMES_KANBAN_ORIGIN into the worker env (inheritance). + +When the dispatcher spawns a worker subprocess for a card, the worker's own +session identity is detached (it is a fresh `hermes -p chat -q` run). +For any card the worker CREATES to inherit the human origin of the workstream, +the dispatcher must seed HERMES_KANBAN_ORIGIN from the card's origin notify-sub. + +``worker_origin_env`` builds that seed value from the card's notify-sub row. +""" + +from __future__ import annotations + +import json + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def conn(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + c = kb.connect() + try: + yield c + finally: + c.close() + + +def test_worker_origin_env_from_thread_bearing_sub(conn): + tid = kb.create_task(conn, title="t", assignee="peer") + kb.add_notify_sub( + conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="THREAD", user_id="U", notifier_profile="p", + ) + blob = kb.worker_origin_env(conn, tid) + assert blob is not None + parsed = json.loads(blob) + assert parsed["platform"] == "discord" + assert parsed["chat_id"] == "CHAN" + assert parsed["thread_id"] == "THREAD" + + +def test_worker_origin_env_prefers_thread_bearing_over_threadless(conn): + """When both a thread sub and a bare-channel sub exist, prefer the thread one.""" + tid = kb.create_task(conn, title="t", assignee="peer") + # (add_notify_sub's guard actually blocks the second here, but assert the + # selection is robust regardless of insert order.) + kb.add_notify_sub(conn, task_id=tid, platform="discord", chat_id="CHAN", + thread_id="THREAD") + blob = kb.worker_origin_env(conn, tid) + parsed = json.loads(blob) + assert parsed["thread_id"] == "THREAD" + + +def test_worker_origin_env_none_when_no_sub(conn): + tid = kb.create_task(conn, title="t", assignee="peer") + assert kb.worker_origin_env(conn, tid) is None + + +def test_worker_origin_env_ignores_tui_and_report_back_only(conn): + """A 'tui' sub is a local UI channel, not a routable chat origin → skip it.""" + tid = kb.create_task(conn, title="t", assignee="peer") + kb.add_notify_sub(conn, task_id=tid, platform="tui", chat_id="sess-key") + assert kb.worker_origin_env(conn, tid) is None diff --git a/tests/tools/test_kanban_origin_bridge.py b/tests/tools/test_kanban_origin_bridge.py new file mode 100644 index 000000000000..b7884bc1e1cf --- /dev/null +++ b/tests/tools/test_kanban_origin_bridge.py @@ -0,0 +1,125 @@ +"""The kanban origin must SURVIVE the subprocess-env bridge (inheritance). + +Complement to ``test_local_env_session_leak.py``: that suite proves the +``HERMES_SESSION_*`` identity vars are STRIPPED across the bridge when unset in +an engaged process (leak guard). This suite proves the opposite contract for +``HERMES_KANBAN_ORIGIN`` — it must be CARRIED across the bridge so a detached +child (dispatched worker / delegate_task / background process) inherits the +human origin of the workstream that spawned it. + +The origin is deliberately NOT a ``_VAR_MAP`` member, so it is not subject to the +engaged-strip. It rides its ``os.environ`` mirror, which the bridge preserves. +""" + +import json +import os + +import pytest + +import gateway.session_context as sc +from gateway.session_context import _VAR_MAP, set_kanban_origin +from tools.environments.local import ( + _make_run_env, + _sanitize_subprocess_env, + hermes_subprocess_env, +) + +_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" +SESSION_VARS = list(_VAR_MAP.keys()) + + +@pytest.fixture(autouse=True) +def _isolate(): + saved_env = {k: os.environ.get(k) for k in SESSION_VARS} + saved_origin = os.environ.get(_ORIGIN_ENV) + saved_ctx = {name: var.get() for name, var in _VAR_MAP.items()} + saved_engaged = sc._session_context_engaged + saved_origin_ctx = sc._KANBAN_ORIGIN.get() + for var in _VAR_MAP.values(): + var.set(sc._UNSET) + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ.pop(_ORIGIN_ENV, None) + sc._session_context_engaged = False + try: + yield + finally: + for var, val in zip(_VAR_MAP.values(), saved_ctx.values()): + var.set(val) + sc._KANBAN_ORIGIN.set(saved_origin_ctx) + sc._session_context_engaged = saved_engaged + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + if saved_origin is None: + os.environ.pop(_ORIGIN_ENV, None) + else: + os.environ[_ORIGIN_ENV] = saved_origin + + +def _engage(): + sc._session_context_engaged = True + + +def _assert_origin(env, chat_id): + blob = env.get(_ORIGIN_ENV) + assert blob, f"{_ORIGIN_ENV} was not carried into the child env" + assert json.loads(blob)["chat_id"] == chat_id + + +# --------------------------------------------------------------------------- # +# Foreground path (_make_run_env) +# --------------------------------------------------------------------------- # + +def test_origin_carried_across_bridge_when_engaged_and_session_detached(): + """The core inheritance case: engaged host, DETACHED session, origin set. + + A dispatched worker's session identity is foreign/unset (so the identity + vars strip), but the inherited kanban origin must ride through to the child. + """ + _engage() + set_kanban_origin(platform="discord", chat_id="HUMAN_ORIGIN", thread_id="HT") + env = _make_run_env({}) + _assert_origin(env, "HUMAN_ORIGIN") + + +def test_origin_carried_via_os_environ_mirror_when_contextvar_unset(): + """A grandchild process (ContextVar _UNSET, only the mirror set) still carries it.""" + _engage() + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ[_ORIGIN_ENV] = json.dumps( + {"platform": "discord", "chat_id": "GRANDCHILD", "thread_id": "GT"} + ) + env = _make_run_env({}) + _assert_origin(env, "GRANDCHILD") + + +def test_no_origin_leaves_var_absent(): + """No origin bound anywhere → the child env carries no origin var.""" + _engage() + env = _make_run_env({}) + assert _ORIGIN_ENV not in env + + +# --------------------------------------------------------------------------- # +# Background / PTY path (_sanitize_subprocess_env) +# --------------------------------------------------------------------------- # + +def test_origin_carried_across_background_bridge(): + _engage() + set_kanban_origin(platform="discord", chat_id="BG_ORIGIN", thread_id="BT") + base = {"PATH": "/usr/bin:/bin", _ORIGIN_ENV: os.environ[_ORIGIN_ENV]} + sanitized = _sanitize_subprocess_env(base) + _assert_origin(sanitized, "BG_ORIGIN") + + +# --------------------------------------------------------------------------- # +# Non-terminal spawn surface (hermes_subprocess_env) +# --------------------------------------------------------------------------- # + +def test_origin_carried_across_hermes_subprocess_env(): + _engage() + set_kanban_origin(platform="discord", chat_id="SPAWN_ORIGIN", thread_id="ST") + env = hermes_subprocess_env() + _assert_origin(env, "SPAWN_ORIGIN") diff --git a/tests/tools/test_kanban_origin_inheritance.py b/tests/tools/test_kanban_origin_inheritance.py new file mode 100644 index 000000000000..6e65c90a4ac3 --- /dev/null +++ b/tests/tools/test_kanban_origin_inheritance.py @@ -0,0 +1,133 @@ +"""C1–C3: _maybe_auto_subscribe prefers the INHERITED kanban origin. + +The card's origin is its ``kanban_notify_subs`` row, stamped at create time by +``_maybe_auto_subscribe``. Historically that row was sourced from the running +process's own ``HERMES_SESSION_*``. That is correct for a card created inside a +live gateway session (root capture) but WRONG once the workstream crosses a spawn +boundary into a detached context (dispatched worker / delegate_task / background +process), where ``HERMES_SESSION_*`` names the detached run, not the human origin. + +These tests pin the fixed source-of-truth: + + origin = get_kanban_origin() or capture_kanban_origin_from_session() + +- C1 (root capture, no regression): live session, no inherited origin → stamp + the live surface (byte-identical to today). +- C2 (inheritance across boundary — the core fix): inherited origin set + + a DETACHED/foreign session → stamp the INHERITED origin, not the detached one. +- C3 (no-origin fallback): neither inherited origin nor live session → behave + exactly as today (no sub in a CLI/test context). +""" + +from __future__ import annotations + +import json +import os + +import pytest + +import gateway.session_context as sc +from gateway.session_context import _VAR_MAP, set_kanban_origin + +SESSION_VARS = list(_VAR_MAP.keys()) +_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" + + +@pytest.fixture(autouse=True) +def _isolate_origin(): + """Reset the origin ContextVar + mirror around each test (worker_env owns HOME).""" + saved_origin_ctx = sc._KANBAN_ORIGIN.get() + saved_origin_env = os.environ.get(_ORIGIN_ENV) + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ.pop(_ORIGIN_ENV, None) + try: + yield + finally: + sc._KANBAN_ORIGIN.set(saved_origin_ctx) + if saved_origin_env is None: + os.environ.pop(_ORIGIN_ENV, None) + else: + os.environ[_ORIGIN_ENV] = saved_origin_env + + +def _list_subs_for_task(task_id): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return [dict(r) for r in kb.list_notify_subs(conn, task_id)] + finally: + conn.close() + + +@pytest.fixture +def worker_env(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_PROFILE", "test-worker") + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + from pathlib import Path as _Path + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + yield home + + +def _create(assignee="peer", title="origin-test"): + from tools import kanban_tools as kt + out = kt._handle_create({"title": title, "assignee": assignee}) + d = json.loads(out) + assert d["ok"] is True, d + return d + + +def test_c1_root_capture_no_inherited_origin(monkeypatch, worker_env): + """C1: live session + no inherited origin → stamp the live surface (no regression).""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "ROOTCHAT") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "ROOTTHREAD") + monkeypatch.setenv("HERMES_SESSION_USER_ID", "ROOTUSER") + + d = _create() + assert d["subscribed"] is True, d + subs = _list_subs_for_task(d["task_id"]) + assert len(subs) == 1 + s = subs[0] + assert s["platform"] == "discord" + assert s["chat_id"] == "ROOTCHAT" + assert s["thread_id"] == "ROOTTHREAD" + assert s["user_id"] == "ROOTUSER" + + +def test_c2_inheritance_beats_detached_session(monkeypatch, worker_env): + """C2 (core fix): inherited origin wins over the detached run's own identity.""" + # The detached worker's OWN session names a foreign/webhook surface... + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "webhook") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "DETACHED_RUN") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "") + # ...but it inherited the HUMAN origin across the spawn boundary. + set_kanban_origin( + platform="discord", chat_id="HUMAN_ORIGIN", thread_id="HUMAN_THREAD", + user_id="HUMAN_USER", + ) + + d = _create() + assert d["subscribed"] is True, d + subs = _list_subs_for_task(d["task_id"]) + assert len(subs) == 1 + s = subs[0] + assert s["platform"] == "discord", s + assert s["chat_id"] == "HUMAN_ORIGIN", s + assert s["thread_id"] == "HUMAN_THREAD", s + assert s["user_id"] == "HUMAN_USER", s + # The detached identity must NOT have been stamped. + assert s["chat_id"] != "DETACHED_RUN" + + +def test_c3_no_origin_no_session_no_sub(monkeypatch, worker_env): + """C3: no inherited origin AND no live session → no sub (unchanged CLI behaviour).""" + for v in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_KEY", "HERMES_SESSION_ID"): + monkeypatch.delenv(v, raising=False) + + d = _create() + assert d["subscribed"] is False, d + assert _list_subs_for_task(d["task_id"]) == [] diff --git a/tests/tools/test_kanban_reassign_origin_tool.py b/tests/tools/test_kanban_reassign_origin_tool.py new file mode 100644 index 000000000000..8ee0300bccd2 --- /dev/null +++ b/tests/tools/test_kanban_reassign_origin_tool.py @@ -0,0 +1,113 @@ +"""D3: the kanban_reassign_origin worker tool. + +Exposes reassign_task_origin to an orchestrator that mints a new thread for a +fork and designates it the origin. Beyond re-pointing the card's notify-sub, the +tool refreshes the caller's HERMES_KANBAN_ORIGIN so *subsequently* created child +cards inherit the reassigned surface (D3). +""" + +from __future__ import annotations + +import json +import os + +import pytest + +import gateway.session_context as sc +from gateway.session_context import get_kanban_origin + +_ORIGIN_ENV = "HERMES_KANBAN_ORIGIN" + + +@pytest.fixture(autouse=True) +def _isolate_origin(): + saved_ctx = sc._KANBAN_ORIGIN.get() + saved_env = os.environ.get(_ORIGIN_ENV) + sc._KANBAN_ORIGIN.set(sc._UNSET) + os.environ.pop(_ORIGIN_ENV, None) + try: + yield + finally: + sc._KANBAN_ORIGIN.set(saved_ctx) + if saved_env is None: + os.environ.pop(_ORIGIN_ENV, None) + else: + os.environ[_ORIGIN_ENV] = saved_env + + +@pytest.fixture +def worker_env(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_PROFILE", "test-worker") + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + from pathlib import Path as _Path + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + yield home + + +def _subs(task_id): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return [dict(r) for r in kb.list_notify_subs(conn, task_id)] + finally: + conn.close() + + +def _make_task(): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return kb.create_task(conn, title="fork", assignee="peer") + finally: + conn.close() + + +def test_reassign_origin_tool_repoints_sub(monkeypatch, worker_env): + from tools import kanban_tools as kt + tid = _make_task() + # seed an old origin + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + kb.add_notify_sub(conn, task_id=tid, platform="discord", + chat_id="CHAN", thread_id="OLD", notifier_profile="p") + finally: + conn.close() + + out = kt._handle_reassign_origin({ + "task_id": tid, "platform": "discord", + "chat_id": "CHAN", "thread_id": "NEW", + }) + d = json.loads(out) + assert d["ok"] is True, d + + subs = [s for s in _subs(tid) if s["platform"] == "discord"] + assert len(subs) == 1 + assert subs[0]["thread_id"] == "NEW" + + +def test_reassign_origin_tool_refreshes_context_for_future_children(monkeypatch, worker_env): + """D3: after reassign, the caller's origin points at the new surface.""" + from tools import kanban_tools as kt + tid = _make_task() + + out = kt._handle_reassign_origin({ + "task_id": tid, "platform": "discord", + "chat_id": "NEWCHAN", "thread_id": "NEWTHREAD", "user_id": "U", + }) + assert json.loads(out)["ok"] is True + + origin = get_kanban_origin() + assert origin is not None + assert origin["platform"] == "discord" + assert origin["chat_id"] == "NEWCHAN" + assert origin["thread_id"] == "NEWTHREAD" + + +def test_reassign_origin_tool_requires_task_platform_chat(worker_env): + from tools import kanban_tools as kt + d = json.loads(kt._handle_reassign_origin({"task_id": "t_x"})) + assert "error" in d and d.get("ok") is not True diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 10dd2cbea300..9b1b4c925f02 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -137,7 +137,7 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): "kanban_list", "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", - "kanban_unblock", + "kanban_unblock", "kanban_reassign_origin", } assert kanban == expected, f"expected {expected}, got {kanban}" diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index d3946f4abb7d..726a04d96424 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -1001,7 +1001,34 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: platform = "" chat_id = "" try: - from gateway.session_context import get_session_env + from gateway.session_context import ( + capture_kanban_origin_from_session, + get_session_env, + ) + # Prefer the INHERITED kanban origin over the running process's own + # session identity. In a live gateway session these are identical (the + # root capture snapshots the live session). But once the workstream + # crosses a spawn boundary into a detached context (dispatched worker / + # delegate_task / background process / nested create), HERMES_SESSION_* + # names the detached run, not the human origin — so a card stamped from + # it would have a wake with nowhere real to land. The origin channel + # (get_kanban_origin, folded into capture_*) carries the human origin + # across that boundary. See gateway/session_context.set_kanban_origin. + origin = capture_kanban_origin_from_session() + if origin is not None: + platform = origin.get("platform") or "" + chat_id = origin.get("chat_id") or "" + if platform and chat_id: + thread_id = origin.get("thread_id") or None + user_id = origin.get("user_id") or None + from hermes_cli import kanban_db as _kb + _kb.add_notify_sub( + conn, task_id=task_id, + platform=platform, chat_id=chat_id, + thread_id=thread_id, user_id=user_id, + notifier_profile=os.environ.get("HERMES_PROFILE"), + ) + return True platform = get_session_env("HERMES_SESSION_PLATFORM", "") chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "") if not platform or not chat_id: @@ -1134,6 +1161,68 @@ def _handle_link(args: dict, **kw) -> str: return tool_error(f"kanban_link: {e}") +def _handle_reassign_origin(args: dict, **kw) -> str: + """Re-point a card's origin (its notify-sub) to a new delivery surface. + + Lets an orchestrator that mints a new thread for a fork designate it the + origin for all future wakes on that card. Beyond re-pointing the card's + notify-sub via ``reassign_task_origin``, this refreshes the caller's + ``HERMES_KANBAN_ORIGIN`` so any child card created *after* this call inherits + the reassigned surface too. + """ + tid = args.get("task_id") + platform = str(args.get("platform") or "").strip() + chat_id = str(args.get("chat_id") or "").strip() + if not tid: + return tool_error("task_id is required") + if not platform or not chat_id: + return tool_error("platform and chat_id are required") + thread_id = args.get("thread_id") + thread_id = str(thread_id).strip() or None if thread_id else None + user_id = args.get("user_id") + user_id = str(user_id).strip() or None if user_id else None + include_descendants = bool(args.get("include_descendants") or False) + board = args.get("board") + try: + kb, conn = _connect(board=board) + try: + row = kb.reassign_task_origin( + conn, + task_id=str(tid), + platform=platform, + chat_id=chat_id, + thread_id=thread_id, + user_id=user_id, + notifier_profile=kb.notifier_delivery_profile(), + include_descendants=include_descendants, + ) + finally: + conn.close() + # Refresh the caller's origin so subsequently-created child cards inherit + # the reassigned surface (the fork's new thread), not the old one. + try: + from gateway.session_context import set_kanban_origin + set_kanban_origin( + platform=platform, chat_id=chat_id, + thread_id=thread_id, user_id=user_id, + ) + except Exception: + logger.warning("reassign_origin: failed to refresh context origin", exc_info=True) + return _ok( + task_id=str(tid), + platform=platform, + chat_id=chat_id, + thread_id=thread_id, + include_descendants=include_descendants, + row=row, + ) + except ValueError as e: + return tool_error(f"kanban_reassign_origin: {e}") + except Exception as e: + logger.exception("kanban_reassign_origin failed") + return tool_error(f"kanban_reassign_origin: {e}") + + # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- @@ -1621,6 +1710,50 @@ def _board_schema_prop() -> dict[str, str]: } +KANBAN_REASSIGN_ORIGIN_SCHEMA = { + "name": "kanban_reassign_origin", + "description": ( + "Re-point a card's ORIGIN — the concrete delivery surface its " + "transition wakes and completion notifications route to — to a new " + "(platform, chat_id, thread_id). Use when a workstream forks and you " + "mint a fresh thread that should own all future wakes for that card: " + "this atomically replaces the card's notify-subscription for that " + "platform (preserving any other-platform fan-out) and seeds the new " + "subscription so NO back-history is replayed. It also refreshes the " + "current session's inherited origin, so child cards you create AFTER " + "this call inherit the new surface too. Idempotent: re-pointing to the " + "surface a card already has is a no-op." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Card whose origin to re-point."}, + "platform": { + "type": "string", + "description": "Delivery platform (e.g. 'discord', 'telegram').", + }, + "chat_id": {"type": "string", "description": "Destination chat/channel id."}, + "thread_id": { + "type": "string", + "description": "Destination thread id within the channel (omit for a channel-level origin).", + }, + "user_id": { + "type": "string", + "description": "Optional participant id for per-user-isolated group chats.", + }, + "include_descendants": { + "type": "boolean", + "description": ( + "When true, also re-point every descendant card linked under " + "this one — 'move the whole fork to the new thread'. Default false." + ), + }, + "board": _board_schema_prop(), + }, + "required": ["task_id", "platform", "chat_id"], + }, +} + # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- @@ -1705,3 +1838,12 @@ def _board_schema_prop() -> dict[str, str]: check_fn=_check_kanban_mode, emoji="🔗", ) + +registry.register( + name="kanban_reassign_origin", + toolset="kanban", + schema=KANBAN_REASSIGN_ORIGIN_SCHEMA, + handler=_handle_reassign_origin, + check_fn=_check_kanban_orchestrator_mode, + emoji="🎯", +) diff --git a/toolsets.py b/toolsets.py index 083ab9d89138..f92b13f631e3 100644 --- a/toolsets.py +++ b/toolsets.py @@ -74,7 +74,7 @@ "kanban_show", "kanban_list", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", - "kanban_unblock", + "kanban_unblock", "kanban_reassign_origin", # Computer use (macOS, gated on cua-driver being installed via check_fn) "computer_use", ] @@ -271,7 +271,7 @@ "kanban_show", "kanban_list", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", - "kanban_unblock", + "kanban_unblock", "kanban_reassign_origin", ], "includes": [], },