From b7212a1bf33ecb6c9742a973936034fe789cd444 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 09:53:14 +0100 Subject: [PATCH] Move freshness smoke to Rust --- epiphany-core/src/bin/epiphany-mvp-status.rs | 28 ++ .../bin/epiphany-phase6-freshness-smoke.rs | 389 ++++++++++++++++++ notes/epiphany-current-algorithmic-map.md | 4 +- notes/epiphany-fork-implementation-plan.md | 4 +- notes/fresh-workspace-handoff.md | 4 +- state/ledgers.msgpack | Bin 44440 -> 44859 bytes state/map.yaml | 2 +- tools/epiphany_phase6_freshness_smoke.py | 240 ----------- 8 files changed, 424 insertions(+), 247 deletions(-) create mode 100644 epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs delete mode 100644 tools/epiphany_phase6_freshness_smoke.py diff --git a/epiphany-core/src/bin/epiphany-mvp-status.rs b/epiphany-core/src/bin/epiphany-mvp-status.rs index 624f6062..41db53fa 100644 --- a/epiphany-core/src/bin/epiphany-mvp-status.rs +++ b/epiphany-core/src/bin/epiphany-mvp-status.rs @@ -480,6 +480,34 @@ impl AppServerClient { } Ok(()) } + + pub fn wait_for_notification( + &mut self, + method: &str, + start_index: usize, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(status) = self.child.try_wait()? { + return Err(anyhow!( + "app-server exited with {} before notification {}", + status, + method + )); + } + if let Ok(notifications) = self.notifications.lock() + && let Some(message) = notifications + .iter() + .skip(start_index) + .find(|message| message.get("method").and_then(Value::as_str) == Some(method)) + { + return Ok(message.clone()); + } + thread::sleep(Duration::from_millis(100)); + } + Err(anyhow!("timed out waiting for notification {method}")) + } } impl Drop for AppServerClient { diff --git a/epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs b/epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs new file mode 100644 index 00000000..36bb6124 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs @@ -0,0 +1,389 @@ +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::time::Duration; + +#[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"; + +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, + 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-freshness-codex-home"), + result: root + .join(".epiphany-smoke") + .join("phase6-freshness-smoke-result.json"), + transcript: root + .join(".epiphany-smoke") + .join("phase6-freshness-smoke-transcript.jsonl"), + stderr: root + .join(".epiphany-smoke") + .join("phase6-freshness-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")?, + "--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 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(), + 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 mut client = status_cli::AppServerClient::start( + &app_server, + &codex_home, + &transcript_path, + &stderr_path, + )?; + client.send( + "initialize", + Some(json!({ + "clientInfo": { + "name": "epiphany-phase6-freshness-smoke", + "title": "Epiphany Phase 6 Freshness Smoke", + "version": "0.1.0", + }, + "capabilities": {"experimentalApi": true}, + })), + true, + )?; + client.send("initialized", None, false)?; + let started = client.send( + "thread/start", + Some(json!({"cwd": root.join("epiphany-core"), "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/freshness", + Some(json!({"threadId": thread_id})), + true, + )?; + require( + missing_response.get("threadId").and_then(Value::as_str) == Some(&thread_id), + "freshness response should echo thread id", + )?; + assert_missing_state_freshness(&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": freshness_patch(), + })), + true, + )?; + require( + update.get("revision").and_then(Value::as_u64) == Some(1), + "freshness 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/freshness", + Some(json!({"threadId": thread_id})), + true, + )?; + assert_ready_freshness(&ready_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + ready_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), + "freshness reflection should not mutate state revision", + )?; + + let result = json!({ + "threadId": thread_id, + "codexHome": codex_home, + "missingRetrievalStatus": missing_response["retrieval"]["status"], + "missingGraphStatus": missing_response["graph"]["status"], + "readyRevision": ready_response["stateRevision"], + "readyRetrievalStatus": ready_response["retrieval"]["status"], + "readyGraphStatus": ready_response["graph"]["status"], + "readyDirtyPathCount": ready_response["graph"]["dirtyPathCount"], + "freshnessNotificationCount": client.notification_count( + "thread/epiphany/stateUpdated", + ready_notification_start, + ), + "finalReadRevision": final_read["thread"]["epiphanyState"]["revision"], + }); + write_json(&result_path, &result)?; + Ok(result) +} + +fn freshness_patch() -> Value { + json!({ + "objective": "Expose read-only Epiphany freshness reflection without inventing watcher-driven invalidation.", + "activeSubgoalId": "phase6-freshness-smoke", + "subgoals": [{ + "id": "phase6-freshness-smoke", + "title": "Live-smoke freshness reflection", + "status": "active", + "summary": "The freshness surface should reflect graph staleness and live retrieval state without mutation." + }], + "graphs": { + "architecture": { + "nodes": [{ + "id": "freshness-surface", + "title": "Freshness reflection surface", + "purpose": "Expose exact retrieval and graph freshness pressure without becoming a scheduler.", + "code_refs": [{ + "path": "app-server/src/codex_message_processor.rs", + "start_line": 4200, + "end_line": 4340, + "symbol": "thread_epiphany_freshness" + }] + }] + }, + "dataflow": {"nodes": []}, + "links": [] + }, + "graphFrontier": { + "active_node_ids": ["freshness-surface"], + "dirty_paths": ["app-server/src/codex_message_processor.rs"], + "open_question_ids": ["q-freshness-gap"] + }, + "graphCheckpoint": { + "checkpoint_id": "ck-freshness-1", + "graph_revision": 1, + "summary": "Freshness smoke checkpoint", + "frontier_node_ids": ["freshness-surface"], + "open_question_ids": ["q-freshness-gap"] + }, + "churn": { + "understanding_status": "ready", + "diff_pressure": "low", + "graph_freshness": "stale", + "unexplained_writes": 0 + } + }) +} + +fn assert_missing_state_freshness(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "freshness should report live source for a loaded thread", + )?; + require( + response.get("stateRevision").is_none(), + "missing-state freshness should not invent a revision", + )?; + let retrieval = &response["retrieval"]; + require( + retrieval.get("status").and_then(Value::as_str) == Some("ready"), + "live retrieval freshness should be available", + )?; + require( + retrieval.get("note").and_then(Value::as_str) == Some("Retrieval catalog is ready."), + "fresh live retrieval should report ready note", + )?; + let graph = &response["graph"]; + require( + graph.get("status").and_then(Value::as_str) == Some("missing"), + "missing Epiphany state should block graph freshness", + )?; + require( + graph.get("note").and_then(Value::as_str) + == Some("Epiphany state is missing, so graph freshness cannot be assessed."), + "missing graph freshness should explain itself", + ) +} + +fn assert_ready_freshness(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "ready freshness should report live source", + )?; + require( + response.get("stateRevision").and_then(Value::as_u64) == Some(1), + "freshness should preserve state revision identity", + )?; + let retrieval = &response["retrieval"]; + require( + retrieval.get("status").and_then(Value::as_str) == Some("ready"), + "live retrieval freshness should stay ready for the smoke workspace", + )?; + require( + retrieval.get("semanticAvailable").and_then(Value::as_bool) == Some(true), + "live retrieval freshness should report semantic availability", + )?; + let graph = &response["graph"]; + require( + graph.get("status").and_then(Value::as_str) == Some("stale"), + "dirty graph frontier should report stale freshness", + )?; + require( + graph.get("graphFreshness").and_then(Value::as_str) == Some("stale"), + "graph freshness should expose the churn hint", + )?; + require( + graph.get("checkpointId").and_then(Value::as_str) == Some("ck-freshness-1"), + "graph freshness should expose the checkpoint id", + )?; + require( + graph.get("dirtyPathCount").and_then(Value::as_u64) == Some(1), + "graph freshness should count dirty paths", + )?; + require( + string_array_eq( + graph.get("dirtyPaths").and_then(Value::as_array), + &["app-server/src/codex_message_processor.rs"], + ), + "graph freshness should expose dirty paths", + )?; + require( + graph.get("openQuestionCount").and_then(Value::as_u64) == Some(1), + "graph freshness should count open questions", + )?; + require( + graph.get("openGapCount").and_then(Value::as_u64) == Some(0), + "graph freshness should count open gaps", + ) +} + +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 6ce34040..09701814 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -1502,7 +1502,7 @@ Current tests cover the landed flows at useful seams: - reusable app-server smoke for the richer Phase 5 chain in `tools/epiphany_phase5_smoke.py`, including update/promote response metadata, `thread/epiphany/stateUpdated` notifications with source, revision, changed-field checks, retrieval-summary presence in successful write response/notification states, the verifier-only promotion edge where `Evidence` changes because verifier evidence is appended, invalid direct observation/evidence and replacement update rejection without mutation or notification, and explicit zero-notification assertions for rejected promotions. - live app-server smoke for the first Phase 6 scene boundary in `tools/epiphany_phase6_scene_smoke.py`. - live app-server smoke for the first Phase 6 jobs boundary in `tools/epiphany_phase6_jobs_smoke.py`. -- live app-server smoke for the first Phase 6 freshness boundary in `tools/epiphany_phase6_freshness_smoke.py`. +- live app-server smoke for the first Phase 6 freshness boundary through native `epiphany-phase6-freshness-smoke`. - live app-server smoke for watcher-backed invalidation inputs inside the Phase 6 freshness boundary in `tools/epiphany_phase6_invalidation_smoke.py`. - live app-server smoke for the first Phase 6 context boundary in `tools/epiphany_phase6_context_smoke.py`. - live app-server smoke for the first Phase 6 graph traversal boundary in `tools/epiphany_phase6_graph_query_smoke.py`. @@ -1526,7 +1526,7 @@ $env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex'; cargo test -p codex-a & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase5_smoke.py' & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_scene_smoke.py' & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_jobs_smoke.py' -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_freshness_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_invalidation_smoke.py' & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_context_smoke.py' & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_graph_query_smoke.py' diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index 6a119f01..22853537 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -63,7 +63,7 @@ The landed machine now has: - read-only Phase 6 job/progress reflection through `thread/epiphany/jobs`, with durable `jobBindings` plus live runtime `agent_jobs` overlay - live Phase 6 jobs app-server smoke coverage in `tools/epiphany_phase6_jobs_smoke.py` - read-only Phase 6 retrieval/graph freshness reflection plus watcher-backed invalidation inputs through `thread/epiphany/freshness` -- live Phase 6 freshness app-server smoke coverage in `tools/epiphany_phase6_freshness_smoke.py` +- live Phase 6 freshness app-server smoke coverage through native `epiphany-phase6-freshness-smoke` - live Phase 6 watcher-backed invalidation smoke coverage in `tools/epiphany_phase6_invalidation_smoke.py` - read-only Phase 6 targeted state-shard reflection through `thread/epiphany/context` - live Phase 6 context app-server smoke coverage in `tools/epiphany_phase6_context_smoke.py` @@ -441,7 +441,7 @@ Before modifying jobs reflection behavior, run: Before modifying freshness reflection behavior, run: ```powershell -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_freshness_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke ``` Before modifying watcher-backed invalidation behavior inside freshness reflection, run: diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 784e05d2..b8fa8aac 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -145,7 +145,7 @@ The current spine, blessed but not yet finished: - first GUI graph dashboard through the adjacent `@epiphanygraph/epiphany-graph-viewer` package over typed Epiphany graph state - live scene app-server smoke through `tools/epiphany_phase6_scene_smoke.py` - live jobs app-server smoke through `tools/epiphany_phase6_jobs_smoke.py` -- live freshness app-server smoke through `tools/epiphany_phase6_freshness_smoke.py` +- live freshness app-server smoke through native `epiphany-phase6-freshness-smoke` - live watcher-backed invalidation smoke through `tools/epiphany_phase6_invalidation_smoke.py` - live context app-server smoke through `tools/epiphany_phase6_context_smoke.py` - live graph traversal app-server smoke through `tools/epiphany_phase6_graph_query_smoke.py` @@ -216,7 +216,7 @@ For launch/interrupt authority changes over the thin job seam, run: For freshness reflection behavior changes, run: ```powershell -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_freshness_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke ``` For watcher-backed invalidation behavior inside freshness reflection, run: diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 877a7267c332e2d44f73a0812fc3dd3bc889a4a8..3f3fa2df23468ff7fc13666429fe88184b6b3147 100644 GIT binary patch delta 285 zcmbPnn`!qwrU{Bn>zOwyPh(`v+PsVL#OldRi-lQCjjT*2Uu@A~jGrvIMw&CND782v zFSWRMvdtQ6Bwqa*@yWB+sMMDyW|kD|C=?Xsm!%df7G;tR$xJN**_jobnp^_Z3^Xt=1?C92H9)fyQ*@IOld~04@`0x0<(DYr fmV%uIcA#Fv79#^AGhG8yT?4Za0}CsV-wmPw(Tiz1 delta 33 pcmdmek7>qjrU{BnYZEpqPh(`v+`NnN#Olea>jl_MEUXNSq5$gp3}yfT diff --git a/state/map.yaml b/state/map.yaml index 889abf18..980d4c7f 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -201,7 +201,7 @@ landed_baseline: - Use `tools/epiphany_phase6_scene_smoke.py` before modifying scene projection behavior. - Use `tools/epiphany_phase6_jobs_smoke.py` before modifying job/progress reflection behavior. - Use `tools/epiphany_phase6_job_control_smoke.py` before modifying launch/interrupt authority over the thin job seam. - - Use `tools/epiphany_phase6_freshness_smoke.py` before modifying freshness reflection behavior. + - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke` before modifying freshness reflection behavior. - Use `tools/epiphany_phase6_invalidation_smoke.py` before modifying watcher-backed invalidation behavior inside freshness reflection. - Use `tools/epiphany_phase6_context_smoke.py` before modifying targeted context-shard behavior. - Use `tools/epiphany_phase6_graph_query_smoke.py` before modifying graph traversal behavior. diff --git a/tools/epiphany_phase6_freshness_smoke.py b/tools/epiphany_phase6_freshness_smoke.py deleted file mode 100644 index 28d4e107..00000000 --- a/tools/epiphany_phase6_freshness_smoke.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -import argparse -import json -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-freshness-codex-home" -DEFAULT_RESULT = ROOT / ".epiphany-smoke" / "phase6-freshness-smoke-result.json" -DEFAULT_TRANSCRIPT = ROOT / ".epiphany-smoke" / "phase6-freshness-smoke-transcript.jsonl" -DEFAULT_STDERR = ROOT / ".epiphany-smoke" / "phase6-freshness-smoke-server.stderr.log" - -FRESHNESS_CODE_REF = { - "path": "app-server/src/codex_message_processor.rs", - "start_line": 4200, - "end_line": 4340, - "symbol": "thread_epiphany_freshness", -} - - -def freshness_patch() -> dict[str, Any]: - return { - "objective": "Expose read-only Epiphany freshness reflection without inventing watcher-driven invalidation.", - "activeSubgoalId": "phase6-freshness-smoke", - "subgoals": [ - { - "id": "phase6-freshness-smoke", - "title": "Live-smoke freshness reflection", - "status": "active", - "summary": "The freshness surface should reflect graph staleness and live retrieval state without mutation.", - } - ], - "graphs": { - "architecture": { - "nodes": [ - { - "id": "freshness-surface", - "title": "Freshness reflection surface", - "purpose": "Expose exact retrieval and graph freshness pressure without becoming a scheduler.", - "code_refs": [FRESHNESS_CODE_REF], - } - ] - }, - "dataflow": {"nodes": []}, - "links": [], - }, - "graphFrontier": { - "active_node_ids": ["freshness-surface"], - "dirty_paths": ["app-server/src/codex_message_processor.rs"], - "open_question_ids": ["q-freshness-gap"], - }, - "graphCheckpoint": { - "checkpoint_id": "ck-freshness-1", - "graph_revision": 1, - "summary": "Freshness smoke checkpoint", - "frontier_node_ids": ["freshness-surface"], - "open_question_ids": ["q-freshness-gap"], - }, - "churn": { - "understanding_status": "ready", - "diff_pressure": "low", - "graph_freshness": "stale", - "unexplained_writes": 0, - }, - } - - -def assert_missing_state_freshness(response: dict[str, Any]) -> None: - require( - response["source"] == "live", - "freshness should report live source for a loaded thread", - ) - require( - "stateRevision" not in response, - "missing-state freshness should not invent a revision", - ) - retrieval = response["retrieval"] - require(retrieval["status"] == "ready", "live retrieval freshness should be available") - require( - retrieval["note"] == "Retrieval catalog is ready.", - "fresh live retrieval should report ready note", - ) - graph = response["graph"] - require(graph["status"] == "missing", "missing Epiphany state should block graph freshness") - require( - graph["note"] == "Epiphany state is missing, so graph freshness cannot be assessed.", - "missing graph freshness should explain itself", - ) - - -def assert_ready_freshness(response: dict[str, Any]) -> None: - require(response["source"] == "live", "ready freshness should report live source") - require(response["stateRevision"] == 1, "freshness should preserve state revision identity") - - retrieval = response["retrieval"] - require( - retrieval["status"] == "ready", - "live retrieval freshness should stay ready for the smoke workspace", - ) - require( - retrieval["semanticAvailable"] is True, - "live retrieval freshness should report semantic availability", - ) - - graph = response["graph"] - require(graph["status"] == "stale", "dirty graph frontier should report stale freshness") - require( - graph["graphFreshness"] == "stale", - "graph freshness should expose the churn hint", - ) - require( - graph["checkpointId"] == "ck-freshness-1", - "graph freshness should expose the checkpoint id", - ) - require(graph["dirtyPathCount"] == 1, "graph freshness should count dirty paths") - require(graph["dirtyPaths"] == ["app-server/src/codex_message_processor.rs"], "graph freshness should expose dirty paths") - require(graph["openQuestionCount"] == 1, "graph freshness should count open questions") - require(graph["openGapCount"] == 0, "graph freshness should count open gaps") - - -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() - 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-freshness-smoke", - "title": "Epiphany Phase 6 Freshness Smoke", - "version": "0.1.0", - }, - "capabilities": {"experimentalApi": True}, - }, - ) - client.send("initialized", expect_response=False) - started = client.send( - "thread/start", - {"cwd": str(ROOT / "epiphany-core"), "ephemeral": True}, - ) - assert started is not None - thread_id = started["thread"]["id"] - - missing_notification_start = len(client.notifications) - missing_response = client.send("thread/epiphany/freshness", {"threadId": thread_id}) - assert missing_response is not None - require( - missing_response["threadId"] == thread_id, - "freshness response should echo thread id", - ) - assert_missing_state_freshness(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": freshness_patch()}, - ) - assert update is not None - require(update["revision"] == 1, "freshness 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/freshness", {"threadId": thread_id}) - assert ready_response is not None - assert_ready_freshness(ready_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=ready_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, - "freshness reflection should not mutate state revision", - ) - - result = { - "threadId": thread_id, - "codexHome": str(codex_home), - "missingRetrievalStatus": missing_response["retrieval"]["status"], - "missingGraphStatus": missing_response["graph"]["status"], - "readyRevision": ready_response["stateRevision"], - "readyRetrievalStatus": ready_response["retrieval"]["status"], - "readyGraphStatus": ready_response["graph"]["status"], - "readyDirtyPathCount": ready_response["graph"]["dirtyPathCount"], - "freshnessNotificationCount": client.count_notifications( - "thread/epiphany/stateUpdated", - start_index=ready_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 freshness reflection 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("--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())