Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rejects /, but the existing skill loader accepts categorized skill paths such as devops/kanban-worker through skill_view() direct-path lookup. The validation should match the loader's accepted identifier grammar, otherwise valid per-task skills get blocked.

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
Expand Down Expand Up @@ -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])
Expand Down
65 changes: 65 additions & 0 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
156 changes: 147 additions & 9 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes the run-filtered runs list into the evidence rollup, so ?run_state_type=outcome&run_state_name=failed makes completion evidence report zero completed runs even if the task has completed attempts. Fetch an unfiltered run list for _completion_evidence and keep this filtered list only for the drawer's run history.

}
finally:
conn.close()
Expand Down
19 changes: 19 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading