Skip to content

Commit 2e06272

Browse files
Merge pull request #448 from ScriptedAlchemy/fix/user-message-search-storage-scope
fix(sessions): harden user routing and daemon shutdown
2 parents c86952c + 497500b commit 2e06272

44 files changed

Lines changed: 1529 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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+
Fix user-session search and startup ingestion, cross-provider session attribution, final Codex turn ingestion, legacy Hermes profile migration, dashboard session ambiguity, and daemon shutdown of automation process trees.

.config/nextest.toml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ filter = '(binary(=daemon_suite) & test(/^git_watch_test::(fifty_commit_rebase_n
2222
slow-timeout = { period = "60s", terminate-after = 10 }
2323

2424
[test-groups]
25-
windows-dashboard-server = { max-threads = 32 }
2625
windows-tracedecay-init = { max-threads = 32 }
2726
windows-init-heavy = { max-threads = 32 }
2827
windows-profile-storage = { max-threads = 32 }
@@ -34,8 +33,19 @@ windows-timing-sensitive = { max-threads = 32 }
3433

3534
[[profile.ci.overrides]]
3635
filter = 'binary(=dashboard_api_test)'
37-
platform = { host = 'cfg(windows)' }
38-
test-group = 'windows-dashboard-server'
36+
threads-required = "num-cpus"
37+
38+
[[profile.default.overrides]]
39+
filter = 'binary(=dashboard_api_test)'
40+
threads-required = "num-cpus"
41+
42+
[[profile.ci.overrides]]
43+
filter = 'binary(=core_cli_suite) & test(/^tool_daemon_test::(daemon_socket_is_owner_only|daemon_sigterm_exits_while_project_client_is_connected)$/)'
44+
threads-required = "num-cpus"
45+
46+
[[profile.default.overrides]]
47+
filter = 'binary(=core_cli_suite) & test(/^tool_daemon_test::(daemon_socket_is_owner_only|daemon_sigterm_exits_while_project_client_is_connected)$/)'
48+
threads-required = "num-cpus"
3949

4050
[[profile.ci.overrides]]
4151
filter = 'binary(=agent_suite) | (binary(=storage_suite) & test(/^branch_db_safety_test::/)) | (binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))'

src/agents/codex.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,12 @@ const CODEX_MANAGED_HOOKS: &[CodexManagedHook] = &[
716716
timeout_secs: 120,
717717
matcher: Some("auto|manual"),
718718
},
719+
CodexManagedHook {
720+
event: "Stop",
721+
subcommand: "hook-codex-stop",
722+
timeout_secs: 5,
723+
matcher: None,
724+
},
719725
];
720726

721727
/// Subcommands from older bundles that uninstall must also strip even though

src/agents/codex/tests.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ fn codex_hook_trust_state_reports_missing_entries() {
173173
CodexHookTrustState::Missing(vec![
174174
"post_compact".to_string(),
175175
"session_start".to_string(),
176+
"stop".to_string(),
176177
"subagent_start".to_string(),
177178
"user_prompt_submit".to_string(),
178179
])
@@ -230,6 +231,9 @@ trusted_hash = "sha256:subagent"
230231
231232
[hooks.state."tracedecay@local-repo:hooks/hooks.json:post_compact:0:0"]
232233
trusted_hash = "sha256:compact"
234+
235+
[hooks.state."tracedecay@local-repo:hooks/hooks.json:stop:0:0"]
236+
trusted_hash = "sha256:stop"
233237
"#,
234238
)
235239
.unwrap();
@@ -240,6 +244,7 @@ trusted_hash = "sha256:compact"
240244
"post_compact".to_string(),
241245
"post_tool_use".to_string(),
242246
"session_start".to_string(),
247+
"stop".to_string(),
243248
"subagent_start".to_string(),
244249
"user_prompt_submit".to_string(),
245250
])

