# NousResearch/hermes-agent · Kanban completion artifact validation and preservation before cleanup

## Open now

- 1 issue, 1 PR open on GitHub.
- #53699: \[Bug\]: kanban_complete accepts completion referencing a nonexistent durable artifact
- PR #75572: fix(kanban): reject completion artifacts that do not exist

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.

## TL;DR

- 4 member(s): 1 open issue(s), 0 closed issue(s), 1 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #63619. Verify it addresses your case.
- **Recommended:** read PR #63619 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #53699: \[Bug\]: kanban_complete accepts completion referencing a nonexistent durable artifact

## Prior & competing PRs

- **#63619: fix(kanban): preserve completion artifacts before scratch cleanup**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/platforms/base.py`

    ```diff
    @@ -1072,10 +1072,33 @@ def _profile_cache_roots() -> List[Path]:
         return roots
     
     
    +def _kanban_attachment_roots() -> List[Path]:
    +    """Return durable Kanban attachment roots without importing kanban_db."""
    +    override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
    +    if override:
    +        return [Path(override).expanduser()]
    +    home_override = os.environ.get("HERMES_KANBAN_HOME", "").strip()
    +    root = Path(home_override).expanduser() if home_override else _HERMES_ROOT
    +    roots = [root / "kanban" / "attachments"]
    +    boards_root = root / "kanban" / "boards"
    +    try:
    +        board_dirs = [
    +            path for path in boards_root.iterdir()
    +            if path.is_dir() and not path.is_symlink()
    +            and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", path.name)
    +            and (path / "kanban.db").is_file()
    +        ]
    +    except OSError:
    +        return roots
    +    roots.extend(path / "attachments" for path in board_dirs)
    +    return roots
    +
    +
     def _media_delivery_allowed_roots() -> List[Path]:
         """Return roots from which model-emitted local media may be delivered."""
         roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS]
         roots.extend(_profile_cache_roots())
    +    roots.extend(_kanban_attachment_roots())
         extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "")
         for chunk in extra_roots.split(os.pathsep):
             for raw_root in chunk.split(","):
    ```
  - `hermes_cli/kanban_db.py`

    ```diff
    @@ -135,6 +135,7 @@
     VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
     KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
     _IS_WINDOWS = sys.platform == "win32"
    +KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024
     
     
     def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None:
    ```

    ```diff
    @@ -3975,6 +3976,10 @@ def __init__(self, phantom: list[str], completing_task_id: str):
             )
     
     
    +class ArtifactPreservationError(RuntimeError):
    +    """Raised when a declared scratch deliverable cannot be preserved."""
    +
    +
     def complete_task(
         conn: sqlite3.Connection,
         task_id: str,
    ```

    ```diff
    @@ -4042,6 +4047,9 @@ def complete_task(
         else:
             verified_cards = []
     
    +    metadata = _merge_completion_prose_artifacts(
    +        conn, task_id, metadata, summary=summary, result=result,
    +    )
         with write_txn(conn):
             if expected_run_id is None:
                 cur = conn.execute(
    ```

    ```diff
    @@ -4080,6 +4088,18 @@ def complete_task(
                 )
             if cur.rowcount != 1:
                 return False
    +        if isinstance(metadata, dict):
    +            _persist_scratch_completion_artifacts(conn, task_id, metadata)
    +            for stored_path in metadata.pop("_staged_artifacts", []):
    +                path = Path(stored_path)
    +                _insert_completion_attachment(
    +                    conn,
    +                    task_id,
    +                    filename=path.name,
    +                    stored_path=str(path),
    +                    size=path.stat().st_size,
    +                    created_at=now,
    +                )
             run_id = _end_run(
                 conn, task_id,
                 outcome="completed", status="done",
    ```

    ```diff
    @@ -4174,40 +4194,215 @@ def complete_task(
     # Workspace / tmux cleanup
     # ---------------------------------------------------------------------------
     
    -def _is_managed_scratch_path(p: Path) -> bool:
    -    """Return True iff *p* is a strict descendant of a kanban-managed scratch root.
     
    -    A managed root is exclusively a ``workspaces/`` directory — never the
    -    broader kanban home, a board root, or sibling subtrees like ``logs/`` or
    -    ``boards/<slug>/`` itself. Allowed roots:
    +def _merge_completion_prose_artifacts(
    +    conn: sqlite3.Connection,
    +    task_id: str,
    +    metadata: Optional[dict],
    +    *,
    +    summary: Optional[str],
    +    result: Optional[str],
    +) -> Optional[dict]:
    +    """Promote existing scratch files named in legacy completion prose.
    +
    +    ``artifacts=[...]`` is preferred. Older workers only wrote an absolute
    +    deliverable path in ``summary``/``result``; discover it while scratch still
    +    exists so cleanup cannot erase the file the user was promised.
    +    """
    +    row = conn.execute(
    +        "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?",
    +        (task_id,),
    +    ).fetchone()
    +    if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]:
    +        return metadata
    +    workspace = Path(row["workspace_path"]).expanduser()
    +    if not _is_managed_scratch_path(workspace):
    +        return metadata
    +    text = "\n".join(part for part in (summary, result) if part)
    +    if not text:
    +        return metadata
    +    prefix = re.escape(str(workspace))
    +    discovered: list[str] = []
    +    for match in re.finditer(prefix + r"(?:[/\\][^\s`\"'<>]+)", text):
    +        raw = match.group(0).rstrip(".,;:!?)]}")
    +        candidate = Path(raw)
    +        if candidate.is_file():
    +            discovered.append(str(candidate))
    +    if not discovered:
    +        return metadata
    +    updated = dict(metadata) if isinstance(metadata, dict) else {}
    +    existing = updated.get("artifacts")
    +    merged = list(existing) if isinstance(existing, (list, tuple)) else []
    +    seen = {str(path) for path in merged}
    +    for path in discovered:
    +        if path not in seen:
    +            merged.append(path)
    +            seen.add(path)
    +    updated["artifacts"] = merged
    +    return updated
    +
    +
    +def _persist_scratch_completion_artifacts(
    +    conn: sqlite3.Connection,
    +    task_id: str,
    +    metadata: dict,
    +) -> None:
    +    """Copy scratch-workspace completion artifacts before cleanup removes them."""
    +    raw_artifacts = metadata.get("artifacts")
    +    if not isinstance(raw_artifacts, (list, tuple)):
    +        return
     
    -    * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override
    -      injected by the dispatcher).
    -    * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.
    -    * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug
    -      that currently exists on disk.
    +    row = conn.execute(
    +        "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?",
    +        (task_id,),
    +    ).fetchone()
    +    if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]:
    +        return
     
    -    The check requires strict descendancy: a path equal to one of these
    -    roots is NOT managed (deleting the workspaces root would wipe every
    -    task's scratch dir at once), and a path that resolves to ``<kanban_home>
    -    /kanban`` itself, ``<kanban_home>/kanban/logs``, or
    -    ``<kanban_home>/kanban/boards/<slug>`` is rejected because those
    -    subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.
    +    workspace = Path(row["workspace_path"]).expanduser()
    +    is_managed, board = _managed_scratch_path_info(workspace)
    +    if not is_managed:
    +        return
     
    -    Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths
    -    outside Hermes-managed storage. A board ``default_workdir`` pointing at a
    -    real source tree can otherwise pair with ``workspace_kind='scratch'`` and
    -    cause task completion to delete user data (#28818).
    -    """
    +    try:
    +        workspace_root = workspace.resolve()
    +    except OSError:
    +        return
    +
    +    attachment_dir = task_attachments_dir(task_id, board=board)
    +    persisted: list[str] = []
    +    used_destinations: set[Path] = set()
    +    changed = False
    +
    +    def _discard_copies() -> None:
    +        for copied in used_destinations:
    +            try:
    +                copied.unlink(missing_ok=True)
    +            except OSError:
    +                pass
    +        try:
    +            attachment_dir.rmdir()
    +        except OSError:
    +            pass
    +
    +    for item in raw_artifacts:
    +        artifact = str(item).strip() if isinstance(item, str) else ""
    +        if not artifact:
    +            continue
    +        src = Path(artifact).expanduser()
    +        try:
    +            resolved_src = src.resolve()
    +        except OSError:
    +            persisted.append(artifact)
    +            continue
    +
    +        if not resolved_src.is_relative_to(workspace_root):
    +            persisted.append(artifact)
    +            continue
    +
    +        if not src.is_file():
    +            _discard_copies()
    +            raise ArtifactPreservationError(
    +                f"declared scratch artifact is unavailable or not a regular file: {artifact}"
    +            )
    +
    +        size = resolved_src.stat().st_size
    +        if size > KANBAN_ATTACHMENT_MAX_BYTES:
    +            _discard_copies()
    +            raise ArtifactPreservationError(
    +                f"declared scratch artifact exceeds the "
    +                f"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}"
    +            )
    +
    +        dest: Optional[Path] = None
    +        try:
    +            attachment_dir.mkdir(parents=True, exist_ok=True)
    +            dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations)
    +            with resolved_src.open("rb") as source_file, dest.open("xb") as destination_file:
    +                copied = 0
    +                while chunk := source_file.read(1024 * 1024):
    +                    copied += len(chunk)
    +                    if copied > KANBAN_ATTACHMENT_MAX_BYTES:
    +                        raise ArtifactPreservationError(
    +                            f"declared scratch artifact grew beyond the size limit: {artifact}"
    +                        )
    +                    destination_file.write(chunk)
    +        except Exception as exc:
    +            if dest is not None:
    +                try:
    +                    dest.unlink(missing_ok=True)
    +                except OSError:
    +                    pass
    +            _discard_copies()
    +            if isinstance(exc, ArtifactPreservationError):
    +                raise
    +            raise ArtifactPreservationError(
    +                f"could not preserve declared scratch artifact {artifact}: {exc}"
    +            ) from exc
    +
    +        used_destinations.add(dest)
    +        persisted.append(str(dest.resolve()))
    +        changed = True
    +
    +    if changed:
    +        metadata["artifacts"] = persisted
    +        metadata["_staged_artifacts"] = [
    +            path for path in persisted if path.startswith(str(attachment_dir.resolve()))
    +        ]
    +
    +
    +def _insert_completion_attachment(
    +    conn: sqlite3.Connection,
    +    task_id: str,
    +    *,
    +    filename: str,
    +    stored_path: str,
    +    size: int,
    +    created_at: int,
    +) -> None:
    +    """Record a worker-produced artifact in the existing attachment table."""
    +    conn.execute(
    +        "INSERT INTO task_attachments "
    +        "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
    +        "VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)",
    +        (task_id, filename, stored_path, size, created_at),
    +    )
    +    _append_event(
    +        conn,
    +        task_id,
    +        "attached",
    +        {"filename": filename, "size": size, "by": "kanban_complete"},
    +    )
    +
    +
    +def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path:
    +    """Return a non-conflicting path under ``directory`` for ``filename``."""
    +    safe_name = Path(filename).name or "artifact"
    +    candidate = directory / safe_name
    +    if candidate not in used and not candidate.exists():
    +        return candidate
    +
    +    stem = Path(safe_name).stem or "artifact"
    +    suffix = Path(safe_name).suffix
    +    idx = 1
    +    while True:
    +        candidate = directory / f"{stem}_{idx}{suffix}"
    +        if candidate not in used and not candidate.exists():
    +            return candidate
    +        idx += 1
    +
    +
    +def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]:
    +    """Return whether *p* is managed scratch storage and the matching board."""
         try:
             p_abs = p.resolve(strict=False)
         except OSError:
    -        return False
    -    roots: list[Path] = []
    +        return False, None
    +    roots: list[tuple[Path, Optional[str]]] = []
         override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
         if override:
             try:
    -            roots.append(Path(override).expanduser().resolve(strict=False))
    +            roots.append((Path(override).expanduser().resolve(strict=False), None))
             except OSError:
                 pass
         try:
    ```

    ```diff
    @@ -4216,7 +4411,7 @@ def _is_managed_scratch_path(p: Path) -> bool:
             home = None
         if home is not None:
             try:
    -            roots.append((home / "kanban" / "workspaces").resolve(strict=False))
    +            roots.append(((home / "kanban" / "workspaces").resolve(strict=False), DEFAULT_BOARD))
             except OSError:
                 pass
             try:
    ```

    ```diff
    @@ -4235,18 +4430,47 @@ def _is_managed_scratch_path(p: Path) -> bool:
                     except OSError:
                         continue
                     try:
    -                    roots.append((entry / "workspaces").resolve(strict=False))
    +                    roots.append(((entry / "workspaces").resolve(strict=False), entry.name))
                     except OSError:
                         continue
    -    for root in roots:
    +    for root, board in roots:
             if p_abs == root:
                 continue
             try:
                 if p_abs.is_relative_to(root):
    -                return True
    +                return True, board
             except ValueError:
                 continue
    -    return False
    +    return False, None
    +
    +
    +def _is_managed_scratch_path(p: Path) -> bool:
    +    """Return True iff *p* is a strict descendant of a kanban-managed scratch root.
    +
    +    A managed root is exclusively a ``workspaces/`` directory — never the
    +    broader kanban home, a board root, or sibling subtrees like ``logs/`` or
    +    ``boards/<slug>/`` itself. Allowed roots:
    +
    +    * ``HERMES_KANBAN_WORKSPACES_ROOT`` when set (worker-side override
    +      injected by the dispatcher).
    +    * ``<kanban_home>/kanban/workspaces`` — legacy default-board scratch root.
    +    * ``<kanban_home>/kanban/boards/<slug>/workspaces`` for each board slug
    +      that currently exists on disk.
    +
    +    The check requires strict descendancy: a path equal to one of these
    +    roots is NOT managed (deleting the workspaces root would wipe every
    +    task's scratch dir at once), and a path that resolves to ``<kanban_home>
    +    /kanban`` itself, ``<kanban_home>/kanban/logs``, or
    +    ``<kanban_home>/kanban/boards/<slug>`` is rejected because those
    +    subtrees hold Hermes' own DB, metadata, and logs, not task workspaces.
    +
    +    Used by :func:`_cleanup_workspace` to refuse to ``shutil.rmtree`` paths
    +    outside Hermes-managed storage. A board ``default_workdir`` pointing at a
    +    real source tree can otherwise pair with ``workspace_kind='scratch'`` and
    +    cause task completion to delete user data (#28818).
    +    """
    +    is_managed, _board = _managed_scratch_path_info(p)
    +    return is_managed
     
     
     def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None:
    ```
  - `plugins/kanban/dashboard/plugin_api.py`

    ```diff
    @@ -646,7 +646,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
     
     # Cap a single upload so a runaway request can't fill the disk. 25 MB
     # comfortably covers PDFs, images, and source docs — the kanban use case.
    -_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
    +_MAX_ATTACHMENT_BYTES = kanban_db.KANBAN_ATTACHMENT_MAX_BYTES
     
     
     def _safe_attachment_name(raw: str) -> str:
    ```
  - `scripts/release.py`

    ```diff
    @@ -239,6 +239,7 @@
         "[redacted:email]": "w31rdm4ch1nZ",
         "[redacted:email]": "x7peeps",
         "[redacted:email]": "ahmadashfq",
    +    "182213728+yinkev@users.noreply.github.com": "yinkev",  # scratch artifact preservation salvage
         "[redacted:email]": "rratmansky",
         "lkz-de@users.noreply.github.com": "lkz-de",
         "[redacted:email]": "salesondemandio",
    ```
  - `tests/gateway/test_platform_base.py`

    ```diff
    @@ -786,6 +786,28 @@ def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch):
     
             assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
     
    +    def test_allows_stale_kanban_attachment_but_not_neighboring_workspace(
    +        self, tmp_path, monkeypatch,
    +    ):
    +        """Strict mode trusts durable attachments without trusting scratch."""
    +        self._patch_roots(monkeypatch)
    +        monkeypatch.setenv("HERMES_KANBAN_HOME", str(tmp_path / "hermes"))
    +        monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
    +        board_root = tmp_path / "hermes" / "kanban" / "boards" / "research"
    +        board_root.mkdir(parents=True)
    +        (board_root / "kanban.db").touch()
    +        attachment = board_root / "attachments" / "t_12345678" / "report.pdf"
    +        scratch = board_root / "workspaces" / "t_12345678" / "notes.txt"
    +        attachment.parent.mkdir(parents=True)
    +        scratch.parent.mkdir(parents=True)
    +        attachment.write_bytes(b"%PDF")
    +        scratch.write_text("private", encoding="utf-8")
    +
    +        assert BasePlatformAdapter.validate_media_delivery_path(str(attachment)) == str(
    +            attachment.resolve()
    +        )
    +        assert BasePlatformAdapter.validate_media_delivery_path(str(scratch)) is None
    +
         def test_recency_trust_allows_freshly_produced_file(self, tmp_path, monkeypatch):
             """A PDF the agent just wrote to /tmp should be deliverable.
     
    ```
  - `tests/hermes_cli/test_kanban_db.py`

    ```diff
    @@ -2363,6 +2363,175 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):
         assert not ws.exists(), "Hermes-managed scratch dir should be cleaned up"
     
     
    +def test_complete_task_persists_scratch_artifacts_before_cleanup(kanban_home):
    +    """Completion artifacts from scratch workspaces survive workspace cleanup."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="render chart")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, t, ws)
    +        artifact = ws / "chart.png"
    +        artifact.write_bytes(b"png-bytes")
    +
    +        assert kb.complete_task(
    +            conn,
    +            t,
    +            result="ok",
    +            metadata={"artifacts": [str(artifact)]},
    +        )
    +
    +        completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
    +        persisted = Path(completed.payload["artifacts"][0])
    +        run = kb.latest_run(conn, t)
    +
    +    assert not ws.exists(), "scratch workspace should still be cleaned up"
    +    assert persisted.exists(), "artifact copy should survive scratch cleanup"
    +    assert persisted.parent == kb.task_attachments_dir(t)
    +    assert persisted.name == "chart.png"
    +    assert persisted.read_bytes() == b"png-bytes"
    +    assert str(persisted) != str(artifact)
    +    assert run is not None
    +    assert run.metadata["artifacts"] == [str(persisted)]
    +    with kb.connect() as conn:
    +        attachments = kb.list_attachments(conn, t)
    +    assert [(a.filename, a.stored_path) for a in attachments] == [
    +        ("chart.png", str(persisted.resolve()))
    +    ]
    +
    +
    +def test_complete_task_rejects_missing_declared_scratch_artifact(kanban_home):
    +    """A declared scratch deliverable must not disappear behind a false Done."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="missing report")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, t, ws)
    +        missing = ws / "report.md"
    +
    +        with pytest.raises(kb.ArtifactPreservationError, match="unavailable"):
    +            kb.complete_task(
    +                conn,
    +                t,
    +                result="report complete",
    +                metadata={"artifacts": [str(missing)]},
    +            )
    +
    +        assert kb.get_task(conn, t).status == "ready"
    +        assert kb.list_attachments(conn, t) == []
    +    assert ws.exists(), "failed completion must keep scratch available for retry"
    +
    +
    +def test_complete_task_preserves_legacy_artifact_path_from_summary(kanban_home):
    +    """Summary-only workers keep the file they tell the user was delivered."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="legacy report")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, t, ws)
    +        report = ws / "report.md"
    +        report.write_text("legacy deliverable", encoding="utf-8")
    +
    +        assert kb.complete_task(
    +            conn,
    +            t,
    +            summary=f"Task complete — delivered {report}",
    +        )
    +        run = kb.latest_run(conn, t)
    +
    +    persisted = Path(run.metadata["artifacts"][0])
    +    assert not ws.exists()
    +    assert persisted.read_text(encoding="utf-8") == "legacy deliverable"
    +    assert persisted.parent == kb.task_attachments_dir(t)
    +
    +
    +def test_complete_task_leaves_non_scratch_artifact_paths_unchanged(
    +    kanban_home,
    +    tmp_path,
    +):
    +    """Only artifacts inside the managed scratch workspace are copied."""
    +    external = tmp_path / "report.md"
    +    external.write_text("keep me here", encoding="utf-8")
    +
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="external report")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, t, ws)
    +
    +        assert kb.complete_task(
    +            conn,
    +            t,
    +            result="ok",
    +            metadata={"artifacts": [str(external)]},
    +        )
    +
    +        completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
    +        run = kb.latest_run(conn, t)
    +
    +    assert not ws.exists(), "scratch workspace should still be cleaned up"
    +    assert external.exists()
    +    assert completed.payload["artifacts"] == [str(external)]
    +    assert run is not None
    +    assert run.metadata["artifacts"] == [str(external)]
    +
    +
    +def test_complete_task_persists_duplicate_scratch_artifact_names(kanban_home):
    +    """Scratch artifact persistence does not overwrite duplicate basenames."""
    +    with kb.connect() as conn:
    +        t = kb.create_task(conn, title="render reports")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, t, ws)
    +        first = ws / "a" / "report.txt"
    +        second = ws / "b" / "report.txt"
    +        first.parent.mkdir(parents=True)
    +        second.parent.mkdir(parents=True)
    +        first.write_text("first", encoding="utf-8")
    +        second.write_text("second", encoding="utf-8")
    +
    +        assert kb.complete_task(
    +            conn,
    +            t,
    +            result="ok",
    +            metadata={"artifacts": [str(first), str(second)]},
    +        )
    +
    +        completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
    +        persisted = [Path(p) for p in completed.payload["artifacts"]]
    +
    +    assert not ws.exists(), "scratch workspace should still be cleaned up"
    +    assert [p.name for p in persisted] == ["report.txt", "report_1.txt"]
    +    assert [p.read_text(encoding="utf-8") for p in persisted] == ["first", "second"]
    +    assert all(p.parent == kb.task_attachments_dir(t) for p in persisted)
    +
    +
    +def test_complete_task_persists_board_scratch_artifacts_to_board_attachments(kanban_home):
    +    """Board scratch artifacts are copied under that board's attachment root."""
    +    kb.create_board("work-proj")
    +
    +    with kb.connect(board="work-proj") as conn:
    +        t = kb.create_task(conn, title="board chart", board="work-proj")
    +        task = kb.get_task(conn, t)
    +        ws = kb.resolve_workspace(task, board="work-proj")
    +        kb.set_workspace_path(conn, t, ws)
    +        artifact = ws / "chart.png"
    +        artifact.write_bytes(b"board-png")
    +
    +        assert kb.complete_task(
    +            conn,
    +            t,
    +            result="ok",
    +            metadata={"artifacts": [str(artifact)]},
    +        )
    +
    +        completed = [e for e in kb.list_events(conn, t) if e.kind == "completed"][-1]
    +        persisted = Path(completed.payload["artifacts"][0])
    +
    +    assert not ws.exists(), "board scratch workspace should still be cleaned up"
    +    assert persisted.exists()
    +    assert persisted.parent == kb.task_attachments_dir(t, board="work-proj")
    +
    +
     def test_cleanup_workspace_refuses_path_outside_scratch_root(kanban_home, tmp_path):
         """A scratch task with a user path outside the workspaces root must NOT be deleted (#28818).
     
    ```
  - `tests/tools/test_kanban_tools.py`

    ```diff
    @@ -484,6 +484,31 @@ def test_complete_rejects_non_list_artifacts(worker_env):
         assert "artifacts must be a list" in err
     
     
    +def test_complete_missing_scratch_artifact_stays_in_flight(worker_env):
    +    """A false deliverable claim must return retry guidance, not mark Done."""
    +    from hermes_cli import kanban_db as kb
    +    from tools import kanban_tools as kt
    +
    +    with kb.connect() as conn:
    +        task = kb.get_task(conn, worker_env)
    +        assert task is not None
    +        workspace = kb.resolve_workspace(task)
    +        kb.set_workspace_path(conn, worker_env, workspace)
    +
    +    output = kt._handle_complete({
    +        "summary": "report complete",
    +        "artifacts": [str(workspace / "missing-report.md")],
    +    })
    +    error = json.loads(output).get("error", "")
    +
    +    assert "could not preserve" in error
    +    assert "still in-flight" in error
    +    assert "retry kanban_complete" in error
    +    with kb.connect() as conn:
    +        assert kb.get_task(conn, worker_env).status == "running"
    +    assert workspace.exists()
    +
    +
     def test_complete_rejects_no_handoff(worker_env):
         from tools import kanban_tools as kt
         out = kt._handle_complete({})
    ```
  - `tools/kanban_tools.py`

    ```diff
    @@ -630,6 +630,13 @@ def _handle_complete(args: dict, **kw) -> str:
                         created_cards=created_cards,
                         expected_run_id=_worker_run_id(tid),
                     )
    +            except kb.ArtifactPreservationError as artifact_err:
    +                return tool_error(
    +                    f"kanban_complete could not preserve the declared artifacts: "
    +                    f"{artifact_err}. Your task is still in-flight and its "
    +                    f"scratch workspace was kept. Fix the artifact path or "
    +                    f"storage error, then retry kanban_complete with the same handoff."
    +                )
                 except kb.HallucinatedCardsError as hall_err:
                     # Structured rejection — surface the phantom ids so the
                     # worker can retry with a corrected list or drop the
    ```

    ```diff
    @@ -1277,8 +1284,10 @@ def _board_schema_prop() -> dict[str, str]:
                         "lands with the completion notification. Skip "
                         "intermediate scratch files and references that "
                         "are not the deliverable. The path must exist "
    -                    "on disk when the notifier runs; missing files "
    -                    "are silently skipped."
    +                    "on disk at completion. Files inside a managed scratch "
    +                    "workspace are copied to durable task attachments before "
    +                    "cleanup; a missing declared scratch artifact keeps the "
    +                    "task in-flight so you can fix the path and retry."
                     ),
                 },
                 "board": _board_schema_prop(),
    ```
  - `website/docs/user-guide/features/kanban.md`

    ```diff
    @@ -63,7 +63,7 @@ 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). **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>`).
    +  - `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>`).
       - `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.
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md`

    ```diff
    @@ -59,7 +59,7 @@ Hermes Kanban 是一个持久化任务看板，在所有 Hermes 配置文件之
     - **Link（链接）** —— `task_links` 行，记录父 → 子依赖关系。当所有父任务变为 `done` 时，调度器将 `todo → ready`。
     - **Comment（评论）** —— agent 间协议。Agent 和人类追加评论；当 worker 被（重新）启动时，它将完整的评论线程作为上下文的一部分读取。
     - **Workspace（工作区）** —— worker 操作的目录。三种类型：
    -  - `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>` 查看）。
    +  - `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>` 查看）。
       - `dir:<path>` —— 现有的共享目录（Obsidian vault、邮件运维目录、每账号文件夹）。**必须是绝对路径。** 像 `dir:../tenants/foo/` 这样的相对路径在调度时会被拒绝，因为它们会相对于调度器碰巧所在的 CWD 解析，这是模糊的，也是混淆代理（confused-deputy）逃逸向量。路径本身是受信任的 —— 这是你的机器、你的文件系统，worker 以你的 uid 运行。这是受信任本地用户的威胁模型；kanban 设计为单主机。**完成时保留。**
       - `worktree` —— 用于编码任务的 git worktree，位于 `.worktrees/<id>/` 下。使用 `worktree:<path>` 固定确切的目标路径。Worker 端的 `git worktree add` 创建它，提供 `--branch` 时使用该分支。**完成时保留。**
     - **Dispatcher（调度器）** —— 一个长期运行的循环，每 N 秒（默认 60 秒）执行一次：回收过期的认领、回收崩溃的 worker（PID 消失但 TTL 尚未过期）、推进就绪任务、原子性认领、启动已分配的配置文件。默认**在 gateway 内部运行**（`kanban.dispatch_in_gateway: true`）。每次 tick 一个调度器扫描所有看板；worker 启动时固定了 `HERMES_KANBAN_BOARD`，因此无法看到其他看板。在同一任务上连续启动失败 `kanban.failure_limit` 次（默认：2）后，调度器会以最后一个错误为原因自动阻塞该任务 —— 防止因配置文件不存在、工作区无法挂载等原因导致的反复抖动。
    ```
- **#53723: fix(kanban): validate artifact file existence before accepting completion**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#63619`
- **#75572: fix(kanban): reject completion artifacts that do not exist**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `19cd749d68bc`, `#53699`, `#53723`, `#75572`, `#63619`, `#1`, `#15803`, `#14532`, `#6`, `#9`, `#374151`, `#4338`
  - files: `hermes_cli/kanban_db.py`, `tests/hermes_cli/test_kanban_db.py`, `tests/hermes_cli/test_kanban_notify.py`, `tests/tools/test_kanban_tools.py`

## Fix status

- Merged to main in PR #63619 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-13`.
- Subsystem: `cron`
