mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Add rollout migration tooling and background migration (#37348)
## What changed - Add `codex migrate-rollouts` with dry-run inspection by default, explicit `--apply`, thread filtering, optional I/O throttling, progress output, and JSON or verbose reports. - Add the disabled-by-default `background_paginated_rollout_migration` feature to migrate legacy local sessions at startup before rollout compression begins. - Persist a migration cursor and skipped-file fingerprints so later startups avoid full rescans while retrying changed files and recovering pending migrations. - Coordinate migration with live writers and emit metrics for manual and startup runs. ## Testing - Cover startup cursor advancement and lookback, pending migration recovery, live-writer coordination, changed empty rollouts, and progress reporting. GitOrigin-RevId: 276ac506c50ebec5140fd319faca1bb998172061
This commit is contained in:
@@ -67,6 +67,7 @@ codex-skills-extension = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-stdio-to-uds = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
|
||||
@@ -51,6 +51,7 @@ mod doctor;
|
||||
mod exec_server_telemetry;
|
||||
mod marketplace_cmd;
|
||||
mod mcp_cmd;
|
||||
mod migrate_rollouts;
|
||||
mod plugin_cmd;
|
||||
mod remote_control_cmd;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -190,6 +191,9 @@ enum Subcommand {
|
||||
/// Permanently delete a saved session by id or session name.
|
||||
Delete(DeleteCommand),
|
||||
|
||||
/// Inspect or migrate legacy local sessions to paginated thread history.
|
||||
MigrateRollouts(migrate_rollouts::MigrateRolloutsCommand),
|
||||
|
||||
/// Unarchive a saved session by id or session name.
|
||||
Unarchive(SessionArchiveCommand),
|
||||
|
||||
@@ -1339,6 +1343,14 @@ async fn cli_main(
|
||||
.await?;
|
||||
println!("{output}");
|
||||
}
|
||||
Some(Subcommand::MigrateRollouts(command)) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"migrate-rollouts",
|
||||
)?;
|
||||
migrate_rollouts::run(command, root_config_overrides).await?;
|
||||
}
|
||||
Some(Subcommand::Unarchive(cmd)) => {
|
||||
let output = run_session_archive_cli_command(
|
||||
codex_tui::SessionArchiveAction::Unarchive,
|
||||
@@ -2245,6 +2257,7 @@ fn unsupported_subcommand_name_for_strict_config(
|
||||
Some(Subcommand::RemoteControl(remote_control)) => Some(remote_control.subcommand_name()),
|
||||
Some(Subcommand::Mcp(_)) => Some("mcp"),
|
||||
Some(Subcommand::Plugin(_)) => Some("plugin"),
|
||||
Some(Subcommand::MigrateRollouts(_)) => Some("migrate-rollouts"),
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
Some(Subcommand::App(_)) => Some("app"),
|
||||
Some(Subcommand::Login(_)) => Some("login"),
|
||||
|
||||
369
codex-rs/cli/src/migrate_rollouts.rs
Normal file
369
codex-rs/cli/src/migrate_rollouts.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::RolloutMigrationMode;
|
||||
use codex_thread_store::RolloutMigrationOptions;
|
||||
use codex_thread_store::RolloutMigrationProgress;
|
||||
use codex_thread_store::RolloutMigrationReport;
|
||||
use codex_thread_store::RolloutMigrationStatus;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub(crate) struct MigrateRolloutsCommand {
|
||||
/// Publish the migration. Without this flag the command only reports eligible sessions.
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
|
||||
/// Restrict inspection or migration to one or more thread IDs.
|
||||
#[arg(long, value_name = "THREAD_ID", value_parser = ThreadId::from_string)]
|
||||
thread: Vec<ThreadId>,
|
||||
|
||||
/// Limit aggregate rollout read and write throughput, in MiB per second.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "MIB",
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
max_mib_per_second: Option<u64>,
|
||||
|
||||
/// Emit the complete per-thread report as JSON.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// Print one line for every inspected rollout.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
command: MigrateRolloutsCommand,
|
||||
config_overrides: CliConfigOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
let overrides = config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let config = ConfigBuilder::default()
|
||||
.cli_overrides(overrides)
|
||||
.build()
|
||||
.await?;
|
||||
let otel = codex_core::otel_init::build_provider(
|
||||
&config,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
/*service_name_override*/ None,
|
||||
/*default_analytics_enabled*/ true,
|
||||
)
|
||||
.unwrap_or_else(|error| {
|
||||
eprintln!("Could not create otel exporter: {error}");
|
||||
None
|
||||
});
|
||||
codex_core::otel_init::record_process_start(otel.as_ref(), "codex_migrate_rollouts");
|
||||
let mode = if command.apply {
|
||||
RolloutMigrationMode::Apply
|
||||
} else {
|
||||
RolloutMigrationMode::DryRun
|
||||
};
|
||||
let json = command.json;
|
||||
let verbose = command.verbose;
|
||||
let state_db = if mode == RolloutMigrationMode::Apply {
|
||||
Some(
|
||||
codex_rollout::state_db::try_init(&config)
|
||||
.await
|
||||
.context("failed to initialize local thread metadata")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&config), state_db);
|
||||
let mut progress = MigrationProgress::new(mode, json);
|
||||
progress.begin();
|
||||
let result = store
|
||||
.migrate_rollouts_with_progress(
|
||||
RolloutMigrationOptions {
|
||||
mode,
|
||||
thread_ids: command.thread,
|
||||
max_mib_per_second: command.max_mib_per_second,
|
||||
},
|
||||
|update| progress.update(update),
|
||||
)
|
||||
.await;
|
||||
progress.finish();
|
||||
let report = result?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_human_report(&report, mode, verbose, progress.elapsed());
|
||||
}
|
||||
|
||||
if report
|
||||
.outcomes
|
||||
.iter()
|
||||
.any(|outcome| outcome.status == RolloutMigrationStatus::Failed)
|
||||
{
|
||||
anyhow::bail!("one or more rollout migrations failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const NON_TTY_PROGRESS_INTERVAL: usize = 1_000;
|
||||
const MAX_EXCEPTION_DETAILS: usize = 20;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ProgressOutput {
|
||||
Quiet,
|
||||
Tty,
|
||||
Plain,
|
||||
}
|
||||
|
||||
struct MigrationProgress {
|
||||
mode: RolloutMigrationMode,
|
||||
output: ProgressOutput,
|
||||
started_at: Instant,
|
||||
last_rendered_at: Instant,
|
||||
last_plain_processed: usize,
|
||||
counts: MigrationCounts,
|
||||
wrote_tty_line: bool,
|
||||
}
|
||||
|
||||
impl MigrationProgress {
|
||||
fn new(mode: RolloutMigrationMode, json: bool) -> Self {
|
||||
let now = Instant::now();
|
||||
let output = if json {
|
||||
ProgressOutput::Quiet
|
||||
} else if io::stderr().is_terminal()
|
||||
&& std::env::var("TERM").ok().as_deref() != Some("dumb")
|
||||
{
|
||||
ProgressOutput::Tty
|
||||
} else {
|
||||
ProgressOutput::Plain
|
||||
};
|
||||
Self {
|
||||
mode,
|
||||
output,
|
||||
started_at: now,
|
||||
last_rendered_at: now,
|
||||
last_plain_processed: 0,
|
||||
counts: MigrationCounts::default(),
|
||||
wrote_tty_line: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(&self) {
|
||||
if self.output != ProgressOutput::Quiet {
|
||||
eprintln!("Scanning local rollouts...");
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, update: RolloutMigrationProgress) {
|
||||
if let Some(status) = update.outcome_status {
|
||||
self.counts.observe(status);
|
||||
}
|
||||
match self.output {
|
||||
ProgressOutput::Quiet => {}
|
||||
ProgressOutput::Tty
|
||||
if update.processed_paths == update.total_paths
|
||||
|| self.last_rendered_at.elapsed() >= TTY_PROGRESS_INTERVAL =>
|
||||
{
|
||||
let line = self.line(update);
|
||||
let mut stderr = io::stderr().lock();
|
||||
let _ = write!(stderr, "\r\x1b[2K{line}");
|
||||
let _ = stderr.flush();
|
||||
self.last_rendered_at = Instant::now();
|
||||
self.wrote_tty_line = true;
|
||||
}
|
||||
ProgressOutput::Plain
|
||||
if update.processed_paths == update.total_paths
|
||||
|| update
|
||||
.processed_paths
|
||||
.saturating_sub(self.last_plain_processed)
|
||||
>= NON_TTY_PROGRESS_INTERVAL =>
|
||||
{
|
||||
eprintln!("{}", self.line(update));
|
||||
self.last_plain_processed = update.processed_paths;
|
||||
}
|
||||
ProgressOutput::Tty | ProgressOutput::Plain => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
if self.output != ProgressOutput::Tty || !self.wrote_tty_line {
|
||||
return;
|
||||
}
|
||||
let mut stderr = io::stderr().lock();
|
||||
let _ = write!(stderr, "\r\x1b[2K");
|
||||
let _ = stderr.flush();
|
||||
self.wrote_tty_line = false;
|
||||
}
|
||||
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.started_at.elapsed()
|
||||
}
|
||||
|
||||
fn line(&self, update: RolloutMigrationProgress) -> String {
|
||||
let percent = update
|
||||
.processed_paths
|
||||
.saturating_mul(100)
|
||||
.checked_div(update.total_paths)
|
||||
.unwrap_or(100);
|
||||
let action = match self.mode {
|
||||
RolloutMigrationMode::DryRun => "Checking",
|
||||
RolloutMigrationMode::Apply => "Migrating",
|
||||
};
|
||||
let status_counts = match self.mode {
|
||||
RolloutMigrationMode::DryRun => format!(
|
||||
"{} eligible • {} already paginated",
|
||||
self.counts.eligible, self.counts.already_paginated
|
||||
),
|
||||
RolloutMigrationMode::Apply => format!(
|
||||
"{} migrated • {} already paginated",
|
||||
self.counts.migrated, self.counts.already_paginated
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{action} rollouts {}/{} ({percent}%) • {status_counts} • {} skipped • {} failed • {}",
|
||||
update.processed_paths,
|
||||
update.total_paths,
|
||||
self.counts.skipped(),
|
||||
self.counts.failed,
|
||||
format_elapsed(self.elapsed()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MigrationCounts {
|
||||
eligible: usize,
|
||||
migrated: usize,
|
||||
already_paginated: usize,
|
||||
skipped_empty: usize,
|
||||
skipped_busy: usize,
|
||||
failed: usize,
|
||||
}
|
||||
|
||||
impl MigrationCounts {
|
||||
fn observe(&mut self, status: RolloutMigrationStatus) {
|
||||
match status {
|
||||
RolloutMigrationStatus::Eligible => self.eligible += 1,
|
||||
RolloutMigrationStatus::Migrated => self.migrated += 1,
|
||||
RolloutMigrationStatus::AlreadyPaginated => self.already_paginated += 1,
|
||||
RolloutMigrationStatus::SkippedEmpty => self.skipped_empty += 1,
|
||||
RolloutMigrationStatus::SkippedBusy => self.skipped_busy += 1,
|
||||
RolloutMigrationStatus::Failed => self.failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn skipped(&self) -> usize {
|
||||
self.skipped_empty + self.skipped_busy
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human_report(
|
||||
report: &RolloutMigrationReport,
|
||||
mode: RolloutMigrationMode,
|
||||
verbose: bool,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
let mut counts = MigrationCounts::default();
|
||||
for outcome in &report.outcomes {
|
||||
counts.observe(outcome.status);
|
||||
}
|
||||
let completion = match mode {
|
||||
RolloutMigrationMode::DryRun => "Scan complete",
|
||||
RolloutMigrationMode::Apply => "Migration complete",
|
||||
};
|
||||
println!("{completion} in {}.", format_elapsed(elapsed));
|
||||
match mode {
|
||||
RolloutMigrationMode::DryRun => println!(
|
||||
"Scanned {} rollout(s): {} eligible, {} already paginated, {} skipped ({} empty, {} busy), {} failed.",
|
||||
report.outcomes.len(),
|
||||
counts.eligible,
|
||||
counts.already_paginated,
|
||||
counts.skipped(),
|
||||
counts.skipped_empty,
|
||||
counts.skipped_busy,
|
||||
counts.failed,
|
||||
),
|
||||
RolloutMigrationMode::Apply => println!(
|
||||
"Scanned {} rollout(s): {} migrated, {} already paginated, {} skipped ({} empty, {} busy), {} failed.",
|
||||
report.outcomes.len(),
|
||||
counts.migrated,
|
||||
counts.already_paginated,
|
||||
counts.skipped(),
|
||||
counts.skipped_empty,
|
||||
counts.skipped_busy,
|
||||
counts.failed,
|
||||
),
|
||||
}
|
||||
if mode == RolloutMigrationMode::DryRun && counts.eligible > 0 {
|
||||
println!("Run `codex migrate-rollouts --apply` to migrate eligible sessions.");
|
||||
}
|
||||
|
||||
if verbose {
|
||||
for outcome in &report.outcomes {
|
||||
print_outcome(outcome);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let exceptions = report.outcomes.iter().filter(|outcome| {
|
||||
matches!(
|
||||
outcome.status,
|
||||
RolloutMigrationStatus::SkippedBusy | RolloutMigrationStatus::Failed
|
||||
)
|
||||
});
|
||||
let exception_count = exceptions.clone().count();
|
||||
if exception_count == 0 {
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
for outcome in exceptions.take(MAX_EXCEPTION_DETAILS) {
|
||||
print_outcome(outcome);
|
||||
}
|
||||
if exception_count > MAX_EXCEPTION_DETAILS {
|
||||
println!(
|
||||
"... and {} more; rerun with --json for the complete report.",
|
||||
exception_count - MAX_EXCEPTION_DETAILS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_outcome(outcome: &codex_thread_store::RolloutMigrationOutcome) {
|
||||
let status = match outcome.status {
|
||||
RolloutMigrationStatus::Eligible => "eligible",
|
||||
RolloutMigrationStatus::Migrated => "migrated",
|
||||
RolloutMigrationStatus::AlreadyPaginated => "already paginated",
|
||||
RolloutMigrationStatus::SkippedEmpty => "skipped empty",
|
||||
RolloutMigrationStatus::SkippedBusy => "skipped busy",
|
||||
RolloutMigrationStatus::Failed => "failed",
|
||||
};
|
||||
let thread_id = outcome
|
||||
.thread_id
|
||||
.map_or_else(|| "unknown".to_string(), |thread_id| thread_id.to_string());
|
||||
match &outcome.message {
|
||||
Some(message) => println!("{status}\t{thread_id}\t{message}"),
|
||||
None => println!("{status}\t{thread_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(elapsed: Duration) -> String {
|
||||
let seconds = elapsed.as_secs();
|
||||
let minutes = seconds / 60;
|
||||
if minutes == 0 {
|
||||
return format!("{seconds}s");
|
||||
}
|
||||
let hours = minutes / 60;
|
||||
if hours == 0 {
|
||||
return format!("{minutes}m{:02}s", seconds % 60);
|
||||
}
|
||||
format!("{hours}h{:02}m{:02}s", minutes % 60, seconds % 60)
|
||||
}
|
||||
Reference in New Issue
Block a user