task_execution.py 9.98 KB
"""c_task_execute / c_task_pause (2026-08-03): manual Start/Pause/Stop
controls for v_task_detail, confirmed with the user first:

1. **Soft stop only.** `org_node_trigger.py`/`work_graph_execute.py` both
   invoke the real `claude` CLI via blocking `subprocess.run()` -- no
   process handle is stored anywhere a "stop" action could reach in and
   kill. Pause/Stop let whatever real call is currently in flight finish
   naturally; only the NEXT phase is withheld. A genuine hard-kill would
   need switching those call sites to `Popen` + tracking PIDs somewhere
   reachable by task_id, which is out of scope for this pass.
2. **No new `task.status` enum value.** Paused/stopped tasks reuse
   `status='blocked_pending_human'` with `blocked_reason='user_paused'` /
   `'user_stopped'` (distinguished in the audit trail, not functionally --
   both resume the same way) rather than a 01-M schema change. This reuses
   the already-built, already-tested `manual_resume` mechanism
   (`org_loop_route._manual_resume`) to restore the pre-paused status.

**Why a manual Start button doesn't contradict the "lights-out" design**:
`c_task_create`'s own contract (03-C) already assumed task creation would
auto-trigger scheduling ("若 max_concurrent_goals 已达上限,任务落 queued
但不触发调度" -- implying it DOES trigger scheduling otherwise) via an
always-on Org Graph scheduler that was never actually wired up as a running
process (`src/scheduler.py` builds the mechanism, nothing spins it up
continuously). `start_task_execution_in_background()` below is that same
auto-trigger, just invoked by an explicit user action instead of firing
automatically the instant a task is created.
"""

import threading

from src import e2e_pipeline, org_node_lifecycle, schema, scheduler

_RESUMABLE_STATUSES = ("queued", "planning_brd_prd_sdd", "coding")
_DOC_ARTIFACT_TYPES = ("brd", "prd", "sdd", "benchmark")
_CODE_ARTIFACT_TYPES = ("code_diff", "test_report")


class TaskExecutionError(Exception):
    """The task isn't in a state this module knows how to (re)start from --
    e.g. already running, already done, or blocked for a reason unrelated
    to a user-initiated pause/stop."""


class ExecutionPausedError(Exception):
    """Internal control-flow signal, not a failure: a pause/stop was
    requested while this background thread was between phases. Caught by
    the background runner and treated as a clean stop -- the task is
    ALREADY `blocked_pending_human` by construction (that's literally how
    the pause was requested), nothing more to record."""


def _check_not_paused(conn, task_id):
    status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
    if status == "blocked_pending_human":
        raise ExecutionPausedError(task_id)


def _latest_artifact_ids(conn, task_id, artifact_types):
    rows = conn.execute(
        "SELECT artifact_type, artifact_id FROM artifact WHERE task_id = ? AND artifact_type IN ({}) "
        "ORDER BY created_at".format(",".join("?" * len(artifact_types))),
        (task_id, *artifact_types),
    ).fetchall()
    return {r[0]: r[1] for r in rows}


def _artifact_content(conn, target_folder_path, task_id, artifact_type):
    row = conn.execute(
        "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
        (task_id, artifact_type),
    ).fetchone()
    if row is None:
        raise TaskExecutionError("task {} has no {} artifact to resume from".format(task_id, artifact_type))
    return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])


def _artifact_content_or_default(conn, target_folder_path, task_id, artifact_type, default):
    """Like _artifact_content, but for the 2026-08-06 test_dataset/
    test_script nodes -- a task resumed from before this feature existed
    (or any other reason those two specific artifacts are missing) should
    still be resumable, just without them, not hard-fail."""
    row = conn.execute(
        "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
        (task_id, artifact_type),
    ).fetchone()
    if row is None:
        return default
    return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])


