Merge branch 'simp/r2-kanban' into simp/integration2

This commit is contained in:
Teknium
2026-09-02 17:05:05 -07:00
8 changed files with 5804 additions and 5704 deletions
+417 -5697
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
"""Notification subscriptions consumed by the gateway kanban-notifier: per-(task, platform, chat, thread) rows with delivery metadata, unseen-event cursors and purge of stale done-task subs.
Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and
origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching
``kanban_db.<name>`` keeps working.
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
from typing import Iterable
from typing import Mapping
from typing import Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hermes_cli.kanban_db import Event
# ---------------------------------------------------------------------------
# Notification subscriptions (used by the gateway kanban-notifier)
# ---------------------------------------------------------------------------
# How the gateway kanban-notifier reacts to a terminal event for a
# subscription:
# "notify" -> passive ``adapter.send`` only (default)
# "notify+wake" -> passive send AND wake the destination gateway agent
# "wake" -> wake the agent only; no passive message is sent
_NOTIFY_DELIVERY_MODES = ("notify", "notify+wake", "wake")
def _encode_notify_delivery_metadata(
metadata: Optional[Mapping[str, Any]],
) -> Optional[str]:
"""Serialize platform send metadata stored on notification subscriptions."""
if not isinstance(metadata, Mapping):
return None
clean: dict[str, Any] = {}
for key, value in metadata.items():
if value is None:
continue
if isinstance(value, (str, int, float, bool)):
clean[str(key)] = value
if not clean:
return None
return json.dumps(clean, sort_keys=True, separators=(",", ":"))
def _decode_notify_delivery_metadata(raw: Any) -> dict[str, Any]:
if isinstance(raw, Mapping):
return dict(raw)
if not raw:
return {}
try:
data = json.loads(str(raw))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {
str(key): value
for key, value in data.items()
if isinstance(value, (str, int, float, bool))
}
def add_notify_sub(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
user_id_alt: Optional[str] = None,
chat_type: Optional[str] = None,
notifier_profile: Optional[str] = None,
delivery_mode: Optional[str] = None,
delivery_metadata: Optional[Mapping[str, Any]] = None,
) -> None:
"""Register a gateway source that wants terminal-state notifications
for ``task_id``. Idempotent on (task, platform, chat, thread).
``user_id_alt`` records the originating source's platform-specific stable
alt ID (Signal UUID, Feishu union_id, ...) alongside ``user_id``. Active-wake
replay must reproduce it so the woken turn's ``build_session_key`` matches
the original event's — ``build_session_key`` prefers ``user_id_alt`` over
``user_id`` (gateway/session.py), so replaying only ``user_id`` would key a
wake into a different session whenever the two diverge for this source.
``chat_type`` records the originating source's chat type; the active-wake
delivery modes replay it so the woken turn resolves the operator's real
channel. ``None`` keeps an existing row's value.
``delivery_mode`` (see ``_NOTIFY_DELIVERY_MODES``) selects how the
kanban-notifier reacts to a terminal event for this subscription. ``None``
leaves an existing row's mode untouched (and inserts the ``"notify"``
default for a fresh row); an explicit value is last-write-wins, so an
operator can intentionally re-subscribe to change the mode (e.g.
``notify`` -> ``wake``). An unknown value falls back to ``"notify"``.
New subscriptions start "caught up": ``last_event_id`` snaps to the task's
current ``MAX(task_events.id)`` instead of the schema default 0, which made
the notifier replay every historical terminal event on its next tick (a
boot-time burst of 100+ messages with many stale subs). Subscribers only
want events AFTER they subscribe; auto-subscribe at task creation snapshots
0 anyway.
"""
insert_mode = delivery_mode if delivery_mode in _NOTIFY_DELIVERY_MODES else (
# api_server is stateless: the adapter has no send() — the wake
# self-post IS the delivery on that path (see gateway/wake.py and
# test_kanban_notifier_apiserver_wake). A plain-'notify' default
# would leave those subscriptions with no delivery mechanism at
# all, regressing the pre-delivery_mode behavior where a task
# carrying a session_id always woke. Explicit modes still win.
"notify+wake" if platform == "api_server" else "notify"
)
insert_chat_type = chat_type or "dm"
now = int(time.time())
metadata_json = _encode_notify_delivery_metadata(delivery_metadata)
with _kb.write_txn(conn):
conn.execute(
"""
INSERT OR IGNORE INTO kanban_notify_subs
(task_id, platform, chat_id, thread_id, user_id, user_id_alt,
chat_type, notifier_profile, delivery_mode, delivery_metadata,
created_at, last_event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT MAX(id) FROM task_events WHERE task_id = ?), 0))
""",
(
task_id,
platform,
chat_id,
thread_id or "",
user_id,
user_id_alt,
insert_chat_type,
notifier_profile,
insert_mode,
metadata_json,
now,
task_id,
),
)
# Re-subscribe semantics per column: chat_type / delivery_mode /
# delivery_metadata are last-write-wins; user_id_alt and
# notifier_profile only self-heal legacy rows that never had one.
key = (task_id, platform, chat_id, thread_id or "")
for column, value, fill_only in (
("chat_type", chat_type, False),
("user_id_alt", user_id_alt, True),
("notifier_profile", notifier_profile, True),
("delivery_mode", delivery_mode if delivery_mode in _NOTIFY_DELIVERY_MODES else None, False),
("delivery_metadata", metadata_json, False),
):
if not value:
continue
guard = f" AND ({column} IS NULL OR {column} = '')" if fill_only else ""
conn.execute(
f"UPDATE kanban_notify_subs SET {column} = ? "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?"
+ guard,
(value, *key),
)
def _notify_profile_filter(
notifier_profiles: Optional[Iterable[str]],
*,
include_unowned: bool,
) -> tuple[str, list[str]]:
"""Build an optional SQL predicate for notification profile ownership."""
if notifier_profiles is None:
return "", []
profiles = sorted(
{
str(profile).strip()
for profile in notifier_profiles
if str(profile).strip()
}
)
clauses: list[str] = []
params: list[str] = []
if profiles:
clauses.append(
"notifier_profile IN (" + ",".join("?" for _ in profiles) + ")"
)
params.extend(profiles)
if include_unowned:
clauses.append("notifier_profile IS NULL OR notifier_profile = ''")
if not clauses:
return "0", []
return "(" + ") OR (".join(clauses) + ")", params
def list_notify_subs(
conn: sqlite3.Connection,
task_id: Optional[str] = None,
*,
notifier_profiles: Optional[Iterable[str]] = None,
include_unowned: bool = False,
) -> list[dict]:
"""List subscriptions, optionally restricted to notifier profile owners.
Passing no ``notifier_profiles`` preserves the historical all-subscriptions
result. Gateway notifier processes pass the profiles whose adapters they
own so they cannot claim another gateway's events. ``include_unowned`` is
used by the dispatch owner for legacy rows created before profile stamping.
"""
owner_where, owner_params = _notify_profile_filter(
notifier_profiles, include_unowned=include_unowned,
)
where: list[str] = []
params: list[Any] = []
if task_id is not None:
where.append("task_id = ?")
params.append(task_id)
if owner_where:
where.append(owner_where)
params.extend(owner_params)
sql = "SELECT * FROM kanban_notify_subs"
if where:
sql += " WHERE " + " AND ".join(f"({clause})" for clause in where)
rows = conn.execute(sql, params).fetchall()
out: list[dict] = []
for row in rows:
item = dict(row)
if "delivery_metadata" in item:
item["delivery_metadata"] = _decode_notify_delivery_metadata(
item.get("delivery_metadata")
)
out.append(item)
return out
def count_notify_subs(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
notifier_profiles: Optional[Iterable[str]] = None,
include_unowned: bool = False,
platform: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
) -> int:
"""Count ``kanban_notify_subs`` rows via a read-only connection.
Cheap probe for the gateway notifier's zero-subscription early exit:
unlike :func:`connect`, this never creates the DB file, never runs
schema init/migration, and never opens the database writable (no
write locks, no checkpoints — though a read-only open of a WAL
database may still create the ``-shm``/``-wal`` sidecars, it cannot
write table content). Rows in a not-yet-checkpointed WAL are
visible, so a freshly added subscription is never missed. A missing
DB, or a legacy DB that predates the subscriptions table, counts as
zero. When ``notifier_profiles`` is supplied, only subscriptions owned
by those profiles are counted; ``include_unowned`` also includes legacy
rows without an owner stamp. Optional platform/chat/thread filters narrow
the probe to one notification owner without changing the unfiltered count.
Platform matching is case-insensitive, matching notifier routing; chat and
thread identifiers are exact. Path resolution matches :func:`connect`
(explicit ``db_path``, else ``board`` via :func:`kanban_db_path`). Raises
:class:`sqlite3.Error` when the DB exists but cannot be read
(locked, corrupt); callers choose their own fallback.
"""
path = db_path if db_path is not None else _kb.kanban_db_path(board=board)
if not path.exists():
return 0
conn = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)
try:
try:
owner_where, owner_params = _notify_profile_filter(
notifier_profiles, include_unowned=include_unowned,
)
clauses: list[str] = []
params: list[Any] = []
if owner_where:
clauses.append(f"({owner_where})")
params.extend(owner_params)
if platform is not None:
clauses.append("LOWER(platform) = LOWER(?)")
params.append(platform)
if chat_id is not None:
clauses.append("chat_id = ?")
params.append(chat_id)
if thread_id is not None:
clauses.append("thread_id = ?")
params.append(thread_id)
query = "SELECT COUNT(*) FROM kanban_notify_subs"
if clauses:
query += " WHERE " + " AND ".join(clauses)
row = conn.execute(query, params).fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc).lower():
return 0
raise
return int(row[0]) if row else 0
finally:
conn.close()
def remove_notify_sub(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
) -> bool:
with _kb.write_txn(conn):
cur = conn.execute(
"DELETE FROM kanban_notify_subs WHERE task_id = ? "
"AND platform = ? AND chat_id = ? AND thread_id = ?",
(task_id, platform, chat_id, thread_id or ""),
)
return cur.rowcount > 0
def purge_stale_done_notify_subs(
conn: sqlite3.Connection,
*,
max_age_days: int = 30,
) -> int:
"""Delete notify subscriptions whose task has sat in ``done`` or
``blocked`` untouched for longer than ``max_age_days``.
The notifier keeps subscriptions alive through ``done`` because a
completed task can be reopened (review corrections, continuation) and
the reopened cycle must still notify its origin session. On boards
that never archive, that retention would otherwise accumulate
subscription rows forever — each one scanned every notifier tick.
This GC bounds that: a task that has been ``done`` with no new events
for the retention window is treated as settled and its subscriptions
are purged. ``blocked`` tasks (circuit-breaker trips, dead workers)
are reaped on the same clock — they are abandoned, not idle, unlike a
``backlog``/``ready`` card that is merely waiting for pickup (#100955).
Age is measured from the task's most recent event
(falling back to ``completed_at`` then ``created_at``), so ANY
activity — including a reopen, which also moves the task off
``done`` — resets or exempts it.
``max_age_days <= 0`` disables the sweep entirely. Returns the number
of subscription rows deleted.
"""
try:
days = int(max_age_days)
except (TypeError, ValueError):
days = 30
if days <= 0:
return 0
cutoff = int(time.time()) - days * 86400
with _kb.write_txn(conn):
cur = conn.execute(
"DELETE FROM kanban_notify_subs WHERE task_id IN ("
" SELECT t.id FROM tasks t"
" WHERE t.status IN ('done', 'blocked')"
" AND COALESCE("
" (SELECT MAX(e.created_at) FROM task_events e"
" WHERE e.task_id = t.id),"
" t.completed_at, t.created_at, 0"
" ) < ?)",
(cutoff,),
)
return int(cur.rowcount or 0)
def _notify_cursor(
conn: sqlite3.Connection, task_id: str, platform: str, chat_id: str, thread_id: Optional[str],
) -> Optional[int]:
"""``last_event_id`` of one subscription row, or ``None`` when unsubscribed."""
row = conn.execute(
"SELECT last_event_id FROM kanban_notify_subs "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
(task_id, platform, chat_id, thread_id or ""),
).fetchone()
return None if row is None else int(row["last_event_id"])
def unseen_events_for_sub(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
kinds: Optional[Iterable[str]] = None,
) -> tuple[int, list[Event]]:
"""Return ``(new_cursor, events)`` for a given subscription.
Only events with ``id > last_event_id`` are returned. The subscription's
cursor is NOT advanced here; call :func:`advance_notify_cursor` after
the gateway has successfully delivered the notifications.
"""
cursor = _notify_cursor(conn, task_id, platform, chat_id, thread_id)
if cursor is None:
return 0, []
kind_list = list(kinds) if kinds else None
q = (
"SELECT * FROM task_events WHERE task_id = ? AND id > ? "
+ ("AND kind IN (" + ",".join("?" * len(kind_list)) + ") " if kind_list else "")
+ "ORDER BY id ASC"
)
params: list[Any] = [task_id, cursor]
if kind_list:
params.extend(kind_list)
rows = conn.execute(q, params).fetchall()
out: list[Event] = []
max_id = cursor
for r in rows:
out.append(_kb.Event.from_row(r))
max_id = max(max_id, int(r["id"]))
return max_id, out
def claim_unseen_events_for_sub(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
kinds: Optional[Iterable[str]] = None,
) -> tuple[int, int, list[Event]]:
"""Atomically claim unseen notification events for one subscription.
Returns ``(old_cursor, new_cursor, events)``. When events are returned,
``kanban_notify_subs.last_event_id`` has already been advanced to
``new_cursor`` inside a ``BEGIN IMMEDIATE`` transaction. That makes the
notifier's read/claim step single-owner across multiple gateway watcher
processes pointed at the same board DB: concurrent watchers serialize on
SQLite's writer lock, and only the first process sees and claims a given
event range.
Callers should send the claimed events, then either leave the cursor at
``new_cursor`` on success or call :func:`rewind_notify_cursor` if delivery
failed before any terminal unsubscribe removed the row.
"""
with _kb.write_txn(conn):
old_cursor = _notify_cursor(conn, task_id, platform, chat_id, thread_id)
if old_cursor is None:
return 0, 0, []
new_cursor, events = unseen_events_for_sub(
conn,
task_id=task_id,
platform=platform,
chat_id=chat_id,
thread_id=thread_id,
kinds=kinds,
)
if not events:
return old_cursor, old_cursor, []
conn.execute(
"UPDATE kanban_notify_subs SET last_event_id = ? "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? "
"AND last_event_id = ?",
(int(new_cursor), task_id, platform, chat_id, thread_id or "", int(old_cursor)),
)
return old_cursor, new_cursor, events
def advance_notify_cursor(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
new_cursor: int,
) -> None:
with _kb.write_txn(conn):
conn.execute(
"UPDATE kanban_notify_subs SET last_event_id = ? "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
(int(new_cursor), task_id, platform, chat_id, thread_id or ""),
)
def rewind_notify_cursor(
conn: sqlite3.Connection,
*,
task_id: str,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
claimed_cursor: int,
old_cursor: int,
) -> bool:
"""Undo a notification claim when delivery fails.
The CAS guard only rewinds if no later notifier advanced the row after our
claim. This keeps retry behavior for transient send failures without
clobbering newer progress.
"""
with _kb.write_txn(conn):
cur = conn.execute(
"UPDATE kanban_notify_subs SET last_event_id = ? "
"WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? "
"AND last_event_id = ?",
(
int(old_cursor), task_id, platform, chat_id, thread_id or "",
int(claimed_cursor),
),
)
return cur.rowcount > 0
# Late-bound origin namespace (see module docstring). Imported LAST so this
# module is fully populated before ``kanban_db`` re-exports from it.
from hermes_cli import kanban_db as _kb # noqa: E402
+670
View File
@@ -0,0 +1,670 @@
"""Task workspace lifecycle: scratch/dir/worktree resolution (incl. git worktree creation), post-completion cleanup with containment guards, worker tmux teardown and the first-use scratch-workspace tip.
Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and
origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching
``kanban_db.<name>`` keeps working.
"""
from __future__ import annotations
import logging
import os
import sqlite3
import subprocess
from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING
import contextlib
if TYPE_CHECKING:
from hermes_cli.kanban_db import Task
# Log-record parity with the origin module.
_log = logging.getLogger("hermes_cli.kanban_db")
def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]:
"""Return whether *p* is managed scratch storage and the matching board."""
try:
p_abs = p.resolve(strict=False)
except OSError:
return False, None
roots: list[tuple[Path, Optional[str]]] = []
override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
if override:
with contextlib.suppress(OSError):
roots.append((Path(override).expanduser().resolve(strict=False), None))
try:
home = _kb.kanban_home()
except OSError:
home = None
if home is not None:
with contextlib.suppress(OSError):
roots.append(((home / "kanban" / "workspaces").resolve(strict=False), _kb.DEFAULT_BOARD))
try:
boards_parent = (home / "kanban" / "boards").resolve(strict=False)
except OSError:
boards_parent = None
if boards_parent is not None:
try:
entries = list(boards_parent.iterdir())
except OSError:
entries = []
for entry in entries:
try:
if not entry.is_dir():
continue
except OSError:
continue
try:
roots.append(((entry / "workspaces").resolve(strict=False), entry.name))
except OSError:
continue
for root, board in roots:
if p_abs == root:
continue
try:
if p_abs.is_relative_to(root):
return True, board
except ValueError:
continue
return False, None
def _scratch_workspace(conn: sqlite3.Connection, task_id: str) -> Optional[Path]:
"""Expanded ``workspace_path`` when the task uses a scratch workspace, else ``None``."""
row = conn.execute(
"SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]:
return None
return Path(row["workspace_path"]).expanduser()
def _is_managed_scratch_path(p: Path) -> bool:
"""Return True iff *p* is a strict descendant of a kanban-managed scratch root.
A managed root is exclusively a ``workspaces/`` directory — never the
broader kanban home, a board root, or sibling subtrees like ``logs/`` or
``boards/<slug>/`` itself. Allowed roots:
* ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override
injected by the dispatcher).
* ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.
* ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug
that currently exists on disk.
The check requires strict descendancy: a path equal to one of these
roots is NOT managed (deleting the workspaces root would wipe every
task's scratch dir at once), and a path that resolves to ``<kanban_home>
/kanban`` itself, ``<kanban_home>/kanban/logs``, or
``<kanban_home>/kanban/boards/<slug>`` is rejected because those
subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.
Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths
outside Hermes-managed storage. A board ``default_workdir`` pointing at a
real source tree can otherwise pair with ``workspace_kind='scratch'`` and
cause task completion to delete user data (#28818).
"""
is_managed, _board = _managed_scratch_path_info(p)
return is_managed
def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None:
"""Remove a task's scratch workspace dir and kill its stale tmux session.
Called from :func:`complete_task` after the DB transaction commits.
Best-effort — any error is swallowed so cleanup never blocks task completion.
``scratch`` workspaces are removed; ``worktree`` workspaces are removed only
when provably free of work (clean tree, every commit reachable from a
remote-tracking ref); ``dir`` workspaces are intentionally preserved.
"""
try:
row = conn.execute(
"SELECT workspace_kind, workspace_path, branch_name FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return
kind: Optional[str] = row["workspace_kind"]
path: Optional[str] = row["workspace_path"]
if kind not in ("scratch", "worktree") or not path:
# This task's own workspace isn't a removable scratch dir, but its
# completion may still unblock a deferred parent scratch cleanup
# (e.g. a 'dir' child whose scratch parent was waiting on it). #33774
_try_cleanup_parent_workspaces(conn, task_id)
return
# Check if this task has children that still need the workspace.
# If any child is not yet done/archived, defer cleanup so the
# child can read handoff artifacts from the workspace (#33774).
_active_children = conn.execute(
"SELECT 1 FROM task_links l "
"JOIN tasks t ON t.id = l.child_id "
"WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'failed', 'cancelled') "
"LIMIT 1",
(task_id,),
).fetchone()
if _active_children:
_kb._log.debug(
"Deferring %s workspace cleanup for task %s: "
"active children still need workspace at %s",
kind, task_id, path,
)
return
if kind == "worktree":
# Kill the (dead) tmux worker session BEFORE removing the
# worktree so a lingering worker never has its cwd deleted out
# from under it. Both steps stay best-effort.
_cleanup_worker_tmux(conn, task_id)
_cleanup_worktree_workspace(task_id, path, row["branch_name"])
_try_cleanup_parent_workspaces(conn, task_id)
return
import shutil
wp = Path(path)
if wp.is_dir():
# Containment guard (#28818): a board's ``default_workdir`` can
# pair ``workspace_kind='scratch'`` with a user-supplied path
# pointing at a real source tree. Without this check, task
# completion would unconditionally ``shutil.rmtree`` that path
# and silently delete the user's source data.
if _is_managed_scratch_path(wp):
shutil.rmtree(wp, ignore_errors=True)
_kb._log.debug("Removed scratch workspace: %s", wp)
else:
_kb._log.warning(
"Refusing to remove out-of-scratch workspace for task %s: %s "
"(workspace_kind='scratch' but path is outside any "
"kanban-managed workspaces root)",
task_id, wp,
)
# Also kill the tmux session for the worker that owned this task,
# if the tmux session is now dead (worker process exited).
_cleanup_worker_tmux(conn, task_id)
# After cleaning up this task's workspace, check if any parent
# tasks now have all children done — their deferred cleanup can
# proceed (#33774).
_try_cleanup_parent_workspaces(conn, task_id)
except Exception:
pass # best-effort — never block completion
def _cleanup_worktree_workspace(
task_id: str, path: str, branch_name: Optional[str] = None
) -> None:
"""Remove a finished task's linked git worktree when it holds no work.
Mirrors the safety judgment of the CLI startup pruner
(``cli._prune_stale_worktrees``): removal requires a clean working tree
AND every commit reachable from a remote-tracking ref. Any doubt — dirty
files, unpushed commits, unresolvable repo, failing git — preserves the
worktree. The task's auto-generated ``wt/<task-id>`` branch is deleted
with it; custom branches are kept. Best-effort like the scratch path.
"""
try:
from cli import _worktree_has_unpushed_commits, _worktree_is_dirty
except Exception:
return # CLI safety predicates unavailable — preserve
try:
wp = Path(path).expanduser()
if not wp.is_dir():
return
common = _git_common_dir(wp)
if common is None or common.name != ".git":
return # not a linked worktree of a normal repo — never guess
repo_root = common.parent
if wp.resolve(strict=False) == repo_root.resolve(strict=False):
return # never remove the main checkout
if _worktree_is_dirty(str(wp)) or _worktree_has_unpushed_commits(str(wp)):
_kb._log.info(
"Preserving worktree for task %s: dirty or unpushed work at %s",
task_id, wp,
)
return
# No --force: the dirty/unpushed checks above run before removal, so
# git's own dirty guard re-verifies at removal time. If the tree
# became dirty between our check and the removal (TOCTOU), removal
# fails safe and the worktree is preserved.
result = subprocess.run(
["git", "-C", str(repo_root), "worktree", "remove", str(wp)],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=60,
check=False,
)
if result.returncode != 0:
_kb._log.warning(
"git worktree remove failed for task %s at %s: %s",
task_id, wp, (result.stderr or result.stdout or "").strip(),
)
return
_kb._log.debug("Removed worktree workspace: %s", wp)
branch = (branch_name or "").strip() or f"wt/{task_id}"
if branch.startswith("wt/"):
subprocess.run(
["git", "-C", str(repo_root), "branch", "-D", branch],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=30,
check=False,
)
except Exception:
pass # best-effort — never block completion
def _try_cleanup_parent_workspaces(conn: sqlite3.Connection, task_id: str) -> None:
"""Clean up parent scratch workspaces now that *task_id* completed.
When a parent task's cleanup was deferred because it had active children,
this function is called after each child completes. If all children of a
parent are now done/archived/failed/cancelled, the parent's scratch
workspace is removed (#33774).
"""
try:
parents = conn.execute(
"SELECT parent_id FROM task_links WHERE child_id = ?",
(task_id,),
).fetchall()
for (parent_id,) in parents:
row = conn.execute(
"SELECT workspace_kind, workspace_path, branch_name FROM tasks WHERE id = ?",
(parent_id,),
).fetchone()
if (
not row
or row["workspace_kind"] not in ("scratch", "worktree")
or not row["workspace_path"]
):
continue
# Check if ALL children of this parent are terminal
active = conn.execute(
"SELECT 1 FROM task_links l "
"JOIN tasks t ON t.id = l.child_id "
"WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived', 'failed', 'cancelled') "
"LIMIT 1",
(parent_id,),
).fetchone()
if active:
continue # still has active children
# All children done — safe to clean up parent workspace
if row["workspace_kind"] == "worktree":
_cleanup_worktree_workspace(
parent_id, row["workspace_path"], row["branch_name"]
)
continue
import shutil
wp = Path(row["workspace_path"])
if wp.is_dir() and _is_managed_scratch_path(wp):
shutil.rmtree(wp, ignore_errors=True)
_kb._log.debug("Deferred cleanup: removed parent %s scratch workspace: %s", parent_id, wp)
except Exception:
pass # best-effort
def _cleanup_worker_tmux(conn: sqlite3.Connection, task_id: str) -> None:
"""Kill the tmux session associated with a task's assignee, if dead."""
try:
row = conn.execute(
"SELECT assignee FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if not row or not row["assignee"]:
return
assignee: str = row["assignee"]
# Workers named swarm1-12 use tmux sessions named swarm-swarm1 etc.
session = f"swarm-{assignee}"
# Check if session exists and pane is dead before killing
out = subprocess.run(
["tmux", "list-panes", "-t", session, "-F", "#{pane_dead}"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
)
if out.stdout.strip() == "1":
subprocess.run(
["tmux", "kill-session", "-t", session],
capture_output=True, timeout=5,
)
_kb._log.debug("Killed stale tmux session: %s", session)
except Exception:
pass # best-effort — never block completion
_SCRATCH_TIP_SENTINEL_NAME = ".scratch_tip_shown"
_SCRATCH_TIP_MESSAGE = (
"scratch workspaces are ephemeral — they're deleted when the task "
"completes. Use --workspace worktree: (git worktree) or "
"--workspace dir:/abs/path (existing dir) to preserve worker output."
)
def _scratch_tip_sentinel_path() -> Path:
"""Path to the per-install scratch-workspace-tip sentinel file."""
return _kb.kanban_home() / _SCRATCH_TIP_SENTINEL_NAME
def _scratch_tip_shown() -> bool:
"""True iff the scratch-workspace tip has already been emitted on this
install. Best-effort — any error means we re-emit, which is the safer
failure mode for a help message."""
try:
return _scratch_tip_sentinel_path().exists()
except OSError:
return False
def _mark_scratch_tip_shown() -> None:
"""Touch the sentinel so future scratch workspaces stay silent.
Best-effort: a failure here just means the tip might appear once more,
which is preferable to crashing dispatch over a help message.
"""
try:
path = _scratch_tip_sentinel_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
except OSError:
pass
def _maybe_emit_scratch_tip(
conn: sqlite3.Connection,
task_id: str,
workspace_kind: Optional[str],
) -> None:
"""Emit the first-use scratch-workspace tip exactly once per install.
Called from the dispatcher right after a scratch workspace is
materialized. No-op for ``worktree`` / ``dir`` workspaces (they're
preserved by design) and no-op after the sentinel exists.
"""
if (workspace_kind or "scratch") != "scratch":
return
if _scratch_tip_shown():
return
try:
_kb._log.warning("kanban: %s (task %s)", _SCRATCH_TIP_MESSAGE, task_id)
with _kb.write_txn(conn):
_kb._append_event(
conn, task_id, "tip_scratch_workspace",
{"message": _SCRATCH_TIP_MESSAGE},
)
except Exception:
# Best-effort — never block the spawn loop over a help message.
pass
finally:
_mark_scratch_tip_shown()
# ---------------------------------------------------------------------------
# Workspace resolution
# ---------------------------------------------------------------------------
def _git_toplevel(path: Path) -> Optional[Path]:
"""Return the git toplevel containing ``path``, or ``None`` if not in a repo."""
out = _kb._git_out(path, "rev-parse", "--show-toplevel")
if out is None:
return None
try:
return Path(out).expanduser().resolve()
except Exception:
return Path(out).expanduser()
def _git_branch_exists(repo_root: Path, branch_name: str) -> bool:
try:
result = subprocess.run(
["git", "-C", str(repo_root), "show-ref", "--verify", f"refs/heads/{branch_name}"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=30,
check=False,
)
except Exception:
return False
return result.returncode == 0
def _git_common_dir(path: Path) -> Optional[Path]:
out = _kb._git_out(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
return Path(out).expanduser().resolve(strict=False) if out else None
def _git_dir(path: Path) -> Optional[Path]:
out = _kb._git_out(path, "rev-parse", "--path-format=absolute", "--git-dir")
return Path(out).expanduser().resolve(strict=False) if out else None
def _git_current_branch(path: Path) -> Optional[str]:
return _kb._git_out(path, "branch", "--show-current")
def _is_linked_worktree_checkout(path: Path) -> bool:
git_dir = _git_dir(path)
common_dir = _git_common_dir(path)
if git_dir is None or common_dir is None:
return False
return git_dir != common_dir
def _nearest_existing_path(path: Path) -> Path:
current = path
while not current.exists() and current != current.parent:
current = current.parent
return current
def _repo_root_for_worktree_target(path: Path) -> Optional[Path]:
current = _nearest_existing_path(path).resolve(strict=False)
while True:
repo_root = _git_toplevel(current)
if repo_root is not None:
return repo_root
if current == current.parent:
return None
current = current.parent
def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> None:
"""Materialize ``target`` as a linked git worktree under ``repo_root``."""
target = target.expanduser()
repo_common = _git_common_dir(repo_root)
if target.exists() and repo_common is not None:
target_common = _git_common_dir(target)
if target_common == repo_common:
return
target.parent.mkdir(parents=True, exist_ok=True)
if _git_branch_exists(repo_root, branch_name):
cmd = ["git", "-C", str(repo_root), "worktree", "add", str(target), branch_name]
else:
cmd = [
"git", "-C", str(repo_root), "worktree", "add", "-b", branch_name,
str(target), "HEAD",
]
result = subprocess.run(
cmd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=60,
check=False,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
raise RuntimeError(
f"git worktree add failed for {target} on branch {branch_name}: {stderr}"
)
def _resolve_worktree_workspace(
task: Task, *, board: Optional[str] = None
) -> tuple[Path, str]:
"""Resolve + materialize a linked git worktree for ``task``.
When ``task.workspace_path`` is unset, the anchor is the board's
``default_workdir`` (a persistent project checkout). This keeps every
worktree task under a meaningful, board-owned repo — ``<repo>/.worktrees/
<task-id>`` — instead of silently landing under the dispatcher's current
working directory (which is whatever directory the gateway happened to be
launched from, e.g. the Hermes checkout). If no anchor is configured
anywhere, we fail loudly rather than guess.
"""
branch_name = (task.branch_name or "").strip() or f"wt/{task.id}"
if not task.workspace_path:
# Anchor on the board's configured default_workdir, not Path.cwd().
# The dispatcher's CWD is incidental (gateway launch dir) and using it
# scatters worktrees under whatever repo the gateway started in.
board_slug = board if board else _kb.get_current_board()
board_default = (_kb.read_board_metadata(board_slug).get("default_workdir") or "").strip()
if not board_default:
raise ValueError(
f"task {task.id} has workspace_kind=worktree but no workspace_path, "
f"and board {board_slug!r} has no default_workdir set. Set a board "
"default workdir (a git repo) or create the task with "
"--workspace worktree:<absolute-repo-path>."
)
anchor = Path(board_default).expanduser()
if not anchor.is_absolute():
raise ValueError(
f"board {board_slug!r} default_workdir {board_default!r} is not "
"absolute; use an absolute path to a git repo"
)
repo_root = _git_toplevel(anchor)
if repo_root is None:
raise ValueError(
f"task {task.id} has workspace_kind=worktree but board "
f"{board_slug!r} default_workdir {board_default!r} is not inside a git repo"
)
target = repo_root / ".worktrees" / task.id
_ensure_git_worktree(repo_root, target, branch_name)
return target, branch_name
requested = Path(task.workspace_path).expanduser()
if not requested.is_absolute():
raise ValueError(
f"task {task.id} has non-absolute worktree path "
f"{task.workspace_path!r}; use an absolute path"
)
requested_resolved = requested.resolve(strict=False)
if requested.exists() and _is_linked_worktree_checkout(requested):
actual_branch = _git_current_branch(requested)
if actual_branch == branch_name:
return requested_resolved, actual_branch
# The requested path is an existing checkout of a DIFFERENT
# task's branch. Decompose children inherit the root's
# workspace_path verbatim, so siblings all point here; reusing
# the checkout as-is would run this task on the other task's
# branch — silent cross-task provenance corruption, and unsafe
# when siblings run concurrently. Fall back to a fresh worktree
# of our own under the same repo.
fallback_root = _repo_root_for_worktree_target(requested.parent)
if fallback_root is not None:
fallback = fallback_root / ".worktrees" / task.id
if fallback.resolve(strict=False) != requested_resolved:
_ensure_git_worktree(fallback_root, fallback, branch_name)
return fallback.resolve(strict=False), branch_name
# No repo to anchor a fallback on (or the occupied path IS this
# task's own canonical worktree): keep the legacy reuse rather
# than failing dispatch.
return requested_resolved, actual_branch or branch_name
repo_root = _git_toplevel(requested)
if repo_root is not None and requested_resolved == repo_root:
target = repo_root / ".worktrees" / task.id
_ensure_git_worktree(repo_root, target, branch_name)
return target, branch_name
repo_root = _repo_root_for_worktree_target(requested.parent)
if repo_root is None:
raise ValueError(
f"task {task.id} worktree path {task.workspace_path!r} is not inside a git repo "
"and does not point at a git repo root"
)
_ensure_git_worktree(repo_root, requested, branch_name)
return requested, branch_name
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
where ``<board-root>`` is the active board's root. The path is the
same for the dispatcher and every profile worker, so handoff is
path-stable.
- ``dir:<path>``: the path stored in ``workspace_path``. Created
if missing. MUST be absolute — relative paths are rejected to
prevent confused-deputy traversal where ``../../../tmp/attacker``
resolves against the dispatcher's CWD instead of a meaningful
root. Users who want a kanban-root-relative workspace should
compute the absolute path themselves.
- ``worktree``: a real linked git worktree. If ``workspace_path`` names
a repo root, Hermes treats it as an anchor and materializes a linked
worktree at ``<repo>/.worktrees/<task-id>``. If ``workspace_path`` names
a concrete target path, Hermes creates/reuses that linked worktree. With
no ``workspace_path``, Hermes anchors on the board's ``default_workdir``
and materializes ``<repo>/.worktrees/<task-id>`` per task; if no
``default_workdir`` is configured it raises rather than guessing from the
dispatcher's CWD. When ``branch_name`` is empty, Hermes uses
``wt/<task-id>``.
Persist the resolved path back to the task row via ``set_workspace_path``
so subsequent runs reuse the same directory.
"""
kind = task.workspace_kind or "scratch"
if kind == "scratch":
if task.workspace_path:
# Legacy scratch tasks that were set to an explicit path get the
# same absolute-path guard as dir: — consistent with the
# threat model.
p = Path(task.workspace_path).expanduser()
if not p.is_absolute():
raise ValueError(
f"task {task.id} has non-absolute workspace_path "
f"{task.workspace_path!r}; workspace paths must be absolute"
)
else:
p = _kb.workspaces_root(board=board) / task.id
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "dir":
if not task.workspace_path:
raise ValueError(
f"task {task.id} has workspace_kind=dir but no workspace_path"
)
p = Path(task.workspace_path).expanduser()
if not p.is_absolute():
raise ValueError(
f"task {task.id} has non-absolute workspace_path "
f"{task.workspace_path!r}; use an absolute path "
f"(relative paths are ambiguous against the dispatcher's CWD)"
)
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "worktree":
p, _branch_name = _resolve_worktree_workspace(task, board=board)
return p
raise ValueError(f"unknown workspace_kind: {kind}")
def set_workspace_path(
conn: sqlite3.Connection, task_id: str, path: Path | str
) -> None:
with _kb.write_txn(conn):
conn.execute(
"UPDATE tasks SET workspace_path = ? WHERE id = ?",
(str(path), task_id),
)
def set_branch_name(
conn: sqlite3.Connection, task_id: str, branch_name: str
) -> None:
with _kb.write_txn(conn):
conn.execute(
"UPDATE tasks SET branch_name = ? WHERE id = ?",
(str(branch_name), task_id),
)
# Late-bound origin namespace (see module docstring). Imported LAST so this
# module is fully populated before ``kanban_db`` re-exports from it.
from hermes_cli import kanban_db as _kb # noqa: E402
+2 -2
View File
@@ -385,10 +385,10 @@ def test_every_dispatcher_kanban_var_is_identity_gated():
Fails loudly if a new dispatcher var is added without registering it.
"""
import hermes_cli.kanban_db as kanban_db
import hermes_cli.kanban_db_dispatch as kanban_db_dispatch
from agent.delegation_context import KANBAN_ENV_KEYS
source = ast.parse(open(kanban_db.__file__, encoding="utf-8").read())
source = ast.parse(open(kanban_db_dispatch.__file__, encoding="utf-8").read())
spawn = next(
node for node in ast.walk(source)
if isinstance(node, ast.FunctionDef) and node.name == "_default_spawn"
+4 -2
View File
@@ -1091,6 +1091,8 @@ def test_add_column_if_missing_is_idempotent_on_race(kanban_home):
"""
import sqlite3
from hermes_cli.sqlite_util import add_column_if_missing as _add_column_if_missing
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
@@ -1098,14 +1100,14 @@ def test_add_column_if_missing_is_idempotent_on_race(kanban_home):
)
# First call adds the column — returns True.
added = kb._add_column_if_missing(conn, "tasks", "extra_col", "extra_col TEXT")
added = _add_column_if_missing(conn, "tasks", "extra_col", "extra_col TEXT")
assert added is True
cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")}
assert "extra_col" in cols
# Second call on same connection — column already exists — must return
# False without raising, simulating the race the dispatcher hits.
added_again = kb._add_column_if_missing(
added_again = _add_column_if_missing(
conn, "tasks", "extra_col", "extra_col TEXT"
)
assert added_again is False
+3 -3
View File
@@ -196,7 +196,7 @@ class TestSigkillFallback:
@pytest.mark.parametrize(
"module_path, line_pattern",
[
("hermes_cli.kanban_db", 'getattr(signal, "SIGKILL", signal.SIGTERM)'),
("hermes_cli.kanban_db_dispatch", 'getattr(signal, "SIGKILL", signal.SIGTERM)'),
],
)
def test_module_uses_getattr_fallback(self, module_path, line_pattern):
@@ -541,7 +541,7 @@ class TestTuiGatewayEntrySignalGuards:
# ---------------------------------------------------------------------------
# hermes_cli/kanban_db.py waitpid guard
# hermes_cli/kanban_db_dispatch.py waitpid guard
# ---------------------------------------------------------------------------
@@ -551,7 +551,7 @@ class TestKanbanWaitpidWindowsGuard:
def test_source_gates_waitpid_loop(self):
root = Path(__file__).resolve().parents[2]
source = (root / "hermes_cli" / "kanban_db.py").read_text(encoding="utf-8")
source = (root / "hermes_cli" / "kanban_db_dispatch.py").read_text(encoding="utf-8")
# Find the waitpid call and confirm it's inside a POSIX gate.
idx = source.find("os.waitpid(-1, os.WNOHANG)")
assert idx > 0, "waitpid call must exist"