Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/plans/thread-origin-autonomy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Thread-origin autonomy: close the loop back to the origin thread + wake Hollis

## Problem (Casey, verbatim intent)

> "Any work originating from a particular thread should always report back and
> do work in the open on that thread. And it's not just me that needs
> information. You do, too. Hollis needs to know when a subagent is done with
> its work in order to proceed."

Today the plumbing exists but is broken end-to-end, so movement through the
system does NOT proactively reach either Casey (in his thread) or Hollis (to
proceed). Casey must run around saying "status / update / check it." That is the
opposite of the value proposition.

## Root-cause findings (ground truth, integration head `8bbf8b8e6`)

Three distinct defects, all real, verified in code + live data:

### F1 — notify-sub profile mismatch (the silent-drop bug)
- The notifier delivers a subscription's ping ONLY when the sub's
`notifier_profile` equals the running gateway notifier's profile
(`kanban_watchers.py:241-242`, owner-profile gate).
- BUT subscriptions are stamped with **the profile of whoever CREATED them**
(`_active_profile_name()` = process-global `get_active_profile_name()`), not
the profile of the gateway that will DELIVER them.
- The gateway notifier runs as `default`. Subscriptions created by workers /
CLI under other profiles get stamped `salton`, `avram`, `hollis`, … and are
**silently dropped**. Live `notify-list` today: 61×salton, 5×default,
4×hollis, 2×avram — only the 5 `default` ones can ever deliver.
- This is why Lamport's PASS ping never reached Casey, and why thread report-back
is broken in general.

### F2 — cards do not carry their origin session
- `tasks` has a `session_id` column and `create_task(..., session_id=...)`
accepts it — but the gateway `/kanban create` path (`slash_commands.py:342-380`)
never passes it. Every thread-created card has `session_id = None`.
- Without it there is no way to wake "the session that owns this work" when the
card later transitions.

### F3 — the transition wake targets a throwaway session, not the origin
- The 4c loopback route (`kanban-transition`) delivers to `log` and spins an
isolated `webhook:kanban-transition:<delivery_id>` agent with NO thread
context. So even though the wake fires (proven: 202 → run), it neither posts
into the origin thread nor wakes the origin session. It dies in the log.

## Design (locked with Casey)

One path, no human-ping vs agent-wake split:

**card carries origin (session + thread source) → on ANY terminal transition,
a synthetic message is delivered INTO the origin thread's session → Hollis wakes
there with full context, notices the transition, and either acts or waits for
Casey.**

Casey's answers that fix the design:
1. Every transition wakes Hollis on the origin session; Hollis decides if there's
anything to do. (notice-everything)
2. When something is waiting for Casey (acceptance/merge gate, genuine fork),
Hollis waits — never acts past those gates.
3. All card pings route to Hollis on the session (single wire).
4. Wake = **message into the thread** (cache-safe; also what Casey sees in the
open). Reuse the existing `notify_on_complete`-style synthetic-message
injection; NEVER interrupt/rebuild a live session mid-turn.
5. Non-thread-origin work (cron/webhook/direct) → default channel
`1515879019269197885`, unless the cron/hook explicitly specifies elsewhere.
6. Hollis owns noise control (collapse/dedupe before anything reaches Casey).
7. Full dev-workflow: TDD, PR → cwest/integration, Casey merges.

## Scope — three fixes, one PR (they are one feature)

### Fix 1 (F1): stamp subs with the NOTIFIER's profile, not the creator's
The subscription must record the profile of the gateway that will deliver it.
- In the gateway auto-subscribe path, stamp `notifier_profile` from the running
gateway's notifier profile (`self._kanban_notifier_profile`), which is the
same value the notifier gates on — guaranteeing match by construction.
- Broader: the owner-profile gate exists to stop a multi-gateway fan-out from
double-delivering. The correct invariant is "a sub is owned by the gateway
that will deliver it." A sub created under a worker profile but intended for
the `default` gateway must be stamped `default`. Fix at the create site(s):
the gateway slash path and any orchestrator/skill subscribe helper default to
the delivering gateway's profile, not `get_active_profile_name()`.
- Reconcile existing mis-stamped live subs (data migration / one-shot re-stamp)
is an OPS step, not code — handled at deploy, out of PR scope.

### Fix 2 (F2): stamp origin session_id on thread-created cards
- `slash_commands.py` `/kanban create`: pass `session_id` = the origin session
key (derived from `event.source`: platform+chat+thread → the session id the
gateway uses for that thread) into the create call.
- Also auto-subscribe the origin thread (already happens) — keep, but with the
corrected profile from Fix 1.

