From 6b3812e7f636e1572fc867dda56532fd5ef11094 Mon Sep 17 00:00:00 2001 From: jiayuxiaochaoren <1468693932@qq.com> Date: Tue, 19 May 2026 21:02:53 +0800 Subject: [PATCH 1/3] fix: surface kanban completion evidence and validate skills --- hermes_cli/kanban_db.py | 26 ++- plugins/kanban/dashboard/dist/index.js | 65 ++++++++ plugins/kanban/dashboard/plugin_api.py | 156 +++++++++++++++++- tests/hermes_cli/test_kanban_db.py | 19 +++ tests/hermes_cli/test_kanban_db_init.py | 78 +++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 44 +++++ 6 files changed, 373 insertions(+), 15 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index edeae51707b0c..c1eb68d07ce94 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -98,6 +98,11 @@ VALID_INITIAL_STATUSES = {"running", "blocked"} VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) +# Per-task skills are passed to `hermes --skills ` at worker startup. +# Hermes skill identifiers are slug-like; natural-language labels such as +# "ppt skill" are invalid and make the worker fail before it can report a +# useful kanban_block/kanban_complete handoff. +_VALID_TASK_SKILL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$") _IS_WINDOWS = sys.platform == "win32" # A running task's claim is valid for 15 minutes by default; after that the @@ -865,8 +870,6 @@ class Event: session_id TEXT ); -CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id); - CREATE TABLE IF NOT EXISTS task_links ( parent_id TEXT NOT NULL, child_id TEXT NOT NULL, @@ -1174,10 +1177,10 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: _add_column_if_missing( conn, "tasks", "session_id", "session_id TEXT" ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " - "ON tasks(session_id)" - ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_session_id " + "ON tasks(session_id)" + ) # task_events gained a run_id column; back-fill it as NULL for # historical events (they predate runs and can't be attributed). @@ -1420,6 +1423,12 @@ def create_task( f"skill name cannot contain comma: {name!r} " f"(pass a list of separate names instead of a comma-joined string)" ) + if not _VALID_TASK_SKILL_RE.match(name): + raise ValueError( + f"invalid skill name {name!r}. Per-task skills must be installed skill " + "identifiers such as 'kanban-worker' or 'plugin:skill'; do not use " + "natural-language labels like 'ppt skill'." + ) if name.casefold() in KNOWN_TOOLSET_NAMES: toolset_typos.append(name) continue @@ -5272,6 +5281,11 @@ def _default_spawn( if task.skills: for sk in task.skills: if sk and sk != "kanban-worker": + if not _VALID_TASK_SKILL_RE.match(str(sk)): + raise RuntimeError( + f"invalid per-task skill name {sk!r}; use an installed skill identifier " + "such as 'kanban-worker' or clear the task's skills field" + ) cmd.extend(["--skills", sk]) if task.model_override: cmd.extend(["-m", task.model_override]) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 7f2fd573e7451..a8ebe2a13842e 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2960,6 +2960,67 @@ ); } + function CompletionEvidenceSection(props) { + const ev = props.evidence; + if (!ev) return null; + const deps = (ev.dependencies && ev.dependencies.items) || []; + const counts = ev.run_counts || {}; + const deliverables = ev.deliverables || []; + const suspicious = !!ev.suspicious; + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, + suspicious ? "⚠ Completion evidence / 完成证据(存疑)" : "Completion evidence / 完成证据"), + h("div", { className: suspicious ? "hermes-kanban-diag hermes-kanban-diag--warning" : "hermes-kanban-comment" }, + h("div", { className: "text-xs" }, + `Runs: ${counts.completed || 0} completed / ${counts.failed || 0} failed / ${counts.total || 0} total`, + deps.length ? ` · Dependencies: ${(ev.dependencies && ev.dependencies.done) || 0}/${(ev.dependencies && ev.dependencies.total) || 0} done` : "", + ), + (ev.invalid_skills && ev.invalid_skills.length) + ? h("div", { className: "hermes-kanban-run-error" }, + "Invalid task skills: ", ev.invalid_skills.join(", "), + " — worker startup can fail before doing any work.") + : null, + (ev.reasons && ev.reasons.length) + ? h("ul", { className: "text-xs mt-1" }, + ev.reasons.map(function (r, i) { return h("li", { key: i }, "• ", r); })) + : null, + ev.latest_summary + ? h("div", { className: "mt-2" }, + h("div", { className: "text-xs font-semibold" }, "Latest completion summary"), + h(MarkdownBlock, { source: ev.latest_summary, enabled: props.renderMarkdown })) + : null, + deliverables.length + ? h("div", { className: "mt-2" }, + h("div", { className: "text-xs font-semibold" }, `Deliverables (${deliverables.length})`), + h("ul", { className: "text-xs" }, + deliverables.slice(0, 12).map(function (d, i) { + return h("li", { key: i }, h("code", null, d)); + }), + deliverables.length > 12 ? h("li", null, `… ${deliverables.length - 12} more`) : null, + )) + : null, + deps.length + ? h("details", { className: "mt-2", open: suspicious }, + h("summary", { className: "text-xs font-semibold cursor-pointer" }, + `Dependencies / 子任务证据 (${(ev.dependencies && ev.dependencies.done) || 0}/${(ev.dependencies && ev.dependencies.total) || 0})`), + deps.map(function (d) { + return h("div", { key: d.id, className: "hermes-kanban-run" }, + h("div", { className: "hermes-kanban-run-head" }, + h("code", null, d.id), + h("span", null, d.status), + h("span", null, d.title || "")), + d.latest_summary ? h("div", { className: "hermes-kanban-run-summary" }, d.latest_summary) : null, + d.deliverables && d.deliverables.length + ? h("div", { className: "text-xs" }, "Artifacts: ", d.deliverables.join(", ")) + : null, + ); + }), + ) + : null, + ), + ); + } + function TaskDetail(props) { const { t: i18n } = useI18n(); const t = props.data.task; @@ -3005,6 +3066,10 @@ onSpecify: props.onSpecify, onDecompose: props.onDecompose, }), + h(CompletionEvidenceSection, { + evidence: props.data.completion_evidence, + renderMarkdown: props.renderMarkdown, + }), h(DiagnosticsSection, { task: t, boardSlug: props.boardSlug, diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 104f666c30082..13bc85ddf81e8 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -208,6 +208,145 @@ def _run_dict(r: kanban_db.Run) -> dict[str, Any]: } +def _extract_deliverables_from_metadata(meta: Any) -> list[str]: + """Pull human-checkable artifact paths/URLs out of run metadata.""" + out: list[str] = [] + if not isinstance(meta, dict): + return out + + def add(value: Any) -> None: + if value is None: + return + if isinstance(value, str): + v = value.strip() + if v and v not in out: + out.append(v) + elif isinstance(value, (list, tuple)): + for item in value: + add(item) + elif isinstance(value, dict): + for k, v in value.items(): + if any(tok in str(k).lower() for tok in ("deliver", "artifact", "file", "path", "url", "diff", "pr")): + add(v) + + for key in ( + "deliverable", "deliverables", "artifact", "artifacts", + "file", "files", "path", "paths", "url", "urls", + "changed_files", "diff_path", "pr_url", + ): + if key in meta: + add(meta.get(key)) + return out + + +def _looks_like_junk_completion(text: Optional[str]) -> bool: + """Heuristic for manual/compressed completions that are not evidence.""" + s = (text or "").strip() + if not s: + return True + lowered = s.lower() + if lowered in {"ok", "done", "complete", "completed", "finish", "finished", "dddd", "test", "na", "n/a"}: + return True + if len(s) < 12: + return True + if len(set(s)) <= 2 and len(s) <= 16: + return True + return False + + +def _invalid_task_skill_names(skills: Any) -> list[str]: + """Return obviously-invalid per-task skill names.""" + bad: list[str] = [] + if not skills: + return bad + for sk in skills: + name = str(sk or "").strip() + if not name: + continue + if any(ch.isspace() for ch in name) or "," in name or "/" in name: + bad.append(name) + return bad + + +def _completion_evidence(conn: sqlite3.Connection, task: kanban_db.Task, runs: list[kanban_db.Run]) -> dict[str, Any]: + """Operator-facing proof-of-completion rollup for the drawer.""" + task_id = task.id + failed_outcomes = {"spawn_failed", "timed_out", "crashed", "failed"} + failed_runs = [r for r in runs if r.outcome in failed_outcomes] + completed_runs = [r for r in runs if r.outcome == "completed"] + latest_completed = completed_runs[-1] if completed_runs else None + latest_summary = latest_completed.summary if latest_completed else kanban_db.latest_summary(conn, task_id) + + deps: list[dict[str, Any]] = [] + for row in conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", + (task_id,), + ).fetchall(): + dep = kanban_db.get_task(conn, row["parent_id"]) + if dep is None: + continue + dep_summary = kanban_db.latest_summary(conn, dep.id) + dep_runs = kanban_db.list_runs(conn, dep.id) + dep_deliverables: list[str] = [] + for r in dep_runs: + dep_deliverables.extend(_extract_deliverables_from_metadata(r.metadata)) + seen: set[str] = set() + dep_deliverables = [d for d in dep_deliverables if not (d in seen or seen.add(d))] + deps.append({ + "id": dep.id, + "title": dep.title, + "status": dep.status, + "completed_at": dep.completed_at, + "latest_summary": dep_summary, + "deliverables": dep_deliverables, + }) + + deliverables: list[str] = [] + for r in runs: + deliverables.extend(_extract_deliverables_from_metadata(r.metadata)) + for d in deps: + deliverables.extend(d.get("deliverables") or []) + seen_paths: set[str] = set() + deliverables = [d for d in deliverables if not (d in seen_paths or seen_paths.add(d))] + + invalid_skills = _invalid_task_skill_names(task.skills) + suspicious = False + reasons: list[str] = [] + if task.status == "done" and _looks_like_junk_completion(task.result or latest_summary): + suspicious = True + reasons.append("completion summary/result is empty or looks like filler text") + if failed_runs and task.status == "done" and _looks_like_junk_completion(latest_summary or task.result): + suspicious = True + reasons.append("task was marked done after previous failures but without a meaningful final summary") + if invalid_skills: + suspicious = True + reasons.append("task has invalid per-task skill names that can make worker startup fail") + + return { + "status": task.status, + "is_done": task.status == "done", + "suspicious": suspicious, + "reasons": reasons, + "latest_summary": latest_summary, + "manual_result": task.result, + "latest_completed_run": _run_dict(latest_completed) if latest_completed else None, + "run_counts": { + "total": len(runs), + "completed": len(completed_runs), + "failed": len(failed_runs), + "crashed": len([r for r in runs if r.outcome == "crashed"]), + "blocked": len([r for r in runs if r.outcome == "blocked"]), + }, + "dependencies": { + "total": len(deps), + "done": len([d for d in deps if d.get("status") == "done"]), + "items": deps, + }, + "deliverables": deliverables, + "invalid_skills": invalid_skills, + } + + # Hallucination-warning event kinds — see complete_task() in kanban_db.py. # completion_blocked_hallucination: kernel rejected created_cards with # phantom ids; task stays in prior state. @@ -527,20 +666,19 @@ def get_task( if diag_list: task_d["diagnostics"] = diag_list task_d["warnings"] = _warnings_summary_from_diagnostics(diag_list) + runs = kanban_db.list_runs( + conn, + task_id, + state_type=run_state_type, + state_name=run_state_name, + ) return { "task": task_d, "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], "links": _links_for(conn, task_id), - "runs": [ - _run_dict(r) - for r in kanban_db.list_runs( - conn, - task_id, - state_type=run_state_type, - state_name=run_state_name, - ) - ], + "runs": [_run_dict(r) for r in runs], + "completion_evidence": _completion_evidence(conn, task, runs), } finally: conn.close() diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index a33becb4e0f88..424cb63d07de3 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -81,6 +81,25 @@ def test_workspace_kind_validation(kanban_home): kb.create_task(conn, title="bad ws", workspace_kind="cloud") +def test_create_task_rejects_natural_language_skill_names(kanban_home): + with kb.connect() as conn, pytest.raises(ValueError, match="invalid skill name"): + kb.create_task(conn, title="bad skill", assignee="default", skills=["ppt skill"]) + + +def test_create_task_accepts_slug_like_skill_names(kanban_home): + with kb.connect() as conn: + tid = kb.create_task( + conn, + title="good skill", + assignee="default", + skills=["kanban-worker", "plugin:skill_name"], + ) + task = kb.get_task(conn, tid) + + assert task is not None + assert task.skills == ["kanban-worker", "plugin:skill_name"] + + def test_create_task_persists_worktree_branch_name(kanban_home, tmp_path): target = tmp_path / ".worktrees" / "t6-wire" with kb.connect() as conn: diff --git a/tests/hermes_cli/test_kanban_db_init.py b/tests/hermes_cli/test_kanban_db_init.py index c400b1d90f99f..5ad40e1fbdbdf 100644 --- a/tests/hermes_cli/test_kanban_db_init.py +++ b/tests/hermes_cli/test_kanban_db_init.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sqlite3 import threading from pathlib import Path @@ -36,3 +37,80 @@ def worker() -> None: with kb.connect(board="default") as conn: cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} assert "max_retries" in cols + + +def test_connect_migrates_legacy_db_without_session_id(tmp_path, monkeypatch): + """Legacy boards without session_id must not fail before migrations run.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + db_path = home / "kanban.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER DEFAULT 0, + created_by TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + workspace_kind TEXT NOT NULL DEFAULT 'scratch', + workspace_path TEXT, + claim_lock TEXT, + claim_expires INTEGER, + tenant TEXT, + result TEXT, + idempotency_key TEXT, + spawn_failures INTEGER NOT NULL DEFAULT 0, + worker_pid INTEGER, + last_spawn_error TEXT, + max_runtime_seconds INTEGER, + last_heartbeat_at INTEGER, + current_run_id INTEGER, + workflow_template_id TEXT, + current_step_key TEXT, + skills TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_failure_error TEXT, + max_retries INTEGER + ); + CREATE TABLE task_links ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + PRIMARY KEY (parent_id, child_id) + ); + CREATE TABLE task_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + run_id INTEGER, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL + ); + INSERT INTO tasks (id, title, status, created_at) VALUES ('t_legacy', 'legacy', 'ready', 1); + """ + ) + conn.close() + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + + with kb.connect(board="default") as migrated: + cols = {row["name"] for row in migrated.execute("PRAGMA table_info(tasks)")} + indexes = {row["name"] for row in migrated.execute("PRAGMA index_list(tasks)")} + + assert "session_id" in cols + assert "model_override" in cols + assert "idx_tasks_session_id" in indexes diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 5fa1881fa3299..c89710daea948 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -275,6 +275,50 @@ def test_task_detail_includes_links_and_events(client): # Events exist from creation. assert len(data["events"]) >= 1 + assert "completion_evidence" in data + + +def test_task_detail_completion_evidence_rolls_up_dependencies(client): + req = client.post("/api/plugins/kanban/tasks", json={"title": "requirements"}).json()["task"] + final = client.post( + "/api/plugins/kanban/tasks", + json={"title": "final", "parents": [req["id"]], "skills": ["kanban-worker"]}, + ).json()["task"] + + r = client.patch( + f"/api/plugins/kanban/tasks/{req['id']}", + json={ + "status": "done", + "summary": "requirements completed with a concrete artifact", + "metadata": {"deliverable": "/tmp/requirements.md"}, + }, + ) + assert r.status_code == 200, r.text + + r = client.patch( + f"/api/plugins/kanban/tasks/{final['id']}", + json={"status": "done", "result": "dddd", "summary": "dddd"}, + ) + assert r.status_code == 200, r.text + + detail = client.get(f"/api/plugins/kanban/tasks/{final['id']}").json() + evidence = detail["completion_evidence"] + assert evidence["suspicious"] is True + assert evidence["dependencies"]["done"] == 1 + assert evidence["dependencies"]["total"] == 1 + assert evidence["dependencies"]["items"][0]["id"] == req["id"] + assert "/tmp/requirements.md" in evidence["deliverables"] + assert "completion summary/result is empty" in evidence["reasons"][0] + + +def test_dashboard_bundle_renders_completion_evidence_section(): + repo_root = Path(__file__).resolve().parents[2] + bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + js = bundle.read_text() + + assert "function CompletionEvidenceSection" in js + assert "Completion evidence / 完成证据" in js + assert "props.data.completion_evidence" in js def test_task_detail_404_on_unknown(client): From 5e0d23f95c0122184fdf562a6a50636bfacf9ed2 Mon Sep 17 00:00:00 2001 From: jiayuxiaochaoren <1468693932@qq.com> Date: Tue, 19 May 2026 22:56:59 +0800 Subject: [PATCH 2/3] fix: wrap kanban columns instead of horizontal scroll --- plugins/kanban/dashboard/dist/style.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 052fa4622c5bb..908821366687e 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -64,17 +64,16 @@ .hermes-kanban-columns { display: flex; + flex-wrap: wrap; gap: 0.75rem; - align-items: start; - overflow-x: auto; - scrollbar-width: none; -} -.hermes-kanban-columns::-webkit-scrollbar { - display: none; + align-items: flex-start; + overflow-x: visible; } .hermes-kanban-column { - flex: 0 0 280px; + flex: 1 1 280px; + min-width: 260px; + max-width: 360px; display: flex; flex-direction: column; background: color-mix(in srgb, var(--color-card) 85%, transparent); From d4fa59faa3058fa16bdf792f29a9fa4df98e3d3f Mon Sep 17 00:00:00 2001 From: jiayuxiaochaoren <1468693932@qq.com> Date: Tue, 19 May 2026 22:59:50 +0800 Subject: [PATCH 3/3] revert: keep kanban column wrap out of evidence PR --- plugins/kanban/dashboard/dist/style.css | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 908821366687e..052fa4622c5bb 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -64,16 +64,17 @@ .hermes-kanban-columns { display: flex; - flex-wrap: wrap; gap: 0.75rem; - align-items: flex-start; - overflow-x: visible; + align-items: start; + overflow-x: auto; + scrollbar-width: none; +} +.hermes-kanban-columns::-webkit-scrollbar { + display: none; } .hermes-kanban-column { - flex: 1 1 280px; - min-width: 260px; - max-width: 360px; + flex: 0 0 280px; display: flex; flex-direction: column; background: color-mix(in srgb, var(--color-card) 85%, transparent);