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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3094,6 +3094,93 @@ def _cleanup_worker_tmux(conn: sqlite3.Connection, task_id: str) -> None:
pass # best-effort — never block completion


# ---------------------------------------------------------------------------
# First-use tip for scratch workspaces
# ---------------------------------------------------------------------------
#
# Scratch workspaces are intentionally ephemeral — ``_cleanup_workspace``
# removes them as soon as ``complete_task`` runs. New users often don't
# realize that and lose worker output (community report, May 2026). The
# behavior is right; the lack of warning is the bug.
#
# On the FIRST scratch workspace materialization across the whole install
# we:
# 1. Log a warning line on the dispatcher logger.
# 2. Append a ``tip_scratch_workspace`` event on the task so it's visible
# via ``hermes kanban show <id>`` and the dashboard.
# 3. Touch a sentinel file under ``kanban_home() / '.scratch_tip_shown'``
# so we don't repeat the tip — once you know, you know.
#
# Scope is per-install, not per-board: a user creating a second board
# already learned the lesson on board #1.

_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 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:
_log.warning("kanban: %s (task %s)", _SCRATCH_TIP_MESSAGE, task_id)
with write_txn(conn):
_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()


def edit_completed_task_result(
conn: sqlite3.Connection,
task_id: str,
Expand Down Expand Up @@ -5025,6 +5112,7 @@ def dispatch_once(
continue
# Persist the resolved workspace path so the worker can cd there.
set_workspace_path(conn, claimed.id, str(workspace))
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
# Back-compat: older spawn_fn signatures accept only
Expand Down Expand Up @@ -5103,6 +5191,7 @@ def dispatch_once(
continue
# Persist the resolved workspace path so the worker can cd there.
set_workspace_path(conn, claimed.id, str(workspace))
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
# Force-load sdlc-review skill for review agents. The
# _default_spawn function already auto-loads kanban-worker, and
# appends task.skills via --skills. Setting task.skills here
Expand Down
106 changes: 106 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3082,3 +3082,109 @@ def test_init_db_allows_missing_then_healthy(tmp_path):
with kb.connect(db_path=db_path) as conn:
tasks = kb.list_tasks(conn)
assert [t.title for t in tasks] == ["keeps"]


# ---------------------------------------------------------------------------
# First-use tip for scratch workspaces
# ---------------------------------------------------------------------------

def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog):
"""First scratch workspace materialization warns + emits an event.

Subsequent scratch workspaces on the SAME install stay silent — the
sentinel file under kanban_home() flips after the first emit.
"""
import logging

with kb.connect() as conn:
t1 = kb.create_task(conn, title="first scratch")
t2 = kb.create_task(conn, title="second scratch")

# Sentinel must not exist yet on a fresh install.
assert not kb._scratch_tip_shown()

with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t1, "scratch")

# Sentinel is now set.
assert kb._scratch_tip_shown()
assert kb._scratch_tip_sentinel_path().exists()

# Warning was logged exactly once.
tip_records = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert len(tip_records) == 1, (
f"Expected exactly one tip warning, got {len(tip_records)}: "
f"{[r.getMessage() for r in tip_records]!r}"
)

# An event row was appended on the first task.
with kb.connect() as conn:
events = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ? ORDER BY id",
(t1,),
).fetchall()
kinds = [e["kind"] for e in events]
assert "tip_scratch_workspace" in kinds, (
f"Expected tip_scratch_workspace event on first scratch task; "
f"got {kinds!r}"
)

# Second scratch materialization on the same install stays silent.
caplog.clear()
with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t2, "scratch")
tip_records2 = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert tip_records2 == [], (
f"Tip should not re-fire after sentinel is set; got "
f"{[r.getMessage() for r in tip_records2]!r}"
)
with kb.connect() as conn:
events2 = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ? ORDER BY id",
(t2,),
).fetchall()
assert "tip_scratch_workspace" not in [e["kind"] for e in events2], (
"Tip event should not be appended for subsequent scratch tasks."
)


def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog):
"""worktree/dir workspaces are preserved on completion and must not
trigger the scratch-cleanup tip."""
import logging

with kb.connect() as conn:
t_wt = kb.create_task(conn, title="worktree task")
t_dir = kb.create_task(conn, title="dir task")

assert not kb._scratch_tip_shown()

