Codex Handbook
memories/write/src/phase2.rs 577 lines
use crate::build_consolidation_prompt;use crate::memory_root;use crate::metrics::MEMORY_PHASE_TWO_E2E_MS;use crate::metrics::MEMORY_PHASE_TWO_INPUT;use crate::metrics::MEMORY_PHASE_TWO_JOBS;use crate::metrics::MEMORY_PHASE_TWO_TOKEN_USAGE;use crate::prune_old_extension_resources;use crate::rebuild_raw_memories_file_from_memories;use crate::runtime::MemoryStartupContext;use crate::runtime::SpawnedConsolidationAgent;use crate::sync_rollout_summaries_from_memories;use crate::workspace::memory_workspace_diff;use crate::workspace::prepare_memory_workspace;use crate::workspace::reset_memory_workspace_baseline;use crate::workspace::write_workspace_diff;use codex_config::Constrained;use codex_core::config::Config;use codex_features::Feature;use codex_model_provider::ModelProvider;use codex_protocol::ThreadId;use codex_protocol::protocol::AgentStatus;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::SandboxPolicy;use codex_protocol::protocol::TokenUsage;use codex_protocol::user_input::UserInput;use codex_state::Stage1Output;use codex_state::StateRuntime;use std::collections::HashMap;use std::path::Path;use std::sync::Arc;use std::time::Duration;#[derive(Debug, Clone, Default)]struct Claim {    token: String,    watermark: i64,}#[derive(Debug, Clone, Default)]struct Counters {    input: i64,}/// Runs memory phase 2 (aka consolidation) in strict order. The method represents the linear/// flow of the consolidation phase.pub async fn run(context: Arc<MemoryStartupContext>, config: Arc<Config>) {    let phase_two_e2e_timer = context.start_timer(MEMORY_PHASE_TWO_E2E_MS);    let Some(db) = context.state_db() else {        // This should not happen.        return;    };    let root = memory_root(&config.codex_home);    let max_raw_memories = config.memories.max_raw_memories_for_consolidation;    let max_unused_days = config.memories.max_unused_days;    // 1. Claim the global Phase 2 lock before touching the memory workspace.    let claim = match job::claim(context.as_ref(), db.as_ref()).await {        Ok(claim) => claim,        Err(e) => {            context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", e)]);            return;        }    };    // 2. Ensure the memories root has a git baseline repository.    if let Err(err) = prepare_memory_workspace(&root).await {        tracing::error!("failed preparing memory workspace: {err}");        job::failed(            context.as_ref(),            db.as_ref(),            &claim,            "failed_prepare_workspace",        )        .await;        return;    }    // 3. Build the locked-down config used by the consolidation agent.    let Some(agent_config) = agent::get_config(config.as_ref(), context.provider()) else {        // If we can't get the config, we can't consolidate.        tracing::error!("failed to get agent config");        job::failed(            context.as_ref(),            db.as_ref(),            &claim,            "failed_sandbox_policy",        )        .await;        return;    };    // 4. Load current DB-backed Phase 2 inputs.    let raw_memories = match db        .memories()        .get_phase2_input_selection(max_raw_memories, max_unused_days)        .await    {        Ok(raw_memories) => raw_memories,        Err(err) => {            tracing::error!("failed to list stage1 outputs from global: {err}");            job::failed(                context.as_ref(),                db.as_ref(),                &claim,                "failed_load_stage1_outputs",            )            .await;            return;        }    };    let raw_memory_count = raw_memories.len();    let new_watermark = get_watermark(claim.watermark, &raw_memories);    // 5. Sync the current inputs into the memory workspace.    if let Err(err) = sync_phase2_workspace_inputs(&root, &raw_memories).await {        tracing::error!("failed syncing phase2 workspace inputs: {err}");        job::failed(            context.as_ref(),            db.as_ref(),            &claim,            "failed_sync_workspace_inputs",        )        .await;        return;    }    // 6. Use git to decide whether the synced workspace actually changed.    let workspace_diff = match memory_workspace_diff(&root).await {        Ok(diff) => diff,        Err(err) => {            tracing::error!("failed checking memory workspace changes: {err}");            job::failed(                context.as_ref(),                db.as_ref(),                &claim,                "failed_workspace_status",            )            .await;            return;        }    };    if !workspace_diff.has_changes() {        tracing::error!("Phase 2 no changes");        // We check only after sync of the file system.        job::succeed(            context.as_ref(),            db.as_ref(),            &claim,            new_watermark,            &raw_memories,            "succeeded_no_workspace_changes",        )        .await;        return;    }    // 7. Persist the diff for the consolidation agent to inspect.    if let Err(err) = write_workspace_diff(&root, &workspace_diff).await {        tracing::error!("failed writing memory workspace diff file: {err}");        job::failed(            context.as_ref(),            db.as_ref(),            &claim,            "failed_workspace_diff_file",        )        .await;        return;    }    // 8. Spawn the consolidation agent.    let prompt = agent::get_prompt(&root);    let agent = match context        .spawn_consolidation_agent(agent_config, prompt)        .await    {        Ok(agent) => agent,        Err(err) => {            tracing::error!("failed to spawn global memory consolidation agent: {err}");            job::failed(context.as_ref(), db.as_ref(), &claim, "failed_spawn_agent").await;            return;        }    };    // 9. Hand off completion handling, heartbeats, and baseline reset.    agent::handle(        Arc::clone(&context),        claim,        new_watermark,        raw_memories.clone(),        root,        agent,        phase_two_e2e_timer,    );    // 10. Emit dispatch metrics.    let counters = Counters {        input: raw_memory_count as i64,    };    emit_metrics(context.as_ref(), counters);}async fn sync_phase2_workspace_inputs(    root: &Path,    raw_memories: &[Stage1Output],) -> std::io::Result<()> {    let raw_memory_count = raw_memories.len();    sync_rollout_summaries_from_memories(root, raw_memories, raw_memory_count).await?;    rebuild_raw_memories_file_from_memories(root, raw_memories, raw_memory_count).await?;    prune_old_extension_resources(root).await;    Ok(())}mod job {    use super::*;    pub(super) async fn claim(        context: &MemoryStartupContext,        db: &StateRuntime,    ) -> Result<Claim, &'static str> {        let claim = db            .memories()            .try_claim_global_phase2_job(context.thread_id(), crate::stage_two::JOB_LEASE_SECONDS)            .await            .map_err(|e| {                tracing::error!("failed to claim job: {e}");                "failed_claim"            })?;        let (token, watermark) = match claim {            codex_state::Phase2JobClaimOutcome::Claimed {                ownership_token,                input_watermark,            } => {                context.counter(                    MEMORY_PHASE_TWO_JOBS,                    /*inc*/ 1,                    &[("status", "claimed")],                );                (ownership_token, input_watermark)            }            codex_state::Phase2JobClaimOutcome::SkippedRetryUnavailable => {                return Err("skipped_retry_unavailable");            }            codex_state::Phase2JobClaimOutcome::SkippedCooldown => {                return Err("skipped_cooldown");            }            codex_state::Phase2JobClaimOutcome::SkippedRunning => return Err("skipped_running"),        };        Ok(Claim { token, watermark })    }    pub(super) async fn failed(        context: &MemoryStartupContext,        db: &StateRuntime,        claim: &Claim,        reason: &'static str,    ) {        context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", reason)]);        if matches!(            db.memories()                .mark_global_phase2_job_failed(                    &claim.token,                    reason,                    crate::stage_two::JOB_RETRY_DELAY_SECONDS,                )                .await,            Ok(false)        ) {            let _ = db                .memories()                .mark_global_phase2_job_failed_if_unowned(                    &claim.token,                    reason,                    crate::stage_two::JOB_RETRY_DELAY_SECONDS,                )                .await;        }    }    pub(super) async fn succeed(        context: &MemoryStartupContext,        db: &StateRuntime,        claim: &Claim,        completion_watermark: i64,        selected_outputs: &[codex_state::Stage1Output],        reason: &'static str,    ) -> bool {        context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", reason)]);        db.memories()            .mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)            .await            .unwrap_or(false)    }}mod agent {    use super::*;    use tracing::warn;    pub(super) fn get_config(config: &Config, provider: &dyn ModelProvider) -> Option<Config> {        let root = memory_root(&config.codex_home);        let mut agent_config = config.clone();        agent_config.cwd = root.clone();        // Consolidation threads must never feed back into phase-1 memory generation.        agent_config.ephemeral = true;        agent_config.memories.generate_memories = false;        agent_config.memories.use_memories = false;        agent_config.include_apps_instructions = false;        agent_config.mcp_servers = Constrained::allow_only(HashMap::new());        // Approval policy        agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);        // Consolidation runs as an internal worker and must not recursively delegate.        let _ = agent_config.features.disable(Feature::SpawnCsv);        let _ = agent_config.features.disable(Feature::Collab);        let _ = agent_config.features.disable(Feature::MemoryTool);        let _ = agent_config.features.disable(Feature::Apps);        let _ = agent_config.features.disable(Feature::Plugins);        let _ = agent_config            .features            .disable(Feature::SkillMcpDependencyInstall);        // Sandbox policy        let writable_roots = vec![root];        // The consolidation agent only needs local memory-root write access and no network.        let consolidation_sandbox_policy = SandboxPolicy::WorkspaceWrite {            writable_roots,            network_access: false,            exclude_tmpdir_env_var: true,            exclude_slash_tmp: true,        };        agent_config            .permissions            .set_legacy_sandbox_policy(consolidation_sandbox_policy, agent_config.cwd.as_path())            .ok()?;        agent_config.model = Some(            config                .memories                .consolidation_model                .clone()                .unwrap_or_else(|| provider.memory_consolidation_preferred_model().to_string()),        );        agent_config.model_reasoning_effort = Some(crate::stage_two::REASONING_EFFORT);        Some(agent_config)    }    pub(super) fn get_prompt(root: &Path) -> Vec<UserInput> {        let prompt = build_consolidation_prompt(root);        vec![UserInput::Text {            text: prompt,            text_elements: vec![],        }]    }    /// Handle the agent while it is running.    #[allow(clippy::too_many_arguments)]    pub(super) fn handle(        context: Arc<MemoryStartupContext>,        claim: Claim,        new_watermark: i64,        selected_outputs: Vec<codex_state::Stage1Output>,        memory_root: codex_utils_absolute_path::AbsolutePathBuf,        agent: SpawnedConsolidationAgent,        phase_two_e2e_timer: Option<codex_otel::Timer>,    ) {        let Some(db) = context.state_db() else {            return;        };        tokio::spawn(async move {            let _phase_two_e2e_timer = phase_two_e2e_timer;            let SpawnedConsolidationAgent { thread_id, thread } = agent;            // Loop the agent until we have the final status.            let final_status =                loop_agent(db.clone(), claim.token.clone(), thread_id, &thread).await;            if matches!(final_status, AgentStatus::Completed(_)) {                if let Some(token_usage) = thread                    .token_usage_info()                    .await                    .map(|info| info.total_token_usage)                {                    emit_token_usage_metrics(context.as_ref(), &token_usage);                }                // Do not reset the workspace baseline if we lost the lock.                let still_owns_lock = match db                    .memories()                    .heartbeat_global_phase2_job(                        &claim.token,                        crate::stage_two::JOB_LEASE_SECONDS,                    )                    .await                    .inspect_err(|err| {                        tracing::error!(                            "failed confirming global memory consolidation ownership before resetting workspace baseline: {err}"                        );                    }) {                    Ok(true) => true,                    Ok(false) => {                        tracing::error!(                            "lost global memory consolidation ownership before resetting workspace baseline"                        );                        false                    }                    Err(_) => {                        job::failed(context.as_ref(), &db, &claim, "failed_confirm_ownership")                            .await;                        false                    }                };                if still_owns_lock {                    if let Err(err) = reset_memory_workspace_baseline(&memory_root).await {                        tracing::error!("failed resetting memory workspace baseline: {err}");                        job::failed(context.as_ref(), &db, &claim, "failed_workspace_commit").await;                    } else if !job::succeed(                        context.as_ref(),                        &db,                        &claim,                        new_watermark,                        &selected_outputs,                        "succeeded",                    )                    .await                    {                        tracing::error!(                            "failed marking global memory consolidation job succeeded after resetting workspace baseline"                        );                    }                }            } else {                job::failed(context.as_ref(), &db, &claim, "failed_agent").await;            }            let cleanup_context = Arc::clone(&context);            tokio::spawn(async move {                if let Err(err) = cleanup_context                    .shutdown_consolidation_agent(SpawnedConsolidationAgent { thread_id, thread })                    .await                {                    warn!(                        "failed to auto-close global memory consolidation agent {thread_id}: {err}"                    );                }            });        });    }    async fn loop_agent(        db: Arc<StateRuntime>,        token: String,        thread_id: ThreadId,        thread: &codex_core::CodexThread,    ) -> AgentStatus {        let mut heartbeat_interval =            tokio::time::interval(Duration::from_secs(crate::stage_two::JOB_HEARTBEAT_SECONDS));        heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);        let mut status_poll_interval = tokio::time::interval(Duration::from_secs(1));        status_poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);        let session_termination = thread.wait_until_terminated();        tokio::pin!(session_termination);        loop {            let status = thread.agent_status().await;            if is_final_agent_status(&status) {                break status;            }            tokio::select! {                _ = &mut session_termination => {                    let status = thread.agent_status().await;                    if is_final_agent_status(&status) {                        break status;                    }                    tracing::warn!(                        "memory consolidation agent {thread_id} exited before final status; last status was {status:?}"                    );                    break AgentStatus::Errored(format!(                        "memory consolidation agent exited before final status: {status:?}"                    ));                }                _ = status_poll_interval.tick() => {                }                _ = heartbeat_interval.tick() => {                    match db                        .memories()                        .heartbeat_global_phase2_job(                            &token,                            crate::stage_two::JOB_LEASE_SECONDS,                        )                        .await                    {                        Ok(true) => {}                        Ok(false) => {                            tracing::warn!(                                "lost global phase-2 ownership during heartbeat for memory consolidation agent {thread_id}"                            );                            break AgentStatus::Errored(                                "lost global phase-2 ownership during heartbeat".to_string(),                            );                        }                        Err(err) => {                            tracing::warn!(                                "phase-2 heartbeat update failed for memory consolidation agent {thread_id}: {err}"                            );                            break AgentStatus::Errored(format!(                                "phase-2 heartbeat update failed: {err}"                            ));                        }                    }                }            }        }    }}pub(super) fn get_watermark(    claimed_watermark: i64,    latest_memories: &[codex_state::Stage1Output],) -> i64 {    latest_memories        .iter()        .map(|memory| memory.source_updated_at.timestamp())        .max()        .unwrap_or(claimed_watermark)        .max(claimed_watermark)}fn is_final_agent_status(status: &AgentStatus) -> bool {    !matches!(        status,        AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted    )}fn emit_metrics(context: &MemoryStartupContext, counters: Counters) {    if counters.input > 0 {        context.counter(MEMORY_PHASE_TWO_INPUT, counters.input, &[]);    }    context.counter(        MEMORY_PHASE_TWO_JOBS,        /*inc*/ 1,        &[("status", "agent_spawned")],    );}fn emit_token_usage_metrics(context: &MemoryStartupContext, token_usage: &TokenUsage) {    context.histogram(        MEMORY_PHASE_TWO_TOKEN_USAGE,        token_usage.total_tokens.max(0),        &[("token_type", "total")],    );    context.histogram(        MEMORY_PHASE_TWO_TOKEN_USAGE,        token_usage.input_tokens.max(0),        &[("token_type", "input")],    );    context.histogram(        MEMORY_PHASE_TWO_TOKEN_USAGE,        token_usage.cached_input(),        &[("token_type", "cached_input")],    );    context.histogram(        MEMORY_PHASE_TWO_TOKEN_USAGE,        token_usage.output_tokens.max(0),        &[("token_type", "output")],    );    context.histogram(        MEMORY_PHASE_TWO_TOKEN_USAGE,        token_usage.reasoning_output_tokens.max(0),        &[("token_type", "reasoning_output")],    );}