Skip to content
Merged
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
92 changes: 77 additions & 15 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Initial card status. Use 'blocked' for cards "
"that require immediate human ops (R3 gate) "
"to skip the brief running-to-blocked transition.")
p_create.add_argument("--model", default=None, dest="model_override",
metavar="MODEL",
help="Per-task model override. Pins the worker to "
"this model (passed as `hermes -m MODEL`) "
"instead of the assignee profile's default. "
"Omit to use the profile default.")
p_create.add_argument("--json", action="store_true", help="Emit JSON output")

# --- swarm ---
Expand Down Expand Up @@ -535,7 +541,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_edit.add_argument("task_id")
p_edit.add_argument(
"--result",
required=True,
default=None,
help="Backfilled task result text for a done task",
)
p_edit.add_argument(
Expand All @@ -548,6 +554,22 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
default=None,
help="JSON dict of structured facts to store on the latest completed run.",
)
p_edit.add_argument(
"--model",
default=None,
dest="model_override",
metavar="MODEL",
help="Set the per-task model override (passed to the worker as "
"`hermes -m MODEL`). Omitting --model leaves the existing "
"override untouched; use --clear-model to remove it.",
)
p_edit.add_argument(
"--clear-model",
action="store_true",
dest="clear_model",
help="Clear the per-task model override (revert to the assignee "
"profile default). Mutually exclusive with --model.",
)

p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
p_block.add_argument("task_id")
Expand Down Expand Up @@ -1346,6 +1368,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
goal_mode=bool(getattr(args, "goal_mode", False)),
goal_max_turns=getattr(args, "goal_max_turns", None),
initial_status=getattr(args, "initial_status", "running"),
model_override=getattr(args, "model_override", None),
)
task = kb.get_task(conn, task_id)
if getattr(args, "json", False):
Expand Down Expand Up @@ -1919,21 +1942,60 @@ def _cmd_edit(args: argparse.Namespace) -> int:
except (ValueError, json.JSONDecodeError) as exc:
print(f"kanban: --metadata: {exc}", file=sys.stderr)
return 2

model_override = getattr(args, "model_override", None)
clear_model = bool(getattr(args, "clear_model", False))
if model_override is not None and clear_model:
print(
"kanban: --model and --clear-model are mutually exclusive",
file=sys.stderr,
)
return 2

# The result-backfill edit is only attempted when --result is given;
# --model / --clear-model are independent edits that apply to any task.
do_result = getattr(args, "result", None) is not None
do_model = model_override is not None or clear_model

if not do_result and not do_model:
print(
"kanban: nothing to edit (pass --result, --model, or --clear-model)",
file=sys.stderr,
)
return 2
Comment on lines +1960 to +1965

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 "Nothing to edit" message omits --summary / --metadata

do_result is only set when --result is provided, so kanban edit TASK_ID --summary 'foo' or kanban edit TASK_ID --metadata '{}' alone would hit this branch and print "nothing to edit (pass --result, --model, or --clear-model)" without mentioning the flags the user actually passed. Adding --summary and --metadata to the message (or to do_result) would prevent confusion.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


rc = 0
with kb.connect_closing() as conn:
if not kb.edit_completed_task_result(
conn,
args.task_id,
result=args.result,
summary=getattr(args, "summary", None),
metadata=metadata,
):
print(
f"cannot edit {args.task_id} (unknown id or task is not done)",
file=sys.stderr,
)
return 1
print(f"Edited {args.task_id}")
return 0
if do_model:
# --clear-model writes NULL; --model X writes X literally. The
# None sentinel ("--model omitted") never reaches here.
new_model = None if clear_model else model_override
affected = kb.set_task_model(conn, args.task_id, new_model)
if affected == 0:
print(
f"cannot set model on {args.task_id} (unknown id)",
file=sys.stderr,
)
return 1
if clear_model:
print(f"Cleared model override on {args.task_id}")
else:
print(f"Set model override on {args.task_id}: {new_model}")
if do_result:
if not kb.edit_completed_task_result(
conn,
args.task_id,
result=args.result,
summary=getattr(args, "summary", None),
metadata=metadata,
):
print(
f"cannot edit {args.task_id} (unknown id or task is not done)",
file=sys.stderr,
)
return 1
print(f"Edited {args.task_id}")
return rc
Comment on lines +1967 to +1998

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 rc is initialized but never modified

rc = 0 is set once and only ever reaches return rc when all operations succeed — every failure path exits early via return 1. This means the variable is dead code. If the intent was to allow partial-success tracking (e.g., model edit committed but result backfill failed), rc never gets set to a non-zero value, so a caller checking exit code when both --model and --result are supplied can see exit 1 even though the model write already committed and was printed as successful.



def _cmd_block(args: argparse.Namespace) -> int:
Expand Down
39 changes: 37 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2072,6 +2072,7 @@ def create_task(
initial_status: str = "running",
session_id: Optional[str] = None,
board: Optional[str] = None,
model_override: Optional[str] = None,
) -> str:
"""Create a new task and optionally link it under parent tasks.

Expand Down Expand Up @@ -2236,8 +2237,9 @@ def create_task(
id, title, body, assignee, status, priority,
created_by, created_at, workspace_kind, workspace_path,
branch_name, tenant, idempotency_key, max_runtime_seconds,
skills, max_retries, goal_mode, goal_max_turns, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
skills, max_retries, goal_mode, goal_max_turns, session_id,
model_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
Expand All @@ -2259,6 +2261,7 @@ def create_task(
1 if goal_mode else 0,
int(goal_max_turns) if goal_max_turns is not None else None,
session_id,
model_override,
),
)
for pid in parents:
Expand Down Expand Up @@ -4708,6 +4711,28 @@ def set_workspace_path(
)


def set_task_model(
conn: sqlite3.Connection, task_id: str, model: Optional[str]
) -> int:
"""Set (or clear) a task's per-task model override.

``model`` is taken literally: a non-empty string pins that model, and
``None`` writes SQL NULL (clears the override). The DB layer does NOT
interpret ``""`` — empty-string handling is a CLI concern; whatever is
passed is stored verbatim.

Returns the number of rows affected: a call against a nonexistent
``task_id`` returns ``0`` (never a silent success), so callers can tell
a real write from a no-op.
"""
with write_txn(conn):
cur = conn.execute(
"UPDATE tasks SET model_override = ? WHERE id = ?",
(model, task_id),
)
return int(cur.rowcount or 0)


# ---------------------------------------------------------------------------
def schedule_task(
conn: sqlite3.Connection,
Expand Down Expand Up @@ -6758,6 +6783,16 @@ def _default_spawn(
cmd.extend(["--skills", sk])
if task.model_override:
cmd.extend(["-m", task.model_override])
# Structured spawn line so per-task model overrides are auditable
# post-hoc ("why did this task cost Opus money"). Only emitted when
# an override is actually set — a cleared/no-override task logs
# nothing here, so a stale line never misattributes spend.
_log.info(
"kanban spawn task=%s assignee=%s model_override=%s",
task.id,
profile_arg,
task.model_override,
)
cmd.extend([
"chat",
"-q", prompt,
Expand Down
Loading
Loading