org_node_lifecycle.py 6.23 KB
"""c_org_node_trigger (03-C §3.3): full implementation.

U-010 scope: per 05 §5.1 4.4's staged rollout (05-实施约束与依赖.md §5.5:
"手工调用验证 Planner/Coder/Validator 三个 Org Agent 分别可被触发" -- NOT
architecture doc §11, which doesn't contain that phrase; development-plan.md's
citation was wrong and is corrected in this session's notes), this drives the
three DIRECT forward transitions in `p_org_task_lifecycle` (04-P) that a single
`c_org_node_trigger` call causes:

    queued -> planning_brd_prd_sdd   (trigger planner)
    planning_brd_prd_sdd -> coding   (trigger coder)
    coding -> validating             (trigger validator)

with TASK_NOT_READY / CONCURRENCY_LIMIT_REACHED / USAGE_LIMIT_REACHED guards.

Everything past that -- planning_failed/coding_failed retries, the
validator_pass/validator_fail decision and bug_record-driven routing,
blocked_pending_human -- needs real Work Graph artifact production (U-011+)
or Org-level Loop routing (U-015/U-016, PH2) that doesn't exist yet, and is
not attempted here. The artifact-validation guards below are real, testable
logic even though nothing produces passing artifacts for real yet -- tests
seed the precondition rows directly, same as any consumer of an
upstream contract that hasn't been built.
"""

import json
import uuid

from src.org_node_trigger import OrgNodeTriggerError, trigger_org_node

_PRE_STATE = {
    "planner": "queued",
    "planner_reviewer": "planning_brd_prd_sdd",
    "coder": "reviewing_planner_output",
    "validator": "coding",
}
_POST_STATE = {
    "planner": "planning_brd_prd_sdd",
    "planner_reviewer": "reviewing_planner_output",
    "coder": "coding",
    "validator": "validating",
}
# Org Graph unification Phase 1 (2026-08-08): _REQUIRED_ARTIFACTS["coder"] used to be
# ("brd", "prd", "sdd", "benchmark"), but artifact_write.py sets validation_status='passed'
# unconditionally on write (no real semantic check exists yet -- see that module's own
# docstring), so this gate never actually blocked anything. planner_reviewer is now the
# first real semantic gate before Coder starts (see e2e_pipeline.review_planner_output).
# Only `sdd` is kept here, as pure existence-defense: implement_code() requires a real
# sdd_content positional argument, and a missing sdd artifact should fail clearly here
# rather than crash deeper inside implement_code() with a harder-to-diagnose error.
_REQUIRED_ARTIFACTS = {
    "coder": ("sdd",),
    "validator": ("code_diff", "test_report"),
}
# REQ-14 happy case cites "You've hit your session limit" as the real signal
# text; weekly/generic phrasing added defensively. Best-effort until we've
# observed the real error text from an actual exhausted-quota call (U-020).
_USAGE_LIMIT_PHRASES = ("hit your session limit", "hit your weekly limit", "usage limit")


class TaskNotReadyError(Exception):
    """TASK_NOT_READY -- 03-C §3.3."""


class ConcurrencyLimitReachedError(Exception):
    """CONCURRENCY_LIMIT_REACHED -- 03-C §3.3."""


class UsageLimitReachedError(Exception):
    """USAGE_LIMIT_REACHED -- 03-C §3.3 / 04-P p_usage_limit_wait. Detection
    only here; the wait-and-retry loop itself is U-012/U-020's job."""


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


def _artifacts_ready(conn, task_id, artifact_types):
    for artifact_type in artifact_types:
        row = conn.execute(
            "SELECT 1 FROM artifact WHERE task_id = ? AND artifact_type = ? AND validation_status = 'passed'",
            (task_id, artifact_type),
        ).fetchone()
        if row is None:
            return False
    return True


def _check_concurrency(conn):
    row = conn.execute(
        "SELECT max_concurrent_goals FROM org_concurrency_config WHERE config_id = 'default'"
    ).fetchone()
    limit = row[0] if row else 1  # 01-M 1.11 default
    active = conn.execute(
        "SELECT COUNT(*) FROM task WHERE status NOT IN ('queued', 'done', 'blocked_pending_human')"
    ).fetchone()[0]
    if active >= limit:
        raise ConcurrencyLimitReachedError(
            "max_concurrent_goals={} reached ({} active)".format(limit, active)
        )


def _looks_like_usage_limit(message):
    lowered = message.lower()
    return any(phrase in lowered for phrase in _USAGE_LIMIT_PHRASES)


def trigger_org_node_full(conn, org_node, task_id, target_folder_path, prompt, timeout=300):
    """Full c_org_node_trigger: guards + real subprocess call (src.org_node_trigger)
    + task.status transition, all in one call. Returns the updated
    {"task_id", "status", "assigned_org_node"}.
    """
    if org_node not in _PRE_STATE:
        raise ValueError(org_node)

    task = _get_task(conn, task_id)
    if task["status"] != _PRE_STATE[org_node]:
        raise TaskNotReadyError(
            "task {} is in status={}, not ready for {} (needs {})".format(
                task_id, task["status"], org_node, _PRE_STATE[org_node]
            )
        )
    if org_node in _REQUIRED_ARTIFACTS and not _artifacts_ready(conn, task_id, _REQUIRED_ARTIFACTS[org_node]):
        raise TaskNotReadyError("required artifacts not validation_status=passed yet for task " + task_id)
    if org_node == "planner":
        _check_concurrency(conn)

    try:
        trigger_org_node(org_node, prompt, target_folder_path, timeout=timeout)
    except OrgNodeTriggerError as e:
        if _looks_like_usage_limit(str(e)):
            raise UsageLimitReachedError(str(e)) from e
        raise

    old_status = task["status"]
    new_status = _POST_STATE[org_node]
    conn.execute(
        "UPDATE task SET status = ?, assigned_org_node = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
        (new_status, org_node, task_id),
    )
    conn.execute(
        "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
        "VALUES (?, ?, ?, 'task_status_changed', ?)",
        (
            str(uuid.uuid4()),
            task["project_id"],
            task_id,
            json.dumps({"from_status": old_status, "to_status": new_status}),
        ),
    )
    conn.commit()
    return {"task_id": task_id, "status": new_status, "assigned_org_node": org_node}