feat(kanban): goal_mode cards run workers in a /goal loop (#35710)
* feat(kanban): goal_mode cards run workers in a /goal loop A goal_mode card wraps its dispatched worker in the Ralph-style goal loop behind /goal: after each turn an auxiliary judge checks the worker's response against the card title+body, and if not done the worker keeps going in the SAME session until the judge agrees, the worker terminates the task itself, or the turn budget runs out (which blocks the card for human review — never a silent exit). - kanban_db: goal_mode + goal_max_turns columns (additive migration), Task fields, create_task params, INSERT wiring, created-event payload. - kanban_tools: goal_mode/goal_max_turns on the kanban_create tool so orchestrators can opt cards in when fanning out. - kanban CLI: --goal / --goal-max-turns on 'kanban create'. - dashboard API: goal_mode/goal_max_turns on the create endpoint (auto-surfaced back via asdict). - _default_spawn: sets HERMES_KANBAN_GOAL_MODE / _GOAL_MAX_TURNS only when the card opts in. - goals.run_kanban_goal_loop: standalone, callback-injected loop engine (no SessionDB persistence; ephemeral worker). cli.py quiet path calls it after the worker's first turn when the env vars are set. - Docs: orchestrator skill + kanban feature page. Tests: DB roundtrip + legacy migration, spawn env gating, and the loop's continuation/completion/budget-block/finalize-nudge branches. E2E run against a real kanban DB confirms a budget-exhausted goal worker lands in a sticky blocked state. * feat(kanban/dashboard): goal-mode toggle in the create form Wires the goal_mode card setting into the dashboard UI (the plugin's hand-written IIFE bundle, no build step): - InlineCreate: 'goal mode' checkbox after the skills field; checking it reveals an optional 'max turns' number input. Both reset on submit and only post goal_mode/goal_max_turns when enabled. - TaskDrawer: a 'Goal mode: on (max N turns)' MetaRow so a card's goal-mode setting is visible after creation (auto-fed by asdict via the existing _task_dict). Live-tested through the running dashboard with a browser: created a goal-mode card with max-turns=8, confirmed it persisted to the kanban DB (goal_mode=1, goal_max_turns=8) and rendered back in the drawer as 'on (max 8 turns)'. No JS console errors.
This commit is contained in:
@@ -15074,6 +15074,96 @@ class HermesCLI:
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
"""Drive a kanban goal_mode worker through the Ralph-style goal loop.
|
||||
|
||||
Called from the quiet single-query path AFTER the worker's first turn,
|
||||
only when ``HERMES_KANBAN_GOAL_MODE`` is set (dispatcher-spawned
|
||||
goal_mode card). Wires the worker's ``run_conversation`` and the kanban
|
||||
DB into ``goals.run_kanban_goal_loop``. All errors are swallowed by the
|
||||
caller — a broken goal loop must never wedge a worker, the dispatcher's
|
||||
claim TTL / crash detection is the backstop.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS
|
||||
|
||||
# Resolve goal text from the card (title + body = the acceptance
|
||||
# criteria the judge evaluates against).
|
||||
conn = _kb.connect()
|
||||
try:
|
||||
task = _kb.get_task(conn, task_id)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if task is None:
|
||||
return
|
||||
|
||||
goal_parts = [task.title or ""]
|
||||
if task.body:
|
||||
goal_parts.append(task.body)
|
||||
goal_text = "\n\n".join(p for p in goal_parts if p).strip()
|
||||
if not goal_text:
|
||||
return
|
||||
|
||||
max_turns = task.goal_max_turns or _DEF_TURNS
|
||||
|
||||
def _run_turn(prompt: str) -> str:
|
||||
result = cli.agent.run_conversation(
|
||||
user_message=prompt,
|
||||
conversation_history=cli.conversation_history,
|
||||
)
|
||||
# Keep session_id in sync if mid-run compression rotated it.
|
||||
if (
|
||||
getattr(cli.agent, "session_id", None)
|
||||
and cli.agent.session_id != cli.session_id
|
||||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
resp = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
if resp:
|
||||
print(resp)
|
||||
return resp or ""
|
||||
|
||||
def _task_status() -> "str | None":
|
||||
c = _kb.connect()
|
||||
try:
|
||||
t = _kb.get_task(c, task_id)
|
||||
return t.status if t is not None else None
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
c = _kb.connect()
|
||||
try:
|
||||
_kb.block_task(c, task_id, reason=reason)
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run_loop(
|
||||
task_id=task_id,
|
||||
goal_text=goal_text,
|
||||
run_turn=_run_turn,
|
||||
task_status_fn=_task_status,
|
||||
block_fn=_block,
|
||||
max_turns=max_turns,
|
||||
first_response=first_response or "",
|
||||
log=lambda m: logger.info("%s", m),
|
||||
)
|
||||
|
||||
|
||||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
@@ -15471,6 +15561,20 @@ def main(
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
elif response:
|
||||
print(response)
|
||||
|
||||
# Kanban goal-loop mode: a worker spawned for a
|
||||
# goal_mode card keeps working in THIS session until an
|
||||
# auxiliary judge agrees the card is done, the worker
|
||||
# terminates the task itself, or the turn budget runs
|
||||
# out (→ sticky block). Gated on the env vars the
|
||||
# dispatcher sets in `_default_spawn`; a no-op for every
|
||||
# normal worker and every non-kanban `-q` run.
|
||||
if os.environ.get("HERMES_KANBAN_GOAL_MODE") == "1":
|
||||
try:
|
||||
_run_kanban_goal_loop_q(cli, response)
|
||||
except Exception as _goal_exc:
|
||||
logger.debug("kanban goal loop failed: %s", _goal_exc)
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user