Skip to content

Commit c86952c

Browse files
Merge pull request #447 from ScriptedAlchemy/fix/session-catchup-integrity
perf(sessions): coalesce and bulk-route catch-ups
2 parents e888393 + 986ef30 commit c86952c

18 files changed

Lines changed: 1163 additions & 106 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tracedecay": patch
3+
---
4+
5+
Coalesce transcript catch-ups, route Hermes history through one shared source sweep, harden branch database recovery markers, emit Codex hook trust state in the exact form required for non-interactive approval, and reduce test-profile link overhead while preserving line-table backtraces.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ jsonschema = { version = "0.46.8", default-features = false }
183183
name = "large_repos"
184184
harness = false
185185

186+
# Full debuginfo makes each large integration-test binary 500-700 MB and
187+
# dominates local/CI link time. Level 1 preserves source line tables for
188+
# useful panic backtraces while substantially shrinking linker input/output.
189+
[profile.test]
190+
debug = 1
191+
186192
# Compile the SQLite stack with optimizations even in dev/test builds. The
187193
# bundled SQLite C sources in libsql-ffi are otherwise built at -O0 (cc honors
188194
# cargo's OPT_LEVEL), and nearly every test creates databases and runs many

src/agents/codex.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,10 +1041,38 @@ fn sync_codex_hook_trust(home: &Path, tracedecay_bin: &str) -> Result<CodexHookT
10411041
trusted += 1;
10421042
}
10431043

1044-
write_toml_file(&config_path, &config)?;
1044+
write_codex_hook_trust_config(&config_path, &config)?;
10451045
Ok(CodexHookTrustSyncOutcome { trusted, skipped })
10461046
}
10471047