src/automation/jobs.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@ async fn run_pre_run_command(command: &str, project_root: Option<&Path>) -> Resu
815815
if let Some(project_root) = project_root {
816816
process.current_dir(project_root);
817817
}
818+
// Scheduler shutdown aborts the owning future. Ensure a pre-run command
819+
// does not outlive that future and keep the daemon cgroup alive.
820+
process.kill_on_drop(true);
818821
let output = tokio::time::timeout(
819822
Duration::from_secs(JOB_COMMAND_TIMEOUT_SECS),
820823
process.output(),

src/cli.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ pub enum Commands {
254254
/// Codex PostCompact hook handler for app-server LCM summaries (called by Codex)
255255
#[command(name = "hook-codex-post-compact", hide = true)]
256256
HookCodexPostCompact,
257+
/// Codex Stop hook handler for final-turn user-session ingestion (called by Codex)
258+
#[command(name = "hook-codex-stop", hide = true)]
259+
HookCodexStop,
257260
/// Hermes terminal receipt handler (called by the TraceDecay plugin)
258261
#[command(name = "hook-hermes-terminal-receipt", hide = true)]
259262
HookHermesTerminalReceipt,

src/commands.rs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -494,42 +494,84 @@ pub(crate) async fn handle_migrate_action(action: MigrateAction) -> tracedecay::
494494
&prefixes,
495495
tracedecay::migrate::registry::StaleRootScope::CanonicalRootMissing,
496496
);
497-
let deleted = if apply {
497+
let profile_root = tracedecay::config::user_data_dir();
498+
let mut stale_storage_projects = Vec::new();
499+
for project_path in global_db.list_project_paths().await {
500+
let path = Path::new(&project_path);
501+
if !prefixes.is_empty() && !prefixes.iter().any(|prefix| path.starts_with(prefix)) {
502+
continue;
503+
}
504+
let location = global::classify_project_storage_with_registry(
505+
path,
506+
Some(&global_db),
507+
profile_root.as_deref(),
508+
)
509+
.await;
510+
if location.status == global::ProjectStorageStatus::Stale {
511+
stale_storage_projects.push(project_path);
512+
}
513+
}
514+
let (deleted_code_projects, deleted_storage_projects) = if apply {
498515
let project_ids: Vec<String> = stale
499516
.iter()
500517
.map(|project| project.project_id.clone())
501518
.collect();
502-
global_db.delete_code_projects(&project_ids).await
519+
(
520+
global_db.delete_code_projects(&project_ids).await,
521+
global_db.delete_projects(&stale_storage_projects).await,
522+
)
503523
} else {
504-
0
524+
(0, 0)
505525
};
526+
let candidate_paths = stale
527+
.iter()
528+
.map(|project| {
529+
tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(
530+
&project.canonical_root,
531+
))
532+
})
533+
.chain(stale_storage_projects.iter().map(|path| {
534+
tracedecay::global_db::GlobalDb::canonical_project_key(Path::new(path))
535+
}))
536+
.collect::<std::collections::BTreeSet<_>>();
537+
let candidate_count = candidate_paths.len();
538+
let metadata_candidate_count = stale.len() + stale_storage_projects.len();
539+
let deleted_count = deleted_code_projects + deleted_storage_projects;
506540
if json {
507541
println!(
508542
"{}",
509543
serde_json::to_string_pretty(&serde_json::json!({
510544
"apply": apply,
511545
"prefix": prefix,
512-
"candidate_count": stale.len(),
513-
"deleted_count": deleted,
546+
"candidate_count": candidate_count,
547+
"metadata_candidate_count": metadata_candidate_count,
548+
"code_project_candidate_count": stale.len(),
549+
"storage_project_candidate_count": stale_storage_projects.len(),
550+
"deleted_count": deleted_count,
551+
"deleted_code_project_count": deleted_code_projects,
552+
"deleted_storage_project_count": deleted_storage_projects,
514553
"candidates": stale,
554+
"storage_project_candidates": stale_storage_projects,
515555
}))?
516556
);
517557
} else {
518558
println!(
519559
"registry-gc: {} stale project(s){}",
520-
stale.len(),
560+
candidate_count,
521561
if apply { " selected" } else { " found" }
522562
);
523563
if apply {
524-
println!("deleted: {deleted}");
564+
println!(
565+
"metadata rows deleted: {deleted_count} ({deleted_code_projects} identity, {deleted_storage_projects} storage)"
566+
);
525567
} else {
526568
println!("dry run: re-run with --apply to delete registry metadata");
527569
}
528-
for project in stale.iter().take(20) {
529-
println!("{} {}", project.project_id, project.canonical_root);
570+
for project_path in candidate_paths.iter().take(20) {
571+
println!("{project_path}");
530572
}
531-
if stale.len() > 20 {
532-
println!("... {} more", stale.len() - 20);
573+
if candidate_count > 20 {
574+
println!("... {} more", candidate_count - 20);
533575
}
534576
}
535577
}

src/config.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -982,14 +982,17 @@ pub fn lock_user_data_dir_test_env() -> std::sync::MutexGuard<'static, ()> {
982982
.unwrap_or_else(std::sync::PoisonError::into_inner)
983983
}
984984

985-
/// Pins [`USER_DATA_DIR_ENV`] to an isolated temp profile while holding
986-
/// [`USER_DATA_DIR_TEST_LOCK`], so parallel lib tests cannot race profile
987-
/// resolution during `TraceDecay::init` / indexing.
985+
/// Pins [`USER_DATA_DIR_ENV`] and agent home discovery to an isolated temp
986+
/// profile while holding [`USER_DATA_DIR_TEST_LOCK`], so parallel lib tests
987+
/// cannot race profile resolution or scan live host transcripts during
988+
/// `TraceDecay::init` / indexing.
988989
#[cfg(test)]
989990
pub struct PinnedUserDataDir {
990991
_lock: std::sync::MutexGuard<'static, ()>,
991992
_root: tempfile::TempDir,
992993
previous: Option<OsString>,
994+
previous_home: Option<OsString>,
995+
previous_userprofile: Option<OsString>,
993996
}
994997

995998
#[cfg(test)]
@@ -1002,13 +1005,19 @@ impl PinnedUserDataDir {
10021005
fs::create_dir_all(&profile)
10031006
.unwrap_or_else(|err| panic!("failed to create isolated profile root: {err}"));
10041007
let previous = std::env::var_os(USER_DATA_DIR_ENV);
1008+
let previous_home = std::env::var_os("HOME");
1009+
let previous_userprofile = std::env::var_os("USERPROFILE");
10051010
unsafe {
10061011
std::env::set_var(USER_DATA_DIR_ENV, &profile);
1012+
std::env::set_var("HOME", root.path());
1013+
std::env::set_var("USERPROFILE", root.path());
10071014
}
10081015
Self {
10091016
_lock: lock,
10101017
_root: root,
10111018
previous,
1019+
previous_home,
1020+
previous_userprofile,
10121021
}
10131022
}
10141023
}
@@ -1028,6 +1037,14 @@ impl Drop for PinnedUserDataDir {
10281037
Some(previous) => std::env::set_var(USER_DATA_DIR_ENV, previous),
10291038
None => std::env::remove_var(USER_DATA_DIR_ENV),
10301039
}
1040+
match self.previous_home.take() {
1041+
Some(previous) => std::env::set_var("HOME", previous),
1042+
None => std::env::remove_var("HOME"),
1043+
}
1044+
match self.previous_userprofile.take() {
1045+
Some(previous) => std::env::set_var("USERPROFILE", previous),
1046+
None => std::env::remove_var("USERPROFILE"),
1047+
}
10311048
}
10321049
}
10331050
}

