From 72e82f88c00d92bddc0fa6d9bec5b600a3b4dce3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:26:57 -0700 Subject: [PATCH] fix(kanban): decompose children inherit root workspace instead of forcing scratch (#37172) decompose_triage_task hardcoded every fan-out child to workspace_kind 'scratch', ignoring the root task's workspace. A code-gen task created with a dir:/worktree: workspace would fan out into throwaway scratch tmp dirs (GC'd on archive), so generated code never landed in the project. Children now inherit the root's workspace_kind + workspace_path. A child dict may still override with its own workspace_kind/workspace_path; the path only carries over when kinds match. Scratch roots are unchanged. --- hermes_cli/kanban_db.py | 28 ++++++++- tests/hermes_cli/test_kanban_decompose_db.py | 62 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 3bb14573e9e..2274ddf4086 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4353,13 +4353,21 @@ def decompose_triage_task( child_ids: list[str] = [] with write_txn(conn): root_row = conn.execute( - "SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,) + "SELECT id, status, tenant, workspace_kind, workspace_path " + "FROM tasks WHERE id = ?", + (task_id,), ).fetchone() if root_row is None: return None if root_row["status"] != "triage": return None tenant = root_row["tenant"] + # Children inherit the root's workspace by default so a fan-out + # of a code-gen task lands in the parent's project dir/worktree + # rather than throwaway scratch tmp dirs. A child dict can still + # override with its own 'workspace_kind' / 'workspace_path'. + root_ws_kind = root_row["workspace_kind"] or "scratch" + root_ws_path = root_row["workspace_path"] # Create children. Status is 'todo' regardless of parents — we # link them under the root AFTER creation so the dispatcher @@ -4370,16 +4378,30 @@ def decompose_triage_task( title = child["title"].strip() body = child.get("body") assignee = _canonical_assignee(child.get("assignee")) + # Per-child override wins; otherwise inherit the root's + # workspace. A child that sets workspace_kind without a path + # falls back to the root path only when kinds match (so a + # child can't accidentally point a 'dir' at the root's + # worktree path or vice versa). + child_ws_kind = child.get("workspace_kind") or root_ws_kind + if child.get("workspace_path"): + child_ws_path = child.get("workspace_path") + elif child_ws_kind == root_ws_kind: + child_ws_path = root_ws_path + else: + child_ws_path = None conn.execute( "INSERT INTO tasks " "(id, title, body, assignee, status, workspace_kind, " - " tenant, created_at, created_by) " - "VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)", + " workspace_path, tenant, created_at, created_by) " + "VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)", ( new_id, title, body if isinstance(body, str) else None, assignee, + child_ws_kind, + child_ws_path, tenant, now, (author or "decomposer"), diff --git a/tests/hermes_cli/test_kanban_decompose_db.py b/tests/hermes_cli/test_kanban_decompose_db.py index 85026fd5a97..f0f11fb82fc 100644 --- a/tests/hermes_cli/test_kanban_decompose_db.py +++ b/tests/hermes_cli/test_kanban_decompose_db.py @@ -166,3 +166,65 @@ def test_decompose_records_audit_comment_and_event(kanban_home): assert any("Decomposed into" in (c.body or "") for c in comments) assert any(ev.kind == "decomposed" for ev in events) + + +def test_decompose_children_inherit_dir_workspace(kanban_home): + """Fan-out children inherit the root's dir workspace, not scratch.""" + proj = "/home/teknium/myproject" + with kb.connect() as conn: + tid = kb.create_task( + conn, title="codegen root", assignee="worker", + workspace_kind="dir", workspace_path=proj, triage=True, + ) + child_ids = kb.decompose_triage_task( + conn, tid, root_assignee="orchestrator", + children=[{"title": "part A"}, {"title": "part B", "parents": [0]}], + author="decomposer", + ) + assert child_ids and len(child_ids) == 2 + with kb.connect() as conn: + for cid in child_ids: + t = kb.get_task(conn, cid) + assert t.workspace_kind == "dir" + assert t.workspace_path == proj + + +def test_decompose_children_stay_scratch_when_root_scratch(kanban_home): + """No regression: a scratch root still fans out into scratch children.""" + with kb.connect() as conn: + tid = kb.create_task( + conn, title="scratch root", assignee="worker", + workspace_kind="scratch", triage=True, + ) + child_ids = kb.decompose_triage_task( + conn, tid, root_assignee="orchestrator", + children=[{"title": "s1"}], author="decomposer", + ) + with kb.connect() as conn: + t = kb.get_task(conn, child_ids[0]) + assert t.workspace_kind == "scratch" + assert t.workspace_path is None + + +def test_decompose_per_child_workspace_override(kanban_home): + """An explicit per-child workspace beats inheritance.""" + proj = "/home/teknium/myproject" + with kb.connect() as conn: + tid = kb.create_task( + conn, title="root", assignee="worker", + workspace_kind="dir", workspace_path=proj, triage=True, + ) + child_ids = kb.decompose_triage_task( + conn, tid, root_assignee="orchestrator", + children=[ + {"title": "override", "workspace_kind": "dir", + "workspace_path": "/other/repo"}, + {"title": "inherit"}, + ], + author="decomposer", + ) + with kb.connect() as conn: + over = kb.get_task(conn, child_ids[0]) + inh = kb.get_task(conn, child_ids[1]) + assert over.workspace_path == "/other/repo" + assert inh.workspace_path == proj