with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t_wt, "worktree")
kb._maybe_emit_scratch_tip(conn, t_dir, "dir")

# Sentinel stays unset — these workspaces are preserved by design,
# so the warning is irrelevant for them and we save the one-shot
# for a real scratch user.
assert not kb._scratch_tip_shown()
tip_records = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert tip_records == []
with kb.connect() as conn:
for tid in (t_wt, t_dir):
events = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ?", (tid,),
).fetchall()
assert "tip_scratch_workspace" not in [e["kind"] for e in events]

6 changes: 3 additions & 3 deletions website/docs/user-guide/features/kanban.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ They coexist: a kanban worker may call `delegate_task` internally during its run
- **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`.
- **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context.
- **Workspace** — the directory a worker operates in. Three kinds:
- `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards).
- `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design.
- `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Use `worktree:<path>` to pin the exact target path. Worker-side `git worktree add` creates it, using `--branch` when provided.
- `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards). **Deleted when the task completes** — scratch is ephemeral by design, so the dir is wiped the moment the worker (or `hermes kanban complete <id>`) marks the task done. If you want to keep the worker's output, use `worktree:` or `dir:<path>` instead. The first time a scratch workspace is created on an install, the dispatcher logs a warning and emits a `tip_scratch_workspace` event on the task (visible via `hermes kanban show <id>`).
- `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design. **Preserved on completion.**
- `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Use `worktree:<path>` to pin the exact target path. Worker-side `git worktree add` creates it, using `--branch` when provided. **Preserved on completion.**
- **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). One dispatcher sweeps all boards per tick; workers are spawned with `HERMES_KANBAN_BOARD` pinned so they can't see other boards. After `kanban.failure_limit` consecutive spawn failures on the same task (default: 2) the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc.
- **Tenant** — optional string namespace *within* a board. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. Tenants are a soft filter; boards are the hard isolation boundary.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ Hermes Kanban은 모든 Hermes 프로필이 함께 쓰는 **지속형 작업 보
- **Link** — 부모 → 자식 의존성을 기록하는 `task_links` row. 부모가 모두 `done`이면 dispatcher가 `todo → ready`로 승격시킵니다.
- **Comment** — 에이전트 간 프로토콜. agent와 사람이 comment를 붙이고, worker가 (재)실행될 때 전체 thread를 컨텍스트로 읽습니다.
- **Workspace** — worker가 실제 작업을 수행하는 디렉터리.
- `scratch` (기본값) — `~/.hermes/kanban/workspaces/<id>/` 아래의 새 tmp 디렉터리 (non-default board는 board 경로 아래)
- `dir:<path>` — 기존 공유 디렉터리. **절대경로만 허용**됩니다.
- `worktree` — 코딩 task를 위한 git worktree (`.worktrees/<id>/`)
- `scratch` (기본값) — `~/.hermes/kanban/workspaces/<id>/` 아래의 새 tmp 디렉터리 (non-default board는 board 경로 아래). **task가 완료되면 삭제됩니다** — scratch는 설계상 일회용이라 worker(또는 `hermes kanban complete <id>`)가 task를 done 처리하는 순간 디렉터리가 비워집니다. worker 결과물을 보존하려면 `worktree:` 또는 `dir:<path>`를 사용하세요. 설치 후 처음으로 scratch workspace가 생성될 때 dispatcher가 경고를 로그에 남기고 해당 task에 `tip_scratch_workspace` 이벤트를 추가합니다(`hermes kanban show <id>`로 확인 가능).
- `dir:<path>` — 기존 공유 디렉터리. **절대경로만 허용**됩니다. **완료 시 보존됩니다.**
- `worktree` — 코딩 task를 위한 git worktree (`.worktrees/<id>/`). **완료 시 보존됩니다.**
- **Dispatcher** — 주기적으로 stale claim 회수, crashed worker 정리, ready task 승격, atomic claim, assigned profile spawn을 수행하는 장기 실행 루프. 기본적으로 gateway 내부(`kanban.dispatch_in_gateway: true`)에서 동작합니다.
- **Tenant** — board 내부의 선택적 namespace. 예를 들어 하나의 specialist fleet가 여러 고객사를 처리할 때 `--tenant business-a`처럼 사용합니다. tenant는 soft filter이고, board가 hard isolation boundary입니다.

Expand Down
Loading