src/daemon.rs

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750);
4343
const DAEMON_SHUTDOWN_DEADLINE: Duration = Duration::from_secs(45);
4444
#[cfg(unix)]
4545
const DAEMON_CLIENT_DRAIN_DEADLINE: Duration = Duration::from_secs(15);
46+
#[cfg(unix)]
47+
const DAEMON_TASK_ABORT_DEADLINE: Duration = Duration::from_secs(2);
4648

4749
#[derive(Clone, Default)]
4850
pub(crate) struct DaemonLifecycle {
@@ -1534,22 +1536,40 @@ async fn run_foreground_unix(socket_path: PathBuf) -> Result<()> {
15341536
client_tasks.spawn(async move { Box::pin(serve_socket_client(stream, engine)).await });
15351537
}
15361538
engine.lifecycle.begin_draining();
1537-
log_daemon_event(
1538-
"daemon_shutdown",
1539-
&[("socket", socket_path.display().to_string())],
1540-
);
15411539
// Stop accepting and unlink the socket before draining so clients that
15421540
// connect during shutdown get NotFound/ConnectionRefused (which they retry
15431541
// via `connect_with_restart_grace`) instead of a queued connection that
15441542
// will never be served.
15451543
drop(listener);
15461544
let _ = std::fs::remove_file(&socket_path);
1547-
let clients_drained = drain_client_tasks(&mut client_tasks, DAEMON_CLIENT_DRAIN_DEADLINE).await;
1548-
engine.lifecycle.wait_for_idle().await;
1545+
// Keep auxiliary process creation blocked until every scheduler and client
1546+
// task is drained or abandoned. A killed app-server call may retry before
1547+
// unwinding, so a shorter guard leaves a shutdown-time respawn race.
1548+
let _codex_shutdown = crate::sessions::codex_app_server::begin_codex_app_server_shutdown();
1549+
// Stop automation before announcing shutdown or waiting for clients.
1550+
// Scheduler tasks may be inside a synchronous auxiliary-agent call, so
1551+
// shutdown also terminates their tracked process trees before joining.
1552+
engine.shutdown_automation_schedulers().await;
1553+
log_daemon_event(
1554+
"daemon_shutdown",
1555+
&[("socket", socket_path.display().to_string())],
1556+
);
1557+
let in_flight_drained = timeout(
1558+
DAEMON_CLIENT_DRAIN_DEADLINE,
1559+
engine.lifecycle.wait_for_idle(),
1560+
)
1561+
.await
1562+
.is_ok();
1563+
// Once admitted requests are finished (or their bound elapsed), every
1564+
// remaining client task is an idle socket reader or already-cancelled
1565+
// request wrapper. Abort those immediately instead of making shutdown wait
1566+
// for clients to close persistent connections themselves.
1567+
client_tasks.abort_all();
1568+
let clients_drained = drain_client_tasks(&mut client_tasks, DAEMON_TASK_ABORT_DEADLINE).await;
15491569
// Client setup and in-flight requests may create schedulers or project
15501570
// servers. Sweep owned background tasks only after all client work drains.
15511571
engine.shutdown_background_tasks().await;
1552-
if !clients_drained {
1572+
if !in_flight_drained || !clients_drained {
15531573
log_daemon_event(
15541574
"daemon_shutdown",
15551575
&[
@@ -1618,9 +1638,12 @@ async fn drain_client_tasks(clients: &mut JoinSet<Result<()>>, deadline: Duratio
16181638
}
16191639

16201640
clients.abort_all();
1621-
while let Some(completed) = clients.join_next().await {
1622-
log_client_task_result(completed);
1623-
}
1641+
let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, async {
1642+
while let Some(completed) = clients.join_next().await {
1643+
log_client_task_result(completed);
1644+
}
1645+
})
1646+
.await;
16241647
false
16251648
}
16261649

@@ -1905,6 +1928,9 @@ impl DaemonEngine {
19051928
project_path: PathBuf,
19061929
handshake: DaemonHandshake,
19071930
) {
1931+
if !self.lifecycle.accepting() {
1932+
return;
1933+
}
19081934
{
19091935
let schedulers = self.automation_schedulers.lock().await;
19101936
if schedulers.contains_key(&key) {
@@ -1976,8 +2002,11 @@ impl DaemonEngine {
19762002
project_path: PathBuf,
19772003
handshake: DaemonHandshake,
19782004
) {
2005+
if !self.lifecycle.accepting() {
2006+
return;
2007+
}
19792008
let mut schedulers = self.automation_schedulers.lock().await;
1980-
if schedulers.contains_key(&key) {
2009+
if !self.lifecycle.accepting() || schedulers.contains_key(&key) {
19812010
return;
19822011
}
19832012
let wake = Arc::new(tokio::sync::Notify::new());
@@ -1994,14 +2023,7 @@ impl DaemonEngine {
19942023
}
19952024

19962025
async fn shutdown_background_tasks(&self) {
1997-
let scheduler_handles: Vec<JoinHandle<()>> = {
1998-
let mut schedulers = self.automation_schedulers.lock().await;
1999-
schedulers.drain().map(|(_, handle)| handle.task).collect()
2000-
};
2001-
for handle in scheduler_handles {
2002-
handle.abort();
2003-
let _ = handle.await;
2004-
}
2026+
self.shutdown_automation_schedulers().await;
20052027

20062028
self.git_watcher.shutdown().await;
20072029
if let Some(handle) = self.pr_autotrack_task.lock().await.take() {
@@ -2010,6 +2032,23 @@ impl DaemonEngine {
20102032
}
20112033
}
20122034

2035+
async fn shutdown_automation_schedulers(&self) {
2036+
let scheduler_handles: Vec<JoinHandle<()>> = {
2037+
let mut schedulers = self.automation_schedulers.lock().await;
2038+
schedulers.drain().map(|(_, handle)| handle.task).collect()
2039+
};
2040+
let _child_shutdown = crate::sessions::codex_app_server::begin_codex_app_server_shutdown();
2041+
for handle in &scheduler_handles {
2042+
handle.abort();
2043+
}
2044+
let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, async {
2045+
for handle in scheduler_handles {
2046+
let _ = handle.await;
2047+
}
2048+
})
2049+
.await;
2050+
}
2051+
20132052
async fn shutdown_servers(&self) {
20142053
let servers: Vec<Arc<crate::mcp::McpServer>> = {
20152054
let servers = self.project_servers.lock().await;

0 commit comments

Comments
 (0)