feat(kanban): warn users that scratch workspaces are deleted on completion (#30949)

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.
This commit is contained in:
Teknium 2026-05-23 11:27:00 -07:00 committed by GitHub
parent cae7537359
commit ad11327db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 201 additions and 6 deletions

View file

@ -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,
@ -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

View file

@ -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]

View file

@ -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.

View file

@ -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입니다.