mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
bb83eec825
Extract memories into 2 different crates
1359 lines
52 KiB
Rust
1359 lines
52 KiB
Rust
use chrono::TimeZone;
|
|
use chrono::Utc;
|
|
use codex_config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
|
|
use codex_memories_write::clear_memory_roots_contents;
|
|
use codex_memories_write::ensure_layout;
|
|
use codex_memories_write::memory_root;
|
|
use codex_memories_write::raw_memories_file;
|
|
use codex_memories_write::rebuild_raw_memories_file_from_memories;
|
|
use codex_memories_write::rollout_summaries_dir;
|
|
use codex_memories_write::sync_rollout_summaries_from_memories;
|
|
use codex_protocol::ThreadId;
|
|
use codex_state::Stage1Output;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn memory_root_uses_shared_global_path() {
|
|
let codex_home = AbsolutePathBuf::current_dir().expect("cwd").join("codex");
|
|
assert_eq!(memory_root(&codex_home), codex_home.join("memories"));
|
|
}
|
|
|
|
#[test]
|
|
fn stage_one_output_schema_requires_rollout_slug_and_keeps_it_nullable() {
|
|
let schema = crate::memories::phase1::output_schema();
|
|
let properties = schema
|
|
.get("properties")
|
|
.and_then(Value::as_object)
|
|
.expect("properties object");
|
|
let required = schema
|
|
.get("required")
|
|
.and_then(Value::as_array)
|
|
.expect("required array");
|
|
|
|
let mut required_keys = required
|
|
.iter()
|
|
.map(|key| key.as_str().expect("required key string"))
|
|
.collect::<Vec<_>>();
|
|
required_keys.sort_unstable();
|
|
|
|
assert!(
|
|
properties.contains_key("rollout_slug"),
|
|
"schema should declare rollout_slug"
|
|
);
|
|
|
|
let rollout_slug_type = properties
|
|
.get("rollout_slug")
|
|
.and_then(Value::as_object)
|
|
.and_then(|schema| schema.get("type"))
|
|
.and_then(Value::as_array)
|
|
.expect("rollout_slug type array");
|
|
let mut rollout_slug_types = rollout_slug_type
|
|
.iter()
|
|
.map(|entry| entry.as_str().expect("type entry string"))
|
|
.collect::<Vec<_>>();
|
|
rollout_slug_types.sort_unstable();
|
|
|
|
assert_eq!(
|
|
required_keys,
|
|
vec!["raw_memory", "rollout_slug", "rollout_summary"]
|
|
);
|
|
assert_eq!(rollout_slug_types, vec!["null", "string"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_memory_root_contents_preserves_root_directory() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let root = dir.path().join("memories");
|
|
let nested_dir = root.join("rollout_summaries");
|
|
tokio::fs::create_dir_all(&nested_dir)
|
|
.await
|
|
.expect("create rollout summaries dir");
|
|
tokio::fs::write(root.join("MEMORY.md"), "stale memory index\n")
|
|
.await
|
|
.expect("write memory index");
|
|
tokio::fs::write(nested_dir.join("rollout.md"), "stale rollout\n")
|
|
.await
|
|
.expect("write rollout summary");
|
|
|
|
clear_memory_roots_contents(dir.path())
|
|
.await
|
|
.expect("clear memory root contents");
|
|
|
|
assert!(
|
|
tokio::fs::try_exists(&root)
|
|
.await
|
|
.expect("check memory root existence"),
|
|
"memory root should still exist after clearing contents"
|
|
);
|
|
let mut entries = tokio::fs::read_dir(&root)
|
|
.await
|
|
.expect("read memory root after clear");
|
|
assert!(
|
|
entries
|
|
.next_entry()
|
|
.await
|
|
.expect("read next entry")
|
|
.is_none(),
|
|
"memory root should be empty after clearing contents"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn clear_memory_root_contents_rejects_symlinked_root() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let target = dir.path().join("outside");
|
|
tokio::fs::create_dir_all(&target)
|
|
.await
|
|
.expect("create symlink target dir");
|
|
let target_file = target.join("keep.txt");
|
|
tokio::fs::write(&target_file, "keep\n")
|
|
.await
|
|
.expect("write target file");
|
|
|
|
let root = dir.path().join("memories");
|
|
std::os::unix::fs::symlink(&target, &root).expect("create memory root symlink");
|
|
|
|
let err = clear_memory_roots_contents(dir.path())
|
|
.await
|
|
.expect_err("symlinked memory root should be rejected");
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(
|
|
tokio::fs::try_exists(&target_file)
|
|
.await
|
|
.expect("check target file existence"),
|
|
"rejecting a symlinked memory root should not delete the symlink target"
|
|
);
|
|
}
|
|
|
|
struct ConsolidatedOutputPaths {
|
|
memory_index: PathBuf,
|
|
memory_summary: PathBuf,
|
|
skill: PathBuf,
|
|
}
|
|
|
|
async fn write_consolidated_outputs(root: &Path) -> ConsolidatedOutputPaths {
|
|
let paths = ConsolidatedOutputPaths {
|
|
memory_index: root.join("MEMORY.md"),
|
|
memory_summary: root.join("memory_summary.md"),
|
|
skill: root.join("skills/demo/SKILL.md"),
|
|
};
|
|
|
|
tokio::fs::write(&paths.memory_index, "consolidated memory index\n")
|
|
.await
|
|
.expect("write memory index");
|
|
tokio::fs::write(&paths.memory_summary, "consolidated memory summary\n")
|
|
.await
|
|
.expect("write memory summary");
|
|
tokio::fs::create_dir_all(paths.skill.parent().expect("skill parent"))
|
|
.await
|
|
.expect("create skill dir");
|
|
tokio::fs::write(&paths.skill, "consolidated skill\n")
|
|
.await
|
|
.expect("write skill");
|
|
|
|
paths
|
|
}
|
|
|
|
async fn assert_consolidated_outputs_exist(paths: &ConsolidatedOutputPaths, context: &str) {
|
|
assert!(
|
|
tokio::fs::try_exists(&paths.memory_index)
|
|
.await
|
|
.expect("check memory index existence"),
|
|
"{context} should leave MEMORY.md untouched"
|
|
);
|
|
assert!(
|
|
tokio::fs::try_exists(&paths.memory_summary)
|
|
.await
|
|
.expect("check memory summary existence"),
|
|
"{context} should leave memory_summary.md untouched"
|
|
);
|
|
assert!(
|
|
tokio::fs::try_exists(&paths.skill)
|
|
.await
|
|
.expect("check skill existence"),
|
|
"{context} should leave skills untouched"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let root = dir.path().join("memory");
|
|
ensure_layout(&root).await.expect("ensure layout");
|
|
|
|
let keep_id = ThreadId::default().to_string();
|
|
let drop_id = ThreadId::default().to_string();
|
|
let keep_path = rollout_summaries_dir(&root).join(format!("{keep_id}.md"));
|
|
let drop_path = rollout_summaries_dir(&root).join(format!("{drop_id}.md"));
|
|
tokio::fs::write(&keep_path, "keep")
|
|
.await
|
|
.expect("write keep");
|
|
tokio::fs::write(&drop_path, "drop")
|
|
.await
|
|
.expect("write drop");
|
|
|
|
let memories = vec![Stage1Output {
|
|
thread_id: ThreadId::try_from(keep_id.clone()).expect("thread id"),
|
|
source_updated_at: Utc.timestamp_opt(100, 0).single().expect("timestamp"),
|
|
raw_memory: "raw memory".to_string(),
|
|
rollout_summary: "short summary".to_string(),
|
|
rollout_slug: None,
|
|
rollout_path: PathBuf::from("/tmp/rollout-100.jsonl"),
|
|
cwd: PathBuf::from("/tmp/workspace"),
|
|
git_branch: None,
|
|
generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"),
|
|
}];
|
|
|
|
sync_rollout_summaries_from_memories(
|
|
&root,
|
|
&memories,
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("sync rollout summaries");
|
|
rebuild_raw_memories_file_from_memories(
|
|
&root,
|
|
&memories,
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("rebuild raw memories");
|
|
|
|
assert!(
|
|
!tokio::fs::try_exists(&keep_path)
|
|
.await
|
|
.expect("check stale keep path"),
|
|
"sync should prune stale filename that used thread id only"
|
|
);
|
|
assert!(
|
|
!tokio::fs::try_exists(&drop_path)
|
|
.await
|
|
.expect("check stale drop path"),
|
|
"sync should prune stale filename for dropped thread"
|
|
);
|
|
|
|
let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root))
|
|
.await
|
|
.expect("open rollout summaries dir");
|
|
let mut files = Vec::new();
|
|
while let Some(entry) = dir.next_entry().await.expect("read dir entry") {
|
|
files.push(entry.file_name().to_string_lossy().to_string());
|
|
}
|
|
files.sort_unstable();
|
|
assert_eq!(files.len(), 1);
|
|
let canonical_rollout_summary_file = &files[0];
|
|
|
|
let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root))
|
|
.await
|
|
.expect("read raw memories");
|
|
assert!(raw_memories.contains("raw memory"));
|
|
assert!(raw_memories.contains(&keep_id));
|
|
assert!(raw_memories.contains("cwd: /tmp/workspace"));
|
|
assert!(raw_memories.contains("rollout_path: /tmp/rollout-100.jsonl"));
|
|
assert!(raw_memories.contains(&format!(
|
|
"rollout_summary_file: {canonical_rollout_summary_file}"
|
|
)));
|
|
let thread_header = format!("## Thread `{keep_id}`");
|
|
let thread_pos = raw_memories
|
|
.find(&thread_header)
|
|
.expect("thread header should exist");
|
|
let updated_pos = raw_memories[thread_pos..]
|
|
.find("updated_at: ")
|
|
.map(|offset| thread_pos + offset)
|
|
.expect("updated_at should exist after thread header");
|
|
let cwd_pos = raw_memories[thread_pos..]
|
|
.find("cwd: /tmp/workspace")
|
|
.map(|offset| thread_pos + offset)
|
|
.expect("cwd should exist after thread header");
|
|
let rollout_path_pos = raw_memories[thread_pos..]
|
|
.find("rollout_path: /tmp/rollout-100.jsonl")
|
|
.map(|offset| thread_pos + offset)
|
|
.expect("rollout_path should exist after thread header");
|
|
let file_pos = raw_memories[thread_pos..]
|
|
.find(&format!(
|
|
"rollout_summary_file: {canonical_rollout_summary_file}"
|
|
))
|
|
.map(|offset| thread_pos + offset)
|
|
.expect("rollout_summary_file should exist after thread header");
|
|
assert!(thread_pos < updated_pos);
|
|
assert!(updated_pos < cwd_pos);
|
|
assert!(cwd_pos < rollout_path_pos);
|
|
assert!(rollout_path_pos < file_pos);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_empty_inputs_preserves_consolidated_outputs() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let root = dir.path().join("memory");
|
|
ensure_layout(&root).await.expect("ensure layout");
|
|
|
|
let stale_rollout_summary_path = rollout_summaries_dir(&root).join("stale.md");
|
|
tokio::fs::write(&stale_rollout_summary_path, "stale summary\n")
|
|
.await
|
|
.expect("write stale rollout summary");
|
|
let outputs = write_consolidated_outputs(&root).await;
|
|
|
|
sync_rollout_summaries_from_memories(
|
|
&root,
|
|
&[],
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("sync empty rollout summaries");
|
|
rebuild_raw_memories_file_from_memories(
|
|
&root,
|
|
&[],
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("rebuild empty raw memories");
|
|
|
|
assert!(
|
|
!tokio::fs::try_exists(&stale_rollout_summary_path)
|
|
.await
|
|
.expect("check stale rollout summary existence"),
|
|
"empty sync should prune stale rollout summaries"
|
|
);
|
|
let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root))
|
|
.await
|
|
.expect("read raw memories");
|
|
assert_eq!(raw_memories, "# Raw Memories\n\nNo raw memories yet.\n");
|
|
assert_consolidated_outputs_exist(&outputs, "empty sync").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let root = dir.path().join("memory");
|
|
ensure_layout(&root).await.expect("ensure layout");
|
|
|
|
let thread_id = ThreadId::new();
|
|
let stale_unslugged_path = rollout_summaries_dir(&root).join(format!("{thread_id}.md"));
|
|
let stale_old_slug_path =
|
|
rollout_summaries_dir(&root).join(format!("{thread_id}--old-slug.md"));
|
|
tokio::fs::write(&stale_unslugged_path, "stale")
|
|
.await
|
|
.expect("write stale unslugged file");
|
|
tokio::fs::write(&stale_old_slug_path, "stale")
|
|
.await
|
|
.expect("write stale old-slug file");
|
|
|
|
let memories = vec![Stage1Output {
|
|
thread_id,
|
|
source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"),
|
|
raw_memory: "raw memory".to_string(),
|
|
rollout_summary: "short summary".to_string(),
|
|
rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()),
|
|
rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"),
|
|
cwd: PathBuf::from("/tmp/workspace"),
|
|
git_branch: Some("feature/memory-branch".to_string()),
|
|
generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"),
|
|
}];
|
|
|
|
sync_rollout_summaries_from_memories(
|
|
&root,
|
|
&memories,
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("sync rollout summaries");
|
|
|
|
let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root))
|
|
.await
|
|
.expect("open rollout summaries dir");
|
|
let mut files = Vec::new();
|
|
while let Some(entry) = dir.next_entry().await.expect("read dir entry") {
|
|
files.push(entry.file_name().to_string_lossy().to_string());
|
|
}
|
|
files.sort_unstable();
|
|
|
|
assert_eq!(files.len(), 1);
|
|
let file_name = &files[0];
|
|
let stem = file_name
|
|
.strip_suffix(".md")
|
|
.expect("rollout summary file should end with .md");
|
|
let (prefix, slug) = stem
|
|
.rsplit_once('-')
|
|
.expect("rollout summary filename should include slug");
|
|
let (timestamp, short_hash) = prefix
|
|
.rsplit_once('-')
|
|
.expect("rollout summary filename should include short hash");
|
|
|
|
assert_eq!(timestamp.len(), 19, "timestamp should be second precision");
|
|
let parsed_timestamp = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%S");
|
|
assert!(
|
|
parsed_timestamp.is_ok(),
|
|
"timestamp should use YYYY-MM-DDThh-mm-ss"
|
|
);
|
|
assert_eq!(short_hash.len(), 4, "short hash should be exactly 4 chars");
|
|
assert!(
|
|
short_hash.chars().all(|ch| ch.is_ascii_alphanumeric()),
|
|
"short hash should use only alphanumeric chars"
|
|
);
|
|
assert!(slug.len() <= 60, "slug should be capped at 60 chars");
|
|
assert!(
|
|
slug.chars()
|
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'),
|
|
"slug should be file-safe lowercase ascii with underscores"
|
|
);
|
|
|
|
let summary = tokio::fs::read_to_string(rollout_summaries_dir(&root).join(file_name))
|
|
.await
|
|
.expect("read rollout summary");
|
|
assert!(summary.contains(&format!("thread_id: {thread_id}")));
|
|
assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl"));
|
|
assert!(summary.contains("git_branch: feature/memory-branch"));
|
|
assert!(
|
|
!tokio::fs::try_exists(&stale_unslugged_path)
|
|
.await
|
|
.expect("check stale unslugged path"),
|
|
"slugged sync should prune stale unslugged filename for same thread"
|
|
);
|
|
assert!(
|
|
!tokio::fs::try_exists(&stale_old_slug_path)
|
|
.await
|
|
.expect("check stale old slug path"),
|
|
"slugged sync should prune stale slugged filename for same thread"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_raw_memories_file_adds_canonical_rollout_summary_file_header() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let root = dir.path().join("memory");
|
|
ensure_layout(&root).await.expect("ensure layout");
|
|
|
|
let thread_id =
|
|
ThreadId::try_from("0194f5a6-89ab-7cde-8123-456789abcdef").expect("valid thread id");
|
|
let memories = vec![Stage1Output {
|
|
thread_id,
|
|
source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"),
|
|
raw_memory: "\
|
|
---
|
|
description: Added a migration test
|
|
keywords: codex-state, migrations
|
|
---
|
|
### Task 1: migration-test
|
|
task: add-migration-test
|
|
task_group: codex-state
|
|
task_outcome: success
|
|
- Added regression coverage for migration uniqueness.
|
|
|
|
### Task 2: validate-migration
|
|
task: validate-migration-ordering
|
|
task_group: codex-state
|
|
task_outcome: success
|
|
- Confirmed no ordering regressions."
|
|
.to_string(),
|
|
rollout_summary: "short summary".to_string(),
|
|
rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()),
|
|
rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"),
|
|
cwd: PathBuf::from("/tmp/workspace"),
|
|
git_branch: None,
|
|
generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"),
|
|
}];
|
|
|
|
sync_rollout_summaries_from_memories(
|
|
&root,
|
|
&memories,
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("sync rollout summaries");
|
|
rebuild_raw_memories_file_from_memories(
|
|
&root,
|
|
&memories,
|
|
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
|
)
|
|
.await
|
|
.expect("rebuild raw memories");
|
|
|
|
let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root))
|
|
.await
|
|
.expect("open rollout summaries dir");
|
|
let mut files = Vec::new();
|
|
while let Some(entry) = dir.next_entry().await.expect("read dir entry") {
|
|
files.push(entry.file_name().to_string_lossy().to_string());
|
|
}
|
|
files.sort_unstable();
|
|
assert_eq!(files.len(), 1);
|
|
let canonical_rollout_summary_file = &files[0];
|
|
|
|
let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root))
|
|
.await
|
|
.expect("read raw memories");
|
|
let summary = tokio::fs::read_to_string(
|
|
rollout_summaries_dir(&root).join(canonical_rollout_summary_file),
|
|
)
|
|
.await
|
|
.expect("read rollout summary");
|
|
assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl"));
|
|
assert!(raw_memories.contains(&format!(
|
|
"rollout_summary_file: {canonical_rollout_summary_file}"
|
|
)));
|
|
assert!(raw_memories.contains("description: Added a migration test"));
|
|
assert!(raw_memories.contains("### Task 1: migration-test"));
|
|
assert!(raw_memories.contains("task: add-migration-test"));
|
|
assert!(raw_memories.contains("task_group: codex-state"));
|
|
assert!(raw_memories.contains("task_outcome: success"));
|
|
}
|
|
|
|
mod phase2 {
|
|
use crate::ThreadManager;
|
|
use crate::agent::AgentControl;
|
|
use crate::config::Config;
|
|
use crate::config::test_config;
|
|
use crate::memories::phase2;
|
|
use crate::session::session::Session;
|
|
use crate::session::tests::make_session_and_context;
|
|
use chrono::Duration as ChronoDuration;
|
|
use chrono::Utc;
|
|
use codex_config::Constrained;
|
|
use codex_config::types::McpServerConfig;
|
|
use codex_features::Feature;
|
|
use codex_login::CodexAuth;
|
|
use codex_memories_write::memory_root;
|
|
use codex_memories_write::raw_memories_file;
|
|
use codex_memories_write::rebuild_raw_memories_file_from_memories;
|
|
use codex_memories_write::rollout_summaries_dir;
|
|
use codex_memories_write::sync_rollout_summaries_from_memories;
|
|
use codex_memories_write::workspace::prepare_memory_workspace;
|
|
use codex_protocol::AgentPath;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_state::Phase2JobClaimOutcome;
|
|
use codex_state::Stage1Output;
|
|
use codex_state::ThreadMetadataBuilder;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
|
|
fn stage1_output_with_source_updated_at(source_updated_at: i64) -> Stage1Output {
|
|
Stage1Output {
|
|
thread_id: ThreadId::new(),
|
|
source_updated_at: chrono::DateTime::<Utc>::from_timestamp(source_updated_at, 0)
|
|
.expect("valid source_updated_at timestamp"),
|
|
raw_memory: "raw memory".to_string(),
|
|
rollout_summary: "rollout summary".to_string(),
|
|
rollout_slug: None,
|
|
rollout_path: PathBuf::from("/tmp/rollout-summary.jsonl"),
|
|
cwd: PathBuf::from("/tmp/workspace"),
|
|
git_branch: None,
|
|
generated_at: chrono::DateTime::<Utc>::from_timestamp(source_updated_at + 1, 0)
|
|
.expect("valid generated_at timestamp"),
|
|
}
|
|
}
|
|
|
|
struct DispatchHarness {
|
|
_codex_home: TempDir,
|
|
config: Arc<Config>,
|
|
session: Arc<Session>,
|
|
manager: ThreadManager,
|
|
state_db: Arc<codex_state::StateRuntime>,
|
|
}
|
|
|
|
impl DispatchHarness {
|
|
async fn new() -> Self {
|
|
Self::new_with_config(|_| {}).await
|
|
}
|
|
|
|
async fn new_with_config(configure: impl FnOnce(&mut Config)) -> Self {
|
|
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
|
let mut config = test_config().await;
|
|
config.codex_home =
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
|
|
.expect("codex home is absolute");
|
|
config.cwd = config.codex_home.clone();
|
|
let permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::unrestricted(),
|
|
NetworkSandboxPolicy::Enabled,
|
|
);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("permissions are configurable");
|
|
configure(&mut config);
|
|
let config = Arc::new(config);
|
|
|
|
let state_db = codex_state::StateRuntime::init(
|
|
config.codex_home.to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("initialize state db");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let (mut session, _turn_context) = make_session_and_context().await;
|
|
session.services.state_db = Some(Arc::clone(&state_db));
|
|
session.services.agent_control = manager.agent_control();
|
|
|
|
Self {
|
|
_codex_home: codex_home,
|
|
config,
|
|
session: Arc::new(session),
|
|
manager,
|
|
state_db,
|
|
}
|
|
}
|
|
|
|
async fn seed_stage1_output(&self, source_updated_at: i64) -> ThreadId {
|
|
let thread_id = ThreadId::new();
|
|
let mut metadata_builder = ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
self.config
|
|
.codex_home
|
|
.join(format!("rollout-{thread_id}.jsonl"))
|
|
.to_path_buf(),
|
|
Utc::now(),
|
|
SessionSource::Cli,
|
|
);
|
|
metadata_builder.cwd = self.config.cwd.to_path_buf();
|
|
metadata_builder.model_provider = Some(self.config.model_provider_id.clone());
|
|
let metadata = metadata_builder.build(&self.config.model_provider_id);
|
|
|
|
self.state_db
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("upsert thread metadata");
|
|
|
|
let claim = self
|
|
.state_db
|
|
.try_claim_stage1_job(
|
|
thread_id,
|
|
self.session.conversation_id,
|
|
source_updated_at,
|
|
/*lease_seconds*/ 3_600,
|
|
/*max_running_jobs*/ 64,
|
|
)
|
|
.await
|
|
.expect("claim stage-1 job");
|
|
let ownership_token = match claim {
|
|
codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
|
|
other => panic!("unexpected stage-1 claim outcome: {other:?}"),
|
|
};
|
|
assert!(
|
|
self.state_db
|
|
.mark_stage1_job_succeeded(
|
|
thread_id,
|
|
&ownership_token,
|
|
source_updated_at,
|
|
"raw memory",
|
|
"rollout summary",
|
|
/*rollout_slug*/ None,
|
|
)
|
|
.await
|
|
.expect("mark stage-1 success"),
|
|
"stage-1 success should enqueue global consolidation"
|
|
);
|
|
thread_id
|
|
}
|
|
|
|
async fn shutdown_threads(&self) {
|
|
let report = self
|
|
.manager
|
|
.shutdown_all_threads_bounded(std::time::Duration::from_secs(10))
|
|
.await;
|
|
assert!(report.submit_failed.is_empty());
|
|
assert!(report.timed_out.is_empty());
|
|
}
|
|
|
|
fn user_input_ops_count(&self) -> usize {
|
|
self.manager
|
|
.captured_ops()
|
|
.into_iter()
|
|
.filter(|(_, op)| matches!(op, Op::UserInput { .. }))
|
|
.count()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn completion_watermark_never_regresses_below_claimed_input_watermark() {
|
|
let stage1_output = stage1_output_with_source_updated_at(/*source_updated_at*/ 123);
|
|
|
|
let completion = phase2::get_watermark(/*claimed_watermark*/ 1_000, &[stage1_output]);
|
|
pretty_assertions::assert_eq!(completion, 1_000);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_watermark_uses_claimed_watermark_when_there_are_no_memories() {
|
|
let completion = phase2::get_watermark(/*claimed_watermark*/ 777, &[]);
|
|
pretty_assertions::assert_eq!(completion, 777);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_watermark_uses_latest_memory_timestamp_when_it_is_newer() {
|
|
let older = stage1_output_with_source_updated_at(/*source_updated_at*/ 123);
|
|
let newer = stage1_output_with_source_updated_at(/*source_updated_at*/ 456);
|
|
|
|
let completion = phase2::get_watermark(/*claimed_watermark*/ 200, &[older, newer]);
|
|
pretty_assertions::assert_eq!(completion, 456);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_skips_when_memory_workspace_is_not_dirty() {
|
|
let harness = DispatchHarness::new().await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
rebuild_raw_memories_file_from_memories(
|
|
&root,
|
|
&[],
|
|
/*max_raw_memories_for_consolidation*/ 0,
|
|
)
|
|
.await
|
|
.expect("write empty raw memories baseline");
|
|
let outputs = super::write_consolidated_outputs(&root).await;
|
|
prepare_memory_workspace(&root)
|
|
.await
|
|
.expect("commit empty memory workspace as baseline");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
super::assert_consolidated_outputs_exist(&outputs, "clean no-input phase2").await;
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_uses_git_dirty_state_without_db_dirty_watermark() {
|
|
let harness = DispatchHarness::new().await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
rebuild_raw_memories_file_from_memories(
|
|
&root,
|
|
&[],
|
|
/*max_raw_memories_for_consolidation*/ 0,
|
|
)
|
|
.await
|
|
.expect("write empty raw memories baseline");
|
|
prepare_memory_workspace(&root)
|
|
.await
|
|
.expect("commit empty memory workspace as baseline");
|
|
let extension_resource = root
|
|
.join("extensions")
|
|
.join("chronicle")
|
|
.join("resources")
|
|
.join("2026-04-22T12-00-00-abcd-10min-memory.md");
|
|
tokio::fs::create_dir_all(
|
|
extension_resource
|
|
.parent()
|
|
.expect("extension resource parent"),
|
|
)
|
|
.await
|
|
.expect("create extension resource dir");
|
|
tokio::fs::write(
|
|
root.join("extensions/chronicle/instructions.md"),
|
|
"instructions\n",
|
|
)
|
|
.await
|
|
.expect("write extension instructions");
|
|
tokio::fs::write(&extension_resource, "extension memory\n")
|
|
.await
|
|
.expect("write extension resource");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1);
|
|
let workspace_diff = tokio::fs::read_to_string(root.join("phase2_workspace_diff.md"))
|
|
.await
|
|
.expect("read workspace diff");
|
|
assert!(
|
|
workspace_diff.contains("- A extensions/chronicle/instructions.md"),
|
|
"git-only extension instructions should dirty phase2: {workspace_diff}"
|
|
);
|
|
assert!(
|
|
workspace_diff.contains("- A extensions/chronicle/resources/"),
|
|
"git-only extension resource should dirty phase2: {workspace_diff}"
|
|
);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 1);
|
|
|
|
harness.shutdown_threads().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_skips_when_global_job_is_already_running() {
|
|
let harness = DispatchHarness::new().await;
|
|
harness
|
|
.state_db
|
|
.enqueue_global_consolidation(/*input_watermark*/ 123)
|
|
.await
|
|
.expect("enqueue global consolidation");
|
|
let claimed = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim running global lock");
|
|
assert!(
|
|
matches!(claimed, Phase2JobClaimOutcome::Claimed { .. }),
|
|
"precondition should claim the running lock"
|
|
);
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
let running_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim while lock is still running");
|
|
pretty_assertions::assert_eq!(running_claim, Phase2JobClaimOutcome::SkippedRunning);
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_reclaims_stale_global_lock_and_starts_consolidation() {
|
|
let harness = DispatchHarness::new_with_config(|config| {
|
|
let server: McpServerConfig =
|
|
toml::from_str("command = \"docs-server\"").expect("deserialize MCP server");
|
|
config
|
|
.mcp_servers
|
|
.set(HashMap::from([("docs".to_string(), server)]))
|
|
.expect("parent MCP servers are configurable");
|
|
config
|
|
.features
|
|
.enable(Feature::Apps)
|
|
.expect("apps feature is configurable");
|
|
config
|
|
.features
|
|
.enable(Feature::Plugins)
|
|
.expect("plugins feature is configurable");
|
|
config.include_apps_instructions = true;
|
|
})
|
|
.await;
|
|
harness.seed_stage1_output(Utc::now().timestamp()).await;
|
|
|
|
let stale_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 0)
|
|
.await
|
|
.expect("claim stale global lock");
|
|
assert!(
|
|
matches!(stale_claim, Phase2JobClaimOutcome::Claimed { .. }),
|
|
"stale lock precondition should be claimed"
|
|
);
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
let post_dispatch_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim after stale lock dispatch");
|
|
assert!(
|
|
matches!(
|
|
post_dispatch_claim,
|
|
Phase2JobClaimOutcome::SkippedRunning
|
|
| Phase2JobClaimOutcome::SkippedRetryUnavailable
|
|
),
|
|
"stale-lock dispatch should either keep the reclaimed job running or finish it before re-claim"
|
|
);
|
|
|
|
let user_input_ops = harness.user_input_ops_count();
|
|
pretty_assertions::assert_eq!(user_input_ops, 1);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 1);
|
|
let thread_id = thread_ids[0];
|
|
let subagent = harness
|
|
.manager
|
|
.get_thread(thread_id)
|
|
.await
|
|
.expect("get consolidation thread");
|
|
let config_snapshot = subagent.config_snapshot().await;
|
|
pretty_assertions::assert_eq!(config_snapshot.approval_policy, AskForApproval::Never);
|
|
assert!(config_snapshot.ephemeral);
|
|
pretty_assertions::assert_eq!(
|
|
config_snapshot.cwd.as_path(),
|
|
memory_root(&harness.config.codex_home).as_path()
|
|
);
|
|
match &config_snapshot.sandbox_policy {
|
|
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
|
|
assert!(!*network_access);
|
|
let effective_writable_roots: Vec<_> = config_snapshot
|
|
.sandbox_policy
|
|
.get_writable_roots_with_cwd(config_snapshot.cwd.as_path())
|
|
.into_iter()
|
|
.map(|root| root.root)
|
|
.collect();
|
|
pretty_assertions::assert_eq!(
|
|
effective_writable_roots.as_slice(),
|
|
[memory_root(&harness.config.codex_home)],
|
|
"consolidation subagent should only be able to write the memory root"
|
|
);
|
|
}
|
|
other => panic!("unexpected sandbox policy: {other:?}"),
|
|
}
|
|
pretty_assertions::assert_eq!(
|
|
config_snapshot.session_source.get_agent_path(),
|
|
Some(AgentPath::morpheus())
|
|
);
|
|
assert!(
|
|
harness
|
|
.session
|
|
.services
|
|
.agent_control
|
|
.get_agent_metadata(thread_id)
|
|
.is_none(),
|
|
"memory consolidation should not be registered in the root collab agent registry"
|
|
);
|
|
let turn_context = subagent.codex.session.new_default_turn().await;
|
|
let file_system_sandbox_policy = turn_context.file_system_sandbox_policy();
|
|
let legacy_file_system_sandbox_policy =
|
|
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
|
|
&config_snapshot.sandbox_policy,
|
|
config_snapshot.cwd.as_path(),
|
|
);
|
|
assert!(
|
|
file_system_sandbox_policy.is_semantically_equivalent_to(
|
|
&legacy_file_system_sandbox_policy,
|
|
config_snapshot.cwd.as_path(),
|
|
),
|
|
"consolidation subagent split filesystem policy should match the memory-root legacy policy"
|
|
);
|
|
assert!(
|
|
file_system_sandbox_policy.can_write_path_with_cwd(
|
|
memory_root(&harness.config.codex_home).as_path(),
|
|
config_snapshot.cwd.as_path(),
|
|
),
|
|
"consolidation subagent should be able to write the memory root"
|
|
);
|
|
assert!(
|
|
!file_system_sandbox_policy.can_write_path_with_cwd(
|
|
harness.config.codex_home.join("config.toml").as_path(),
|
|
config_snapshot.cwd.as_path(),
|
|
),
|
|
"consolidation subagent should not inherit codex_home write access"
|
|
);
|
|
pretty_assertions::assert_eq!(
|
|
turn_context.network_sandbox_policy(),
|
|
NetworkSandboxPolicy::Restricted,
|
|
"consolidation subagent split network policy should preserve no-network sandboxing"
|
|
);
|
|
assert!(
|
|
!turn_context.features.enabled(Feature::MemoryTool),
|
|
"consolidation subagent should have the memories feature disabled"
|
|
);
|
|
assert!(
|
|
turn_context.config.mcp_servers.get().is_empty(),
|
|
"consolidation subagent should not inherit configured MCP servers"
|
|
);
|
|
assert!(
|
|
!subagent
|
|
.codex
|
|
.session
|
|
.services
|
|
.mcp_connection_manager
|
|
.read()
|
|
.await
|
|
.has_servers(),
|
|
"consolidation subagent should not initialize MCP servers"
|
|
);
|
|
assert!(
|
|
!turn_context.features.enabled(Feature::Apps),
|
|
"consolidation subagent should not expose app-backed MCP"
|
|
);
|
|
assert!(
|
|
!turn_context.features.enabled(Feature::Plugins),
|
|
"consolidation subagent should not expose plugin-backed MCP"
|
|
);
|
|
assert!(
|
|
!turn_context.config.include_apps_instructions,
|
|
"consolidation subagent should not include apps instructions"
|
|
);
|
|
assert!(
|
|
!turn_context.config.memories.generate_memories,
|
|
"consolidation subagent should not generate memories"
|
|
);
|
|
assert!(
|
|
!turn_context.config.memories.use_memories,
|
|
"consolidation subagent should not read memories"
|
|
);
|
|
assert!(
|
|
subagent.rollout_path().is_none(),
|
|
"ephemeral consolidation thread should not materialize a rollout"
|
|
);
|
|
let memory_mode = harness
|
|
.state_db
|
|
.get_thread_memory_mode(thread_id)
|
|
.await
|
|
.expect("read consolidation thread memory mode");
|
|
pretty_assertions::assert_eq!(memory_mode, None);
|
|
|
|
harness.shutdown_threads().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_with_empty_stage1_outputs_spawns_for_workspace_changes() {
|
|
let harness = DispatchHarness::new().await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
let summaries_dir = rollout_summaries_dir(&root);
|
|
tokio::fs::create_dir_all(&summaries_dir)
|
|
.await
|
|
.expect("create rollout summaries dir");
|
|
|
|
let stale_summary_path = summaries_dir.join(format!("{}.md", ThreadId::new()));
|
|
tokio::fs::write(&stale_summary_path, "stale summary\n")
|
|
.await
|
|
.expect("write stale rollout summary");
|
|
let raw_memories_path = raw_memories_file(&root);
|
|
tokio::fs::write(&raw_memories_path, "stale raw memories\n")
|
|
.await
|
|
.expect("write stale raw memories");
|
|
let outputs = super::write_consolidated_outputs(&root).await;
|
|
|
|
harness
|
|
.state_db
|
|
.enqueue_global_consolidation(/*input_watermark*/ 999)
|
|
.await
|
|
.expect("enqueue global consolidation");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
assert!(
|
|
!tokio::fs::try_exists(&stale_summary_path)
|
|
.await
|
|
.expect("check stale summary existence"),
|
|
"empty consolidation should prune stale rollout summary files"
|
|
);
|
|
let raw_memories = tokio::fs::read_to_string(&raw_memories_path)
|
|
.await
|
|
.expect("read rebuilt raw memories");
|
|
pretty_assertions::assert_eq!(raw_memories, "# Raw Memories\n\nNo raw memories yet.\n");
|
|
super::assert_consolidated_outputs_exist(&outputs, "empty consolidation").await;
|
|
let next_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global job after empty consolidation dispatch");
|
|
pretty_assertions::assert_eq!(next_claim, Phase2JobClaimOutcome::SkippedRunning);
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 1);
|
|
|
|
harness.shutdown_threads().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_with_empty_selected_inputs_preserves_consolidated_outputs() {
|
|
let harness = DispatchHarness::new().await;
|
|
let source_updated_at = Utc::now().timestamp();
|
|
let thread_id = harness.seed_stage1_output(source_updated_at).await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
let selected = harness
|
|
.state_db
|
|
.get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30)
|
|
.await
|
|
.expect("load phase2 input selection");
|
|
sync_rollout_summaries_from_memories(&root, &selected, selected.len())
|
|
.await
|
|
.expect("sync selected rollout summaries");
|
|
rebuild_raw_memories_file_from_memories(&root, &selected, selected.len())
|
|
.await
|
|
.expect("sync selected raw memories");
|
|
let outputs = super::write_consolidated_outputs(&root).await;
|
|
prepare_memory_workspace(&root)
|
|
.await
|
|
.expect("commit current memory workspace as baseline");
|
|
|
|
let claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global phase2 job");
|
|
let Phase2JobClaimOutcome::Claimed {
|
|
ownership_token, ..
|
|
} = claim
|
|
else {
|
|
panic!("unexpected phase2 claim outcome: {claim:?}");
|
|
};
|
|
assert!(
|
|
harness
|
|
.state_db
|
|
.mark_global_phase2_job_succeeded(&ownership_token, source_updated_at, &selected)
|
|
.await
|
|
.expect("mark phase2 succeeded"),
|
|
"phase2 success should update selected baseline"
|
|
);
|
|
assert!(
|
|
harness
|
|
.state_db
|
|
.mark_thread_memory_mode_polluted(thread_id)
|
|
.await
|
|
.expect("mark thread polluted"),
|
|
"polluted selected thread should enqueue phase2 forgetting"
|
|
);
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1);
|
|
super::assert_consolidated_outputs_exist(&outputs, "empty selected phase2").await;
|
|
let workspace_diff = tokio::fs::read_to_string(root.join("phase2_workspace_diff.md"))
|
|
.await
|
|
.expect("read workspace diff");
|
|
assert!(
|
|
workspace_diff.contains("- D rollout_summaries/"),
|
|
"empty selected phase2 should surface deleted rollout summaries: {workspace_diff}"
|
|
);
|
|
assert!(
|
|
!workspace_diff.contains("- D MEMORY.md"),
|
|
"empty selected phase2 should not delete MEMORY.md directly: {workspace_diff}"
|
|
);
|
|
assert!(
|
|
!workspace_diff.contains("- D memory_summary.md"),
|
|
"empty selected phase2 should not delete memory_summary.md directly: {workspace_diff}"
|
|
);
|
|
assert!(
|
|
!workspace_diff.contains("- D skills/demo/SKILL.md"),
|
|
"empty selected phase2 should not delete skills directly: {workspace_diff}"
|
|
);
|
|
|
|
harness.shutdown_threads().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_with_clean_workspace_rebuilds_selected_phase2_baseline() {
|
|
let harness = DispatchHarness::new().await;
|
|
let source_updated_at = (Utc::now() - ChronoDuration::days(1)).timestamp();
|
|
let thread_id = harness.seed_stage1_output(source_updated_at).await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
let selected = harness
|
|
.state_db
|
|
.get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30)
|
|
.await
|
|
.expect("load phase2 input selection");
|
|
|
|
sync_rollout_summaries_from_memories(&root, &selected, selected.len())
|
|
.await
|
|
.expect("sync selected rollout summaries");
|
|
rebuild_raw_memories_file_from_memories(&root, &selected, selected.len())
|
|
.await
|
|
.expect("sync selected raw memories");
|
|
prepare_memory_workspace(&root)
|
|
.await
|
|
.expect("commit current memory workspace as baseline");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
let pruned = harness
|
|
.state_db
|
|
.prune_stage1_outputs_for_retention(/*max_unused_days*/ 0, /*limit*/ 10)
|
|
.await
|
|
.expect("prune stage1 outputs after clean phase2");
|
|
pretty_assertions::assert_eq!(pruned, 0);
|
|
|
|
let selected = harness
|
|
.state_db
|
|
.get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30)
|
|
.await
|
|
.expect("load phase2 input selection after clean workspace success");
|
|
pretty_assertions::assert_eq!(selected.len(), 1);
|
|
pretty_assertions::assert_eq!(selected[0].thread_id, thread_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_marks_job_for_retry_when_sandbox_policy_cannot_be_overridden() {
|
|
let harness = DispatchHarness::new().await;
|
|
harness
|
|
.state_db
|
|
.enqueue_global_consolidation(/*input_watermark*/ 99)
|
|
.await
|
|
.expect("enqueue global consolidation");
|
|
let mut constrained_config = harness.config.as_ref().clone();
|
|
constrained_config.permissions.permission_profile = Constrained::allow_only(
|
|
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
|
);
|
|
|
|
phase2::run(&harness.session, Arc::new(constrained_config)).await;
|
|
|
|
let retry_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global job after sandbox policy failure");
|
|
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable);
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_marks_job_for_retry_when_syncing_artifacts_fails() {
|
|
let harness = DispatchHarness::new().await;
|
|
harness.seed_stage1_output(/*source_updated_at*/ 100).await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
tokio::fs::write(&root, "not a directory")
|
|
.await
|
|
.expect("create file at memory root");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
let retry_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global job after sync failure");
|
|
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable);
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_marks_job_for_retry_when_rebuilding_raw_memories_fails() {
|
|
let harness = DispatchHarness::new().await;
|
|
harness.seed_stage1_output(/*source_updated_at*/ 100).await;
|
|
let root = memory_root(&harness.config.codex_home);
|
|
tokio::fs::create_dir_all(raw_memories_file(&root))
|
|
.await
|
|
.expect("create raw_memories.md as a directory");
|
|
|
|
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
|
|
|
let retry_claim = harness
|
|
.state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global job after rebuild failure");
|
|
pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable);
|
|
pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0);
|
|
let thread_ids = harness.manager.list_thread_ids().await;
|
|
pretty_assertions::assert_eq!(thread_ids.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_marks_job_for_retry_when_spawn_agent_fails() {
|
|
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
|
let mut config = test_config().await;
|
|
config.codex_home =
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
|
|
.expect("codex home is absolute");
|
|
config.cwd = config.codex_home.clone();
|
|
let config = Arc::new(config);
|
|
|
|
let state_db = codex_state::StateRuntime::init(
|
|
config.codex_home.to_path_buf(),
|
|
config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("initialize state db");
|
|
|
|
let (mut session, _turn_context) = make_session_and_context().await;
|
|
session.services.state_db = Some(Arc::clone(&state_db));
|
|
session.services.agent_control = AgentControl::default();
|
|
let session = Arc::new(session);
|
|
|
|
let thread_id = ThreadId::new();
|
|
let mut metadata_builder = ThreadMetadataBuilder::new(
|
|
thread_id,
|
|
config
|
|
.codex_home
|
|
.join(format!("rollout-{thread_id}.jsonl"))
|
|
.to_path_buf(),
|
|
Utc::now(),
|
|
SessionSource::Cli,
|
|
);
|
|
metadata_builder.cwd = config.cwd.to_path_buf();
|
|
metadata_builder.model_provider = Some(config.model_provider_id.clone());
|
|
let metadata = metadata_builder.build(&config.model_provider_id);
|
|
state_db
|
|
.upsert_thread(&metadata)
|
|
.await
|
|
.expect("upsert thread metadata");
|
|
|
|
let claim = state_db
|
|
.try_claim_stage1_job(
|
|
thread_id,
|
|
session.conversation_id,
|
|
/*source_updated_at*/ 100,
|
|
/*lease_seconds*/ 3_600,
|
|
/*max_running_jobs*/ 64,
|
|
)
|
|
.await
|
|
.expect("claim stage-1 job");
|
|
let ownership_token = match claim {
|
|
codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
|
|
other => panic!("unexpected stage-1 claim outcome: {other:?}"),
|
|
};
|
|
assert!(
|
|
state_db
|
|
.mark_stage1_job_succeeded(
|
|
thread_id,
|
|
&ownership_token,
|
|
/*source_updated_at*/ 100,
|
|
"raw memory",
|
|
"rollout summary",
|
|
/*rollout_slug*/ None,
|
|
)
|
|
.await
|
|
.expect("mark stage-1 success"),
|
|
"stage-1 success should enqueue global consolidation"
|
|
);
|
|
|
|
let chronicle_resources = config
|
|
.codex_home
|
|
.join("memories/extensions/chronicle/resources");
|
|
tokio::fs::create_dir_all(&chronicle_resources)
|
|
.await
|
|
.expect("create chronicle resources");
|
|
tokio::fs::write(
|
|
config
|
|
.codex_home
|
|
.join("memories/extensions/chronicle/instructions.md"),
|
|
"instructions",
|
|
)
|
|
.await
|
|
.expect("write chronicle instructions");
|
|
let old_file = chronicle_resources.join(format!(
|
|
"{}-abcd-10min-old.md",
|
|
(Utc::now() - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S")
|
|
));
|
|
tokio::fs::write(&old_file, "old resource")
|
|
.await
|
|
.expect("write old extension resource");
|
|
|
|
phase2::run(&session, Arc::clone(&config)).await;
|
|
|
|
let retry_claim = state_db
|
|
.try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600)
|
|
.await
|
|
.expect("claim global job after spawn failure");
|
|
pretty_assertions::assert_eq!(
|
|
retry_claim,
|
|
Phase2JobClaimOutcome::SkippedRetryUnavailable,
|
|
"spawn failures should leave the job in retry backoff instead of running"
|
|
);
|
|
assert!(
|
|
!tokio::fs::try_exists(&old_file)
|
|
.await
|
|
.expect("check old extension resource"),
|
|
"old extension resources should still be pruned on failed phase2 attempts"
|
|
);
|
|
let workspace_diff =
|
|
tokio::fs::read_to_string(config.codex_home.join("memories/phase2_workspace_diff.md"))
|
|
.await
|
|
.expect("read workspace diff");
|
|
assert!(
|
|
workspace_diff.contains("- D extensions/chronicle/resources/"),
|
|
"spawn failures should keep a retryable workspace diff: {workspace_diff}"
|
|
);
|
|
}
|
|
}
|