self_hosting_e2e.py 7.87 KB
"""U-042: self-hosting closed-loop acceptance -- wires every PH5 module
built so far (U-034~U-041) plus the Graph Engine's own scheduler into the
literal 9-step scenario `self-development-feasibility.md` walks through:
a real self-target Goal, Planner->Coder->Validator (real `claude` calls via
`scheduler.run_goal_to_completion`, which already forces worktree isolation
and records the `core_engine` audit trail for self-target tasks per U-035),
merge, stage a candidate, health-check it, cut it over, monitor it, and
land on EITHER `finalized` (clean monitoring window) OR a rollback (anomaly
detected) -- "走完整...两条路径中至少一条", not a scripted single outcome.

This module is the acceptance scenario's own orchestration glue, not a
claim about what Deploy Supervisor's real production `poll_fn` (U-036's
still-unfilled hook) should look like long-term -- a continuously-running
daemon would discover self-target merges to stage on its own schedule
rather than drive one linear call end to end. Building that discovery loop
is future work, not this Unit's job; this function exists so the acceptance
scenario has one real, callable entry point to run (or NOT run -- see
test_self_hosting_e2e.py's gating) against.
"""

import time

from supervisor import deploy_cutover, deploy_health_check, deploy_monitor, deploy_rollback, deploy_stage
from src import paths, schema, scheduler


class SelfHostingLoopError(Exception):
    """Raised when a precondition for even attempting the scenario isn't
    met (e.g. target_folder_path isn't genuinely self-target) -- distinct
    from a real outcome like 'rolled_back', which is a valid, successful
    exercise of the safety mechanism, not an error."""


def run_self_hosting_closed_loop(
    db_path, goal_text, target_folder_path=None, prod_db_path=None,
    poll_interval=5.0, monitor_timeout_cycles=None, sleep=time.sleep, now=time.time,
    monitor_window_hours=deploy_monitor.MONITORING_WINDOW_HOURS,
    monitor_min_task_count=deploy_monitor.MONITORING_MIN_TASK_COUNT,
):
    """Returns {"stage", "outcome", ...}. `stage` names how far the scenario
    got (`task_lifecycle` / `classification` / `health_check` / `cutover` /
    `monitor`); `outcome` is the concrete result at that stage. A task that
    doesn't reach 'done', or doesn't get classified core_engine, is a valid
    early-return (not every self-target task is a core-engine change -- see
    U-035), not a scenario failure.

    `monitor_window_hours`/`monitor_min_task_count` default to deploy_monitor's
    real production constants (24h / 10 real tasks in the monitored project --
    NOT the disposable per-cycle canary project `check_canary_task` creates,
    which is a different project_id each cycle and never counts toward this).
    A real run (2026-08-03) reached 'cutover' and then correctly sat in
    'monitor' for the full real production window -- neither waiting 24
    real hours nor submitting 10 real Goals against the monitored project is
    practical for an interactive acceptance run, so these are exposed as
    overridable, same as `monitor_timeout_cycles` already is, rather than
    leaving the real scenario impossible to actually finish observing."""
    target_folder_path = target_folder_path or paths.repo_root()
    prod_db_path = prod_db_path or paths.db_path()
    if not paths.is_self_target(target_folder_path):
        raise SelfHostingLoopError(
            "target_folder_path {!r} is not this instance's own checkout ({!r}) -- "
            "the self-hosting scenario requires self-target".format(target_folder_path, paths.repo_root())
        )

    task_result = scheduler.run_goal_to_completion(db_path, target_folder_path, goal_text)
    if task_result.get("final_status") != "done":
        return {"stage": "task_lifecycle", "outcome": task_result.get("final_status"), "task_id": task_result.get("task_id")}

    conn = schema.connect(db_path)
    try:
        bug = conn.execute(
            "SELECT bug_id FROM bug_record WHERE task_id = ? AND root_cause_category = 'core_engine' "
            "ORDER BY rowid DESC LIMIT 1",
            (task_result["task_id"],),
        ).fetchone()
        if bug is None:
            return {"stage": "classification", "outcome": "not_core_engine", "task_id": task_result["task_id"]}

        merge_commit = deploy_stage._current_commit(target_folder_path)

        staged = deploy_stage.stage_deployment(
            conn, merge_commit, repo_root=target_folder_path, prod_db_path=prod_db_path
        )
        health = deploy_health_check.run_health_check(
            conn, staged["deployment_id"], staged["candidate_pid"], staged["candidate_port"]
        )
        if health["status"] != "health_check_passed":
            candidate_db_path = conn.execute(
                "SELECT candidate_db_path FROM deployment WHERE deployment_id = ?", (staged["deployment_id"],)
            ).fetchone()[0]
            deploy_rollback.rollback_path_a(
                conn, staged["deployment_id"], staged["candidate_pid"], candidate_db_path,
                merge_commit, repo_root=target_folder_path,
                description="health check failed: {}".format(health["signals"]),
                candidate_instance_dir=staged["candidate_instance_dir"],
            )
            return {"stage": "health_check", "outcome": "rolled_back_path_a", "deployment_id": staged["deployment_id"]}

        cutover_result = deploy_cutover.cutover(
            conn, staged["deployment_id"], live_pid=None, candidate_pid=staged["candidate_pid"],
            candidate_instance_dir=staged["candidate_instance_dir"], prod_db_path=prod_db_path,
        )

        project_id = conn.execute(
            "SELECT project_id FROM project WHERE target_folder_path = ?", (target_folder_path,)
        ).fetchone()[0]
        cutover_at = conn.execute(
            "SELECT cutover_at FROM deployment WHERE deployment_id = ?", (staged["deployment_id"],)
        ).fetchone()[0]
        baseline = deploy_monitor.compute_baseline_validator_approval_rate(conn, project_id, cutover_at)
        baseline_rate = baseline[0] if baseline else None

        cycles = 0
        while True:
            cycles += 1
            cycle_result = deploy_monitor.run_monitor_cycle(
                conn, staged["deployment_id"], cutover_result["live_pid"], cutover_result["live_port"],
                project_id, baseline_rate, cutover_at, now=now,
                window_hours=monitor_window_hours, min_task_count=monitor_min_task_count,
            )
            if cycle_result["status"] == "monitoring_window_clean":
                conn.execute(
                    "UPDATE deployment SET status = 'finalized' WHERE deployment_id = ?", (staged["deployment_id"],)
                )
                conn.commit()
                return {"stage": "monitor", "outcome": "finalized", "deployment_id": staged["deployment_id"]}
            if cycle_result["status"] == "anomaly_detected":
                rb = deploy_rollback.rollback_path_b(
                    conn, staged["deployment_id"], cutover_result["live_pid"],
                    _previous_instance_dir(conn, staged["deployment_id"]),
                    prod_db_path, merge_commit, repo_root=target_folder_path,
                    description="post-cutover anomaly: {}".format(cycle_result["latest_signal"]),
                )
                outcome = "rolled_back_path_b" if rb["status"] == "rolled_back" else "rollback_failed"
                return {"stage": "monitor", "outcome": outcome, "deployment_id": staged["deployment_id"]}
            if monitor_timeout_cycles is not None and cycles >= monitor_timeout_cycles:
                return {"stage": "monitor", "outcome": "timed_out_still_monitoring", "deployment_id": staged["deployment_id"]}
            sleep(poll_interval)
    finally:
        conn.close()


def _previous_instance_dir(conn, deployment_id):
    return conn.execute(
        "SELECT previous_instance_dir FROM deployment WHERE deployment_id = ?", (deployment_id,)
    ).fetchone()[0]