Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ codex-threadripper install-service

- `status`:查看当前 provider、SQLite 里各 provider 的线程分布,以及后台服务的运行状态
- `sync`:立即执行一次收敛。执行前会在 SQLite 状态库旁边的 `backups/` 目录里写一份带时间戳的备份;如果 rollout 首行需要改写,也会写 compact journal
- `bucket switch <provider>`:把全部历史切到指定 provider 可见桶;首行有 padding 时原地 patch,旧的冷 rollout 需要扩容时会安全重写并补 padding
- `bucket switch <provider>`:把全部历史切到指定 provider 可见桶;首行有 padding 时原地 patch,Legacy 模式下旧的冷 rollout 需要扩容时会安全重写并补 padding;分页历史需要扩容时会保持 rollout 和对应 SQLite 行不变并返回部分成功,避免破坏 Codex 的历史偏移
- `bucket prepare`:检查 rollout 首行 padding;需要扩容首行的文件会被跳过,避免替换仍可能被 Codex 写入的日志文件
- `watch`:持续监听 `config.toml` 的变化,同时定时收敛新增的线程记录
- `print-service-config`:打印当前平台的后台服务配置内容(launchd plist / systemd unit / Windows 隐藏 VBS 启动器)
Expand All @@ -123,8 +123,8 @@ codex-threadripper install-service
## 它会改什么

- `sync` 会在 SQLite 状态库旁边的 `backups/` 目录里创建备份,然后更新 `state_5.sqlite` 里的 `model_provider` 字段
- `sync` / `bucket switch` 也会改写 rollout JSONL 首行里的 provider 可见桶,并写入 compact journal;有足够 padding 时会原地 patch,旧的冷 rollout 需要扩容时会通过临时文件安全重写并补 padding;刚写过、仍可能被 Codex 写入的 rollout 会被跳过,待稍后重跑。改写后会恢复原本的文件访问时间和修改时间,避免 Codex.app 的最近会话排序被工具污染
- `watch` 在运行期间会持续处理新写入的线程记录,保持数据库与 rollout 首行里的 provider 可见桶和当前 provider 对齐
- `sync` / `bucket switch` 也会改写 rollout JSONL 首行里的 provider 可见桶,并写入 compact journal;有足够 padding 时会原地 patch,Legacy 模式下旧的冷 rollout 需要扩容时会通过临时文件安全重写并补 padding;刚写过、仍可能被 Codex 写入的 rollout 会被跳过,待稍后重跑。最新版 Codex 的分页历史会保存 rollout 字节偏移,因此需要增长首行的分页 rollout 会连同对应 SQLite provider 行一起保持不变,并以部分成功退出,避免留下失效的历史投影。改写后会恢复原本的文件访问时间和修改时间,避免 Codex.app 的最近会话排序被工具污染
- `watch` 在运行期间会持续处理新写入的线程记录,保持数据库与 rollout 首行里的 provider 可见桶和当前 provider 对齐;稳定受阻的分页 rollout 会被缓存,避免每 500ms 重读和重复刷日志,并在配置变化或每 60 秒全量扫描时重新检查
- `--sqlite-only` 只更新 SQLite,适合只关心数据库索引的场景;当前 Codex.app 也会读取 rollout JSONL 元数据,所以日常可见性修复不建议使用它
- 默认状态库路径是 `CODEX_HOME/state_5.sqlite`;如果 Codex 配置了 `sqlite_home` 或环境变量 `CODEX_SQLITE_HOME`,`codex-threadripper` 会跟随 Codex 使用对应目录下的 `state_5.sqlite`

Expand Down Expand Up @@ -220,7 +220,7 @@ codex-threadripper install-service