def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
    """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues
    from wherever `task.status` indicates real work last completed, instead
    of re-running phases that already produced real artifacts. Pausing only
    ever happens BETWEEN phases (checked right before each org_node
    trigger), so the only statuses a paused task can be resumed from are
    'queued' (nothing ran yet), 'planning_brd_prd_sdd' (Planner done, Coder
    not yet triggered), and 'coding' (Coder done, Validator not yet
    triggered) -- anything else raises `TaskExecutionError` rather than
    guessing at a mid-phase resume this module was never designed for."""
    status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
    if status not in _RESUMABLE_STATUSES:
        raise TaskExecutionError("task {} is status={}, not resumable from here".format(task_id, status))

    if status == "queued":
        return e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text)

    doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)
    sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd")
    prd_content = _artifact_content(conn, target_folder_path, task_id, "prd")
    benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark")
    # 2026-08-06 nodes -- see _artifact_content_or_default's own docstring
    # for why these two specifically fall back instead of hard-failing.
    test_dataset_content = _artifact_content_or_default(
        conn, target_folder_path, task_id, "test_dataset", "N/A: not provided"
    )
    test_script_content = _artifact_content_or_default(
        conn, target_folder_path, task_id, "test_script", "N/A: not provided"
    )

    if status == "planning_brd_prd_sdd":
        _check_not_paused(conn, task_id)
        org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
        code_artifact_ids = e2e_pipeline.implement_code(
            conn, task_id, sdd_content, goal_text, target_folder_path,
            benchmark_content=benchmark_content, test_dataset_content=test_dataset_content,
            test_script_content=test_script_content,
        )
    else:  # status == "coding" -- Coder already ran
        code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES)

    _check_not_paused(conn, task_id)
    org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
    verdict = e2e_pipeline.validate_implementation_with_own_graph(
        conn, task_id, sdd_content, prd_content, goal_text, target_folder_path,
    )

    return {
        "task_id": task_id, "doc_artifact_ids": doc_artifact_ids,
        "code_artifact_ids": code_artifact_ids, "verdict": verdict,
    }


def _mark_blocked(conn, task_id, exc):
    reason = scheduler._blocked_reason_for(exc) if isinstance(exc, scheduler._ROUTABLE_EXCEPTIONS) else "execution_error"
    scheduler._block_task(conn, task_id, reason, detail=str(exc))


def start_task_execution_in_background(db_path, task_id, target_folder_path, goal_text):
    """Spawns a daemon thread that runs (or resumes) this task's real
    execution to completion. Own sqlite connection per thread -- same
    reasoning as `scheduler.run_goal_to_completion`, a shared connection
    across threads needs the same `conn_lock` discipline U-027 already
    proved is easy to get wrong. On PASS/FAIL, applies the same
    `scheduler.finish_task_lifecycle` closing step (`close_task_done` /
    `org_level_loop.run_fix_loop`) a scheduled task gets, so a manually
    started one reaches the same real terminal state. Returns the Thread
    (already started) so callers/tests can `.join()` it if needed."""
    def run():
        # check_same_thread=False (2026-08-06, F): same reasoning as
        # scheduler.run_goal_to_completion's own conn -- this thread's conn
        # can reach work_graph_orchestrate.run_graph()'s multi-node
        # concurrent layers (G) via finish_task_lifecycle -> run_fix_loop ->
        # judge_root_cause_via_work_graph, whose worker threads aren't this
        # one.
        conn = schema.connect(db_path, check_same_thread=False)
        try:
            result = run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text)
            scheduler.finish_task_lifecycle(conn, result, target_folder_path, goal_text)
        except ExecutionPausedError:
            pass
        except Exception as e:
            _mark_blocked(conn, task_id, e)
        finally:
            conn.close()

    thread = threading.Thread(target=run, daemon=True)
    thread.start()
    return thread


def request_pause(conn, task_id, reason):
    """`reason`: 'user_paused' or 'user_stopped' -- see module docstring
    for why these are functionally identical (both a soft stop reusing
    blocked_pending_human), kept as distinct strings only so the audit
    trail (execution_event / blocked_reason) preserves which button the
    user actually clicked. Only valid while a background execution could
    plausibly be in flight; raises TaskExecutionError otherwise (nothing
    running to pause, or already terminal)."""
    row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
    if row is None:
        raise TaskExecutionError("no such task: " + task_id)
    project_id, status = row
    if status not in ("planning_brd_prd_sdd", "coding"):
        raise TaskExecutionError(
            "task {} is status={}, nothing in-flight to pause/stop".format(task_id, status)
        )
    scheduler._block_task(conn, task_id, reason)
    return {"task_id": task_id, "status": "blocked_pending_human", "blocked_reason": reason}