### Fix 3 (F3): transition wake delivers INTO the origin session/thread
- The transition emitter/route already has task_id+board+kind. On wake,
resolve the card's `session_id` (+ its origin thread source from the sub) and
deliver the synthetic "card X transitioned" message INTO that session/thread,
not a throwaway webhook session.
- If the card has no origin session (cron/webhook/direct origin), fall back to
the default channel `1515879019269197885` (Casey's #5), unless the route/cron
explicitly set a target.
- Delivery uses the existing notifier chat-ping path (message into thread) — the
notifier ALREADY delivers terminal events to the subscribed thread; once Fix 1
makes the sub deliverable and Fix 2/here ensure the thread is the origin, the
human-facing half is done. The Hollis-wake half is that same message landing
in Hollis's session so his next turn processes it.

## Cache / alternation safety (AGENTS.md hard constraints)
- NEVER inject a synthetic user message mid-loop into a live session. The wake
is a normal inbound message on an IDLE session (exactly how `notify_on_complete`
and the existing notifier chat-ping already behave) — the next turn consumes
it, prefix cache and role alternation preserved.
- No new core model tool. No new HERMES_* env var (behavior stays in config.yaml
/ existing route config).

## TDD plan (RED → GREEN per fix)
1. **F1 test**: a sub created via the gateway auto-subscribe path is stamped with
the notifier's profile, so the notifier's owner-profile gate passes (delivers)
— assert stamped profile == notifier profile, and that a mismatched-creator
context still yields a deliverable sub.
2. **F2 test**: `/kanban create` from a thread source persists `session_id` on
the card (origin session), and `None` when created without a session context.
3. **F3 test**: a terminal transition for a card with an origin session resolves
that session/thread as the delivery target (not `webhook:kanban-transition:*`);
with no origin session it falls back to the default channel.
4. **E2E**: create card from thread → drive to `completed` → assert the notifier
delivery target is the origin thread AND the transition wake targets the origin
session. (Real imports, temp HERMES_HOME, no mock of the resolution chain.)

## Definition of done (Casey's acceptance test)
Create a card FROM a specific thread, dispatch a real subagent, let it finish,
and — with Casey doing nothing — (1) a report lands in THAT thread, and (2)
Hollis wakes in that thread and takes the next step. "I watch it happen in a
thread, untouched."

## Out of PR scope (ops, at deploy)
- Re-stamp existing mis-owned live subscriptions to `default`.
- Point the `kanban-transition` route's default fallback at `1515879019269197885`.
- Restart to load the merged code (restart-gated).
64 changes: 63 additions & 1 deletion gateway/kanban_transition_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,23 @@ def build_transition_payload(
reason: Optional[str],
event_id: int,
title: str = "",
origin_session_id: Optional[str] = None,
origin_platform: Optional[str] = None,
origin_chat_id: Optional[str] = None,
origin_thread_id: Optional[str] = None,
) -> dict[str, Any]:
"""Build the JSON body POSTed to the kanban-transition route.

The idempotency key is stable per ``(board, task_id, kind, event_id)`` so a
webhook retry or a duplicate notifier tick converges on one agent run.

The ``origin_*`` fields carry the thread/session this work was born in, so
the woken orchestrator reports back to that origin thread (the autonomy
contract) instead of a contextless webhook session. They are omitted from
the body when unknown (cron/webhook/direct-origin work), and the route's
handler falls back to the default channel.
"""
return {
body: dict[str, Any] = {
"task_id": task_id,
"board": board,
"kind": kind,
Expand All @@ -104,6 +114,17 @@ def build_transition_payload(
f"kanban-transition:{board}:{task_id}:{kind}:{event_id}"
),
}
# Origin routing (only when known — keeps the body byte-stable for the
# no-origin case and lets the route fall back to the default channel).
if origin_session_id:
body["origin_session_id"] = origin_session_id
if origin_platform:
body["origin_platform"] = origin_platform
if origin_chat_id:
body["origin_chat_id"] = origin_chat_id
if origin_thread_id:
body["origin_thread_id"] = origin_thread_id
return body


def _sign(secret: str, body: bytes) -> str:
Expand All @@ -112,6 +133,47 @@ def _sign(secret: str, body: bytes) -> str:
return "sha256=" + mac.hexdigest()


def resolve_transition_target(
*,
session_id: Optional[str],
sub: Optional[dict],
default_channel: str,
) -> dict[str, Any]:
"""Resolve WHERE a transition wake should be delivered.

The autonomy contract: work born in a thread reports back to THAT thread and
wakes THAT session (so both Casey sees it in the open AND Hollis resumes with
context). Only when a card has no origin (cron/webhook/direct work) do we
fall back to the default channel.

Precedence:
1. The card's origin session + its subscribed thread source (the thread it
was born in). ``is_fallback=False``.
2. The default channel (Casey's #5), when there is no origin session and no
subscribed thread. ``is_fallback=True``.

Never targets a throwaway ``webhook:kanban-transition:*`` session — that is
the F3 bug this replaces (the wake fired into a contextless session and died
in the log).
"""
if session_id or sub:
s = sub or {}
return {
"session_id": session_id or None,
"platform": s.get("platform") or None,
"chat_id": s.get("chat_id") or None,
"thread_id": s.get("thread_id") or None,
"is_fallback": False,
}
return {
"session_id": None,
"platform": None,
"chat_id": default_channel,
"thread_id": None,
"is_fallback": True,
}


def route_url(cfg: dict) -> str:
host = cfg.get("webhook_host", DEFAULT_WEBHOOK_HOST)
port = int(cfg.get("webhook_port", DEFAULT_WEBHOOK_PORT))
Expand Down
19 changes: 18 additions & 1 deletion gateway/kanban_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,13 @@ async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None:
self._kanban_sub_fail_states = sub_fail_states
notifier_profile = getattr(self, "_kanban_notifier_profile", None)
if not notifier_profile:
notifier_profile = self._active_profile_name()
# Resolve via the shared canonical resolver so the notifier's
# owner-profile gate and every subscribe site agree on ONE value
# (config kanban.notifier_profile → active profile → "default").
try:
notifier_profile = _kb.notifier_delivery_profile()
except Exception:
notifier_profile = self._active_profile_name()
self._kanban_notifier_profile = notifier_profile

# 4c — transition emit bridge (event-driven orchestration). When enabled
Expand Down Expand Up @@ -435,13 +441,24 @@ def _collect():
reason_val = None
if ev.payload and ev.payload.get("reason"):
reason_val = str(ev.payload["reason"])
# Carry the ORIGIN (session + thread) so the
# woken orchestrator reports back to the
# thread this work was born in, not a
# contextless webhook session. session_id
# comes from the card; the thread source
# from the subscription being delivered.
origin_sid = getattr(task, "session_id", None) if task else None
payload = build_transition_payload(
task_id=sub["task_id"],
board=board_slug or "default",
kind=kind,
reason=reason_val,
event_id=int(getattr(ev, "id", 0) or 0),
title=title,
origin_session_id=origin_sid,
origin_platform=sub.get("platform"),
origin_chat_id=sub.get("chat_id"),
origin_thread_id=sub.get("thread_id"),
)
await emit_transition(
transition_emit_cfg, payload,
Expand Down
50 changes: 48 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@
logger = logging.getLogger("gateway.run")


def _origin_session_key(source: SessionSource, config_extra: dict) -> str:
"""Derive the origin session key EXACTLY as the live inbound path does.

A card created from a thread stamps this key so a later terminal transition
can wake the originating session and report back to its thread. That wake
only lands if the stamped key is byte-identical to the key the inbound path
(``base.handle_message`` -> :func:`build_session_key`) builds for the same
source. So this mirrors inbound precisely:

- read ``group_sessions_per_user`` / ``thread_sessions_per_user`` from the
platform's ``extra`` config (same source, same defaults), and
- pass NO profile — inbound omits it, so the namespace is ``agent:main``.

Injecting a profile namespace or assuming default per-user flags (as an
earlier version did) makes the stamped key diverge under
``thread_sessions_per_user: true`` or a non-default notifier profile, and
the transition then wakes a session that never existed.
"""
return build_session_key(
source,
group_sessions_per_user=config_extra.get("group_sessions_per_user", True),
thread_sessions_per_user=config_extra.get("thread_sessions_per_user", False),
)


class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""

Expand Down Expand Up @@ -310,7 +335,6 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:
"""
import asyncio
import re
import shlex
from hermes_cli.kanban import run_slash

text = (event.text or "").strip()
Expand Down Expand Up @@ -341,6 +365,22 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:

is_create = action == "create"

# F2 — stamp the ORIGIN session on a card created from a thread, so a
# later terminal transition can wake THAT session and report back to its
# origin thread (the autonomy contract). Only for `create`, only when the
# caller didn't already pass --session-id, and only when we can derive a
# session key from the message source.
if is_create and "--session-id" not in tokens and "--session-id" not in text:
try:
origin_session = _origin_session_key(
event.source,
Comment thread
cwest marked this conversation as resolved.
getattr(getattr(self, "config", None), "extra", None) or {},
)
if origin_session:
text = f"{text} --session-id {shlex.quote(origin_session)}"
except Exception as exc: # pragma: no cover - defensive
logger.warning("kanban create origin-session stamp failed: %s", exc)

try:
output = await asyncio.to_thread(run_slash, text)
except Exception as exc: # pragma: no cover - defensive
Expand Down Expand Up @@ -373,7 +413,13 @@ def _sub():
platform=platform_str, chat_id=chat_id,
thread_id=thread_id or None,
user_id=user_id,
notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(),
# Own the sub with the DELIVERING gateway's
# profile (canonical resolver), not the
# creator's — else the notifier drops it.
notifier_profile=(
getattr(self, "_kanban_notifier_profile", None)
or _kb.notifier_delivery_profile()
),
)
finally:
conn.close()
Expand Down
7 changes: 7 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2330,6 +2330,13 @@ def _ensure_hermes_home_managed(home: Path):
# decomposer prompt, model, or skills; configure that LLM path under
# auxiliary.kanban_decomposer.
"orchestrator_profile": "",
# Profile of the gateway that DELIVERS kanban terminal-event
# notifications. A notify-subscription must be owned by this profile or
# the notifier's owner-profile gate silently drops it (a sub stamped
# with a worker's profile never reaches the shared gateway). When unset,
# resolves to the active profile, then "default". Subscribe sites default
# to this via kanban_db.notifier_delivery_profile().
"notifier_profile": "",
# Where a child task lands if the orchestrator can't match an
# assignee to any installed profile. When unset, falls back to the
# default profile. A task never ends up with assignee=None.
Expand Down
Loading
Loading