- `status`: show the active provider, the per-provider thread counts from SQLite, and whether the background service is running
- `sync`: reconcile once right now, writing a timestamped backup to the `backups/` directory beside the SQLite state DB; rollout first-line changes also get a compact journal
- `bucket switch <provider>`: move all history into the requested provider visibility bucket; prepared first lines are patched in place, while older cold rollouts that need first-line growth are safely rewritten with padding
- `bucket switch <provider>`: move history into the requested provider visibility bucket; prepared first lines are patched in place, while older cold legacy rollouts that need first-line growth are safely rewritten with padding. Paginated rollouts that need growth are left unchanged together with their SQLite rows, and the command reports a partial result so Codex history offsets stay valid
- `bucket prepare`: inspect rollout first-line padding; files that would require first-line growth are skipped so the tool does not replace logs Codex may still be writing
- `watch`: keep watching `config.toml` for provider changes and periodically reconcile any newly added thread rows
- `print-service-config`: print the platform-specific service config (launchd plist, systemd unit, or Windows hidden VBS launcher) without installing it
Expand All @@ -240,8 +240,8 @@ Every command accepts `--provider <provider>` to force the target bucket, or `--
## What it changes

- `sync` writes a backup next to the SQLite state DB and then updates the `model_provider` column in `state_5.sqlite`
- `sync` / `bucket switch` also rewrite the provider visibility bucket in rollout JSONL first lines and write a compact journal. Prepared first lines are patched in place; older cold rollouts that need first-line growth are safely rewritten through a temporary file and padded for future switches; recently written rollouts that Codex may still be writing are skipped and can be retried later. After each rewrite, the original file access and modification times are restored so Codex.app's recent-thread ordering is not polluted by the tool
- `watch` keeps both SQLite and rollout first-line provider buckets aligned with the active provider as new threads are written
- `sync` / `bucket switch` also rewrite the provider visibility bucket in rollout JSONL first lines and write a compact journal. Prepared first lines are patched in place; older cold legacy rollouts that need first-line growth are safely rewritten through a temporary file and padded for future switches; recently written rollouts that Codex may still be writing are skipped and can be retried later. Current Codex paginated history persists rollout byte offsets, so a paginated rollout that would need first-line growth is left unchanged together with its SQLite provider row and the command exits partially instead of invalidating the history projection. After each rewrite, the original file access and modification times are restored so Codex.app's recent-thread ordering is not polluted by the tool
- `watch` keeps both SQLite and rollout first-line provider buckets aligned with the active provider as new threads are written. Stable blocked paginated rollouts are cached instead of being reread and logged every 500 ms, then reevaluated after config changes or during the 60-second full scan
- `--sqlite-only` updates SQLite only. Use it only when you deliberately do not need rollout JSONL metadata; current Codex.app builds also read rollout metadata for visibility
- The default state DB is `CODEX_HOME/state_5.sqlite`; if Codex uses `sqlite_home` or `CODEX_SQLITE_HOME`, `codex-threadripper` follows that directory and uses its `state_5.sqlite`

Expand Down
21 changes: 18 additions & 3 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ pub(crate) fn print_multi_sync_summary(
status_target_provider_label(locale),
summary.provider
);
if summary.checked_rollouts > 0 || summary.changed_rollouts > 0 {
if summary.checked_rollouts > 0 || summary.changed_rollouts > 0 || summary.blocked_rollouts > 0
{
println!(
"{}: {}",
sync_rollouts_checked_label(locale),
Expand All @@ -163,6 +164,13 @@ pub(crate) fn print_multi_sync_summary(
summary.skipped_rollouts
);
}
if summary.blocked_rollouts > 0 {
println!(
"{}: {}",
sync_paginated_rollouts_blocked_label(locale),
summary.blocked_rollouts
);
}
}

println!();
Expand Down Expand Up @@ -945,10 +953,10 @@ pub(crate) fn reconcile_status_line(locale: Locale, status: ReconcileStatus) ->
(ReconcileStatus::Full, Locale::En) => "Result: all stores updated.".to_string(),
(ReconcileStatus::Full, Locale::ZhHans) => "结果:所有库均已更新。".to_string(),
(ReconcileStatus::Partial, Locale::En) => {
"Result: PARTIAL — some stores updated, at least one was skipped or failed (see above). Re-run after it is resolved.".to_string()
"Result: PARTIAL — at least one store or paginated rollout was left unchanged (see above). Re-run after it is resolved.".to_string()
}
(ReconcileStatus::Partial, Locale::ZhHans) => {
"结果:部分成功 —— 部分库已更新,至少一个被跳过或失败(见上)。解决后请重跑。".to_string()
"结果:部分成功 —— 至少一个存储面或分页 rollout 保持未变(见上)。解决后请重跑。".to_string()
}
(ReconcileStatus::Failed, Locale::En) => {
"Result: FAILED — no store could be updated.".to_string()
Expand Down Expand Up @@ -1040,6 +1048,13 @@ pub(crate) fn sync_rollouts_skipped_label(locale: Locale) -> &'static str {
}
}

pub(crate) fn sync_paginated_rollouts_blocked_label(locale: Locale) -> &'static str {
match locale {
Locale::En => "Paginated rollouts kept unchanged to preserve history offsets",
Locale::ZhHans => "为保护历史偏移而保持未变的分页 rollout",
}
}

pub(crate) fn sync_elapsed_label(locale: Locale) -> &'static str {
match locale {
Locale::En => "Elapsed",
Expand Down
47 changes: 46 additions & 1 deletion src/rollout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub(crate) struct RolloutReconcileSummary {
pub(crate) changed_files: u64,
pub(crate) prepared_files: u64,
pub(crate) skipped_files: u64,
/// Thread IDs whose paginated rollouts require a length-changing rewrite.
/// Callers must leave the matching SQLite rows untouched so the rollout and
/// provider index cannot diverge while Codex owns byte-offset checkpoints.
pub(crate) blocked_thread_ids: HashSet<String>,
pub(crate) journal_path: Option<PathBuf>,
}

Expand Down Expand Up @@ -154,6 +158,7 @@ struct RolloutPatchOutcome {
changed: bool,
prepared: bool,
skipped: bool,
block_sqlite: bool,
}

struct RolloutChangeJournal {
Expand Down Expand Up @@ -217,6 +222,7 @@ pub(crate) fn reconcile_rollouts_for_stores(
store_db_paths: &[PathBuf],
provider: &str,
scope: RolloutScope,
known_blocked_thread_ids: &HashSet<String>,
journal_path: Option<&Path>,
padding_bytes: usize,
progress: Option<RolloutProgressConfig>,
Expand All @@ -225,13 +231,27 @@ pub(crate) fn reconcile_rollouts_for_stores(
return Ok(MultiStoreRolloutOutcome::default());
}
let (targets, failed_stores) = rollout_targets_for_store_paths(store_db_paths, provider, scope);
let summary = reconcile_rollout_metadata_files(
let mut cached_blocked_thread_ids = HashSet::new();
let targets = targets
.into_iter()
.filter(|target| {
if known_blocked_thread_ids.contains(&target.thread_id) {
cached_blocked_thread_ids.insert(target.thread_id.clone());
false
} else {
true
}
})
.collect::<Vec<_>>();
let mut summary = reconcile_rollout_metadata_files(
targets.as_slice(),
provider,
journal_path,
padding_bytes,
progress,
)?;
summary.skipped_files += cached_blocked_thread_ids.len() as u64;
summary.blocked_thread_ids.extend(cached_blocked_thread_ids);
Ok(MultiStoreRolloutOutcome {
summary,
failed_stores,
Expand Down Expand Up @@ -333,6 +353,9 @@ fn reconcile_rollout_metadata_files(
if outcome.skipped {
summary.skipped_files += 1;
}
if outcome.block_sqlite {
summary.blocked_thread_ids.insert(target.thread_id.clone());
}
if let Some(progress) = progress.as_mut() {
progress.tick(&summary);
}
Expand Down Expand Up @@ -365,6 +388,7 @@ fn rewrite_rollout_provider_first_line(
});
};
let old_provider = session_meta_provider(&value).unwrap_or("").to_string();
let paginated_history = session_meta_history_is_paginated(&value);

if !set_session_meta_provider(&mut value, provider) {
return Ok(RolloutPatchOutcome::default());
Expand Down Expand Up @@ -396,6 +420,19 @@ fn rewrite_rollout_provider_first_line(
});
}

// Codex paginated history persists a byte offset into the rollout. Growing
// the first line would shift every later record while leaving that
// checkpoint stale. An in-place replacement above is safe because it keeps
// the exact byte length; a growing rewrite must be left untouched, along
// with the matching SQLite provider row.
if paginated_history {
return Ok(RolloutPatchOutcome {
skipped: true,
block_sqlite: true,
..RolloutPatchOutcome::default()
});
}

if !rollout_is_old_enough(&original_metadata, ROLLOUT_GROWTH_REWRITE_MIN_AGE) {
return Ok(RolloutPatchOutcome {
skipped: true,
Expand Down Expand Up @@ -777,6 +814,14 @@ fn session_meta_provider(value: &Value) -> Option<&str> {
.and_then(Value::as_str)
}

fn session_meta_history_is_paginated(value: &Value) -> bool {
value
.get("payload")
.and_then(|payload| payload.get("history_mode"))
.and_then(Value::as_str)
== Some("paginated")
}

fn set_session_meta_provider(value: &mut Value, provider: &str) -> bool {
let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) else {
return false;
Expand Down
45 changes: 41 additions & 4 deletions src/state_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use rusqlite::OptionalExtension;
use rusqlite::TransactionBehavior;
use rusqlite::backup::Backup;
use rusqlite::backup::StepResult;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
Expand Down Expand Up @@ -88,18 +89,54 @@ pub(crate) fn read_backfill_status_with_timeout(
Ok(status)
}

#[cfg(test)]
pub(crate) fn reconcile_sqlite_in_place(sqlite_path: &Path, provider: &str) -> Result<(u64, u64)> {
reconcile_sqlite_in_place_excluding(sqlite_path, provider, &HashSet::new())
}

pub(crate) fn reconcile_sqlite_in_place_excluding(
sqlite_path: &Path,
provider: &str,
excluded_thread_ids: &HashSet<String>,
) -> Result<(u64, u64)> {
ensure_sqlite_exists(sqlite_path)?;
let mut connection = Connection::open(sqlite_path)
.with_context(|| format!("failed to open {}", sqlite_path.display()))?;
connection.busy_timeout(Duration::from_millis(5_000))?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let total_rows: u64 =
transaction.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?;
let changed_rows = transaction.execute(
"UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1",
[provider],
)? as u64;
let changed_rows = if excluded_thread_ids.is_empty() {
transaction.execute(
"UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1",
[provider],
)? as u64
} else {
transaction.execute_batch(
"CREATE TEMP TABLE IF NOT EXISTS threadripper_excluded_threads (
id TEXT PRIMARY KEY
) WITHOUT ROWID;
DELETE FROM temp.threadripper_excluded_threads;",
)?;
{
let mut insert = transaction
.prepare("INSERT INTO temp.threadripper_excluded_threads (id) VALUES (?1)")?;
for thread_id in excluded_thread_ids {
insert.execute([thread_id])?;
}
}
transaction.execute(
"UPDATE threads
SET model_provider = ?1
WHERE model_provider <> ?1
AND NOT EXISTS (
SELECT 1
FROM temp.threadripper_excluded_threads AS excluded
WHERE excluded.id = threads.id
)",
[provider],
)? as u64
};
transaction.commit()?;

Ok((changed_rows, total_rows))
Expand Down
Loading
Loading