org_loop_route.py 7.38 KB
"""c_org_loop_route (03-C §3.11) + the validating-state exit that isn't
covered by any named controller (04-P: validating->done on validator_pass,
action "关闭任务" -- no c_* name given). Built together because they're the
same decision point (did the Validator's verdict PASS or FAIL) that U-015
already produces one half of (FAIL -> root-cause judgment); this module is
where task.status actually reaches 'done' or gets routed back into the loop.

Judgment (which route, or PASS/FAIL) already happened via the Validator --
this module is purely the deterministic code side ("判断用 Agent,路由用代码"
per 03-C §3.11): given a decided_route, apply the matching p_org_task_lifecycle
(04-P) transition.

**Simplification, noted rather than silently assumed**: 04-P's table models
validating->routed_fast_lane->coding as two hops (an observable
'routed_fast_lane'/'routed_full_replan' status, then a second 'route_applied'
transition to the final state). c_org_loop_route's own 03-C output_contract
returns {new_status, new_assigned_org_node} -- the *final* state directly, not
the intermediate one -- and no controller is named for the second hop. This
implementation collapses both into one atomic call (validating straight to
coding/planning_brd_prd_sdd), recording the route decision via an explicit
`execution_event(event_type=loop_routed)` instead of ever persisting
'routed_fast_lane'/'routed_full_replan' as a literal task.status row. Neither
REQ-05 nor REQ-09's acceptance wording checks for that literal intermediate
status, so this doesn't affect Done -- but a future PH3 dashboard wanting to
*see* that transient state would need this revisited.

core_engine routing (03-C: routes to p_deploy_supervisor_blue_green instead
of fast_lane/full_replan) is PH5 scope (U-034+), not attempted here --
core_engine isn't a valid bug_record.root_cause_category yet either (U-001's
schema deliberately excludes it, see src/bug_record.py).
"""

import json
import uuid

_NEW_STATUS = {"fast_lane": "coding", "full_replan": "planning_brd_prd_sdd"}
_NEW_ORG_NODE = {"fast_lane": "coder", "full_replan": "planner"}
# REQ-05/REQ-09 (docs/sdd/06-验收与测试锚点.md) loop_round boundary: loop_round >= _LOOP_LIMIT forces task.status -> blocked_pending_human.
_LOOP_LIMIT = 3


class LoopLimitReachedError(Exception):
    """LOOP_LIMIT_REACHED -- 03-C §3.11."""


class TaskNotBlockedError(Exception):
    """manual_resume called on a task that isn't blocked_pending_human (REQ-09
    failure case: calling it on an already-'done' task must error, not
    silently no-op or corrupt the finished task)."""


class TaskNotValidatingError(Exception):
    """close_task_done / route_org_loop(fast_lane|full_replan) called on a
    task that isn't currently 'validating'."""


def _write_status_event(conn, task_id, project_id, from_status, to_status):
    conn.execute(
        "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
        "VALUES (?, ?, ?, 'task_status_changed', ?)",
        (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": from_status, "to_status": to_status})),
    )


def _get_task(conn, task_id):
    row = conn.execute("SELECT project_id, status, loop_round FROM task WHERE task_id = ?", (task_id,)).fetchone()
    if row is None:
        raise ValueError("no such task: " + task_id)
    return {"project_id": row[0], "status": row[1], "loop_round": row[2]}


def close_task_done(conn, task_id):
    """04-P: validating -> done (validator_pass, "全部 Benchmark/测试用例通过")."""
    task = _get_task(conn, task_id)
    if task["status"] != "validating":
        raise TaskNotValidatingError(
            "task {} is status={}, not validating -- can't close to done".format(task_id, task["status"])
        )
    conn.execute("UPDATE task SET status = 'done', updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", (task_id,))
    _write_status_event(conn, task_id, task["project_id"], "validating", "done")
    conn.commit()
    return {"task_id": task_id, "new_status": "done"}


def route_org_loop(conn, task_id, decided_route):
    """decided_route: 'fast_lane' | 'full_replan' | 'manual_resume'.
    Returns {"task_id", "new_status", "new_assigned_org_node"}.
    """
    if decided_route not in ("fast_lane", "full_replan", "manual_resume"):
        raise ValueError(decided_route)

    task = _get_task(conn, task_id)

    if decided_route == "manual_resume":
        return _manual_resume(conn, task_id, task)

    if task["status"] != "validating":
        raise TaskNotValidatingError(
            "task {} is status={}, not validating -- fast_lane/full_replan routing only applies "
            "right after a Validator fail".format(task_id, task["status"])
        )

    if task["loop_round"] >= _LOOP_LIMIT:
        conn.execute(
            "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached', "
            "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
            (task_id,),
        )
        _write_status_event(conn, task_id, task["project_id"], task["status"], "blocked_pending_human")
        conn.commit()
        raise LoopLimitReachedError(
            "task {} has loop_round={} >= {}, forced to blocked_pending_human".format(
                task_id, task["loop_round"], _LOOP_LIMIT
            )
        )

    new_status = _NEW_STATUS[decided_route]
    new_org_node = _NEW_ORG_NODE[decided_route]
    conn.execute(
        "UPDATE task SET status = ?, assigned_org_node = ?, loop_round = loop_round + 1, "
        "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
        (new_status, new_org_node, task_id),
    )
    conn.execute(
        "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
        "VALUES (?, ?, ?, 'loop_routed', ?)",
        (str(uuid.uuid4()), task["project_id"], task_id, json.dumps({"decided_route": decided_route})),
    )
    _write_status_event(conn, task_id, task["project_id"], task["status"], new_status)
    conn.commit()
    return {"task_id": task_id, "new_status": new_status, "new_assigned_org_node": new_org_node}


def _manual_resume(conn, task_id, task):
    if task["status"] != "blocked_pending_human":
        raise TaskNotBlockedError(
            "task {} is status={}, not blocked_pending_human -- manual_resume only applies to "
            "blocked tasks".format(task_id, task["status"])
        )

    rows = conn.execute(
        "SELECT payload FROM execution_event WHERE task_id = ? AND event_type = 'task_status_changed' "
        "ORDER BY rowid DESC",
        (task_id,),
    ).fetchall()
    restore_status = None
    for (payload_json,) in rows:
        payload = json.loads(payload_json)
        if payload.get("to_status") == "blocked_pending_human":
            restore_status = payload.get("from_status")
            break
    if restore_status is None:
        raise ValueError(
            "could not find the pre-blocked status for task {} from execution_event history".format(task_id)
        )

    conn.execute(
        "UPDATE task SET status = ?, blocked_reason = NULL, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
        (restore_status, task_id),
    )
    _write_status_event(conn, task_id, task["project_id"], "blocked_pending_human", restore_status)
    conn.commit()
    new_org_node = conn.execute("SELECT assigned_org_node FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
    return {"task_id": task_id, "new_status": restore_status, "new_assigned_org_node": new_org_node}