feat(kanban): stamp originating ACP session_id on tasks

Salvages #23208 by @awizemann. Tracks which chat session created a
kanban task so clients can render a per-session board without falling
back to tenant + time-window heuristics.

- Schema: tasks gains nullable session_id TEXT column with index
  (additive migration in _migrate_add_optional_columns).
- ACP: server.py exposes the originating session id via HERMES_SESSION_ID
  with save/restore around the agent loop.
- Tool: kanban_create reads HERMES_SESSION_ID (with explicit override).
- CLI: 'hermes kanban list --session <id>' filter; JSON output exposes
  session_id.
This commit is contained in:
awizemann 2026-05-18 21:15:15 -07:00 committed by Teknium
parent 8e193cf05c
commit 31fe229039
8 changed files with 321 additions and 5 deletions

View file

@ -73,6 +73,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
"result": t.result,
"skills": list(t.skills) if t.skills else [],
"max_retries": t.max_retries,
"session_id": t.session_id,
}
@ -343,6 +344,9 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_list.add_argument("--status", default=None,
choices=sorted(kb.VALID_STATUSES))
p_list.add_argument("--tenant", default=None)
p_list.add_argument("--session", default=None,
help="Filter by originating chat/agent session id "
"(set on tasks created from inside an ACP loop)")
p_list.add_argument("--archived", action="store_true",
help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
@ -1279,6 +1283,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
assignee=assignee,
status=args.status,
tenant=args.tenant,
session_id=args.session,
include_archived=args.archived,
order_by=getattr(args, "sort", None),
)

View file

@ -649,6 +649,12 @@ class Task:
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
max_retries: Optional[int] = None
# Originating chat/agent session id, when the task was created from
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
# tasks created from the CLI, the dashboard, or any path that doesn't
# set the env var. Lets clients render a per-session board without
# relying on tenant + time-window heuristics.
session_id: Optional[str] = None
@classmethod
def from_row(cls, row: sqlite3.Row) -> "Task":
@ -714,6 +720,9 @@ class Task:
max_retries=(
row["max_retries"] if "max_retries" in keys else None
),
session_id=(
row["session_id"] if "session_id" in keys else None
),
)
@ -844,9 +853,17 @@ CREATE TABLE IF NOT EXISTS tasks (
-- ``max_retries=1`` blocks on the first failure. NULL (the common
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
-- config and then ``DEFAULT_FAILURE_LIMIT``.
max_retries INTEGER
max_retries INTEGER,
-- Originating chat/agent session id when the task was created from
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
-- for tasks created from the CLI, dashboard, or any path that doesn't
-- set the env var. Indexed so per-session list queries stay cheap on
-- larger boards.
session_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
CREATE TABLE IF NOT EXISTS task_links (
parent_id TEXT NOT NULL,
child_id TEXT NOT NULL,
@ -1143,6 +1160,20 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
if "model_override" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT")
if "session_id" not in cols:
# Originating agent/chat session id, populated when the task is
# created from within an agent loop that propagated
# ``HERMES_SESSION_ID`` (e.g. ACP). NULL on legacy rows and on any
# creation path that doesn't set the env var (CLI, dashboard).
# Index keeps per-session list queries cheap.
_add_column_if_missing(
conn, "tasks", "session_id", "session_id TEXT"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_session_id "
"ON tasks(session_id)"
)
# task_events gained a run_id column; back-fill it as NULL for
# historical events (they predate runs and can't be attributed).
ev_cols = {row["name"] for row in conn.execute("PRAGMA table_info(task_events)")}
@ -1312,6 +1343,7 @@ def create_task(
skills: Optional[Iterable[str]] = None,
max_retries: Optional[int] = None,
initial_status: str = "running",
session_id: Optional[str] = None,
board: Optional[str] = None,
) -> str:
"""Create a new task and optionally link it under parent tasks.
@ -1466,8 +1498,8 @@ def create_task(
id, title, body, assignee, status, priority,
created_by, created_at, workspace_kind, workspace_path,
tenant, idempotency_key, max_runtime_seconds, skills,
max_retries
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
max_retries, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
@ -1485,6 +1517,7 @@ def create_task(
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
json.dumps(skills_list) if skills_list is not None else None,
int(max_retries) if max_retries is not None else None,
session_id,
),
)
for pid in parents:
@ -1551,6 +1584,7 @@ def list_tasks(
assignee: Optional[str] = None,
status: Optional[str] = None,
tenant: Optional[str] = None,
session_id: Optional[str] = None,
include_archived: bool = False,
limit: Optional[int] = None,
order_by: Optional[str] = None,
@ -1568,6 +1602,9 @@ def list_tasks(
if tenant is not None:
query += " AND tenant = ?"
params.append(tenant)
if session_id is not None:
query += " AND session_id = ?"
params.append(session_id)
if not include_archived and status != "archived":
query += " AND status != 'archived'"
if order_by is not None: