463 lines
20 KiB
Python
463 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Single-claim provider execution and durable Kanban result handoff."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import cli_lane_goal
|
|
from cli_lane_board import _board_call, _resolve_workspace, _task_context, _task_value
|
|
from cli_lane_config import (
|
|
DEFAULT_MAX_RUNTIME,
|
|
TerminalFinalizationPending,
|
|
)
|
|
from cli_lane_failover import _routed_or_blocked, capacity_failover
|
|
from cli_lane_files import (
|
|
_terminal_identity,
|
|
_terminal_path,
|
|
atomic_json,
|
|
load_json,
|
|
state_path,
|
|
)
|
|
from cli_lane_finalization import _finalize_terminal_record, _recover_exact_run
|
|
from cli_lane_health import record_provider_success
|
|
from cli_lane_metrics import record_route_decision
|
|
from cli_lane_prompt import build_prompt, git_handoff, workspace_artifacts
|
|
from cli_lane_provider import run_provider
|
|
from cli_lane_quota import selection_constraint
|
|
from cli_lane_records import _persist_candidate, _write_terminal_record
|
|
from cli_lane_recovery import _has_pending_finalization
|
|
from cli_lane_routing import fresh_unavailable_provider, select_route
|
|
|
|
|
|
def execute_claim(board: str, task_id: str) -> None:
|
|
"""Execute one already-claimed task and commit its outcome to Kanban."""
|
|
from hermes_cli import kanban_db
|
|
|
|
run_id = None
|
|
with kanban_db.scoped_current_board(board):
|
|
conn = kanban_db.connect(board=board)
|
|
try:
|
|
task = kanban_db.get_task(conn, task_id)
|
|
if task is None:
|
|
return
|
|
run_id = _task_value(task, "current_run_id", None)
|
|
assignee = str(_task_value(task, "assignee", "cli-auto") or "cli-auto")
|
|
state_file = state_path(board, task_id)
|
|
state = load_json(state_file)
|
|
state.update(
|
|
{
|
|
"board": board,
|
|
"task_id": task_id,
|
|
"run_id": run_id,
|
|
"assignee": assignee,
|
|
}
|
|
)
|
|
atomic_json(state_file, state)
|
|
log_path = Path(kanban_db.worker_log_path(task_id, board=board))
|
|
workspace = _resolve_workspace(kanban_db, conn, task, board)
|
|
kanban_db.set_workspace_path(conn, task_id, str(workspace))
|
|
context = _task_context(kanban_db, conn, task_id)
|
|
except Exception as error:
|
|
failure_reason = (
|
|
f"Direct CLI lane preparation failed: {type(error).__name__}: {error}"
|
|
)
|
|
conn.close()
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh: kanban_db.block_task(
|
|
fresh,
|
|
task_id,
|
|
reason=failure_reason,
|
|
kind="capability",
|
|
expected_run_id=run_id,
|
|
),
|
|
)
|
|
return
|
|
finally:
|
|
conn.close()
|
|
|
|
def heartbeat(note: str) -> bool:
|
|
try:
|
|
return bool(
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh: kanban_db.heartbeat_worker(
|
|
fresh,
|
|
task_id,
|
|
note=note,
|
|
expected_run_id=run_id,
|
|
),
|
|
)
|
|
)
|
|
except (OSError, sqlite3.Error):
|
|
# The claim TTL is deliberately long. Keep the expensive
|
|
# provider process alive during a transient volume stall and
|
|
# retry on the next heartbeat instead of losing its work.
|
|
return True
|
|
|
|
def comment(body: str) -> None:
|
|
with contextlib.suppress(OSError, sqlite3.Error):
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh: kanban_db.add_comment(
|
|
fresh,
|
|
task_id,
|
|
"cli-lane-runner",
|
|
body,
|
|
),
|
|
)
|
|
# Route state is also written to the durable lane-state file;
|
|
# a later heartbeat or terminal result remains authoritative.
|
|
|
|
try:
|
|
previous_route = state.get("current_route")
|
|
constraint = selection_constraint(assignee)
|
|
unavailable_provider = (
|
|
constraint.exclude_provider if constraint.source == "health" else None
|
|
)
|
|
route = _routed_or_blocked(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
lambda: select_route(
|
|
context,
|
|
assignee,
|
|
exclude_provider=constraint.exclude_provider,
|
|
exclude_reason=constraint.exclude_reason,
|
|
),
|
|
)
|
|
if route is None:
|
|
return
|
|
for note in constraint.notes:
|
|
comment(note)
|
|
if constraint.exclude_provider:
|
|
comment(
|
|
f"Provider routing guard ({constraint.source}) excluded "
|
|
f"{constraint.exclude_provider} before automatic routing.",
|
|
)
|
|
comment(
|
|
f"CLI route: {route.provider}/{route.model} at {route.effort}; classifier={route.classifier}; {route.reason}",
|
|
)
|
|
resume_handoff = ""
|
|
if (
|
|
isinstance(previous_route, dict)
|
|
and previous_route.get("provider")
|
|
and previous_route.get("provider") != route.provider
|
|
):
|
|
try:
|
|
previous_output = log_path.read_text(encoding="utf-8")[-10000:]
|
|
except OSError:
|
|
previous_output = "Previous provider log was unavailable after restart."
|
|
resume_handoff = git_handoff(workspace, previous_output)
|
|
comment(
|
|
f"Restart-time provider change: {previous_route.get('provider')} -> {route.provider}; explicit workspace handoff attached.",
|
|
)
|
|
prompt = build_prompt(context, workspace, resume_handoff)
|
|
max_runtime = int(
|
|
_task_value(task, "max_runtime_seconds", 0) or DEFAULT_MAX_RUNTIME
|
|
)
|
|
goal_mode = bool(_task_value(task, "goal_mode", False))
|
|
goal_max_turns = max(1, int(_task_value(task, "goal_max_turns", 1) or 1))
|
|
goal_turn = max(1, int(state.get("goal_turn", 0) or 0) + 1)
|
|
deadline = time.monotonic() + max_runtime
|
|
while True:
|
|
state["goal_turn"] = goal_turn
|
|
atomic_json(state_file, state)
|
|
remaining = max(1, int(deadline - time.monotonic()))
|
|
result = run_provider(
|
|
route,
|
|
prompt,
|
|
workspace,
|
|
state,
|
|
state_file,
|
|
log_path,
|
|
heartbeat,
|
|
remaining,
|
|
)
|
|
candidate_file = None
|
|
if result.structured:
|
|
candidate_file = _persist_candidate(
|
|
state,
|
|
state_file,
|
|
dict(result.structured),
|
|
route=route,
|
|
returncode=result.returncode,
|
|
goal_turn=goal_turn,
|
|
)
|
|
if result.capacity_failure:
|
|
boundary = capacity_failover(
|
|
kanban_db,
|
|
board=board,
|
|
task_id=task_id,
|
|
run_id=run_id,
|
|
assignee=assignee,
|
|
comment=comment,
|
|
context=context,
|
|
workspace=workspace,
|
|
state=state,
|
|
state_file=state_file,
|
|
log_path=log_path,
|
|
heartbeat=heartbeat,
|
|
deadline=deadline,
|
|
route=route,
|
|
result=result,
|
|
candidate_file=candidate_file,
|
|
goal_turn=goal_turn,
|
|
)
|
|
route = boundary.route
|
|
result = boundary.result
|
|
candidate_file = boundary.candidate_file
|
|
if boundary.unavailable_provider is not None:
|
|
unavailable_provider = boundary.unavailable_provider
|
|
if boundary.blocked:
|
|
break
|
|
if result.returncode == 0:
|
|
record_provider_success(route.provider)
|
|
structured = result.structured
|
|
if structured:
|
|
structured = dict(structured)
|
|
structured["artifacts"] = workspace_artifacts(
|
|
workspace,
|
|
structured.get("artifacts"),
|
|
)
|
|
metadata = {
|
|
"executor": "direct-cli-lane",
|
|
"provider": route.provider,
|
|
"model": route.model,
|
|
"effort": route.effort,
|
|
"classifier": route.classifier,
|
|
"state_file": str(state_file),
|
|
"codex_thread_id": state.get("codex_thread_id"),
|
|
"claude_session_id": state.get("claude_session_id"),
|
|
"goal_mode": goal_mode,
|
|
"goal_turn": goal_turn,
|
|
}
|
|
if candidate_file is not None:
|
|
metadata["candidate_file"] = str(candidate_file)
|
|
if structured:
|
|
for key in (
|
|
"changed_files",
|
|
"tests_run",
|
|
"artifacts",
|
|
"findings",
|
|
"blockers",
|
|
):
|
|
value = structured.get(key)
|
|
metadata[key] = value if isinstance(value, list) else []
|
|
|
|
completion_problem = None
|
|
if structured and result.returncode == 0:
|
|
# Resolve the role from the card itself, before any
|
|
# controller evidence is appended, and hand it to the gate
|
|
# so a review is judged on its verdict rather than on prose
|
|
# about the artifact it reviewed.
|
|
task_role, role_source = cli_lane_goal.task_role(
|
|
context,
|
|
structured,
|
|
)
|
|
metadata["task_role"] = task_role
|
|
metadata["task_role_source"] = role_source
|
|
completion_problem = cli_lane_goal.unfinished_result_reason(
|
|
structured,
|
|
role=task_role,
|
|
)
|
|
if (
|
|
structured.get("status") == "completed"
|
|
and completion_problem is None
|
|
and goal_mode
|
|
):
|
|
heartbeat("local goal-completion judge active")
|
|
rejection_history = state.get("goal_rejections", [])
|
|
if not isinstance(rejection_history, list):
|
|
rejection_history = []
|
|
judge_context = context
|
|
if goal_turn > 1 or rejection_history:
|
|
judge_context += (
|
|
f"\n\n{cli_lane_goal.CONTROLLER_EVIDENCE_HEADING}\n"
|
|
f"current turn {goal_turn}/{goal_max_turns}; prior rejected "
|
|
f"reports: {json.dumps(rejection_history[-5:])}."
|
|
)
|
|
accepted, judge_reason = cli_lane_goal.judge_goal_completion(
|
|
judge_context,
|
|
structured,
|
|
)
|
|
metadata["goal_judge_reason"] = judge_reason
|
|
if not accepted:
|
|
completion_problem = f"local goal judge requested continuation: {judge_reason}"
|
|
|
|
if (
|
|
structured
|
|
and structured.get("status") == "completed"
|
|
and result.returncode == 0
|
|
and completion_problem is None
|
|
):
|
|
terminal_file = _terminal_path(state_file, run_id, "pending")
|
|
metadata["terminal_record"] = str(
|
|
_terminal_path(state_file, run_id, "committed")
|
|
)
|
|
try:
|
|
terminal_file, terminal_record = _write_terminal_record(
|
|
state_file,
|
|
board=board,
|
|
task_id=task_id,
|
|
run_id=run_id,
|
|
structured=structured,
|
|
summary=str(structured.get("summary") or "Completed"),
|
|
metadata=metadata,
|
|
)
|
|
outcome = _finalize_terminal_record(
|
|
kanban_db,
|
|
terminal_file,
|
|
terminal_record,
|
|
)
|
|
except Exception as error:
|
|
identity = _terminal_identity(terminal_file)
|
|
replayable = bool(
|
|
identity is not None
|
|
and _has_pending_finalization(
|
|
identity.board,
|
|
identity.task_id,
|
|
identity.run_id,
|
|
)
|
|
)
|
|
if not replayable:
|
|
_recover_exact_run(
|
|
kanban_db,
|
|
identity,
|
|
f"persistence raised {type(error).__name__}",
|
|
)
|
|
raise TerminalFinalizationPending(
|
|
"accepted worker result remains outside the generic block path; "
|
|
f"terminal replayable={replayable}; persistence/finalization "
|
|
f"raised {type(error).__name__}"
|
|
) from error
|
|
if outcome != "committed":
|
|
raise TerminalFinalizationPending(
|
|
"accepted worker result is durably journaled but "
|
|
f"Kanban finalization is {outcome}"
|
|
)
|
|
record_route_decision(
|
|
route.provider, route.effort, route.classifier, "completed"
|
|
)
|
|
break
|
|
|
|
can_continue = (
|
|
goal_mode
|
|
and completion_problem is not None
|
|
and goal_turn < goal_max_turns
|
|
and deadline - time.monotonic() > 30
|
|
)
|
|
if can_continue:
|
|
rejection_history = state.get("goal_rejections", [])
|
|
if not isinstance(rejection_history, list):
|
|
rejection_history = []
|
|
state["goal_rejections"] = [
|
|
*rejection_history[-4:],
|
|
completion_problem,
|
|
]
|
|
goal_turn += 1
|
|
comment(
|
|
f"Goal completion rejected; continuing turn {goal_turn}/{goal_max_turns}: {completion_problem}",
|
|
)
|
|
escalation_context = (
|
|
context
|
|
+ "\n\nThe previous worker turn missed its completion quality mark: "
|
|
+ completion_problem
|
|
+ "\nSelect a route that can finish and verify the remaining work."
|
|
)
|
|
excluded = unavailable_provider
|
|
if excluded is None and assignee == "cli-auto":
|
|
excluded = fresh_unavailable_provider()
|
|
record_route_decision(
|
|
route.provider, route.effort, route.classifier, "goal-continued"
|
|
)
|
|
next_route = _routed_or_blocked(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
lambda boundary=escalation_context, blocked=excluded: select_route(
|
|
boundary,
|
|
assignee,
|
|
exclude_provider=blocked,
|
|
exclude_reason="is unavailable according to fresh native health"
|
|
if blocked
|
|
else None,
|
|
),
|
|
)
|
|
if next_route is None:
|
|
break
|
|
comment(
|
|
f"Goal route {goal_turn}/{goal_max_turns}: "
|
|
f"{next_route.provider}/{next_route.model} at {next_route.effort}; "
|
|
f"classifier={next_route.classifier}; {next_route.reason}",
|
|
)
|
|
handoff = (
|
|
git_handoff(workspace, result.output)
|
|
if next_route.provider != route.provider
|
|
else ""
|
|
)
|
|
route = next_route
|
|
prompt = build_prompt(
|
|
context,
|
|
workspace,
|
|
handoff
|
|
+ "\nGoal-loop continuation: the previous final report was rejected because "
|
|
+ completion_problem
|
|
+ ". Reinspect live state, finish the outstanding work, and return new final evidence.",
|
|
)
|
|
continue
|
|
|
|
reason = completion_problem
|
|
if reason is None and structured:
|
|
blockers = structured.get("blockers", [])
|
|
reason = "; ".join(str(item) for item in blockers)
|
|
reason = reason or str(structured.get("summary") or "")
|
|
if reason is None:
|
|
reason = result.output[-4000:]
|
|
failure_reason = reason or (
|
|
f"{route.provider} worker failed with exit {result.returncode}"
|
|
)
|
|
failure_kind = (
|
|
"transient" if result.capacity_failure else "capability"
|
|
)
|
|
record_route_decision(
|
|
route.provider, route.effort, route.classifier, f"blocked-{failure_kind}"
|
|
)
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh, failure_reason=failure_reason, failure_kind=failure_kind: kanban_db.block_task(
|
|
fresh,
|
|
task_id,
|
|
reason=failure_reason,
|
|
kind=failure_kind,
|
|
expected_run_id=run_id,
|
|
),
|
|
)
|
|
break
|
|
except TerminalFinalizationPending as error:
|
|
comment(str(error))
|
|
except Exception as error:
|
|
failure_reason = f"Direct CLI lane failed: {type(error).__name__}: {error}"
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh: kanban_db.block_task(
|
|
fresh,
|
|
task_id,
|
|
reason=failure_reason,
|
|
kind="capability",
|
|
expected_run_id=run_id,
|
|
),
|
|
)
|