diff --git a/epiphany-core/src/bin/epiphany-phase6-reorient-smoke.rs b/epiphany-core/src/bin/epiphany-phase6-reorient-smoke.rs new file mode 100644 index 00000000..fd78af54 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-phase6-reorient-smoke.rs @@ -0,0 +1,489 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use serde_json::Value; +use serde_json::json; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +#[allow(dead_code)] +#[path = "epiphany-mvp-status.rs"] +mod status_cli; + +const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe"; +const WATCHED_RELATIVE_PATH: &str = "src/reorient_target.rs"; + +fn main() -> Result<()> { + let args = Args::parse()?; + let result = run_smoke(&args)?; + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} + +#[derive(Debug)] +struct Args { + app_server: PathBuf, + codex_home: PathBuf, + workspace: PathBuf, + result: PathBuf, + transcript: PathBuf, + stderr: PathBuf, +} + +impl Args { + fn parse() -> Result { + let root = env::current_dir().context("failed to resolve current dir")?; + let mut parsed = Self { + app_server: PathBuf::from(DEFAULT_APP_SERVER), + codex_home: root + .join(".epiphany-smoke") + .join("phase6-reorient-codex-home"), + workspace: root + .join(".epiphany-smoke") + .join("phase6-reorient-workspace"), + result: root + .join(".epiphany-smoke") + .join("phase6-reorient-smoke-result.json"), + transcript: root + .join(".epiphany-smoke") + .join("phase6-reorient-smoke-transcript.jsonl"), + stderr: root + .join(".epiphany-smoke") + .join("phase6-reorient-smoke-server.stderr.log"), + }; + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?, + "--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?, + "--workspace" => parsed.workspace = take_path(&mut args, "--workspace")?, + "--result" => parsed.result = take_path(&mut args, "--result")?, + "--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?, + "--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?, + other => return Err(anyhow!("unknown argument: {other}")), + } + } + Ok(parsed) + } +} + +fn run_smoke(args: &Args) -> Result { + let root = env::current_dir().context("failed to resolve current dir")?; + let app_server = status_cli::absolute_path(&args.app_server)?; + if !app_server.exists() { + return Err(anyhow!( + "codex app-server binary not found: {}", + app_server.display() + )); + } + let codex_home = status_cli::absolute_path(&args.codex_home)?; + let workspace = status_cli::absolute_path(&args.workspace)?; + let result_path = status_cli::absolute_path(&args.result)?; + let transcript_path = status_cli::absolute_path(&args.transcript)?; + let stderr_path = status_cli::absolute_path(&args.stderr)?; + reset_smoke_paths( + &root, + &[ + codex_home.clone(), + workspace.clone(), + result_path.clone(), + transcript_path.clone(), + stderr_path.clone(), + ], + )?; + fs::create_dir_all(&codex_home) + .with_context(|| format!("failed to create {}", codex_home.display()))?; + let watched_file = prepare_workspace(&workspace)?; + + let mut client = status_cli::AppServerClient::start( + &app_server, + &codex_home, + &transcript_path, + &stderr_path, + )?; + client.send( + "initialize", + Some(json!({ + "clientInfo": { + "name": "epiphany-phase6-reorient-smoke", + "title": "Epiphany Phase 6 Reorient Smoke", + "version": "0.1.0", + }, + "capabilities": {"experimentalApi": true}, + })), + true, + )?; + client.send("initialized", None, false)?; + let started = client.send( + "thread/start", + Some(json!({"cwd": workspace, "ephemeral": true})), + true, + )?; + let thread_id = started + .pointer("/thread/id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("thread/start response missing thread.id"))? + .to_string(); + + let missing_notification_start = client.notification_len(); + let missing_response = client.send( + "thread/epiphany/reorient", + Some(json!({"threadId": thread_id})), + true, + )?; + assert_missing_reorient(&missing_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + missing_notification_start, + Duration::from_secs(1), + )?; + + let update_notification_start = client.notification_len(); + let update = client.send( + "thread/epiphany/update", + Some(json!({ + "threadId": thread_id, + "expectedRevision": 0, + "patch": reorient_patch(), + })), + true, + )?; + require( + update.get("revision").and_then(Value::as_u64) == Some(1), + "reorient smoke patch should advance revision to 1", + )?; + client.wait_for_notification( + "thread/epiphany/stateUpdated", + update_notification_start, + Duration::from_secs(10), + )?; + + let ready_notification_start = client.notification_len(); + let ready_response = client.send( + "thread/epiphany/reorient", + Some(json!({"threadId": thread_id})), + true, + )?; + assert_ready_reorient(&ready_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + ready_notification_start, + Duration::from_secs(1), + )?; + + fs::write( + watched_file, + "pub fn reorient_target() -> &'static str {\n \"after\"\n}\n", + )?; + + let regather_notification_start = client.notification_len(); + let regather_response = wait_for_regather_reorient(&mut client, &thread_id)?; + assert_regather_reorient(®ather_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + regather_notification_start, + Duration::from_secs(1), + )?; + + let final_read = client.send( + "thread/read", + Some(json!({"threadId": thread_id, "includeTurns": false})), + true, + )?; + require( + final_read + .pointer("/thread/epiphanyState/revision") + .and_then(Value::as_u64) + == Some(1), + "reorient reflection should not mutate durable state", + )?; + + let result = json!({ + "threadId": thread_id, + "codexHome": codex_home, + "workspace": workspace, + "missingAction": missing_response["decision"]["action"], + "readyAction": ready_response["decision"]["action"], + "regatherAction": regather_response["decision"]["action"], + "regatherReasons": regather_response["decision"]["reasons"], + "checkpointChangedPaths": regather_response["decision"]["checkpointChangedPaths"], + "activeFrontierNodeIds": regather_response["decision"]["activeFrontierNodeIds"], + "stateUpdatedNotificationCount": client.notification_count( + "thread/epiphany/stateUpdated", + regather_notification_start, + ), + "finalReadRevision": final_read["thread"]["epiphanyState"]["revision"], + }); + write_json(&result_path, &result)?; + Ok(result) +} + +fn prepare_workspace(workspace: &Path) -> Result { + fs::create_dir_all(workspace)?; + let watched_file = workspace.join(WATCHED_RELATIVE_PATH); + fs::create_dir_all(watched_file.parent().expect("watched path has parent"))?; + fs::write( + &watched_file, + "pub fn reorient_target() -> &'static str {\n \"before\"\n}\n", + )?; + Ok(watched_file) +} + +fn reorient_patch() -> Value { + let code_ref = json!({ + "path": WATCHED_RELATIVE_PATH, + "start_line": 1, + "end_line": 3, + "symbol": "reorient_target" + }); + json!({ + "objective": "Decide whether a durable checkpoint still deserves to be resumed after rehydrate.", + "activeSubgoalId": "phase6-reorient-smoke", + "subgoals": [{ + "id": "phase6-reorient-smoke", + "title": "Live-smoke CRRC reorientation policy", + "status": "active", + "summary": "Resume when the checkpoint is still aligned; regather when the touched file proves it isn't." + }], + "graphs": { + "architecture": { + "nodes": [{ + "id": "reorient-target", + "title": "Reorient target", + "purpose": "Map the file the watcher will touch so reorientation can notice drift.", + "code_refs": [code_ref.clone()] + }] + }, + "dataflow": {"nodes": []}, + "links": [] + }, + "graphFrontier": { + "active_node_ids": ["reorient-target"], + "dirty_paths": [] + }, + "graphCheckpoint": { + "checkpoint_id": "ck-reorient-1", + "graph_revision": 1, + "summary": "Reorientation smoke graph checkpoint", + "frontier_node_ids": ["reorient-target"] + }, + "investigationCheckpoint": { + "checkpoint_id": "ix-reorient-1", + "kind": "source_gathering", + "disposition": "resume_ready", + "focus": "Verify the touched file before broad edits.", + "summary": "This checkpoint should remain resumable until the watched source moves.", + "next_action": "Resume the bounded slice if the watched source still matches the checkpoint.", + "captured_at_turn_id": "turn-phase6-reorient", + "code_refs": [code_ref] + }, + "churn": { + "understanding_status": "ready", + "diff_pressure": "low", + "graph_freshness": "fresh", + "unexplained_writes": 0 + } + }) +} + +fn assert_missing_reorient(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "missing reorient response should report live source", + )?; + require( + response.get("stateStatus").and_then(Value::as_str) == Some("missing"), + "missing reorient response should report missing state", + )?; + let decision = &response["decision"]; + require( + decision.get("action").and_then(Value::as_str) == Some("regather"), + "missing reorient response should regather", + )?; + require( + decision.get("checkpointStatus").and_then(Value::as_str) == Some("missing"), + "missing reorient response should report missing checkpoint", + )?; + require( + string_array_eq( + decision.get("reasons").and_then(Value::as_array), + &["missingState", "missingCheckpoint"], + ), + "missing reorient response should explain missing state and checkpoint", + ) +} + +fn assert_ready_reorient(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "ready reorient response should report live source", + )?; + require( + response.get("stateStatus").and_then(Value::as_str) == Some("ready"), + "ready reorient response should report ready state", + )?; + require( + response.get("stateRevision").and_then(Value::as_u64) == Some(1), + "ready reorient response should preserve revision identity", + )?; + let decision = &response["decision"]; + require( + decision.get("action").and_then(Value::as_str) == Some("resume"), + "clean checkpoint should remain resumable", + )?; + require( + decision.get("checkpointStatus").and_then(Value::as_str) == Some("resumeReady"), + "ready reorient response should report resume-ready checkpoint status", + )?; + require( + string_array_eq( + decision.get("reasons").and_then(Value::as_array), + &["checkpointReady"], + ), + "ready reorient response should explain that the checkpoint is still aligned", + )?; + require( + decision.get("checkpointId").and_then(Value::as_str) == Some("ix-reorient-1"), + "ready reorient response should expose the checkpoint id", + )?; + require( + decision.get("nextAction").and_then(Value::as_str) + == Some("Resume the bounded slice if the watched source still matches the checkpoint."), + "ready reorient response should preserve checkpoint next action", + )?; + require( + decision.get("watcherStatus").and_then(Value::as_str) == Some("clean"), + "ready reorient response should report a clean watcher before drift", + ) +} + +fn wait_for_regather_reorient( + client: &mut status_cli::AppServerClient, + thread_id: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(10); + let mut last_response = Value::Null; + while Instant::now() < deadline { + let response = client.send( + "thread/epiphany/reorient", + Some(json!({"threadId": thread_id})), + true, + )?; + if response.pointer("/decision/action").and_then(Value::as_str) == Some("regather") { + return Ok(response); + } + last_response = response; + thread::sleep(Duration::from_millis(200)); + } + Err(anyhow!( + "reorientation policy did not switch to regather before timeout; last response: {last_response}" + )) +} + +fn assert_regather_reorient(response: &Value) -> Result<()> { + let decision = &response["decision"]; + require( + decision.get("action").and_then(Value::as_str) == Some("regather"), + "touched checkpoint path should force regather", + )?; + require( + decision.get("checkpointStatus").and_then(Value::as_str) == Some("resumeReady"), + "regather response should preserve the underlying checkpoint disposition", + )?; + require( + string_array_eq( + decision.get("reasons").and_then(Value::as_array), + &["checkpointPathsChanged", "frontierChanged"], + ), + "regather response should explain watcher path and frontier drift", + )?; + let changed_paths = decision + .get("checkpointChangedPaths") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("regather response missing changed paths"))?; + require( + changed_paths + .iter() + .filter_map(Value::as_str) + .map(|path| path.replace('\\', "/")) + .any(|path| path == WATCHED_RELATIVE_PATH), + "regather response should report the changed checkpoint path", + )?; + require( + string_array_eq( + decision + .get("activeFrontierNodeIds") + .and_then(Value::as_array), + &["reorient-target"], + ), + "regather response should report touched frontier nodes", + )?; + require( + decision + .get("note") + .and_then(Value::as_str) + .is_some_and(|note| note.starts_with("Re-gather before editing:")), + "regather response should explain why the checkpoint is no longer safe to resume", + ) +} + +fn string_array_eq(values: Option<&Vec>, expected: &[&str]) -> bool { + values.is_some_and(|values| { + values.len() == expected.len() + && values + .iter() + .zip(expected) + .all(|(value, expected)| value.as_str() == Some(*expected)) + }) +} + +fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> { + let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| { + let smoke_root = root.join(".epiphany-smoke"); + fs::create_dir_all(&smoke_root)?; + smoke_root.canonicalize() + })?; + for path in paths { + if !path.exists() { + continue; + } + let resolved = path.canonicalize()?; + if resolved == smoke_root || !resolved.starts_with(&smoke_root) { + return Err(anyhow!( + "refusing to delete non-smoke path: {}", + path.display() + )); + } + if path.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn write_json(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?; + Ok(()) +} + +fn require(condition: bool, message: &str) -> Result<()> { + if condition { + Ok(()) + } else { + Err(anyhow!("{message}")) + } +} + +fn take_path(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .map(PathBuf::from) + .ok_or_else(|| anyhow!("{name} requires a value")) +} diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index f085078d..9099d7a5 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -1508,7 +1508,7 @@ Current tests cover the landed flows at useful seams: - live app-server smoke for the first Phase 6 graph traversal boundary through native `epiphany-phase6-graph-query-smoke`. - live app-server smoke for the first Phase 6 planning boundary through native `epiphany-phase6-planning-smoke`. - live app-server smoke for the first Phase 6 pressure boundary through native `epiphany-phase6-pressure-smoke`. -- live app-server smoke for the first Phase 6 reorientation policy boundary in `tools/epiphany_phase6_reorient_smoke.py`. +- live app-server smoke for the first Phase 6 reorientation policy boundary through native `epiphany-phase6-reorient-smoke`. - live app-server smoke for the first bounded reorient-launch runtime consumer plus CRRC coordinator recommendations in `tools/epiphany_phase6_reorient_launch_smoke.py`. - live app-server smoke for the first MVP operator status view through native `epiphany-mvp-status-smoke`. - focused app-server mapper coverage for `thread/epiphany/roles`, plus MVP status smoke coverage that consumes the roles surface. @@ -1532,7 +1532,7 @@ cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-conte cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-graph-query-smoke cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-planning-smoke cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-pressure-smoke -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_reorient_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-reorient-smoke & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_reorient_launch_smoke.py' cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-mvp-status-smoke & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_job_control_smoke.py' diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index cd0e1c2f..8feacf56 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -91,7 +91,7 @@ The landed machine now has: - first Unity editor/runtime bridge through native `epiphany-unity-bridge`, native `epiphany-unity-bridge-smoke`, and the GUI Inspect Unity action, resolving exact project-pinned editors and writing runtime artifacts while refusing wrong or missing versions - prompt-file ownership for major Epiphany prompt surfaces: rendered state intro/doctrine lives under `epiphany-core/src/prompts/`, while modeling/Body, research/Eyes, implementation/Hands, verification/Soul, reorientation/Life, coordinator/Self, and CRRC templates live in `vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml` - prompt-level anti-Greenspun guardrails: Epiphany keeps Codex's useful operator discipline and requires a bounded research/scout check before agents invent bespoke versions of known algorithms, parsers, schedulers, renderers, protocols, storage layers, security mechanisms, or workflow engines -- live Phase 6 reorientation app-server smoke coverage in `tools/epiphany_phase6_reorient_smoke.py` +- live Phase 6 reorientation app-server smoke coverage through native `epiphany-phase6-reorient-smoke` - live Phase 6 reorient-launch app-server smoke coverage in `tools/epiphany_phase6_reorient_launch_smoke.py` - live Phase 6 MVP status smoke coverage through native `epiphany-mvp-status-smoke` - live Phase 6 job-control app-server smoke coverage in `tools/epiphany_phase6_job_control_smoke.py` diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 33cc3cb1..1735408d 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -152,7 +152,7 @@ The current spine, blessed but not yet finished: - live planning app-server smoke through native `epiphany-phase6-planning-smoke` - live GUI planning adoption smoke through `tools/epiphany_gui_planning_smoke.py` - live pressure app-server smoke through native `epiphany-phase6-pressure-smoke` -- live reorientation app-server smoke through `tools/epiphany_phase6_reorient_smoke.py` +- live reorientation app-server smoke through native `epiphany-phase6-reorient-smoke` - live reorient-guided worker launch smoke through `tools/epiphany_phase6_reorient_launch_smoke.py` - live MVP operator status smoke through native `epiphany-mvp-status-smoke` - live MVP coordinator smoke through native `epiphany-mvp-coordinator-smoke` @@ -252,7 +252,7 @@ cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-press For reorientation policy behavior changes, run: ```powershell -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_reorient_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-reorient-smoke ``` For the bounded reorient-guided worker launch surface, run: diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 2d71713f..209a01d1 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 8cbde80a..0395ec30 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -208,7 +208,7 @@ landed_baseline: - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-planning-smoke` before modifying planning state or the read-only planning projection. - Use `tools/epiphany_gui_planning_smoke.py` before modifying GUI Objective Draft adoption behavior. - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-pressure-smoke` before modifying context-pressure reflection behavior. - - Use `tools/epiphany_phase6_reorient_smoke.py` before modifying bounded CRRC reorientation policy behavior. + - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-reorient-smoke` before modifying bounded CRRC reorientation policy behavior. - Use `tools/epiphany_phase6_reorient_launch_smoke.py` before modifying the bounded reorient-guided worker launch surface. - Use `tools/epiphany_phase6_reorient_launch_smoke.py` before modifying the bounded reorient-worker result read-back surface. - Use `tools/epiphany_phase6_reorient_launch_smoke.py` before modifying the read-only CRRC coordinator recommendation surface. diff --git a/tools/epiphany_phase6_reorient_smoke.py b/tools/epiphany_phase6_reorient_smoke.py deleted file mode 100644 index 88f02e5f..00000000 --- a/tools/epiphany_phase6_reorient_smoke.py +++ /dev/null @@ -1,321 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import shutil -import time -from pathlib import Path -from typing import Any - -from epiphany_phase5_smoke import AppServerClient -from epiphany_phase5_smoke import DEFAULT_APP_SERVER -from epiphany_phase5_smoke import ROOT -from epiphany_phase5_smoke import require -from epiphany_phase5_smoke import reset_smoke_paths - - -DEFAULT_CODEX_HOME = ROOT / ".epiphany-smoke" / "phase6-reorient-codex-home" -DEFAULT_WORKSPACE = ROOT / ".epiphany-smoke" / "phase6-reorient-workspace" -DEFAULT_RESULT = ROOT / ".epiphany-smoke" / "phase6-reorient-smoke-result.json" -DEFAULT_TRANSCRIPT = ROOT / ".epiphany-smoke" / "phase6-reorient-smoke-transcript.jsonl" -DEFAULT_STDERR = ROOT / ".epiphany-smoke" / "phase6-reorient-smoke-server.stderr.log" - -WATCHED_RELATIVE_PATH = Path("src") / "reorient_target.rs" - - -def prepare_workspace(workspace: Path) -> Path: - if workspace.exists(): - shutil.rmtree(workspace) - watched_file = workspace / WATCHED_RELATIVE_PATH - watched_file.parent.mkdir(parents=True, exist_ok=True) - watched_file.write_text( - "pub fn reorient_target() -> &'static str {\n \"before\"\n}\n", - encoding="utf-8", - ) - return watched_file - - -def reorient_patch() -> dict[str, Any]: - return { - "objective": "Decide whether a durable checkpoint still deserves to be resumed after rehydrate.", - "activeSubgoalId": "phase6-reorient-smoke", - "subgoals": [ - { - "id": "phase6-reorient-smoke", - "title": "Live-smoke CRRC reorientation policy", - "status": "active", - "summary": "Resume when the checkpoint is still aligned; regather when the touched file proves it isn't.", - } - ], - "graphs": { - "architecture": { - "nodes": [ - { - "id": "reorient-target", - "title": "Reorient target", - "purpose": "Map the file the watcher will touch so reorientation can notice drift.", - "code_refs": [ - { - "path": WATCHED_RELATIVE_PATH.as_posix(), - "start_line": 1, - "end_line": 3, - "symbol": "reorient_target", - } - ], - } - ] - }, - "dataflow": {"nodes": []}, - "links": [], - }, - "graphFrontier": { - "active_node_ids": ["reorient-target"], - "dirty_paths": [], - }, - "graphCheckpoint": { - "checkpoint_id": "ck-reorient-1", - "graph_revision": 1, - "summary": "Reorientation smoke graph checkpoint", - "frontier_node_ids": ["reorient-target"], - }, - "investigationCheckpoint": { - "checkpoint_id": "ix-reorient-1", - "kind": "source_gathering", - "disposition": "resume_ready", - "focus": "Verify the touched file before broad edits.", - "summary": "This checkpoint should remain resumable until the watched source moves.", - "next_action": "Resume the bounded slice if the watched source still matches the checkpoint.", - "captured_at_turn_id": "turn-phase6-reorient", - "code_refs": [ - { - "path": WATCHED_RELATIVE_PATH.as_posix(), - "start_line": 1, - "end_line": 3, - "symbol": "reorient_target", - } - ], - }, - "churn": { - "understanding_status": "ready", - "diff_pressure": "low", - "graph_freshness": "fresh", - "unexplained_writes": 0, - }, - } - - -def assert_missing_reorient(response: dict[str, Any]) -> None: - require(response["source"] == "live", "missing reorient response should report live source") - require(response["stateStatus"] == "missing", "missing reorient response should report missing state") - decision = response["decision"] - require(decision["action"] == "regather", "missing reorient response should regather") - require( - decision["checkpointStatus"] == "missing", - "missing reorient response should report missing checkpoint", - ) - require( - decision["reasons"] == ["missingState", "missingCheckpoint"], - "missing reorient response should explain missing state and checkpoint", - ) - - -def assert_ready_reorient(response: dict[str, Any]) -> None: - require(response["source"] == "live", "ready reorient response should report live source") - require(response["stateStatus"] == "ready", "ready reorient response should report ready state") - require(response["stateRevision"] == 1, "ready reorient response should preserve revision identity") - decision = response["decision"] - require(decision["action"] == "resume", "clean checkpoint should remain resumable") - require( - decision["checkpointStatus"] == "resumeReady", - "ready reorient response should report resume-ready checkpoint status", - ) - require( - decision["reasons"] == ["checkpointReady"], - "ready reorient response should explain that the checkpoint is still aligned", - ) - require( - decision["checkpointId"] == "ix-reorient-1", - "ready reorient response should expose the checkpoint id", - ) - require( - decision["nextAction"] - == "Resume the bounded slice if the watched source still matches the checkpoint.", - "ready reorient response should preserve checkpoint next action", - ) - require( - decision["watcherStatus"] == "clean", - "ready reorient response should report a clean watcher before drift", - ) - - -def wait_for_regather_reorient( - client: AppServerClient, - thread_id: str, - *, - timeout: float = 10.0, -) -> dict[str, Any]: - deadline = time.time() + timeout - last_response: dict[str, Any] | None = None - while time.time() < deadline: - response = client.send("thread/epiphany/reorient", {"threadId": thread_id}) - assert response is not None - last_response = response - if response["decision"]["action"] == "regather": - return response - time.sleep(0.2) - raise AssertionError( - f"reorientation policy did not switch to regather before timeout; last response: {last_response!r}" - ) - - -def assert_regather_reorient(response: dict[str, Any]) -> None: - decision = response["decision"] - normalized_changed_paths = [ - path.replace("\\", "/") for path in decision["checkpointChangedPaths"] - ] - require(decision["action"] == "regather", "touched checkpoint path should force regather") - require( - decision["checkpointStatus"] == "resumeReady", - "regather response should preserve the underlying checkpoint disposition", - ) - require( - decision["reasons"] == ["checkpointPathsChanged", "frontierChanged"], - "regather response should explain watcher path and frontier drift", - ) - require( - WATCHED_RELATIVE_PATH.as_posix() in normalized_changed_paths, - "regather response should report the changed checkpoint path", - ) - require( - decision["activeFrontierNodeIds"] == ["reorient-target"], - "regather response should report touched frontier nodes", - ) - require( - decision["note"].startswith("Re-gather before editing:"), - "regather response should explain why the checkpoint is no longer safe to resume", - ) - - -def run_smoke(args: argparse.Namespace) -> dict[str, Any]: - app_server = args.app_server.resolve() - if not app_server.exists(): - raise FileNotFoundError(f"codex app-server binary not found: {app_server}") - - codex_home = args.codex_home.resolve() - workspace = args.workspace.resolve() - watched_file = prepare_workspace(workspace) - result_path = args.result.resolve() - transcript_path = args.transcript.resolve() - stderr_path = args.stderr.resolve() - reset_smoke_paths(codex_home, result_path, transcript_path, stderr_path) - - with AppServerClient(app_server, codex_home, transcript_path, stderr_path) as client: - client.send( - "initialize", - { - "clientInfo": { - "name": "epiphany-phase6-reorient-smoke", - "title": "Epiphany Phase 6 Reorient Smoke", - "version": "0.1.0", - }, - "capabilities": {"experimentalApi": True}, - }, - ) - client.send("initialized", expect_response=False) - started = client.send("thread/start", {"cwd": str(workspace), "ephemeral": True}) - assert started is not None - thread_id = started["thread"]["id"] - - missing_notification_start = len(client.notifications) - missing_response = client.send("thread/epiphany/reorient", {"threadId": thread_id}) - assert missing_response is not None - assert_missing_reorient(missing_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=missing_notification_start, - ) - - update_notification_start = len(client.notifications) - update = client.send( - "thread/epiphany/update", - {"threadId": thread_id, "expectedRevision": 0, "patch": reorient_patch()}, - ) - assert update is not None - require(update["revision"] == 1, "reorient smoke patch should advance revision to 1") - client.wait_for_notification( - "thread/epiphany/stateUpdated", - start_index=update_notification_start, - ) - - ready_notification_start = len(client.notifications) - ready_response = client.send("thread/epiphany/reorient", {"threadId": thread_id}) - assert ready_response is not None - assert_ready_reorient(ready_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=ready_notification_start, - ) - - watched_file.write_text( - "pub fn reorient_target() -> &'static str {\n \"after\"\n}\n", - encoding="utf-8", - ) - - regather_notification_start = len(client.notifications) - regather_response = wait_for_regather_reorient(client, thread_id) - assert_regather_reorient(regather_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=regather_notification_start, - ) - - final_read = client.send("thread/read", {"threadId": thread_id, "includeTurns": False}) - assert final_read is not None - require( - final_read["thread"]["epiphanyState"]["revision"] == 1, - "reorient reflection should not mutate durable state", - ) - - result = { - "threadId": thread_id, - "codexHome": str(codex_home), - "workspace": str(workspace), - "missingAction": missing_response["decision"]["action"], - "readyAction": ready_response["decision"]["action"], - "regatherAction": regather_response["decision"]["action"], - "regatherReasons": regather_response["decision"]["reasons"], - "checkpointChangedPaths": regather_response["decision"]["checkpointChangedPaths"], - "activeFrontierNodeIds": regather_response["decision"]["activeFrontierNodeIds"], - "stateUpdatedNotificationCount": client.count_notifications( - "thread/epiphany/stateUpdated", - start_index=regather_notification_start, - ), - "finalReadRevision": final_read["thread"]["epiphanyState"]["revision"], - } - - result_path.parent.mkdir(parents=True, exist_ok=True) - result_path.write_text( - json.dumps(result, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - return result - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Live-smoke the Phase 6 Epiphany reorientation policy surface." - ) - parser.add_argument("--app-server", type=Path, default=DEFAULT_APP_SERVER) - parser.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME) - parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE) - parser.add_argument("--result", type=Path, default=DEFAULT_RESULT) - parser.add_argument("--transcript", type=Path, default=DEFAULT_TRANSCRIPT) - parser.add_argument("--stderr", type=Path, default=DEFAULT_STDERR) - args = parser.parse_args() - result = run_smoke(args) - print(json.dumps(result, indent=2, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())