1048+
/// Codex's hook loader requires the parent table to be explicit on disk. The
1049+
/// `toml` serializer otherwise emits only `[hooks.state."..."]` child tables,
1050+
/// which parses equivalently but still triggers Codex's hook-review prompt.
1051+
fn write_codex_hook_trust_config(config_path: &Path, config: &toml::Value) -> Result<()> {
1052+
super::backup_file(config_path)?;
1053+
let contents = toml::to_string_pretty(config).map_err(|error| TraceDecayError::Config {
1054+
message: format!("failed to serialize {}: {error}", config_path.display()),
1055+
})?;
1056+
let Some(child_offset) = contents.find("[hooks.state.\"") else {
1057+
return Err(TraceDecayError::Config {
1058+
message: "Codex hook trust state serialized without hook entries".to_string(),
1059+
});
1060+
};
1061+
let mut updated = String::with_capacity(contents.len() + "[hooks.state]\n\n".len());
1062+
updated.push_str(&contents[..child_offset]);
1063+
updated.push_str("[hooks.state]\n\n");
1064+
updated.push_str(&contents[child_offset..]);
1065+
std::fs::write(config_path, updated).map_err(|error| TraceDecayError::Config {
1066+
message: format!("failed to write {}: {error}", config_path.display()),
1067+
})?;
1068+
eprintln!("\x1b[32m✔\x1b[0m Wrote {}", config_path.display());
1069+
Ok(())
1070+
}
1071+
1072+
fn codex_hook_state_table_is_explicit(contents: &str) -> bool {
1073+
contents.lines().any(|line| line.trim() == "[hooks.state]")
1074+
}
1075+
10481076
/// Auto-trust the installed plugin's hooks, printing a concise confirmation on
10491077
/// full success and falling back to [`print_hook_trust_guidance`] whenever a
10501078
/// hook is skipped by the safety valve or the config could not be written.
@@ -1855,8 +1883,17 @@ fn doctor_check_hooks(
18551883
let entries = codex_hook_trust_entries_for_marketplace(&hooks, marketplace_name);
18561884
match load_toml_file(config_path) {
18571885
Ok(config) => match codex_plugin_hook_trust_state(&config, &entries) {
1858-
CodexHookTrustState::Trusted => dc.pass(&format!(
1859-
"Codex hook trust entries recorded and current in {}",
1886+
CodexHookTrustState::Trusted
1887+
if std::fs::read_to_string(config_path)
1888+
.is_ok_and(|contents| codex_hook_state_table_is_explicit(&contents)) =>
1889+
{
1890+
dc.pass(&format!(
1891+
"Codex hook trust entries recorded and current in {}",
1892+
config_path.display()
1893+
));
1894+
}
1895+
CodexHookTrustState::Trusted => dc.warn(&format!(
1896+
"Codex hook trust records in {} lack an explicit [hooks.state] table, so Codex still requests review; run `tracedecay update-plugin` to repair and auto-trust them",
18601897
config_path.display()
18611898
)),
18621899
CodexHookTrustState::Missing(missing) => dc.info(&format!(

src/agents/codex/tests.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,33 @@ trusted_hash = "sha256:foreign"
263263
"#,
264264
)
265265
.unwrap();
266+
#[cfg(unix)]
267+
{
268+
use std::os::unix::fs::PermissionsExt;
269+
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600)).unwrap();
270+
}
266271

267272
let outcome = sync_codex_hook_trust(home.path(), TEST_BIN).unwrap();
268273
assert_eq!(outcome.trusted, CODEX_MANAGED_HOOKS.len());
269274
assert!(outcome.skipped.is_empty());
275+
#[cfg(unix)]
276+
{
277+
use std::os::unix::fs::PermissionsExt;
278+
assert_eq!(
279+
std::fs::metadata(&config_path)
280+
.unwrap()
281+
.permissions()
282+
.mode()
283+
& 0o777,
284+
0o600
285+
);
286+
}
287+
288+
let config_text = std::fs::read_to_string(&config_path).unwrap();
289+
assert!(
290+
config_text.lines().any(|line| line == "[hooks.state]"),
291+
"Codex requires an explicit [hooks.state] parent table before trusting child records"
292+
);
270293

271294
let entries = managed_entries(TEST_BIN);
272295
let config = load_toml_file(&config_path).unwrap();

src/db/connection.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,45 @@ impl Database {
256256
/// This ensures all committed transactions are merged into the main DB
257257
/// before the process exits, preventing a stale WAL file on next startup.
258258
pub async fn checkpoint(&self) -> Result<()> {
259-
self.conn
260-
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
259+
let mut rows = self
260+
.conn
261+
.query("PRAGMA wal_checkpoint(TRUNCATE);", ())
261262
.await
262263
.map_err(|e| TraceDecayError::Database {
263264
message: format!("failed to checkpoint WAL: {e}"),
264265
operation: "checkpoint".to_string(),
265266
})?;
267+
let row = rows
268+
.next()
269+
.await
270+
.map_err(|e| TraceDecayError::Database {
271+
message: format!("failed to read WAL checkpoint status: {e}"),
272+
operation: "checkpoint".to_string(),
273+
})?
274+
.ok_or_else(|| TraceDecayError::Database {
275+
message: "WAL checkpoint returned no status row".to_string(),
276+
operation: "checkpoint".to_string(),
277+
})?;
278+
let busy: i64 = row.get(0).map_err(|e| TraceDecayError::Database {
279+
message: format!("failed to read WAL checkpoint busy status: {e}"),
280+
operation: "checkpoint".to_string(),
281+
})?;
282+
let log_frames: i64 = row.get(1).map_err(|e| TraceDecayError::Database {
283+
message: format!("failed to read WAL checkpoint frame count: {e}"),
284+
operation: "checkpoint".to_string(),
285+
})?;
286+
let checkpointed_frames: i64 = row.get(2).map_err(|e| TraceDecayError::Database {
287+
message: format!("failed to read WAL checkpoint completion count: {e}"),
288+
operation: "checkpoint".to_string(),
289+
})?;
290+
if busy != 0 || checkpointed_frames < log_frames {
291+
return Err(TraceDecayError::Database {
292+
message: format!(
293+
"WAL checkpoint incomplete: busy={busy}, log_frames={log_frames}, checkpointed_frames={checkpointed_frames}"
294+
),
295+
operation: "checkpoint".to_string(),
296+
});
297+
}
266298
Ok(())
267299
}
268300

src/doctor.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,10 @@ fn database_recovery_guidance(db_path: &Path) -> String {
309309
let wal_path = db_path.with_extension("db-wal");
310310
let shm_path = db_path.with_extension("db-shm");
311311
let data_root = db_path.parent().unwrap_or_else(|| Path::new("."));
312-
let dirty_path = data_root.join("dirty");
312+
let mut graph_dirty = db_path.as_os_str().to_os_string();
313+
graph_dirty.push(".dirty");
314+
let graph_dirty = PathBuf::from(graph_dirty);
315+
let legacy_dirty = data_root.join("dirty");
313316
let sessions_path = data_root.join(crate::storage::SESSIONS_DB_FILENAME);
314317

315318
format!(
@@ -318,15 +321,17 @@ fn database_recovery_guidance(db_path: &Path) -> String {
318321
DB: {}\n\
319322
WAL: {}\n\
320323
SHM: {}\n\
321-
dirty sentinel: {}\n\
324+
graph dirty sentinel: {}\n\
325+
legacy dirty sentinel (if present): {}\n\
322326
`sessions.db` is separate and must not be removed: {}\n\
323327
Facts are stored in the graph database; automatic rebuild is intentionally blocked because it cannot preserve them generically.\n\
324328
Do not run `tracedecay init`, `tracedecay sync --force`, or `tracedecay wipe` until that recovery set is safely copied.\n\
325329
Report the preserved set at https://github.com/ScriptedAlchemy/tracedecay/issues for offline recovery.",
326330
db_path.display(),
327331
wal_path.display(),
328332
shm_path.display(),
329-
dirty_path.display(),
333+
graph_dirty.display(),
334+
legacy_dirty.display(),
330335
sessions_path.display(),
331336
)
332337
}

src/doctor/tests.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,15 @@ fn database_recovery_guidance_names_the_preserved_recovery_set() {
215215
let db_path = PathBuf::from("/profile/projects/proj_test/tracedecay.db");
216216
let guidance = database_recovery_guidance(&db_path);
217217

218-
assert!(guidance.contains("/profile/projects/proj_test/tracedecay.db"));
219-
assert!(guidance.contains("/profile/projects/proj_test/tracedecay.db-wal"));
220-
assert!(guidance.contains("/profile/projects/proj_test/tracedecay.db-shm"));
221-
assert!(guidance.contains("/profile/projects/proj_test/dirty"));
218+
for path in [
219+
db_path.clone(),
220+
db_path.with_extension("db-wal"),
221+
db_path.with_extension("db-shm"),
222+
PathBuf::from(format!("{}.dirty", db_path.display())),
223+
db_path.parent().unwrap().join("dirty"),
224+
] {
225+
assert!(guidance.contains(&path.display().to_string()));
226+
}
222227
assert!(guidance.contains("stop all TraceDecay daemon and MCP processes"));
223228
assert!(
224229
guidance.contains(

src/global_db.rs

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -971,20 +971,38 @@ impl GlobalDb {
971971
/// in-process and retried briefly to also cover a racing *external*
972972
/// process (e.g. two MCP servers starting simultaneously).
973973
pub async fn open_at(db_path: &std::path::Path) -> Option<Self> {
974+
Self::open_at_with_backfill(db_path, true).await
975+
}
976+
977+
/// Opens and ensures a writable session store without starting detached
978+
/// structured backfill. Bulk multi-store catch-up uses this to avoid
979+
/// launching one competing backfill task per registered project.
980+
pub async fn open_at_without_structured_backfill(db_path: &std::path::Path) -> Option<Self> {
981+
Self::open_at_with_backfill(db_path, false).await
982+
}
983+
984+
async fn open_at_with_backfill(
985+
db_path: &std::path::Path,
986+
spawn_structured_backfill: bool,
987+
) -> Option<Self> {
974988
static OPEN_ENSURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
975989
let _guard = OPEN_ENSURE_LOCK.lock().await;
976990
for attempt in 0..3_u64 {
977991
if attempt > 0 {
978992
tokio::time::sleep(std::time::Duration::from_millis(50 * attempt)).await;
979993
}
980-
if let Some(db) = Self::open_at_unsynchronized(db_path).await {
994+
if let Some(db) = Self::open_at_unsynchronized(db_path, spawn_structured_backfill).await
995+
{
981996
return Some(db);
982997
}
983998
}
984999
None
9851000
}
9861001

987-
async fn open_at_unsynchronized(db_path: &std::path::Path) -> Option<Self> {
1002+
async fn open_at_unsynchronized(
1003+
db_path: &std::path::Path,
1004+
spawn_structured_backfill: bool,
1005+
) -> Option<Self> {
9881006
if let Some(parent) = db_path.parent() {
9891007
std::fs::create_dir_all(parent).ok()?;
9901008
}
@@ -1204,7 +1222,9 @@ impl GlobalDb {
12041222
// runs on every open (per hook event, per CLI/MCP invocation), so it
12051223
// must not block: schedule it on a detached background task rather than
12061224
// synchronously reading and re-parsing a batch of multi-MB transcripts.
1207-
db.spawn_structured_backfill();
1225+
if spawn_structured_backfill {
1226+
db.spawn_structured_backfill();
1227+
}
12081228

12091229
Some(db)
12101230
}
@@ -3477,10 +3497,17 @@ impl GlobalDb {
34773497
return false;
34783498
}
34793499
}
3480-
if !self
3481-
.set_parse_offset_in_existing_tx(parse_offset_path, parse_offset)
3482-
.await
3483-
{
3500+
let cursor_set = match mode {
3501+
TranscriptWriteMode::Full => {
3502+
self.set_parse_offset_in_existing_tx(parse_offset_path, parse_offset)
3503+
.await
3504+
}
3505+
TranscriptWriteMode::ProjectionOnly => {
3506+
self.set_parse_offset_monotonic_in_existing_tx(parse_offset_path, parse_offset)
3507+
.await
3508+
}
3509+
};
3510+
if !cursor_set {
34843511
let _ = self.conn.execute("ROLLBACK", ()).await;
34853512
return false;
34863513
}
@@ -4851,6 +4878,39 @@ impl GlobalDb {
48514878
let _ = self.set_parse_offset_in_existing_tx(path, offset).await;
48524879
}
48534880

4881+
/// Advances a row-style parse cursor without allowing an overlapping,
4882+
/// older sweep to move it backwards.
4883+
pub async fn advance_parse_offset(&self, path: &str, offset: ParseOffset) {
4884+
let _ = self
4885+
.set_parse_offset_monotonic_in_existing_tx(path, offset)
4886+
.await;
4887+
}
4888+
4889+
async fn set_parse_offset_monotonic_in_existing_tx(
4890+
&self,
4891+
path: &str,
4892+
offset: ParseOffset,
4893+
) -> bool {
4894+
self.conn
4895+
.execute(
4896+
"INSERT INTO parse_offsets (file_path, byte_offset, mtime, file_id)
4897+
VALUES (?1, ?2, ?3, ?4)
4898+
ON CONFLICT(file_path) DO UPDATE SET
4899+
byte_offset = excluded.byte_offset,
4900+
mtime = excluded.mtime,
4901+
file_id = excluded.file_id
4902+
WHERE excluded.byte_offset >= parse_offsets.byte_offset",
4903+
params![
4904+
path,
4905+
offset.byte_offset as i64,
4906+
offset.mtime as i64,
4907+
offset.file_id as i64
4908+
],
4909+
)
4910+
.await
4911+
.is_ok()
4912+
}
4913+
48544914
async fn set_parse_offset_in_existing_tx(&self, path: &str, offset: ParseOffset) -> bool {
48554915
if self
48564916
.conn

0 commit comments

Comments
 (0)