From ad11327db0b570e72347eddca60b1d80abf0687b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 23 May 2026 11:27:00 -0700 Subject: [PATCH] feat(kanban): warn users that scratch workspaces are deleted on completion (#30949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First scratch workspace creation on an install now emits a one-shot warning log + a 'tip_scratch_workspace' event on the task. Sentinel file at ~/.hermes/kanban/.scratch_tip_shown silences subsequent creations across the whole install. Behavior unchanged — scratch is still ephemeral by design. This just makes the design visible to new users (reported in user community: 'progress files vanished, no warning anywhere'). Docs (en + ko) updated to spell out 'Deleted when the task completes' on the scratch bullet and 'Preserved on completion' on worktree/dir. --- hermes_cli/kanban_db.py | 89 +++++++++++++++ tests/hermes_cli/test_kanban_db.py | 106 ++++++++++++++++++ website/docs/user-guide/features/kanban.md | 6 +- .../current/user-guide/features/kanban.md | 6 +- 4 files changed, 201 insertions(+), 6 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 33de8945ff5..7975a9fb02f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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 `` 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, @@ -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 @@ -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 diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 24b553e9e65..25ef4e9f865 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -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] + diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index f251dff0c3b..7a51957828d 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -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//` (or `~/.hermes/kanban/boards//workspaces//` on non-default boards). - - `dir:` — 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//` for coding tasks. Use `worktree:` 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//` (or `~/.hermes/kanban/boards//workspaces//` 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 `) marks the task done. If you want to keep the worker's output, use `worktree:` or `dir:` 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 `). + - `dir:` — 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//` for coding tasks. Use `worktree:` 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. diff --git a/website/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md index e48a95e0a6b..69ae12f50a5 100644 --- a/website/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md +++ b/website/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md @@ -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//` 아래의 새 tmp 디렉터리 (non-default board는 board 경로 아래) - - `dir:` — 기존 공유 디렉터리. **절대경로만 허용**됩니다. - - `worktree` — 코딩 task를 위한 git worktree (`.worktrees//`) + - `scratch` (기본값) — `~/.hermes/kanban/workspaces//` 아래의 새 tmp 디렉터리 (non-default board는 board 경로 아래). **task가 완료되면 삭제됩니다** — scratch는 설계상 일회용이라 worker(또는 `hermes kanban complete `)가 task를 done 처리하는 순간 디렉터리가 비워집니다. worker 결과물을 보존하려면 `worktree:` 또는 `dir:`를 사용하세요. 설치 후 처음으로 scratch workspace가 생성될 때 dispatcher가 경고를 로그에 남기고 해당 task에 `tip_scratch_workspace` 이벤트를 추가합니다(`hermes kanban show `로 확인 가능). + - `dir:` — 기존 공유 디렉터리. **절대경로만 허용**됩니다. **완료 시 보존됩니다.** + - `worktree` — 코딩 task를 위한 git worktree (`.worktrees//`). **완료 시 보존됩니다.** - **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입니다.