refactor(kanban-cli): drop redundant parens on kwarg string concatenations; wrap to 100 cols (AST-identical)
This commit is contained in:
@@ -113,7 +113,8 @@ def _latest_event_ts(events: Iterable[Any], kinds: set[str]) -> int:
|
||||
|
||||
|
||||
def _cli_hint(label: str, command: str, *, suggested: bool = False) -> DiagnosticAction:
|
||||
return DiagnosticAction(kind="cli_hint", label=label, payload={"command": command}, suggested=suggested)
|
||||
return DiagnosticAction(kind="cli_hint", label=label, payload={"command": command},
|
||||
suggested=suggested)
|
||||
|
||||
|
||||
def _log_hint_action(task_id: str) -> DiagnosticAction:
|
||||
@@ -200,7 +201,9 @@ def _main_model_visible(raw_config: Any) -> bool:
|
||||
model_cfg = raw_config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
provider = str(model_cfg.get("provider") or "").strip()
|
||||
model = str(model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "").strip()
|
||||
model = str(
|
||||
model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or ""
|
||||
).strip()
|
||||
return bool(provider and model)
|
||||
return bool(str(model_cfg or "").strip())
|
||||
|
||||
@@ -244,17 +247,16 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
hits = _active_hallucination_events(events, "completion_blocked_hallucination")
|
||||
if not hits:
|
||||
return []
|
||||
actions = [DiagnosticAction(kind="comment", label="Add a comment explaining what to do", suggested=False)]
|
||||
actions = [DiagnosticAction(kind="comment", label="Add a comment explaining what to do",
|
||||
suggested=False)]
|
||||
actions += _generic_recovery_actions(task, running=_is_running(task))
|
||||
return [Diagnostic(
|
||||
kind="hallucinated_cards", severity="error",
|
||||
title="Worker claimed cards that don't exist",
|
||||
detail=(
|
||||
"The completing worker declared created_cards that either didn't exist or weren't "
|
||||
"created by its profile. The completion was blocked and the task stayed in its prior "
|
||||
"state. Usually means the worker hallucinated ids instead of capturing return values "
|
||||
"from kanban_create."
|
||||
),
|
||||
detail="The completing worker declared created_cards that either didn't exist or weren't "
|
||||
"created by its profile. The completion was blocked and the task stayed in its prior "
|
||||
"state. Usually means the worker hallucinated ids instead of capturing return values "
|
||||
"from kanban_create.",
|
||||
actions=actions,
|
||||
first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits),
|
||||
data={"phantom_ids": _unique_payload_ids(hits, "phantom_cards")},
|
||||
@@ -289,7 +291,8 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti
|
||||
if status is None:
|
||||
return []
|
||||
|
||||
auto_decompose, main_visible = bool(status.get("auto_decompose")), bool(status.get("main_model_visible"))
|
||||
auto_decompose = bool(status.get("auto_decompose"))
|
||||
main_visible = bool(status.get("main_model_visible"))
|
||||
decomposer_explicit = bool(status.get("decomposer_explicit"))
|
||||
specifier_explicit = bool(status.get("specifier_explicit"))
|
||||
primary_slot, fallback_slot, primary_desc, detail_path = _TRIAGE_SLOTS[auto_decompose]
|
||||
@@ -315,12 +318,10 @@ def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnosti
|
||||
return [Diagnostic(
|
||||
kind="triage_aux_unavailable", severity="warning",
|
||||
title=f"Triage {primary_desc} has no usable model",
|
||||
detail=(
|
||||
f"This task is still in triage and no working auxiliary model is visible to the "
|
||||
f"dispatcher. {detail_path} The default slot uses `provider: auto` which falls back to "
|
||||
f"the main model, but no main model is configured either. Configure the slot directly "
|
||||
f"or set a main model so the auto fallback can take over."
|
||||
),
|
||||
detail=f"This task is still in triage and no working auxiliary model is visible to the "
|
||||
f"dispatcher. {detail_path} The default slot uses `provider: auto` which falls back to "
|
||||
f"the main model, but no main model is configured either. Configure the slot directly "
|
||||
f"or set a main model so the auto fallback can take over.",
|
||||
actions=actions,
|
||||
first_seen_at=now, last_seen_at=now, count=1,
|
||||
data={"task_id": task_id, "auto_decompose": auto_decompose,
|
||||
@@ -337,11 +338,9 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
return [Diagnostic(
|
||||
kind="prose_phantom_refs", severity="warning",
|
||||
title="Completion summary references unknown task ids",
|
||||
detail=(
|
||||
"The completion summary mentions task ids that don't resolve in this board's database. "
|
||||
"The completion itself succeeded, but downstream consumers parsing the summary may be "
|
||||
"pointed at cards that never existed."
|
||||
),
|
||||
detail="The completion summary mentions task ids that don't resolve in this board's database. "
|
||||
"The completion itself succeeded, but downstream consumers parsing the summary may be "
|
||||
"pointed at cards that never existed.",
|
||||
actions=_generic_recovery_actions(task, running=_is_running(task)),
|
||||
first_seen_at=_event_ts(hits[0]), last_seen_at=_event_ts(hits[-1]), count=len(hits),
|
||||
data={"phantom_refs": _unique_payload_ids(hits, "phantom_refs")},
|
||||
@@ -512,7 +511,8 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn
|
||||
actions: list[DiagnosticAction] = []
|
||||
if task_id:
|
||||
actions.append(_cli_hint(
|
||||
"Complete the finished implementation phase", f"hermes kanban complete {task_id}", suggested=True,
|
||||
"Complete the finished implementation phase", f"hermes kanban complete {task_id}",
|
||||
suggested=True,
|
||||
))
|
||||
if task_id and child_ids:
|
||||
actions.append(_cli_hint(
|
||||
@@ -523,12 +523,10 @@ def _rule_review_dependency_deadlock(task, events, runs, now, cfg) -> list[Diagn
|
||||
return [Diagnostic(
|
||||
kind="review_dependency_deadlock", severity="error",
|
||||
title=f"Review handoff blocks {len(child_ids)} dependent task(s)",
|
||||
detail=(
|
||||
"This implementation is sticky-blocked for review while its downstream task(s) require "
|
||||
"the implementation to be done or archived before they can run. Complete the finished "
|
||||
"phase, unlink the incorrect dependency, or migrate this workflow to the first-class "
|
||||
"review lifecycle."
|
||||
),
|
||||
detail="This implementation is sticky-blocked for review while its downstream task(s) require "
|
||||
"the implementation to be done or archived before they can run. Complete the finished "
|
||||
"phase, unlink the incorrect dependency, or migrate this workflow to the first-class "
|
||||
"review lifecycle.",
|
||||
actions=actions,
|
||||
first_seen_at=blocked_at, last_seen_at=blocked_at, count=len(child_ids),
|
||||
data={"blocked_parent_id": task_id, "waiting_child_ids": child_ids, "block_reason": reason},
|
||||
@@ -554,12 +552,11 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
return [Diagnostic(
|
||||
kind="stuck_in_blocked", severity="warning",
|
||||
title=f"Task has been blocked for {int(age_hours)}h",
|
||||
detail=(
|
||||
f"This task transitioned to blocked {int(age_hours)}h ago and has had no comments or "
|
||||
f"unblock attempts since. Blocked tasks are waiting for human input — check the block "
|
||||
f"reason and either unblock with feedback or answer with a comment."
|
||||
),
|
||||
actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task", suggested=True)],
|
||||
detail=f"This task transitioned to blocked {int(age_hours)}h ago and has had no comments or "
|
||||
f"unblock attempts since. Blocked tasks are waiting for human input — check the block "
|
||||
f"reason and either unblock with feedback or answer with a comment.",
|
||||
actions=[DiagnosticAction(kind="comment", label="Add a comment / unblock the task",
|
||||
suggested=True)],
|
||||
first_seen_at=last_blocked_ts, last_seen_at=last_blocked_ts, count=1,
|
||||
data={"blocked_at": last_blocked_ts, "age_hours": round(age_hours, 1)},
|
||||
)]
|
||||
@@ -607,12 +604,10 @@ def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic
|
||||
return [Diagnostic(
|
||||
kind="block_unblock_cycling", severity="warning",
|
||||
title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h",
|
||||
detail=(
|
||||
f"This task has been blocked {cycles} times after being unblocked, suggesting the "
|
||||
f"unblock is not addressing the root cause and the worker keeps hitting the same wall. "
|
||||
f"Review the block reasons in the event history; a different intervention (reassign, "
|
||||
f"change scope, archive) may be needed."
|
||||
),
|
||||
detail=f"This task has been blocked {cycles} times after being unblocked, suggesting the "
|
||||
f"unblock is not addressing the root cause and the worker keeps hitting the same wall. "
|
||||
f"Review the block reasons in the event history; a different intervention (reassign, "
|
||||
f"change scope, archive) may be needed.",
|
||||
actions=actions,
|
||||
first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now),
|
||||
last_seen_at=int(last_cycle_blocked_ts) if last_cycle_blocked_ts else int(now),
|
||||
@@ -667,12 +662,10 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
return [Diagnostic(
|
||||
kind="stranded_in_ready", severity=severity,
|
||||
title=f"Ready for {age_str} with no worker",
|
||||
detail=(
|
||||
f"This task has been ready for {age_str} but nothing has claimed it. Common causes: "
|
||||
f"assignee {assignee!r} is misspelled, the profile was deleted, or the external worker "
|
||||
f"pool for this lane is down. Confirm the assignee is correct and that a worker is "
|
||||
f"actually polling for it."
|
||||
),
|
||||
detail=f"This task has been ready for {age_str} but nothing has claimed it. Common causes: "
|
||||
f"assignee {assignee!r} is misspelled, the profile was deleted, or the external worker "
|
||||
f"pool for this lane is down. Confirm the assignee is correct and that a worker is "
|
||||
f"actually polling for it.",
|
||||
actions=actions,
|
||||
first_seen_at=last_ready_ts, last_seen_at=last_ready_ts, count=1,
|
||||
data={"ready_since": last_ready_ts, "age_seconds": int(age_seconds),
|
||||
@@ -718,7 +711,9 @@ def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict:
|
||||
``kanban.failure_limit`` so diagnostics match the dispatcher's breaker."""
|
||||
kanban_cfg = kanban_cfg or {}
|
||||
diag_cfg = dict(kanban_cfg.get("diagnostics") or {})
|
||||
diag_cfg.setdefault("failure_limit", kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]))
|
||||
diag_cfg.setdefault(
|
||||
"failure_limit", kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]),
|
||||
)
|
||||
if not _has_explicit_threshold(diag_cfg):
|
||||
diag_cfg["failure_threshold"] = diag_cfg["failure_limit"]
|
||||
return diag_cfg
|
||||
|
||||
@@ -63,14 +63,18 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
_cfg = load_config()
|
||||
_kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {}
|
||||
default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None
|
||||
max_in_progress_per_profile = kb._positive_int(_kanban_cfg.get("max_in_progress_per_profile"), None)
|
||||
max_in_progress_per_profile = kb._positive_int(
|
||||
_kanban_cfg.get("max_in_progress_per_profile"), None
|
||||
)
|
||||
# Memory-derived default when unset — same fallback the gateway applies.
|
||||
max_in_progress = kb.resolve_max_in_progress(
|
||||
kb._positive_int(_kanban_cfg.get("max_in_progress"), None)
|
||||
)
|
||||
# CLI --max is the more explicit signal, so it wins over kanban.max_spawn.
|
||||
cli_max = getattr(args, "max", None)
|
||||
max_spawn = cli_max if cli_max is not None else kb._positive_int(_kanban_cfg.get("max_spawn"), None)
|
||||
max_spawn = (
|
||||
cli_max if cli_max is not None else kb._positive_int(_kanban_cfg.get("max_spawn"), None)
|
||||
)
|
||||
except Exception:
|
||||
default_assignee = max_in_progress_per_profile = max_in_progress = None
|
||||
max_spawn = getattr(args, "max", None)
|
||||
@@ -88,7 +92,9 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
_print_json({
|
||||
**{k: getattr(res, k)
|
||||
for k in ("reclaimed", "crashed", "timed_out", "stale", "auto_blocked", "promoted")},
|
||||
"spawned": [{"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned],
|
||||
"spawned": [
|
||||
{"task_id": tid, "assignee": who, "workspace": ws} for (tid, who, ws) in res.spawned
|
||||
],
|
||||
"skipped_unassigned": res.skipped_unassigned,
|
||||
"skipped_nonspawnable": res.skipped_nonspawnable,
|
||||
"skipped_per_profile_capped": [
|
||||
@@ -131,22 +137,14 @@ def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
_DAEMON_DEPRECATED = (
|
||||
"hermes kanban daemon: DEPRECATED — the dispatcher now runs\n"
|
||||
"inside the gateway. To use kanban:\n"
|
||||
"\n"
|
||||
" hermes gateway start # starts the gateway + embedded dispatcher\n"
|
||||
"\n"
|
||||
"Ready tasks will be picked up on the next dispatcher tick\n"
|
||||
"(default: every 60 seconds). Configure via config.yaml:\n"
|
||||
"\n"
|
||||
" kanban:\n"
|
||||
" dispatch_in_gateway: true # default\n"
|
||||
" dispatch_interval_seconds: 60\n"
|
||||
" failure_limit: 2 # consecutive non-success attempts before auto-block\n"
|
||||
"\n"
|
||||
"Running both the gateway AND this standalone daemon will\n"
|
||||
"race for claims. If you truly need the old standalone\n"
|
||||
"daemon (no gateway available), rerun with --force."
|
||||
"hermes kanban daemon: DEPRECATED — the dispatcher now runs\ninside the gateway. To use "
|
||||
"kanban:\n\n hermes gateway start # starts the gateway + embedded dispatcher\n\nReady "
|
||||
"tasks will be picked up on the next dispatcher tick\n(default: every 60 seconds). Configure "
|
||||
"via config.yaml:\n\n kanban:\n dispatch_in_gateway: true # default\n "
|
||||
"dispatch_interval_seconds: 60\n failure_limit: 2 # consecutive non-success "
|
||||
"attempts before auto-block\n\nRunning both the gateway AND this standalone daemon will\nrace "
|
||||
"for claims. If you truly need the old standalone\ndaemon (no gateway available), rerun with "
|
||||
"--force."
|
||||
)
|
||||
|
||||
|
||||
@@ -196,7 +194,10 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
|
||||
def _on_tick(res):
|
||||
ready_pending = bool(res.skipped_unassigned) or _ready_queue_nonempty()
|
||||
health_state["bad_ticks"] = health_state["bad_ticks"] + 1 if ready_pending and not res.spawned else 0
|
||||
if ready_pending and not res.spawned:
|
||||
health_state["bad_ticks"] += 1
|
||||
else:
|
||||
health_state["bad_ticks"] = 0
|
||||
# Warn once per HEALTH_WINDOW bad ticks, at most every 5 minutes.
|
||||
if health_state["bad_ticks"] >= HEALTH_WINDOW:
|
||||
now = int(time.time())
|
||||
@@ -254,10 +255,9 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
nonlocal cursor
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
|
||||
" t.assignee, t.tenant "
|
||||
"FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id "
|
||||
"WHERE e.id > ? ORDER BY e.id ASC LIMIT 200",
|
||||
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, t.assignee, "
|
||||
"t.tenant FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ? "
|
||||
"ORDER BY e.id ASC LIMIT 200",
|
||||
(cursor,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
|
||||
@@ -428,13 +428,11 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
kanban_parser = parent_subparsers.add_parser(
|
||||
"kanban",
|
||||
help="Multi-profile collaboration board (tasks, links, comments)",
|
||||
description=(
|
||||
"Durable SQLite-backed task board shared across Hermes profiles. "
|
||||
"Tasks are claimed atomically, can depend on other tasks, and "
|
||||
"are executed by a named profile in an isolated workspace. "
|
||||
"See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban "
|
||||
"or docs/hermes-kanban-v1-spec.pdf for the full design."
|
||||
),
|
||||
description="Durable SQLite-backed task board shared across Hermes profiles. "
|
||||
"Tasks are claimed atomically, can depend on other tasks, and "
|
||||
"are executed by a named profile in an isolated workspace. "
|
||||
"See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban "
|
||||
"or docs/hermes-kanban-v1-spec.pdf for the full design.",
|
||||
)
|
||||
# --board scopes every subcommand to one board's DB; when omitted the
|
||||
# resolution is HERMES_KANBAN_BOARD, then the persisted current-board
|
||||
|
||||
@@ -208,11 +208,9 @@ def _create_swarm_uncommitted(
|
||||
root = kb.create_task(
|
||||
conn,
|
||||
title=root_title or f"Swarm: {goal.splitlines()[0][:80]}",
|
||||
body=(
|
||||
"Kanban Swarm v1 planning/root card. This card is completed "
|
||||
"immediately so parallel workers can start while it remains the "
|
||||
f"shared blackboard and audit anchor.\n\nGoal:\n{goal}"
|
||||
),
|
||||
body="Kanban Swarm v1 planning/root card. This card is completed "
|
||||
"immediately so parallel workers can start while it remains the "
|
||||
f"shared blackboard and audit anchor.\n\nGoal:\n{goal}",
|
||||
assignee=created_by,
|
||||
priority=priority,
|
||||
idempotency_key=idempotency_key,
|
||||
|
||||
Reference in New Issue
Block a user