From 2b3325c490882fecccfc0efcdc561cd00226418d Mon Sep 17 00:00:00 2001 From: matsromblad Date: Thu, 13 Aug 2026 08:08:07 +0200 Subject: [PATCH] fix(kanban): auto-promote scratch workspace to worktree for code-fix tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a task is created without `--project` (legacy imports, manual dashboard creates, or older code paths), `resolve_workspace` lands it under `/workspaces//` — an empty tmp dir. Workers running such tasks must `cd` into the source tree manually on every turn, and any tool that resolves paths against TERMINAL_CWD lands in the empty dir instead of the project repo. Auto-promote at resolve time: if the task body contains code-fix markers (`src/agents/`, `tests/test_`, `docs/audits/`, `pytest`, `ruff`, `git worktree`) AND a registered project shares a basename substring with the body, promote the workspace to a linked worktree on that project's primary_path. The body never names an absolute path we would trust — only project basenames from the user's own projects.db — so this is loose-by-design but safe: a false match wastes a worktree under a real registered repo, not under an attacker-chosen path. This is the dispatcher-side complement to the project-binding fix in `tools/kanban_github_sync/import_github_issues.py` (which sends `--project ` on the importer side). Together they close the loop on a class of Kanban tasks that have been silently landing in empty workspaces since the auto-improve miner started dispatching Tier-1/Tier-2 audit findings in early August. Tests in `tests/hermes_cli/test_scratch_to_worktree_promotion.py`: - 3× positive (src/agents/, tests/, pytest + project basename in body) - 1× negative (non-code task body) - 1× negative (code markers but no registered project matches) - 1× fail-closed (projects_db unreachable → None) --- hermes_cli/kanban_db.py | 87 +++++++- .../test_scratch_to_worktree_promotion.py | 188 ++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_scratch_to_worktree_promotion.py diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 9a92b03d3033d..8fd5f35166df0 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -7468,7 +7468,14 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: - ``scratch``: a fresh dir under ``/workspaces//``, where ```` is the active board's root. The path is the same for the dispatcher and every profile worker, so handoff is - path-stable. + path-stable. If the task body signals a code-fix workflow (mentions + ``src/``, ``tests/``, ``docs/audits/``, or a known project repo by + basename like ``Chiron``/``Jakobsson_Deluxe``), auto-promote the + workspace to a linked worktree on the matching registered project. + This is the dispatcher-side complement to ``kanban-github-sync``'s + ``--project`` auto-detect and catches tasks created by older code + paths (legacy imports, manual dashboard creates) that lack the + project binding. - ``dir:``: the path stored in ``workspace_path``. Created if missing. MUST be absolute — relative paths are rejected to prevent confused-deputy traversal where ``../../../tmp/attacker`` @@ -7490,6 +7497,22 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: """ kind = task.workspace_kind or "scratch" if kind == "scratch": + # Dispatcher-side auto-promotion: catch tasks imported before the + # kanban-github-sync --project fix was deployed, or created via + # legacy manual paths that didn't bind a project. The body signals + # "this task needs source files" via a couple of well-known + # patterns; if any match and a registered project shares a + # basename (or substring) with one of those signals, promote to + # worktree on that project's primary_path. + promoted_repo = _maybe_promote_scratch_to_worktree(task, board=board) + if promoted_repo is not None: + # Mutate the in-memory copy so the rest of this dispatch + # pass sees workspace_kind='worktree' (avoids second + # resolution branch downstream). + task.workspace_kind = "worktree" + task.workspace_path = str(promoted_repo) + p, _branch = _resolve_worktree_workspace(task, board=board) + return p if task.workspace_path: # Legacy scratch tasks that were set to an explicit path get the # same absolute-path guard as dir: — consistent with the @@ -7524,6 +7547,68 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: raise ValueError(f"unknown workspace_kind: {kind}") +# Markers that strongly suggest "this task needs to land in a source tree". +# Anything matching one of these tokens in the task body triggers +# auto-promotion to a worktree on the matching registered project. Kept +# intentionally narrow — false positives waste a worktree; false negatives +# are recoverable (the worker can manually cd). +_CODE_TASK_MARKERS = ( + "src/agents/", + "src/plan/", + "src/integrations/", + "src/qa/", + "tests/test_", + "docs/audits/", + "pytest ", + "ruff ", + "git worktree", +) + + +def _maybe_promote_scratch_to_worktree( + task: Task, *, board: Optional[str] = None +) -> Optional[Path]: + """Return a registered project's primary_path if the task body + signals a code-fix workflow and a registered project matches. None + otherwise. Does NOT mutate the task; the caller does that based on + the return value. + + The match is a substring search on the project basename (e.g. + 'Chiron' matches body text containing 'Chiron'). This is loose but + safe: a false match wastes a worktree under a real repo, not under + an attacker's chosen path. The body never names an absolute path we + would trust, and ``_resolve_worktree_workspace`` still anchors on + the registered project's primary_path. + """ + body = (task.body or "") + " " + (task.title or "") + if not any(marker in body for marker in _CODE_TASK_MARKERS): + return None + from hermes_cli import projects_db as _pdb + try: + with _pdb.connect_closing() as conn: + rows = conn.execute( + "SELECT slug, name, primary_path FROM projects WHERE archived = 0" + ).fetchall() + except Exception: + return None + best: Optional[Path] = None + best_score = 0 + for row in rows: + slug = row["slug"] or "" + name = row["name"] or "" + primary = row["primary_path"] + if not primary: + continue + score = 0 + for needle in (slug, name, Path(primary).name): + if needle and needle in body: + score += len(needle) + if score > best_score: + best_score = score + best = Path(primary) + return best + + def set_workspace_path( conn: sqlite3.Connection, task_id: str, path: Path | str ) -> None: diff --git a/tests/hermes_cli/test_scratch_to_worktree_promotion.py b/tests/hermes_cli/test_scratch_to_worktree_promotion.py new file mode 100644 index 0000000000000..8054a7d282fd2 --- /dev/null +++ b/tests/hermes_cli/test_scratch_to_worktree_promotion.py @@ -0,0 +1,188 @@ +"""Tests for the scratch→worktree auto-promotion helper. + +When ``import_github_issues.py`` (or any other importer) creates a Kanban +task without ``--project``, the task lands with ``workspace_kind='scratch'``. +The dispatcher-side helper ``_maybe_promote_scratch_to_worktree`` catches +that case at resolve time: if the task body references source code +patterns AND a registered project matches, the workspace is promoted to +a linked worktree on the matching project's primary_path. + +These tests pin both the matching logic and the no-match path. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@dataclass +class FakeTask: + """Minimal stand-in for ``hermes_cli.kanban_db.Task``. + + The helper only reads ``title``, ``body`` and ``workspace_kind``, + so a slim dataclass is enough — no need to drag in the full Task + constructor or its DB row decoder. + """ + + title: str = "" + body: str = "" + workspace_kind: str = "scratch" + workspace_path: str | None = None + branch_name: str | None = None + id: str = "t_fake" + + +@pytest.fixture +def chiron_project(tmp_path, monkeypatch): + """Register a fake 'Chiron' project pointing at a tmp git repo. + + The helper looks up projects by their primary_path via projects_db; + we override that lookup to a controlled table instead of touching + the user's real projects.db. + """ + fake_repo = tmp_path / "Chiron" + fake_repo.mkdir() + # .git dir so _git_toplevel returns something — not strictly needed + # for the helper, but keeps the test path realistic. + (fake_repo / ".git").mkdir() + + # Patch projects_db.connect_closing to return our fake row. + from hermes_cli import projects_db as _pdb + + @dataclass + class FakeRow: + slug: str + name: str + primary_path: str + + def __getitem__(self, key: str): + return getattr(self, key) + + rows = [FakeRow(slug="chiron", name="Chiron", primary_path=str(fake_repo))] + + class FakeConn: + def execute(self, *_args, **_kwargs): + class _Cur: + def fetchall(_self): + return rows + + return _Cur() + + def close(self): + pass + + @dataclass + class FakeCM: + rows: list + + def __enter__(self): + return FakeConn() + + def __exit__(self, *args): + return False + + monkeypatch.setattr(_pdb, "connect_closing", lambda: FakeCM(rows=rows)) + return fake_repo + + +class TestScratchToWorktreePromotion: + def test_promotes_when_body_re_ferences_src_agents(self, chiron_project): + """Issue body with src/agents/ + project basename → promote.""" + task = FakeTask( + title="#339 — Pattern 42 fix", + body="Fix the gate logic in src/agents/architect_agent.py for Chiron", + ) + result = kb._maybe_promote_scratch_to_worktree(task) + assert result == chiron_project, ( + f"expected promotion to {chiron_project}, got {result}" + ) + + def test_promotes_when_body_re_ferences_tests(self, chiron_project): + task = FakeTask( + title="add tests", + body="Add a test for the new behaviour in tests/test_x.py (Chiron)", + ) + assert kb._maybe_promote_scratch_to_worktree(task) == chiron_project + + def test_promotes_when_body_re_ferences_pytest(self, chiron_project): + task = FakeTask( + title="run tests", + body="pytest tests/ should pass after the fix — Chiron vårdcentral", + ) + assert kb._maybe_promote_scratch_to_worktree(task) == chiron_project + + def test_no_promotion_for_non_code_task(self, chiron_project): + """Tasks without source-code markers must NOT trigger promotion.""" + task = FakeTask( + title="follow up on email", + body="Send the spec to the team and schedule a meeting.", + ) + assert kb._maybe_promote_scratch_to_worktree(task) is None + + def test_no_promotion_when_project_does_not_match(self, tmp_path, monkeypatch): + """Body has code markers but no registered project basename matches.""" + unrelated_repo = tmp_path / "SomeOther" + unrelated_repo.mkdir() + + from hermes_cli import projects_db as _pdb + + @dataclass + class FakeRow: + slug: str + name: str + primary_path: str + + def __getitem__(self, key: str): + return getattr(self, key) + + rows = [FakeRow(slug="other", name="Other", primary_path=str(unrelated_repo))] + + class FakeConn: + def execute(self, *_a, **_kw): + class _Cur: + def fetchall(_s): + return rows + + return _Cur() + + def close(self): + pass + + @dataclass + class FakeCM: + rows: list + + def __enter__(self): + return FakeConn() + + def __exit__(self, *a): + return False + + monkeypatch.setattr(_pdb, "connect_closing", lambda: FakeCM(rows=rows)) + + task = FakeTask( + title="#339 — Pattern 42 fix", + body="Fix src/agents/architect_agent.py — this is for Chiron", + ) + # Body says "Chiron" but no project with that name registered → + # helper returns None (no false positive onto Other repo). + assert kb._maybe_promote_scratch_to_worktree(task) is None + + def test_no_promotion_when_projects_db_lookup_fails(self, monkeypatch): + """If projects_db is unreachable, helper must fail closed (None).""" + from hermes_cli import projects_db as _pdb + + def boom(): + raise RuntimeError("simulated DB outage") + + monkeypatch.setattr(_pdb, "connect_closing", boom) + + task = FakeTask( + title="fix", + body="Fix src/agents/foo.py", + ) + assert kb._maybe_promote_scratch_to_worktree(task) is None \ No newline at end of file