feat(kanban): surface-agnostic notification substrate + orchestrator supervision loop - #1
Draft
aeberts wants to merge 1497 commits into
Draft
feat(kanban): surface-agnostic notification substrate + orchestrator supervision loop#1aeberts wants to merge 1497 commits into
aeberts wants to merge 1497 commits into
Conversation
…NSERT OR IGNORE The gateway's get_or_create_session() creates a bare session row (source + user_id) before the agent exists. The agent's later create_session() carries the real model/model_config/system_prompt, but _insert_session_row used INSERT OR IGNORE — silently dropping that enrichment. Gateway sessions were left with NULL model and NULL billing metadata. Switch to INSERT ... ON CONFLICT(id) DO UPDATE with COALESCE so NULL columns get backfilled while values an earlier writer already set are never overwritten (a later bare write with source='unknown' can't clobber a real source/model). Credit: original report and fix direction by @LucidPaths (NousResearch#5048).
…dge path guard Salvages the two still-valid hardenings from NousResearch#5381 onto the relocated plugin adapters (the discord/feishu/whatsapp adapters moved to plugins/platforms/ since the PR was opened, and 4 of its 6 hunks are already on main or superseded). - feishu: rate limiter now denies untracked keys when the tracking table is at capacity after pruning stale entries (was: allow through without tracking). At-capacity-with-all-fresh-entries only happens under abuse, so allowing untracked requests let an attacker who flooded the table bypass the limiter entirely. Already-tracked keys and post-prune room are unaffected. - whatsapp: absolute file paths handed back by the Baileys bridge are now validated to resolve inside a known media cache dir before being attached. A compromised/buggy bridge could otherwise return an arbitrary path (e.g. /etc/passwd) that would be sent verbatim to the model. Guard resolves symlinks and accepts both the canonical cache/<kind> and legacy <kind>_cache layouts.
reset_had_activity gated on entry.total_tokens, which is never written (token counts migrated to agent-direct persistence) so it was always 0. That suppressed session-reset notifications for sessions that genuinely had activity. Switch to last_prompt_tokens, which is updated on every turn.
The reset-had-activity tests set total_tokens (dead state) to simulate activity; production records activity via last_prompt_tokens. Update the fixtures to match the field the fix and runtime actually use.
…ersal Session IDs can originate from untrusted input (e.g. the X-Hermes-Session-Id API header) and are interpolated raw into on-disk artifact filenames under ~/.hermes/sessions/. A traversal-shaped ID (../../../../etc/pwned) would let a caller write the session snapshot or request dump outside the sessions directory. _safe_session_filename_component() collapses every non [A-Za-z0-9_-] character to _, caps the length, and appends a short content hash when sanitization changed the string, always yielding a single traversal-free path segment. Closes NousResearch#5958.
…undary Defense-in-depth on top of _safe_session_filename_component (NousResearch#5958): Sink (makes the bad write impossible regardless of entry point): - run_agent._save_session_log: sanitize session_id before building the session_{sid}.json snapshot path. - agent_runtime_helpers.dump_api_request_debug: sanitize before building the request_dump_{sid}_{ts}.json path. Boundary (clean 400 instead of a silently-hashed filename): - api_server rejects path-traversal-shaped X-Hermes-Session-Id on the session-continuation path and the explicit /api/sessions create path, reusing gateway.session._is_path_unsafe (mirrors the native gateway's entry-boundary guard). Also enforces the session-header length cap on the continuation path. Tests: traversal session_id stays contained at the write site; sanitizer always yields a traversal-free segment; the API header rejects ../, absolute, and Windows-traversal IDs with 400.
Widen NousResearch#5961's _format_untrusted_prompt_value coverage to the Matrix room display name (**Matrix Room:**), a sibling attacker-controllable field the original fix missed. chat_name is user-settable, so an injected room name could render as literal markdown in the system prompt. Adds a regression test.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NVIDIA integrate.api.nvidia.com models such as minimaxai/minimax-m3 can return HTTP 200 with empty choices when max_tokens is omitted. Keep the output cap on auxiliary chat-completions routes, matching the main NVIDIA provider profile behavior.
Let users click the status bar context indicator to see how tokens are split across system prompt, tools, rules, skills, MCP, and conversation. Co-authored-by: Cursor <cursoragent@cursor.com>
…ontext-usage-popover feat(desktop): add context usage breakdown popover
…sResearch#7779) (NousResearch#54862) A manually-installed venv inside the cloned repo can be destroyed by the agent running a relative-path command against its own checkout (rm -rf venv, uv venv venv, etc.), silently wiping the running runtime mid-session. Moving the canonical manual-install venv to ~/.hermes/venvs/hermes-dev means no relative path from the agent's workspace resolves to its own runtime, making the bug class impossible without any command-detection code. Closes the root cause of NousResearch#7779. The managed install.sh layout is unchanged.
…ousResearch#54843) * feat(web_extract): truncate-and-store instead of LLM summarization web_extract no longer runs an auxiliary LLM over scraped pages. The extract backends (Firecrawl/Tavily/Exa/Parallel) already return clean, boilerplate- stripped markdown, so we return it directly: pages within a char budget (default 15000, web.extract_char_limit) come back whole; larger pages get a head+tail window plus an explicit footer giving the stored full-text path and the read_file call to page through the omitted middle. The full clean text is written to cache/web (mounted read-only into remote backends like the other cache dirs), so nothing is lost. Inline base64 images are converted to [IMAGE: alt] placeholders (token bombs dropped) while real http(s) image URLs are preserved as links so the agent can still web_extract/vision_analyze them. Removes process_content_with_llm + the chunked summarizer + check_auxiliary_model + _resolve_web_extract_auxiliary. context_references._default_url_fetcher is updated to the truncate path and its stale data.documents shape read is fixed to results (it was silently returning empty). Live before/after eval (firecrawl, 4 URLs): 11.7x faster overall (176.6s -> 15.1s); 10-60x on large pages. Quality identical; findability 4/4 (answer recoverable from stored full text on every truncated page). web_search is unchanged. No own scraper added; no changes to web_search. * fix(web_extract): add char_limit to execute_code web_extract stub The new web_extract char_limit param must appear in the code_execution_tool _TOOL_STUBS signature (and doc line) or test_stubs_cover_all_schema_params fails — the stub schema must cover every real schema param.
Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so editing/steering the transcript from there makes no sense. Gate the composer and the user-bubble mutations on `isWatchWindow()`: - hide the composer (folds into `showChatBar`) - user prompts become a read-only button that toggles the 2-line clamp so long prompts stay fully readable, instead of opening the edit composer - drop the stop/restore actions and the checkpoint branch-picker Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the new-session and cmd-click pop-outs are unaffected.
…atch-readonly feat(desktop): read-only spectator transcript for subagent watch windows
The Gateway item is the only statusbar entry with variant === 'menu'. Since da73223 wrapped every render branch in `Tip`, the menu branch nested `<DropdownMenu>` (a Radix Root that renders no DOM node) inside `Tip`'s `<TooltipTrigger asChild>`. With no element to attach to, Radix could never wire hover listeners, so the tooltip silently never showed. `Tip` also can't be moved inside `DropdownMenuTrigger asChild` (the shape proposed in NousResearch#54859): it's a plain component, not a Slot-forwarding one, so the trigger's injected ref/handlers would land on `TooltipContent` instead of the button and break the menu's click + popper anchoring. Fix by composing both trigger Slots directly onto a single <button> (`TooltipTrigger asChild` over `DropdownMenuTrigger asChild`), the pattern already used in profile-switcher.tsx, and skip the tooltip wrapper entirely when the item has no title. Supersedes NousResearch#54859. Co-authored-by: wnuuee1 <wnuuee1@users.noreply.github.com>
…tatusbar-tooltip fix(desktop): show Gateway statusbar tooltip via composed trigger Slots
…ains (NousResearch#54824) Add a generic suppress_notification flag to the drain-request marker. When a drain that ends in process exit (e.g. a NAS auto-update image migration on the always-on Hermes Cloud fleet) is flagged, the gateway skips ONLY the home-channel 'gateway shutting down' broadcast — the operator-flavoured ping that would otherwise fire on every routine auto-update, dozens of times a day. The per-active-session interrupt ping is ALWAYS kept: on a drained shutdown it's empty by construction, and in the force-interrupt (deadline-exceeded) case it carries the user-valuable 'your task was cut off, message me to resume' hint. The gateway stays agnostic about WHY a drain is quiet (generic boolean, not a kind enum); the policy of which drain causes set the flag lives in the caller (NAS). Default-false so legacy/operator drains behave exactly as before. The reader reuses the NS-570 epoch-staleness check so an orphaned marker on the durable volume can never silence a fresh gateway's legitimate broadcast. - drain_control.py: write_drain_request gains suppress_notification; new drain_notification_suppressed() reader (current-epoch + truthy flag). - web_server.py: /api/gateway/drain reads + echoes the flag. - run.py: _notify_active_sessions_of_shutdown skips the home-channel loop only. Tests prove: flag round-trips; home-channel suppressed when set, kept when unset; active-session ping always fires; stale/legacy/corrupt markers never suppress.
Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
roamWalkRow() prefers running-left/running-right rows, falling back to the generic running row with a mirror for pets that lack them.
usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render).
Tag both bars with data-slots; the roam loop stands on the status bar's top edge (not over it) and treats the profile rail as a climbable ledge.
Drop the CSS lens overlay (blend modes, noise, inversion) and backdrop-blur from the ops dashboard so compositing no longer competes with xterm on /chat. Use flat theme backgrounds and direct Nous Blue palette colors instead of FG-inversion authoring. Co-authored-by: Cursor <cursoragent@cursor.com>
Make Nous Blue terminal text readable without the inversion layer, re-mount the backdrop plugin slot, and drop unused backdrop CSS vars from theme apply. Co-authored-by: Cursor <cursoragent@cursor.com>
Use momentum easing for sidebar transitions, switch sidebar typography to sans-serif, replace the profile native select with the DS Select, and stop clipping the Models page Use-as dropdown inside model cards. Co-authored-by: Cursor <cursoragent@cursor.com>
…w precedence The cherry-picked fix added explicit-kwarg and top-level image_gen.model resolution but left _resolve_model / _resolve_model_chain docstrings stating the old 'env override -> config -> DEFAULT_MODEL' order. Document the full precedence (explicit kwarg -> env -> scoped -> top-level -> default chain) to match the sibling krea/openai providers.
…t memory retry loops (NousResearch#42405) - replace() and remove() now return entry previews and current_entries when no entry matches old_text, matching the multi-match and add-limit error behavior - add() limit error also now returns previews for consistency - Agent can self-correct after a failed replace/remove instead of looping blindly until turn budget is exhausted with no user response
…ion failures (NousResearch#42405) Builds on the zero-match feedback fix (previous commit) to close the silent-hang symptom: when memory is at capacity, a failed `add`/`replace`/`remove` consolidation could loop the whole turn to iteration-budget exhaustion and deliver no user-facing reply. NousResearch#41755 turned the at-capacity overflow error into a *commanded* in-turn retry ("...then retry this add — all in this turn"); combined with the fragile substring-only `replace`/`remove` matching (LLMs can't reliably re-quote a long entry verbatim), the model loops add↔replace on inexact guesses until the turn dies. The existing tool_guardrails halt would catch this, but hard_stop_enabled is opt-in (off by default), so a default install still hangs. This fixes it at the memory layer without changing global guardrail behavior: - MemoryStore tracks per-turn consolidation failures; after a cap (3) it drops the "retry in this turn" instruction and returns a terminal "leave memory unchanged, continue your reply" result, so a failed memory side effect can never block the turn's reply. - The counter resets on any successful write (progress) and at each turn boundary (turn_context.reset_consolidation_failures, guarded via getattr so plugin memory stores without the method are a no-op). Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
…g chokepoint
A single 'hermes update' / 'hermes -p' could rewrite a hand-curated config.yaml
into a near-full DEFAULT_CONFIG dump (the 'you blow up my profile config on one
tweak' reports). Root cause: migrate_config() had ~16 independent save_config()
call sites, each author deciding ad hoc whether to materialise a value, and many
persisted pure schema defaults with strip_defaults=False. Defaults already merge
transparently at read time via load_config(), so writing them is pure bloat that
also shadows future default changes (see save_config's docstring).
Architectural fix (not a per-site patch): introduce a single _persist_migration()
chokepoint that enforces one invariant — a migration may persist only values that
DIFFER from the current schema default, plus explicit removals/renames of user
data; pure defaults are never written. Every migration write (all 17 sites incl.
the version-bump finalizer) now routes through it. The invariant is mechanically
correct for all cases and verified empirically:
- pure-default seeds (timezone='', curator/auxiliary.curator blocks, interim
flag, curator.consolidate=False, empty plugins.enabled) are stripped → merged
in at read time;
- non-default values (write_approval=True, model_catalog.ttl_hours=1) preserved
via explicit-raw-path preservation;
- behaviour flips (agent.verify_on_stop=False, schema default still 'auto')
preserved because False != 'auto';
- data transforms (custom_providers->providers, stt.model relocation,
write_mode->write_approval, compression.summary_* removal, MCP-disable)
persist their removals/renames.
An explicitly user-set non-default value (e.g. matrix.require_mention: false) is
preserved across the bump.
Guard tests lock the architecture: an AST check asserts migrate_config() makes no
direct save_config() call (all writes go through _persist_migration), and a
full-range v1->latest test asserts a lean config is never dumped. Two existing
change-detector tests that froze the on-disk representation of default-valued
keys are rewritten to assert the effective value via load_config() (behaviour
contract, not snapshot).
Validation: lean v1->latest migration drops from ~567 bytes to ~196 bytes;
148 config+setup and 196 profile/curator/migrate tests pass on scripts/run_tests.sh.
…igration-no-default-expansion fix(config): route every migration write through one default-stripping chokepoint
…ubscriber_kind + target (event-hub F01) Additive columns (id, subscriber_kind, target, scope, delivery_policy) on kanban_notify_subs with in-place migration + backfill of existing rows as subscriber_kind='gateway' (target = JSON of platform/chat/thread/user). PK and the claim/cursor API are unchanged; the unique id index is built in the migration pass to avoid the legacy-board parse-abort. Foundation for the generalized Kanban event substrate (upstream NousResearch#49190). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…with gateway-tuple shim (event-hub F02) Generalize add_notify_sub / remove_notify_sub / unseen_events_for_sub / claim_unseen_events_for_sub / advance_notify_cursor / rewind_notify_cursor to operate by the F01 surrogate id, via a _resolve_notify_sub_id(sub_id | tuple) shim so legacy gateway callers (kanban_watchers, kanban_tools) keep working unchanged. Cursor-CAS single-owner dedup preserved (WHERE id = ? AND last_event_id = <old> inside BEGIN IMMEDIATE). add_notify_sub now returns the surrogate id. Foundation for the delivery-adapter registry (upstream NousResearch#49190). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bscriber_kind (event-hub F03) Extract the gateway notifier's per-event send loop into gateway/kanban_delivery.py as GatewayDeliveryAdapter (registered for subscriber_kind='gateway'). The watcher now resolves the adapter for each subscription's subscriber_kind and dispatches the claimed batch through it, keeping all safety machinery in the watcher (claim/cursor, MAX_SEND_FAILURES dead-channel drop, rewind-on-transient-failure, keep-sub-until-final-status, multi-board fan-out, notifier_profile gating). Gateway delivery is byte-for-byte identical; unknown subscriber_kinds are skipped. Foundation for CLI/TUI adapters (event-hub F05/F06, upstream NousResearch#49190). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ target (event-hub F04) Generalize add_notify_sub with subscriber_kind (default 'gateway') and target kwargs; the gateway path is byte-for-byte unchanged when both default. Non-gateway subscriptions satisfy the NOT NULL PK via the convention platform=subscriber_kind, chat_id=<target id>, thread_id='' — with the structured identity carried in the subscriber_kind column + target JSON. Extend 'kanban notify-subscribe' with --subscriber-kind / --target-id / --target, and surface the fields in notify-list; gateway flags + confirmation message unchanged. _maybe_auto_subscribe (gateway auto-subscribe) untouched. Contract-level fix for the inferred-identity defect (kanban-e2e F0C); the write side for non-gateway delivery (CLI adapter is F05). Upstream NousResearch#49190. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + drain (event-hub F05) First non-gateway delivery adapter. A subscriber_kind='cli' subscription has no live push channel, so delivery persists a plain-text terminal-event notice in a new kanban_cli_notices table keyed by the CLI target id; 'hermes kanban notices' drains it (notice-first, one-shot read). Dedup is owned by the shared claim cursor, so a re-claim writes no duplicate. The watcher gates the live-Platform resolution (and its disconnect->rewind safety) on subscriber_kind=='gateway' in both _collect and the dispatch loop, so non-gateway kinds bypass _Platform(...) and reach their registered adapter. Gateway delivery semantics (fan-out, failure accounting, keep-sub-until-final, profile gating) are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…notice store (event-hub F06)
Second non-gateway delivery adapter, proving the F03 registry generalizes to a
new surface with zero Kanban-core / watcher changes: registering
DELIVERY_ADAPTERS['tui'] is sufficient because F05 already made the watcher's
gateway gating generic, so a tui sub bypasses _Platform(...) and reaches its
adapter automatically (watcher diff is empty).
Generalizes F05's cli-only notice store into one surface-agnostic kanban_notices
table keyed by (subscriber_kind, target_id): add_cli_notice/drain_cli_notices ->
add_notice/drain_notices; CLI + TUI share a _NoticeDeliveryAdapter base differing
only by _kind. 'hermes kanban notices' gains a --kind {cli,tui} filter. The table
is brand-new/unmerged so no migration is needed. Notice-first like CLI (RFC 5.3
'session notice'); live-turn wakeup/WS push stays deferred to M01.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e (event-hub F07) Third non-gateway delivery adapter (subscriber_kind='orchestrator'). A parent subscribes with --scope subtree --delivery-policy supervise; on its subtasks' terminal/blocker events it gets one claim-once structured supervision notice (aggregate snapshot + fan_in_ready) in the shared kanban_notices store, drained via 'kanban supervise'. Observational only — no task creation, no scheduling change; re-engagement (F09) and live wake (M01) are out of scope. The subtree claim observes the subscribed root's task_links PARENTS (= its subtasks): decompose_triage_task links the root as the child of every subtask, so the subtasks are the root's dependency parents. Fan-in is computed in code (no new event kind); dedup is the shared claim cursor. Watcher unchanged (adapter self-claims); gateway/cli/tui adapters byte-for-byte unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…doff comment (event-hub F09) Close-the-loop heartbeat that consumes F07's supervision notices. The orchestrator root already stays alive after decompose and is auto-re-spawned by recompute_ready + the dispatcher when its subtasks finish; the only gap was the curated fan-in handoff the re-spawned turn should read to judge the goal. F09 supplies exactly that and nothing more: - kanban_db.reengage_orchestrator(target_id): drains F07 orchestrator notices one-shot, groups by root (parent_id, latest wins), and for each root whose latest snapshot is fan_in_ready appends ONE structured [kanban:reengage] comment carrying the aggregate snapshot. build_worker_context already surfaces the comment thread, so the handoff reaches the fresh turn in-context with no new read path (the kanban_swarm blackboard convention). - `hermes kanban reengage --target-id X [--json]` CLI entrypoint. Re-engages ONLY on fan_in_ready=true; partial notices are passive visibility (consumed, no comment — no wake-per-partial-batch). Idempotency is the one-shot drain: a re-run with no new notice is a no-op, and a second decompose round's fan-in notice correctly re-engages again (no permanent marker that would break the multi-round loop). Observational toward scheduling — the only mutation is a comment (+ its commented event); no promotion/status write/task creation. F07's delivery adapter + payload contract are untouched. The live call-site wiring (dispatcher tick + ordering before re-spawn) and the real orchestrator-wakes-and-judges proof are deferred to M07 on the Mini. Tests: tests/hermes_cli/test_kanban_reengage.py (10) — fan-in true/false, idempotent re-run, multi-round, at-most-one-per-root (latest wins), observational, CLI plain + --json, and an end-to-end real-decompose → F07 delivery → reengage → build_worker_context close-the-loop proof (no dispatcher). Gate: 117 (107 baseline + 10) + 238 green; ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ked handoff (event-hub F10)
Extend F09's single F07-notice consumer (`reengage_orchestrator`) to branch on
the drained supervision snapshot, closing the second half of the orchestration
loop: fan-in says "work is done → judge", blocked says "work is stuck → triage".
- `fan_in_ready=true` → `[kanban:reengage]` (F09, body byte-for-byte unchanged).
- a blocked child (`kind=="blocked"`; implies `fan_in_ready` false, since a
blocked child is never terminal) → a new `[kanban:triage]` handoff carrying
`{trigger:"blocked", child id, reason}` + the snapshot, surfaced to the
re-spawned orchestrator turn via `build_worker_context`.
- neither (a partial with no blocked child) → passive; no comment.
The two arms are mutually exclusive per snapshot by construction (a blocked
child masks fan-in), so no double-fire. Idempotency stays the F07 one-shot drain
— a re-block after unblock is a new claimed event → a new triage handoff, no
separate marker. Enqueue-only: writes the handoff; does not spawn/wake the
supervisor (that is F11). `kanban reengage` now reports the `trigger` per root
(human `triaged`/`re-engaged`; `--json` adds `trigger` beside root_id/comment_id).
Resolves event-hub F10 (OQ-1 `[kanban:triage]`; OQ-2 reuse F07 cursor;
OQ-3 composes by construction; OQ-4 orchestrator-mode wake split to F11).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tor-mode supervisor on a blocked child (event-hub F11-core) The live "drive" side of the closed loop (F11 increment 1). A blocked child does NOT re-promote its root, so stock ready-dispatch never spawns anything to triage it — the silent deadlock. This adds a supervisor-dispatch sub-loop to the dispatcher tick that, for each supervised root (one with a subscriber_kind='orchestrator' notify-sub) carrying a `blocked` child, wakes a fresh ORCHESTRATOR-MODE turn to answer+unblock or escalate to the human. - `_supervisor_spawn(root_task, *, board)`: a thin `_default_spawn` variant that OMITS `HERMES_KANBAN_TASK` (its absence is the one line that grants the orchestrator-only kanban_unblock/kanban_list tools), board-scoped, the root's profile, prompt "supervise root <id>". Uses top-level `-z` one-shot (not `chat -q`): the right shape for a single fresh supervisor turn AND it sidesteps the orthogonal `chat -q`×Codex tool-wiring bug (R03) until upstream fixes it. - Supervisor-dispatch loop in `_dispatch_once_locked` (mirrors the review loop; injectable `supervisor_spawn_fn`, threaded through `dispatch_once`): scans orchestrator subs → blocked children via `parent_ids` → guards → spawn → record a `task_runs` row (step_key='supervisor'); roots woken this tick land in the new `DispatchResult.supervised`. - OQ-5 guards (no schema change; reuse task_runs + `_pid_alive`): concurrency (a live supervisor PID), cooldown (spaced retries), and a breaker that parks the block "escalated/awaiting human" after K no-resolution turns and RESETS on re-block (counter keyed to the latest `blocked` event = the block episode). Trigger keys on blocked-child STATUS, not a pending handoff (OQ-2), so it also catches dispatcher breaker auto-blocks (quota/auth) that carry no F07 notice. Fan-in stays on the stock task-worker re-spawn (OQ-4) — untouched. Enqueue/spawn side, unit-testable with stub spawn fns. F09/F10 (`reengage_orchestrator`) and `_default_spawn` unchanged. Increment 2 (reengage handoff-enrichment in the tick) + live Mini validation are follow-ons. Tests: tests/hermes_cli/test_kanban_dispatch_supervisor.py (8) — trigger, not-subscribed, no-blocked-child, concurrency/cooldown/breaker guards (+reset on re-block), orchestrator-mode env (no HERMES_KANBAN_TASK, board set, -z not chat), fan-in regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-writes fan-in/triage handoffs (event-hub F11 Increment 2) Completes F11's live drive side. The dispatch tick now runs reengage_orchestrator for each orchestrator target, BEFORE recompute_ready re-promotes the root — so the [kanban:reengage] (fan-in) / [kanban:triage] (blocked) handoff comment exists before the re-promoted root is claimed, spawned, and reads it via build_worker_context (the F09/M07 ordering requirement). Previously reengage ran only via the manual `kanban reengage` CLI, so handoffs were never auto-written. - DispatchResult.reengaged: root ids handed off this tick. - _dispatch_once_locked: enumerate distinct orchestrator targets (list_notify_subs filtered subscriber_kind='orchestrator') → reengage each, per-target try/except so a bad target can't crash the tick; skipped under dry_run. - _resolve_sub_target_id: inline of gateway/kanban_delivery._target_id (no gateway import in the DB layer) — subscription rows have no target_id column; the target lives in the `target` JSON / chat_id. Without this the drain target wouldn't match the kanban_notices.target_id the F07 watcher writes, and reengage-in-tick would be a silent no-op live. Caught in QA. F07 supervision notices are produced by the gateway watcher (kanban_watchers.py); this is their in-tick consumer — the live loop runs watcher + dispatcher together (M07). Purely additive (0 deletions); reengage_orchestrator / F10 / F11-core _supervisor_spawn unchanged. +5 tests (fan-in + triage handoff in-tick, idempotent, no-subs no-op, dry_run skip). Gate: supervisor 14, official 123+238, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (event-hub F12) The notifier watcher gated delivery on the subscribed task's OWN terminal events (claim_unseen_events_for_sub) and continue'd when empty. An orchestrator subtree subscription's root never emits blocked/completed — a child does — so the gate was always empty and OrchestratorDeliveryAdapter.deliver() was never invoked live: no F07 supervision notice, so reengage-in-tick (F11) had nothing to drain and no [kanban:reengage]/[kanban:triage] handoff was ever written. Fix (Option A, peek-gate): for subscriber_kind=='orchestrator', gate on a new non-advancing peek of the children's unseen subtree events (subtree_has_unseen_events_for_sub) instead of the root-only claim. The OrchestratorDeliveryAdapter stays the SOLE owner of the subtree cursor (its internal claim_unseen_subtree_events_for_sub is the one authoritative claimer); the watcher only peeks, and the post-deliver cursor advance is skipped for orchestrator subs so the watcher can't clobber the adapter's claim. Adds tests/gateway/test_kanban_watchers_orchestrator_gate.py — the watcher-level coverage the direct-deliver() F07 tests never had. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vent-hub F13)
claim_unseen_subtree_events_for_sub (and its F12 read-only peek companion
subtree_has_unseen_events_for_sub) observed only the subscribed root's
SUBTASKS, returning empty for a childless node — so a single-card subscription
("a subtree of one") never fired on its own terminal event.
Make both observe the node's CLOSURE = {node} u {direct subtasks} via a shared
_subtree_closure_ids() helper (one source of truth, so the authoritative claim
and the watcher-gate peek cannot diverge). For a decompose root (alive/todo
through supervision, emits no terminal event until it completes) this is a
no-op — n>1 fan-in/blocker behavior is unchanged; the F07 + F12 suites stay
green. Transitive/deep-DAG closure remains deferred (F08).
Enabling change for the agent-facing kanban_subscribe tool: "assign one card and
be told when it's done" now uses the same primitive as supervising a goal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vent-hub F13) Expose subscription as an LLM tool so a chat orchestrator can enroll a goal in the supervision substrate and YIELD instead of busy-polling kanban_list — the gap a live interactive test surfaced: the subscribe->notice->re-engage machinery existed but no agent-callable subscribe did. - kanban_subscribe(task_id): orchestrator-only (check_fn=_check_kanban_orchestrator_mode, like kanban_list/unblock), no scope arg — subscribes to the node's closure (n>=1; single card or decomposed root alike). Writes the orchestrator/subtree/ supervise sub shape the F07 adapter + F11 reengage-in-tick already key off; idempotent; target = the session chat_id, or a deterministic orch:<task_id> fallback for CLI so the row stays reengage-pickup-able. Registered in toolsets.py beside kanban_unblock so it surfaces in the schema. - KANBAN_GUIDANCE: a minimal subscribe-and-yield protocol in the orchestrator-mode section (create root, link subtasks as deps, kanban_subscribe(root), end the turn -- don't poll; single-card case too) with the link-direction warning. The rich pattern catalog stays in the non-shipped P9 cookbook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…saging platform (event-hub F14)
The kanban notifier watcher bailed its entire `_collect` tick when no messaging
platform adapter was connected (`self.adapters == {}`), so it never evaluated
non-gateway subscriptions (orchestrator/cli/tui) — which need no Platform. On any
host with no Discord/Telegram/Slack (e.g. the Mini, Telegram deferred), no
notifier-produced notice was ever delivered live: F07 supervision notices, F05 cli
notices, F06 tui notices, and the F13 n=1 single-card notice all silently no-op'd.
Sibling of F12 (the per-sub gate); this is the tick-level gate one frame up.
Remove the tick-level platform early-return. The per-sub gateway gate
(`subscriber_kind == 'gateway' and platform not in active_platforms`) and the
adapter-resolution rewind are already the authoritative connectivity filters, so
gateway subs with no connected adapter are still skipped — only the surface-agnostic
kinds are unstranded. Keep the `active_platforms` set (the per-sub gate uses it).
Add watcher-level tests driving the real notifier tick with `self.adapters == {}`:
orchestrator subtree sub + cli sub both deliver; a gateway sub with no adapter is
still skipped (cursor stays 0). Verified the exact Mini scenario (n=1 single card)
produces the supervision notice and the sub self-cleans on terminal delivery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… create-then-link (event-hub F13 follow-up) The shipped KANBAN_GUIDANCE told a supervising orchestrator to create the root task first and then link each subtask as a dependency. Live testing showed that races the embedded dispatcher: the root is born `ready` with no parents, a worker claims it before the links exist, self-blocks "waiting for dependencies" (a sticky block), and the supervise loop wedges (root never completes on fan-in). Amend the guidance to the `parents`-at-create idiom (the same one the upstream kanban tutorial uses by hand): FIRST create the subtask cards, THEN create the root in one `kanban_create` with `parents=[<subtask ids>]` so it is born `todo` and auto-promotes to `ready` only once every subtask is done — no race. Keeps the link-direction note (each subtask is a `parent_id`; the root is the `child_id`). Guidance-presence + length tests stay green (5919 chars); ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…LI REPL (event-hub M01) The supervise loop closes end-to-end EXCEPT for the last hop: when a child blocks or the goal fans in, the gateway notifier writes a supervision notice (event-hub F07/F14), but a yielded interactive orchestrator never sees it — the human sits at the prompt and is never pinged. Closing that hop by hand works; M01 makes it automatic, without the mid-turn-injection hazard upstream NousResearch#42173 warns about. Approach (the safe one the exploration found): reuse the EXACT boundary the background-process notifications already use. The CLI `process_loop` now drains `kanban_notices` at two points that are never mid-turn — the idle tick (gated on `not self._agent_running`, throttled) and the post-turn `finally` (forced once per turn end) — and queues a re-engage message onto `_pending_input`. The orchestrator wakes on the next turn to surface the decision / triage / report done; real user input rides the same FIFO so nothing is overwritten. No edit to `run_conversation` or the turn machinery. - `kanban_db.drain_session_notices()`: one-shot, multi-board drain of `subscriber_kind='orchestrator'` notices (single-session-per-process assumption, same as the process-notification queue / TUI poller; enumerates boards like the notifier watcher so an ad-hoc supervised board is still found). Read-once. - `HermesCLI._drain_kanban_live_notices(force=)`: throttle + synth + queue. - Tests: one-shot + cross-kind isolation + board tagging; the CLI helper queues a re-engage message; throttle holds and `force=True` bypasses it. Scope: CLI surface (the live-validated one). TUI poller parity is a small noted follow-up (mirror the drain in `_notification_poller_loop`, gated on `session["running"]`) — deferred to keep this final commit low-risk against the freshly-refactored tui_gateway. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tionable supervision notices (event-hub M01) The F07 adapter writes one supervision notice per subtree event, so a multi-subtask goal woke the supervising orchestrator on every intermediate completion — ~6 turns for a 3-subtask goal in live testing, most carrying nothing to act on. The idle/post-turn drain (_drain_kanban_live_notices) now: - coalesces to the freshest notice per supervised root (earlier snapshots are superseded within a drain window), and - surfaces a re-engage only for an *actionable* state — a blocked child, fan-in ready, or all subtasks done — and only when that state differs from the last one surfaced for that root (kills duplicate re-wakes). Pure-progress snapshots leave the orchestrator idle, since there is no next step. A notice whose payload can't be classified is always surfaced (never silently dropped). New _kanban_notice_signature classifier reads the F07 aggregate snapshot. Also fix the cosmetic mid-word truncation in _truncate_line (word-boundary break + ellipsis) so a notice no longer ends like "…SMS can be mob". 3 new tests (classifier, progress/duplicate suppression, per-root coalesce). Full kanban sweep: 805 passed (was 802); the 19 cross-file isolation failures are pre-existing and identical with/without this change (all green in isolation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot_status in the supervision snapshot (event-hub M01) Live finding (Mini board m01_1, root t_5ea6bf3d): the root completed but the supervisor was never re-engaged. Root cause was a regression in the M01 debounce, not a substrate gap. The subtree closure includes the root node, so the root's own `completed` event correctly fires one final supervision notice (and the watcher then unsubscribes on the done status). But _build_snapshot computes children + fan_in_ready purely over the SUBTASKS, which were already all-done at fan-in — so the completion notice's signature was byte-identical to the earlier fan-in-ready notice, and the debounce dedup suppressed it. Fix: the F07 snapshot now carries the root's own `root_status`, and the M01 signature includes a `root_terminal` field, so "goal complete" is a distinct actionable state from "fan-in ready" and surfaces exactly once. _build_message says "goal complete" (instead of the children-only "fan-in ready" phrasing) when the root is terminal. root_status is additive + optional, so older payloads degrade to prior behavior. Tests: M01 regression (fan-in then completion BOTH surface; duplicate completion deduped) + delivery test (root completion snapshot carries root_status=done and "goal complete" message). 338 green across the delivery/notify suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… an idle TUI session (event-hub F15) M01 gave the CLI a hands-off supervise→yield→re-engage loop; F15 gives the TUI the same, the second interactive surface. The TUI's notification poller already does idle-gated turn injection via _run_prompt_submit (for background-process completions); F15 feeds kanban supervision notices into that same machinery without touching it (Approach B — no process_registry coupling, cleanly poppable). - kanban_db: drain_notices/drain_session_notices gain an additive `task_ids` filter so a TUI session drains ONLY the roots it subscribed to (the TUI multiplexes sessions, breaking M01's single-session-per-process assumption); None keeps the CLI behavior. Factor the M01 debounce classifier into a shared `supervision_notice_signature` (CLI delegates to it — one copy, both surfaces). - tui_gateway/server.py: `_record_kanban_subscription` (OQ-3 i — track this session's subscribed roots via the kanban_subscribe tool-complete hook), `_drain_kanban_tui_notices` (throttled, idle-only drain → shared debounce → status.update chip + _run_prompt_submit turn), called on the poller tick. Decisions (issue F15 OQ-1..4, ratified): B / piggyback@4s / (i) originating- session ownership via in-memory root map / status.update chip. Tests: kanban_db task_ids filter; TUI ownership hook, idle-gate, debounce parity (progress suppressed, completion wakes after fan-in), owned-roots scoping. 585 passed (2 pre-existing unrelated fails: MoA preset, missing Chromium). Unit-complete; live TUI validation on the Mini pending (human). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tector (event-hub F11) The 123-commit rebase onto current upstream surfaced a latent break in the F11 supervisor-breaker test: upstream's block-loop detector (BLOCK_RECURRENCE_LIMIT=2) routes a SAME-kind re-block-after-unblock to `triage` (writing `block_loop_detected`, not `blocked`), so the test's rapid same-cause re-block no longer produced the fresh `blocked` event the breaker resets on — the supervisor was never re-engaged and the assertion failed. The F11 breaker code is correct; the test's scenario collided with an orthogonal new upstream guard. Fix: the re-block now uses a DIFFERENT block kind (a genuinely new blocker) — `same_cause` is false, the recurrence counter resets, a fresh `blocked` event is written, and the breaker resets as designed. This is also the only scenario that still reaches the reset (a same-cause re-block legitimately goes to triage now). Substrate gate: 222 passed (was 221 + this 1 fail). Test-only change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename tests/hermes_cli/test_kanban_m01_live_notices.py to test_kanban_live_notices.py and remove the internal tracking-id tokens (M01/F07/F15) from its docstrings, so the test name and prose read on their own without reference to a private tracker. Test bodies unchanged; substrate gate still 222 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d docstrings Strip the private issue-tracker tokens (F01-F15, M01/M07, event-hub, OQ refs, 'Increment N', and the internal R03 label) from code comments and docstrings across the changed files, rewording the surrounding prose so each comment reads on its own. The one internal bug reference now points to the public issue (NousResearch#53234). No code/behaviour change; substrate gate still 222 passed, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Implements NousResearch#49190. Today, Kanban can only send task notifications to chat apps (Telegram/Discord/Slack). This PR lets those notifications reach any surface — CLI, TUI, web, or an AI orchestrator — and uses that to let an orchestrator wait for its tasks to finish and react, instead of constantly polling.
For example, an orchestrator can sign up for updates on its tasks, hand control back, and be woken again later — either when all of its sub-tasks have finished (so it can review the combined result), or when one gets stuck (so it can step in). It no longer has to repeatedly poll the board with
kanban_listto find out what happened. The change is purely additive: it doesn't alter how tasks are scheduled or recorded (thetask_eventslog is untouched) — it only watches the existing system and delivers the notifications.The gap. Kanban already has a durable task-event log and a "claim-once" notification loop, but both are tied to the gateway messaging
Platform. A subscription is the tuple(task_id, platform, chat_id, thread_id), and the only runtime consumer is the gateway watcher, which can only deliver to a connected messaging adapter. So every non-gateway surface (CLI, TUI, WebUI, or an orchestrator run) has to reinvent its own partial notifier, and subscriber identity is inferred from whoever callskanban_create(a worker that creates a child subscribes itself, not the user).The recommended way to run a dependency graph today, per the kanban tutorial, Story 1, is for a human to script the whole DAG up front (
--parent-at-create). The injectedKANBAN_GUIDANCEprotocol is "decompose, then complete your own task", with no "subscribe, wait for fan-in, then judge or triage" step. A supervising agent's only option is to busy-poll, which burns turns, is unreliable, and ends with its turn so it never sees the result.This is a recurring gap in practice. The common workarounds are a messaging-platform subscription (
kanban notify-subscribe --platform telegram) or a separate CLI watcher (kanban watch --kinds completed), both of which are the gateway-bound paths this substrate generalizes.The primary use case for this PR is to enable LLM-driven kanban use, reusing as much of the existing kanban tools and code as possible while staying close to the Nous Research Kanban tutorial examples.
Related Issue
Closes NousResearch#49190.
Also references NousResearch#53234, a separate
chat -q/openai-codexprovider bug that gates the fully-automated live loop (see Limitations). It is not introduced or fixed here.Type of Change
Changes Made
Note: F01-F15 and M01 are internal tracking IDs that group the commits. Issue details (rationale and trade-offs considered) are available on request.
Substrate (reusable; F01-F06).
kanban_notify_subs: surrogate id plussubscriber_kindplustarget(additive_add_column_if_missingmigration;subscriber_kind="gateway"keeps the old 4-tuple as its target, so existing rows are unchanged).hermes_cli/kanban_db.pyhermes_cli/kanban_db.pysubscriber_kind; the gateway watcher becomes the first registered adapter rather than the only path.gateway/kanban_delivery.py,gateway/kanban_watchers.pykanban_subscribeprimitive (declaresubscriber_kindandtarget), which fixes the inferred-identity problem at the contract level.hermes_cli/kanban_db.py,hermes_cli/kanban.pygateway/kanban_delivery.pySupervision loop (built on the substrate; F07, F09-F15, M01).
gateway/kanban_delivery.py,hermes_cli/kanban_db.py[kanban:reengage]root handoff) and blocked ("stuck, then triage", a[kanban:triage]handoff). They are mutually exclusive per snapshot.hermes_cli/kanban_db.py,hermes_cli/kanban.pyhermes_cli/kanban_db.py,gateway/kanban_watchers.pykanban_subscribetool plus subscribe-and-yield guidance (parents-at-create).tools/kanban_tools.py,toolsets.py,agent/prompt_builder.pygateway/kanban_watchers.pycli.py,tui_gateway/server.py,hermes_cli/kanban_db.pyReuses upstream's existing past-tense event vocabulary (
completed,blocked, and so on); there is no paralleltask.donenamespace. 25 files changed (9 source, 16 test), about +6.7k/-222 lines. No dependency changes.Architecture & data flow
These diagrams make two things visual: (1) we extend the existing event/claim/deliver mechanism rather than replace it, and (2) we don't break the one delivery path that exists today (gateway → messaging) — every non-gateway surface is purely additive, gated behind a new registry seam. In the architecture diagrams, nodes shown in green are new in this PR; everything else is unchanged from upstream.
1. Overall architecture
Everything upstream of the delivery step is untouched: the worker/dispatcher, the
task_eventslog,recompute_readyscheduling, thekanban_notify_substable (additively extended, old rows unchanged), and the watcher's claim/cursor + failure-accounting + multi-board fan-out. The only new seam is the delivery registry keyed bysubscriber_kind. The gateway branch through it is the old inlined send, moved verbatim; CLI/TUI/orchestrator are new branches that register without touching the watcher.Today (upstream)
flowchart LR Wk["Worker / dispatcher"] --> TE[("task_events log")] TE --> RR["recompute_ready scheduler"] NS[("kanban_notify_subs<br/>task_id, platform, chat_id, thread_id")] --> CL TE --> CL subgraph GW["Gateway notifier watcher"] CL["claim_unseen_events_for_sub"] --> SND["inlined per-event send"] end SND --> MSG["Messaging surface<br/>Telegram / Discord / Slack"] Other["CLI / TUI / orchestrator"] -. "no delivery path:<br/>busy-poll kanban_list" .-> TEWith this PR
flowchart LR Wk["Worker / dispatcher"] --> TE[("task_events log<br/>UNCHANGED")] TE --> RR["recompute_ready scheduler<br/>UNCHANGED"] NS[("kanban_notify_subs<br/>+ surrogate id, subscriber_kind, target<br/>old rows unchanged")] --> CL TE --> CL subgraph GW["Gateway notifier watcher — same claim/cursor, failure accounting, fan-out"] CL["claim"] --> REG{"delivery registry<br/>by subscriber_kind"} end REG -->|gateway| GA["GatewayDeliveryAdapter<br/>(old send path, verbatim)"] GA --> MSG["Messaging surface<br/>Telegram / Discord / Slack"] REG -->|cli| CA["CLIDeliveryAdapter"] REG -->|tui| TA["TUIDeliveryAdapter"] REG -->|orchestrator| OA["OrchestratorDeliveryAdapter<br/>subtree claim + fan-in snapshot"] CA --> NOT[("kanban_notices store")] TA --> NOT OA --> NOT NOT --> LW["live-wake / hermes kanban notices<br/>idle CLI + TUI re-engage"] classDef new fill:#e6ffed,stroke:#22863a,color:#22863a; class REG,CA,TA,OA,NOT,LW new;2. A kanban event reaching the CLI / orchestrator
There is no CLI delivery path today — a CLI orchestrator can only busy-poll
kanban_list, and because it polls inside a turn it ends the turn before the result lands, so it never sees fan-in. After this PR the same terminal event is claimed by the registered adapter, persisted as one notice, and surfaced into the idle REPL at a turn boundary (never mid-turn), which re-engages the orchestrator.Today (upstream)
With this PR
3. A kanban event reaching a gateway-mediated surface (Discord)
This is the path that already works, and the point of the diagram is that it does not change. The watcher still polls subs, still claims with the same cursor, still runs the same failure accounting and artifact upload, and still calls
self.adapters[Discord].send(...)with a byte-for-byte identical message. The only difference is one indirection hop — the send now resolves throughget_delivery_adapter('gateway')instead of being inlined.Discord is just the example here: the gateway adapter resolves the messaging platform generically (
self.adapters[platform]), so every chat platform Hermes supports today (Telegram, Slack, and the rest) keeps working through this same path with no new per-platform adapters — the only "adapters" this PR adds are the foursubscriber_kinddelivery adapters (gateway/cli/tui/orchestrator), all included here.Today (upstream)
sequenceDiagram participant Wk as Worker participant B as task_events participant NW as Gateway notifier watcher participant Sub as kanban_notify_subs participant D as Discord adapter Wk->>B: completed NW->>Sub: poll subs NW->>B: claim_unseen_events_for_sub NW->>D: self.adapters[Discord].send(chat_id, msg) D-->>Wk: ✔ Kanban {id} done — {title}With this PR
sequenceDiagram participant Wk as Worker participant B as task_events participant NW as Gateway notifier watcher participant Sub as kanban_notify_subs participant REG as delivery registry participant GA as GatewayDeliveryAdapter participant D as Discord adapter Wk->>B: completed NW->>Sub: poll subs (subscriber_kind='gateway') NW->>B: claim_unseen_events_for_sub (same claim/cursor) NW->>REG: get_delivery_adapter('gateway') REG->>GA: deliver(batch) GA->>D: self.adapters[Discord].send(chat_id, msg) D-->>Wk: ✔ Kanban {id} done — {title} (identical message)How to Test
Automated gate. Run the substrate test files explicitly (
-k kanbanpulls in about 19 pre-existing upstream cross-file isolation failures that pass per-file):pytest \ tests/gateway/test_kanban_orchestrator_delivery.py \ tests/gateway/test_kanban_cli_delivery.py \ tests/gateway/test_kanban_tui_delivery.py \ tests/gateway/test_kanban_delivery_registry.py \ tests/gateway/test_kanban_watchers_orchestrator_gate.py \ tests/hermes_cli/test_kanban_live_notices.py \ tests/hermes_cli/test_kanban_reengage.py \ tests/hermes_cli/test_kanban_notify.py \ tests/hermes_cli/test_kanban_notify_id_rekey.py \ tests/hermes_cli/test_kanban_subtree_closure.py \ tests/hermes_cli/test_kanban_db_init.py \ tests/hermes_cli/test_kanban_dispatch_supervisor.py \ tests/tools/test_kanban_subscribe_tool.py \ tests/tools/test_kanban_tools.py \ tests/tui_gateway/test_kanban_live_wake.py -q # 224 passedManual live testing. Set up a small board the dependency-safe way and supervise it from an idle session:
hermes chat(orchestrator mode): create 2-3 prerequisite cards, then the root in onekanban_createcall with the subtasks asparents(so it is borntodo);kanban_subscribe(root); then yield.The two live transcripts and the gate output can be attached to this PR on request.
Demonstrated behaviour change (A/B)
The patch changes orchestrator behaviour, not just plumbing:
todoand the supervisor never wakes) versus a woken supervisor triaging directly off the handoff. Shown end-to-end live on the TUI: block surfaced, human decides, supervisor records and unblocks, then goal-complete.Relationship to existing features
kanban swarm. The swarm command builds a fixed topology up front (a blackboard root, N parallel workers, a verifier gated on all workers, then a synthesizer). This PR is the general subscribe / deliver / re-engage event mechanism that such a shape sits on, not a competing topology. The re-engagement handoff deliberately mirrors thekanban_swarmblackboard comment shape, so the two share a convention rather than fork one.auto_subscribe_on_create. The existing config gate that auto-subscribes the origin session on create still works unchanged. The explicitkanban_subscribeprimitive (F04) and tool (F13) are complementary: they let a caller declare the target deliberately, which is what fixes the worker-subscribes-itself case, without removing the auto-subscribe default.dependency,needs_input,capability,transient), auto-resumes dependency blocks, and after a recurrence limit routes a repeatedly re-blocked task totriage. The re-engagement here sits on top of it. A supervisor wakes only on theblockedevent feat(kanban): typed block reasons + unblock-loop breaker NousResearch/hermes-agent#52848 reserves for the human-needing kinds; a dependency block emitsdependency_waitand auto-resumes, so it never wakes a supervisor. F11's own brake counts a different signal (consecutive supervisor turns that did not resolve a block) and mirrors the existing dispatcher consecutive-failure machinery, so it does not duplicate feat(kanban): typed block reasons + unblock-loop breaker NousResearch/hermes-agent#52848's per-task recurrence counter.Scope / non-goals
task_eventslog is untouched.recompute_ready).Limitations
The substrate mechanisms (subscribe, wake, fan-in, triage, live-wake delivery) are all demonstrated live. The fully hands-off auto-loop on the stock worker-spawn path is not claimed here: it is gated on a separate provider bug, NousResearch#53234 (
chat -qquiet mode does not execute tool calls withopenai-codex; tools execute under-zbut not underchat -q). The live runs used a local-zworker workaround that is not part of this PR. Once NousResearch#53234 is fixed, the same loop runs hands-off on the stock path.No bundled agent skill, by design. This PR ships the plumbing — subscribe, deliver, re-engage — not an opinionated workflow layered on top of it. The injected
KANBAN_GUIDANCEgains only the minimal subscribe-and-yield step; we deliberately stop short of a packaged "supervisor" skill or preset so adopters can compose the substrate however they like (orchestrator, CLI watcher, TUI, or their own). An opinionated skill can follow once the primitives have settled in use.Future work
scopefield (task/subtree/board), new event kinds (artifact_available,dependency_satisfied,fan_in_ready), and webhook/node adapters.chat -qquiet mode does not execute tool calls with the openai-codex provider (tool calls emitted as text) NousResearch/hermes-agent#53234: thechat -q/ Codex worker bug that gates the fully-automated live loop.Checklist
Code
feat(kanban):,fix(kanban):,test(kanban):)scripts/run_tests.sh, CI-parity). The focused substrate gate (the 15 files listed under How to Test) passes 224/224. The full suite shows some failures on my local macOS dev box, but they are pre-existing and unrelated: all in other subsystems (acp, anthropic-adapter, wecom, gateway-service, state-db, file-tools, browser), and the one kanban-named failure (test_signal_handler_kanban_worker, a SIGTERM-timing test) reproduces identically on a cleanupstream/maincheckout with none of these commits. None of this PR's 25 files touch those paths.scripts/run_tests.sh) triaged clean (every failure pre-existing/environmental, reproduced on a cleanupstream/maincheckout). Previously also verified on macOS Intel (Python 3.11.15). Not run directly on Windows or Linux;scripts/check-windows-footguns.pyis clean across all 25 changed files.Documentation & Housekeeping
KANBAN_GUIDANCEsubscribe-and-yield guidance) or N/Acli-config.yaml.exampleuntouched)CONTRIBUTING.md/AGENTS.mdkanban_subscribetool schema and guidance)