#!/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", 20) or 20)) 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, ), )