{"repo":"NousResearch/hermes-agent","public_id":"53699","display_label":"Kanban completion artifact validation and preservation before cleanup","summary":"These members center on kanban_complete handling of completion artifacts: one bug report describes completion being accepted with a nonexistent durable artifact, and the fix-titled items propose checking that artifact files exist and preserving them before scratch cleanup. Titles alone do not show the final behavior or status.","subsystem":"cron","counts":{"members":4,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":0,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63619,"merged_at":"2026-07-13T06:09:42+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-13T06:09:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":36046,"pr_number":63619,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-13T06:09:42+00:00"},{"issue_number":39747,"pr_number":63619,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-13T06:09:42+00:00"},{"issue_number":53699,"pr_number":63619,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-13T06:09:42+00:00"}],"members":[{"kind":"issue","number":53699,"title":"[Bug]: kanban_complete accepts completion referencing a nonexistent durable artifact","url":null,"state":"open"},{"kind":"pr","number":53723,"title":"fix(kanban): validate artifact file existence before accepting completion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63619","number":63619}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":63619,"title":"fix(kanban): preserve completion artifacts before scratch cleanup","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/plugin_api.py","scripts/release.py","tests/gateway/test_platform_base.py","tests/hermes_cli/test_kanban_db.py","tests/tools/test_kanban_tools.py","tools/kanban_tools.py","website/docs/user-guide/features/kanban.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1072,10 +1072,33 @@ def _profile_cache_roots() -> List[Path]:\n     return roots\n \n \n+def _kanban_attachment_roots() -> List[Path]:\n+    \"\"\"Return durable Kanban attachment roots without importing kanban_db.\"\"\"\n+    override = os.environ.get(\"HERMES_KANBAN_ATTACHMENTS_ROOT\", \"\").strip()\n+    if override:\n+        return [Path(override).expanduser()]\n+    home_override = os.environ.get(\"HERMES_KANBAN_HOME\", \"\").strip()\n+    root = Path(home_override).expanduser() if home_override else _HERMES_ROOT\n+    roots = [root / \"kanban\" / \"attachments\"]\n+    boards_root = root / \"kanban\" / \"boards\"\n+    try:\n+        board_dirs = [\n+            path for path in boards_root.iterdir()\n+            if path.is_dir() and not path.is_symlink()\n+            and re.fullmatch(r\"[a-z0-9][a-z0-9_-]{0,63}\", path.name)\n+            and (path / \"kanban.db\").is_file()\n+        ]\n+    except OSError:\n+        return roots\n+    roots.extend(path / \"attachments\" for path in board_dirs)\n+    return roots\n+\n+\n def _media_delivery_allowed_roots() -> List[Path]:\n     \"\"\"Return roots from which model-emitted local media may be delivered.\"\"\"\n     roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS]\n     roots.extend(_profile_cache_roots())\n+    roots.extend(_kanban_attachment_roots())\n     extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, \"\")\n     for chunk in extra_roots.split(os.pathsep):\n         for raw_root in chunk.split(\",\"):"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -135,6 +135,7 @@\n VALID_WORKSPACE_KINDS = {\"scratch\", \"worktree\", \"dir\"}\n KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())\n _IS_WINDOWS = sys.platform == \"win32\"\n+KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024\n \n \n def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None:","@@ -3975,6 +3976,10 @@ def __init__(self, phantom: list[str], completing_task_id: str):\n         )\n \n \n+class ArtifactPreservationError(RuntimeError):\n+    \"\"\"Raised when a declared scratch deliverable cannot be preserved.\"\"\"\n+\n+\n def complete_task(\n     conn: sqlite3.Connection,\n     task_id: str,","@@ -4042,6 +4047,9 @@ def complete_task(\n     else:\n         verified_cards = []\n \n+    metadata = _merge_completion_prose_artifacts(\n+        conn, task_id, metadata, summary=summary, result=result,\n+    )\n     with write_txn(conn):\n         if expected_run_id is None:\n             cur = conn.execute(","@@ -4080,6 +4088,18 @@ def complete_task(\n             )\n         if cur.rowcount != 1:\n             return False\n+        if isinstance(metadata, dict):\n+            _persist_scratch_completion_artifacts(conn, task_id, metadata)\n+            for stored_path in metadata.pop(\"_staged_artifacts\", []):\n+                path = Path(stored_path)\n+                _insert_completion_attachment(\n+                    conn,\n+                    task_id,\n+                    filename=path.name,\n+                    stored_path=str(path),\n+                    size=path.stat().st_size,\n+                    created_at=now,\n+                )\n         run_id = _end_run(\n             conn, task_id,\n             outcome=\"completed\", status=\"done\",","@@ -4174,40 +4194,215 @@ def complete_task(\n # Workspace / tmux cleanup\n # ---------------------------------------------------------------------------\n \n-def _is_managed_scratch_path(p: Path) -> bool:\n-    \"\"\"Return True iff *p* is a strict descendant of a kanban-managed scratch root.\n \n-    A managed root is exclusively a ``workspaces/`` directory — never the\n-    broader kanban home, a board root, or sibling subtrees like ``logs/`` or\n-    ``boards/<slug>/`` itself. Allowed roots:\n+def _merge_completion_prose_artifacts(\n+    conn: sqlite3.Connection,\n+    task_id: str,\n+    metadata: Optional[dict],\n+    *,\n+    summary: Optional[str],\n+    result: Optional[str],\n+) -> Optional[dict]:\n+    \"\"\"Promote existing scratch files named in legacy completion prose.\n+\n+    ``artifacts=[...]`` is preferred. Older workers only wrote an absolute\n+    deliverable path in ``summary``/``result``; discover it while scratch still\n+    exists so cleanup cannot erase the file the user was promised.\n+    \"\"\"\n+    row = conn.execute(\n+        \"SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?\",\n+        (task_id,),\n+    ).fetchone()\n+    if not row or row[\"workspace_kind\"] != \"scratch\" or not row[\"workspace_path\"]:\n+        return metadata\n+    workspace = Path(row[\"workspace_path\"]).expanduser()\n+    if not _is_managed_scratch_path(workspace):\n+        return metadata\n+    text = \"\\n\".join(part for part in (summary, result) if part)\n+    if not text:\n+        return metadata\n+    prefix = re.escape(str(workspace))\n+    discovered: list[str] = []\n+    for match in re.finditer(prefix + r\"(?:[/\\\\][^\\s`\\\"'<>]+)\", text):\n+        raw = match.group(0).rstrip(\".,;:!?)]}\")\n+        candidate = Path(raw)\n+        if candidate.is_file():\n+            discovered.append(str(candidate))\n+    if not discovered:\n+        return metadata\n+    updated = dict(metadata) if isinstance(metadata, dict) else {}\n+    existing = updated.get(\"artifacts\")\n+    merged = list(existing) if isinstance(existing, (list, tuple)) else []\n+    seen = {str(path) for path in merged}\n+    for path in discovered:\n+        if path not in seen:\n+            merged.append(path)\n+            seen.add(path)\n+    updated[\"artifacts\"] = merged\n+    return updated\n+\n+\n+def _persist_scratch_completion_artifacts(\n+    conn: sqlite3.Connection,\n+    task_id: str,\n+    metadata: dict,\n+) -> None:\n+    \"\"\"Copy scratch-workspace completion artifacts before cleanup removes them.\"\"\"\n+    raw_artifacts = metadata.get(\"artifacts\")\n+    if not isinstance(raw_artifacts, (list, tuple)):\n+        return\n \n-    * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override\n-      injected by the dispatcher).\n-    * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.\n-    * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug\n-      that currently exists on disk.\n+    row = conn.execute(\n+        \"SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?\",\n+        (task_id,),\n+    ).fetchone()\n+    if not row or row[\"workspace_kind\"] != \"scratch\" or not row[\"workspace_path\"]:\n+        return\n \n-    The check requires strict descendancy: a path equal to one of these\n-    roots is NOT managed (deleting the workspaces root would wipe every\n-    task's scratch dir at once), and a path that resolves to ``<kanban_home>\n-    /kanban`` itself, ``<kanban_home>/kanban/logs``, or\n-    ``<kanban_home>/kanban/boards/<slug>`` is rejected because those\n-    subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.\n+    workspace = Path(row[\"workspace_path\"]).expanduser()\n+    is_managed, board = _managed_scratch_path_info(workspace)\n+    if not is_managed:\n+        return\n \n-    Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths\n-    outside Hermes-managed storage. A board ``default_workdir`` pointing at a\n-    real source tree can otherwise pair with ``workspace_kind='scratch'`` and\n-    cause task completion to delete user data (#28818).\n-    \"\"\"\n+    try:\n+        workspace_root = workspace.resolve()\n+    except OSError:\n+        return\n+\n+    attachment_dir = task_attachments_dir(task_id, board=board)\n+    persisted: list[str] = []\n+    used_destinations: set[Path] = set()\n+    changed = False\n+\n+    def _discard_copies() -> None:\n+        for copied in used_destinations:\n+            try:\n+                copied.unlink(missing_ok=True)\n+            except OSError:\n+                pass\n+        try:\n+            attachment_dir.rmdir()\n+        except OSError:\n+            pass\n+\n+    for item in raw_artifacts:\n+        artifact = str(item).strip() if isinstance(item, str) else \"\"\n+        if not artifact:\n+            continue\n+        src = Path(artifact).expanduser()\n+        try:\n+            resolved_src = src.resolve()\n+        except OSError:\n+            persisted.append(artifact)\n+            continue\n+\n+        if not resolved_src.is_relative_to(workspace_root):\n+            persisted.append(artifact)\n+            continue\n+\n+        if not src.is_file():\n+            _discard_copies()\n+            raise ArtifactPreservationError(\n+                f\"declared scratch artifact is unavailable or not a regular file: {artifact}\"\n+            )\n+\n+        size = resolved_src.stat().st_size\n+        if size > KANBAN_ATTACHMENT_MAX_BYTES:\n+            _discard_copies()\n+            raise ArtifactPreservationError(\n+                f\"declared scratch artifact exceeds the \"\n+                f\"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}\"\n+            )\n+\n+        dest: Optional[Path] = None\n+        try:\n+            attachment_dir.mkdir(parents=True, exist_ok=True)\n+            dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations)\n+            with resolved_src.open(\"rb\") as source_file, dest.open(\"xb\") as destination_file:\n+                copied = 0\n+                while chunk := source_file.read(1024 * 1024):\n+                    copied += len(chunk)\n+                    if copied > KANBAN_ATTACHMENT_MAX_BYTES:\n+                        raise ArtifactPreservationError(\n+                            f\"declared scratch artifact grew beyond the size limit: {artifact}\"\n+                        )\n+                    destination_file.write(chunk)\n+        except Exception as exc:\n+            if dest is not None:\n+                try:\n+                    dest.unlink(missing_ok=True)\n+                except OSError:\n+                    pass\n+            _discard_copies()\n+            if isinstance(exc, ArtifactPreservationError):\n+                raise\n+            raise ArtifactPreservationError(\n+                f\"could not preserve declared scratch artifact {artifact}: {exc}\"\n+            ) from exc\n+\n+        used_destinations.add(dest)\n+        persisted.append(str(dest.resolve()))\n+        changed = True\n+\n+    if changed:\n+        metadata[\"artifacts\"] = persisted\n+        metadata[\"_staged_artifacts\"] = [\n+            path for path in persisted if path.startswith(str(attachment_dir.resolve()))\n+        ]\n+\n+\n+def _insert_completion_attachment(\n+    conn: sqlite3.Connection,\n+    task_id: str,\n+    *,\n+    filename: str,\n+    stored_path: str,\n+    size: int,\n+    created_at: int,\n+) -> None:\n+    \"\"\"Record a worker-produced artifact in the existing attachment table.\"\"\"\n+    conn.execute(\n+        \"INSERT INTO task_attachments \"\n+        \"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) \"\n+        \"VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)\",\n+        (task_id, filename, stored_path, size, created_at),\n+    )\n+    _append_event(\n+        conn,\n+        task_id,\n+        \"attached\",\n+        {\"filename\": filename, \"size\": size, \"by\": \"kanban_complete\"},\n+    )\n+\n+\n+def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path:\n+    \"\"\"Return a non-conflicting path under ``directory`` for ``filename``.\"\"\"\n+    safe_name = Path(filename).name or \"artifact\"\n+    candidate = directory / safe_name\n+    if candidate not in used and not candidate.exists():\n+        return candidate\n+\n+    stem = Path(safe_name).stem or \"artifact\"\n+    suffix = Path(safe_name).suffix\n+    idx = 1\n+    while True:\n+        candidate = directory / f\"{stem}_{idx}{suffix}\"\n+        if candidate not in used and not candidate.exists():\n+            return candidate\n+        idx += 1\n+\n+\n+def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]:\n+    \"\"\"Return whether *p* is managed scratch storage and the matching board.\"\"\"\n     try:\n         p_abs = p.resolve(strict=False)\n     except OSError:\n-        return False\n-    roots: list[Path] = []\n+        return False, None\n+    roots: list[tuple[Path, Optional[str]]] = []\n     override = os.environ.get(\"HERMES_KANBAN_WORKSPACES_ROOT\", \"\").strip()\n     if override:\n         try:\n-            roots.append(Path(override).expanduser().resolve(strict=False))\n+            roots.append((Path(override).expanduser().resolve(strict=False), None))\n         except OSError:\n             pass\n     try:","@@ -4216,7 +4411,7 @@ def _is_managed_scratch_path(p: Path) -> bool:\n         home = None\n     if home is not None:\n         try:\n-            roots.append((home / \"kanban\" / \"workspaces\").resolve(strict=False))\n+            roots.append(((home / \"kanban\" / \"workspaces\").resolve(strict=False), DEFAULT_BOARD))\n         except OSError:\n             pass\n         try:","@@ -4235,18 +4430,47 @@ def _is_managed_scratch_path(p: Path) -> bool:\n                 except OSError:\n                     continue\n                 try:\n-                    roots.append((entry / \"workspaces\").resolve(strict=False))\n+                    roots.append(((entry / \"workspaces\").resolve(strict=False), entry.name))\n                 except OSError:\n                     continue\n-    for root in roots:\n+    for root, board in roots:\n         if p_abs == root:\n             continue\n         try:\n             if p_abs.is_relative_to(root):\n-                return True\n+                return True, board\n         except ValueError:\n             continue\n-    return False\n+    return False, None\n+\n+\n+def _is_managed_scratch_path(p: Path) -> bool:\n+    \"\"\"Return True iff *p* is a strict descendant of a kanban-managed scratch root.\n+\n+    A managed root is exclusively a ``workspaces/`` directory — never the\n+    broader kanban home, a board root, or sibling subtrees like ``logs/`` or\n+    ``boards/<slug>/`` itself. Allowed roots:\n+\n+    * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override\n+      injected by the dispatcher).\n+    * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.\n+    * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug\n+      that currently exists on disk.\n+\n+    The check requires strict descendancy: a path equal to one of these\n+    roots is NOT managed (deleting the workspaces root would wipe every\n+    task's scratch dir at once), and a path that resolves to ``<kanban_home>\n+    /kanban`` itself, ``<kanban_home>/kanban/logs``, or\n+    ``<kanban_home>/kanban/boards/<slug>`` is rejected because those\n+    subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.\n+\n+    Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths\n+    outside Hermes-managed storage. A board ``default_workdir`` pointing at a\n+    real source tree can otherwise pair with ``workspace_kind='scratch'`` and\n+    cause task completion to delete user data (#28818).\n+    \"\"\"\n+    is_managed, _board = _managed_scratch_path_info(p)\n+    return is_managed\n \n \n def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None:"]},{"file":"plugins/kanban/dashboard/plugin_api.py","hunks":["@@ -646,7 +646,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):\n \n # Cap a single upload so a runaway request can't fill the disk. 25 MB\n # comfortably covers PDFs, images, and source docs — the kanban use case.\n-_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024\n+_MAX_ATTACHMENT_BYTES = kanban_db.KANBAN_ATTACHMENT_MAX_BYTES\n \n \n def _safe_attachment_name(raw: str) -> str:"]},{"file":"scripts/release.py","hunks":["@@ -239,6 +239,7 @@\n     \"[redacted:email]\": \"w31rdm4ch1nZ\",\n     \"[redacted:email]\": \"x7peeps\",\n     \"[redacted:email]\": \"ahmadashfq\",\n+    \"182213728+yinkev@users.noreply.github.com\": \"yinkev\",  # scratch artifact preservation salvage\n     \"[redacted:email]\": \"rratmansky\",\n     \"lkz-de@users.noreply.github.com\": \"lkz-de\",\n     \"[redacted:email]\": \"salesondemandio\","]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -786,6 +786,28 @@ def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch):\n \n         assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())\n \n+    def test_allows_stale_kanban_attachment_but_not_neighboring_workspace(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        \"\"\"Strict mode trusts durable attachments without trusting scratch.\"\"\"\n+        self._patch_roots(monkeypatch)\n+        monkeypatch.setenv(\"HERMES_KANBAN_HOME\", str(tmp_path / \"hermes\"))\n+        monkeypatch.setenv(\"HERMES_MEDIA_TRUST_RECENT_FILES\", \"0\")\n+        board_root = tmp_path / \"hermes\" / \"kanban\" / \"boards\" / \"research\"\n+        board_root.mkdir(parents=True)\n+        (board_root / \"kanban.db\").touch()\n+        attachment = board_root / \"attachments\" / \"t_12345678\" / \"report.pdf\"\n+        scratch = board_root / \"workspaces\" / \"t_12345678\" / \"notes.txt\"\n+        attachment.parent.mkdir(parents=True)\n+        scratch.parent.mkdir(parents=True)\n+        attachment.write_bytes(b\"%PDF\")\n+        scratch.write_text(\"private\", encoding=\"utf-8\")\n+\n+        assert BasePlatformAdapter.validate_media_delivery_path(str(attachment)) == str(\n+            attachment.resolve()\n+        )\n+        assert BasePlatformAdapter.validate_media_delivery_path(str(scratch)) is None\n+\n     def test_recency_trust_allows_freshly_produced_file(self, tmp_path, monkeypatch):\n         \"\"\"A PDF the agent just wrote to /tmp should be deliverable.\n "]},{"file":"tests/hermes_cli/test_kanban_db.py","hunks":["@@ -2363,6 +2363,175 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):\n     assert not ws.exists(), \"Hermes-managed scratch dir should be cleaned up\"\n \n \n+def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home):\n+    \"\"\"Completion artifacts from scratch workspaces survive workspace cleanup.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"render chart\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, t, ws)\n+        artifact = ws / \"chart.png\"\n+        artifact.write_bytes(b\"png-bytes\")\n+\n+        assert kb.complete_task(\n+            conn,\n+            t,\n+            result=\"ok\",\n+            metadata={\"artifacts\": [str(artifact)]},\n+        )\n+\n+        completed = [e for e in kb.list_events(conn, t) if e.kind == \"completed\"][-1]\n+        persisted = Path(completed.payload[\"artifacts\"][0])\n+        run = kb.latest_run(conn, t)\n+\n+    assert not ws.exists(), \"scratch workspace should still be cleaned up\"\n+    assert persisted.exists(), \"artifact copy should survive scratch cleanup\"\n+    assert persisted.parent == kb.task_attachments_dir(t)\n+    assert persisted.name == \"chart.png\"\n+    assert persisted.read_bytes() == b\"png-bytes\"\n+    assert str(persisted) != str(artifact)\n+    assert run is not None\n+    assert run.metadata[\"artifacts\"] == [str(persisted)]\n+    with kb.connect() as conn:\n+        attachments = kb.list_attachments(conn, t)\n+    assert [(a.filename, a.stored_path) for a in attachments] == [\n+        (\"chart.png\", str(persisted.resolve()))\n+    ]\n+\n+\n+def test_complete_task_rejects_missing_declared_scratch_artifact(kanban_home):\n+    \"\"\"A declared scratch deliverable must not disappear behind a false Done.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"missing report\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, t, ws)\n+        missing = ws / \"report.md\"\n+\n+        with pytest.raises(kb.ArtifactPreservationError, match=\"unavailable\"):\n+            kb.complete_task(\n+                conn,\n+                t,\n+                result=\"report complete\",\n+                metadata={\"artifacts\": [str(missing)]},\n+            )\n+\n+        assert kb.get_task(conn, t).status == \"ready\"\n+        assert kb.list_attachments(conn, t) == []\n+    assert ws.exists(), \"failed completion must keep scratch available for retry\"\n+\n+\n+def test_complete_task_preserves_legacy_artifact_path_from_summary(kanban_home):\n+    \"\"\"Summary-only workers keep the file they tell the user was delivered.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"legacy report\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, t, ws)\n+        report = ws / \"report.md\"\n+        report.write_text(\"legacy deliverable\", encoding=\"utf-8\")\n+\n+        assert kb.complete_task(\n+            conn,\n+            t,\n+            summary=f\"Task complete — delivered {report}\",\n+        )\n+        run = kb.latest_run(conn, t)\n+\n+    persisted = Path(run.metadata[\"artifacts\"][0])\n+    assert not ws.exists()\n+    assert persisted.read_text(encoding=\"utf-8\") == \"legacy deliverable\"\n+    assert persisted.parent == kb.task_attachments_dir(t)\n+\n+\n+def test_complete_task_leaves_non_scratch_artifact_paths_unchanged(\n+    kanban_home,\n+    tmp_path,\n+):\n+    \"\"\"Only artifacts inside the managed scratch workspace are copied.\"\"\"\n+    external = tmp_path / \"report.md\"\n+    external.write_text(\"keep me here\", encoding=\"utf-8\")\n+\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"external report\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, t, ws)\n+\n+        assert kb.complete_task(\n+            conn,\n+            t,\n+            result=\"ok\",\n+            metadata={\"artifacts\": [str(external)]},\n+        )\n+\n+        completed = [e for e in kb.list_events(conn, t) if e.kind == \"completed\"][-1]\n+        run = kb.latest_run(conn, t)\n+\n+    assert not ws.exists(), \"scratch workspace should still be cleaned up\"\n+    assert external.exists()\n+    assert completed.payload[\"artifacts\"] == [str(external)]\n+    assert run is not None\n+    assert run.metadata[\"artifacts\"] == [str(external)]\n+\n+\n+def test_complete_task_persists_duplicate_scratch_artifact_names(kanban_home):\n+    \"\"\"Scratch artifact persistence does not overwrite duplicate basenames.\"\"\"\n+    with kb.connect() as conn:\n+        t = kb.create_task(conn, title=\"render reports\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, t, ws)\n+        first = ws / \"a\" / \"report.txt\"\n+        second = ws / \"b\" / \"report.txt\"\n+        first.parent.mkdir(parents=True)\n+        second.parent.mkdir(parents=True)\n+        first.write_text(\"first\", encoding=\"utf-8\")\n+        second.write_text(\"second\", encoding=\"utf-8\")\n+\n+        assert kb.complete_task(\n+            conn,\n+            t,\n+            result=\"ok\",\n+            metadata={\"artifacts\": [str(first), str(second)]},\n+        )\n+\n+        completed = [e for e in kb.list_events(conn, t) if e.kind == \"completed\"][-1]\n+        persisted = [Path(p) for p in completed.payload[\"artifacts\"]]\n+\n+    assert not ws.exists(), \"scratch workspace should still be cleaned up\"\n+    assert [p.name for p in persisted] == [\"report.txt\", \"report_1.txt\"]\n+    assert [p.read_text(encoding=\"utf-8\") for p in persisted] == [\"first\", \"second\"]\n+    assert all(p.parent == kb.task_attachments_dir(t) for p in persisted)\n+\n+\n+def test_complete_task_persists_board_scratch_artifacts_to_board_attachments(kanban_home):\n+    \"\"\"Board scratch artifacts are copied under that board's attachment root.\"\"\"\n+    kb.create_board(\"work-proj\")\n+\n+    with kb.connect(board=\"work-proj\") as conn:\n+        t = kb.create_task(conn, title=\"board chart\", board=\"work-proj\")\n+        task = kb.get_task(conn, t)\n+        ws = kb.resolve_workspace(task, board=\"work-proj\")\n+        kb.set_workspace_path(conn, t, ws)\n+        artifact = ws / \"chart.png\"\n+        artifact.write_bytes(b\"board-png\")\n+\n+        assert kb.complete_task(\n+            conn,\n+            t,\n+            result=\"ok\",\n+            metadata={\"artifacts\": [str(artifact)]},\n+        )\n+\n+        completed = [e for e in kb.list_events(conn, t) if e.kind == \"completed\"][-1]\n+        persisted = Path(completed.payload[\"artifacts\"][0])\n+\n+    assert not ws.exists(), \"board scratch workspace should still be cleaned up\"\n+    assert persisted.exists()\n+    assert persisted.parent == kb.task_attachments_dir(t, board=\"work-proj\")\n+\n+\n def test_cleanup_workspace_refuses_path_outside_scratch_root(kanban_home, tmp_path):\n     \"\"\"A scratch task with a user path outside the workspaces root must NOT be deleted (#28818).\n "]},{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -484,6 +484,31 @@ def test_complete_rejects_non_list_artifacts(worker_env):\n     assert \"artifacts must be a list\" in err\n \n \n+def test_complete_missing_scratch_artifact_stays_in_flight(worker_env):\n+    \"\"\"A false deliverable claim must return retry guidance, not mark Done.\"\"\"\n+    from hermes_cli import kanban_db as kb\n+    from tools import kanban_tools as kt\n+\n+    with kb.connect() as conn:\n+        task = kb.get_task(conn, worker_env)\n+        assert task is not None\n+        workspace = kb.resolve_workspace(task)\n+        kb.set_workspace_path(conn, worker_env, workspace)\n+\n+    output = kt._handle_complete({\n+        \"summary\": \"report complete\",\n+        \"artifacts\": [str(workspace / \"missing-report.md\")],\n+    })\n+    error = json.loads(output).get(\"error\", \"\")\n+\n+    assert \"could not preserve\" in error\n+    assert \"still in-flight\" in error\n+    assert \"retry kanban_complete\" in error\n+    with kb.connect() as conn:\n+        assert kb.get_task(conn, worker_env).status == \"running\"\n+    assert workspace.exists()\n+\n+\n def test_complete_rejects_no_handoff(worker_env):\n     from tools import kanban_tools as kt\n     out = kt._handle_complete({})"]},{"file":"tools/kanban_tools.py","hunks":["@@ -630,6 +630,13 @@ def _handle_complete(args: dict, **kw) -> str:\n                     created_cards=created_cards,\n                     expected_run_id=_worker_run_id(tid),\n                 )\n+            except kb.ArtifactPreservationError as artifact_err:\n+                return tool_error(\n+                    f\"kanban_complete could not preserve the declared artifacts: \"\n+                    f\"{artifact_err}. Your task is still in-flight and its \"\n+                    f\"scratch workspace was kept. Fix the artifact path or \"\n+                    f\"storage error, then retry kanban_complete with the same handoff.\"\n+                )\n             except kb.HallucinatedCardsError as hall_err:\n                 # Structured rejection — surface the phantom ids so the\n                 # worker can retry with a corrected list or drop the","@@ -1277,8 +1284,10 @@ def _board_schema_prop() -> dict[str, str]:\n                     \"lands with the completion notification. Skip \"\n                     \"intermediate scratch files and references that \"\n                     \"are not the deliverable. The path must exist \"\n-                    \"on disk when the notifier runs; missing files \"\n-                    \"are silently skipped.\"\n+                    \"on disk at completion. Files inside a managed scratch \"\n+                    \"workspace are copied to durable task attachments before \"\n+                    \"cleanup; a missing declared scratch artifact keeps the \"\n+                    \"task in-flight so you can fix the path and retry.\"\n                 ),\n             },\n             \"board\": _board_schema_prop(),"]},{"file":"website/docs/user-guide/features/kanban.md","hunks":["@@ -63,7 +63,7 @@ They coexist: a kanban worker may call `delegate_task` internally during its run\n - **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`.\n - **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.\n - **Workspace** — the directory a worker operates in. Three kinds:\n-  - `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>`).\n+  - `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. Files explicitly declared through `kanban_complete(artifacts=[...])` are copied into durable per-task attachment storage before cleanup; existing deliverable paths in legacy completion summaries receive the same treatment. Other scratch files are removed. A missing declared scratch artifact keeps the task in-flight so the worker can correct the path and retry. Use `worktree:` or `dir:<path>` when the whole workspace should remain available. 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>`).\n   - `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.**\n   - `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.**\n - **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."]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md","hunks":["@@ -59,7 +59,7 @@ Hermes Kanban 是一个持久化任务看板，在所有 Hermes 配置文件之\n - **Link（链接）** —— `task_links` 行，记录父 → 子依赖关系。当所有父任务变为 `done` 时，调度器将 `todo → ready`。\n - **Comment（评论）** —— agent 间协议。Agent 和人类追加评论；当 worker 被（重新）启动时，它将完整的评论线程作为上下文的一部分读取。\n - **Workspace（工作区）** —— worker 操作的目录。三种类型：\n-  - `scratch`（默认）—— 在 `~/.hermes/kanban/workspaces/<id>/` 下（非默认看板为 `~/.hermes/kanban/boards/<slug>/workspaces/<id>/`）创建的临时目录。**任务完成时删除** —— scratch 是临时性的，worker（或 `hermes kanban complete <id>`）将任务标记为完成的那一刻，目录即被清除。如果想保留 worker 的输出，请使用 `worktree:` 或 `dir:<path>`。在某次安装中首次创建 scratch 工作区时，调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件（可通过 `hermes kanban show <id>` 查看）。\n+  - `scratch`（默认）—— 在 `~/.hermes/kanban/workspaces/<id>/` 下（非默认看板为 `~/.hermes/kanban/boards/<slug>/workspaces/<id>/`）创建的临时目录。**任务完成时删除** —— scratch 按设计是临时性的。通过 `kanban_complete(artifacts=[...])` 明确声明的文件会在清理前复制到持久的任务附件存储；旧版完成摘要中已存在的交付文件路径也会得到同样处理。其他 scratch 文件仍会被删除。如果声明的 scratch 交付文件不存在，任务会保持进行中，worker 可修正路径后重试。需要保留整个工作区时，请使用 `worktree:` 或 `dir:<path>`。在某次安装中首次创建 scratch 工作区时，调度器会记录警告并在任务上发出 `tip_scratch_workspace` 事件（可通过 `hermes kanban show <id>` 查看）。\n   - `dir:<path>` —— 现有的共享目录（Obsidian vault、邮件运维目录、每账号文件夹）。**必须是绝对路径。** 像 `dir:../tenants/foo/` 这样的相对路径在调度时会被拒绝，因为它们会相对于调度器碰巧所在的 CWD 解析，这是模糊的，也是混淆代理（confused-deputy）逃逸向量。路径本身是受信任的 —— 这是你的机器、你的文件系统，worker 以你的 uid 运行。这是受信任本地用户的威胁模型；kanban 设计为单主机。**完成时保留。**\n   - `worktree` —— 用于编码任务的 git worktree，位于 `.worktrees/<id>/` 下。使用 `worktree:<path>` 固定确切的目标路径。Worker 端的 `git worktree add` 创建它，提供 `--branch` 时使用该分支。**完成时保留。**\n - **Dispatcher（调度器）** —— 一个长期运行的循环，每 N 秒（默认 60 秒）执行一次：回收过期的认领、回收崩溃的 worker（PID 消失但 TTL 尚未过期）、推进就绪任务、原子性认领、启动已分配的配置文件。默认**在 gateway 内部运行**（`kanban.dispatch_in_gateway: true`）。每次 tick 一个调度器扫描所有看板；worker 启动时固定了 `HERMES_KANBAN_BOARD`，因此无法看到其他看板。在同一任务上连续启动失败 `kanban.failure_limit` 次（默认：2）后，调度器会以最后一个错误为原因自动阻塞该任务 —— 防止因配置文件不存在、工作区无法挂载等原因导致的反复抖动。\n"]}]},{"kind":"pr","number":75572,"title":"fix(kanban): reject completion artifacts that do not exist","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"19cd749d68bc","number":0},{"kind":"pr","value":"#53699","number":53699},{"kind":"pr","value":"#53723","number":53723},{"kind":"pr","value":"#75572","number":75572},{"kind":"pr","value":"#63619","number":63619},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#15803","number":15803},{"kind":"pr","value":"#14532","number":14532},{"kind":"pr","value":"#6","number":6},{"kind":"pr","value":"#9","number":9},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#4338","number":4338}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}