From d50588d76e4661d5dab99010b8e4b53c1defd036 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 22:51:59 +0000 Subject: [PATCH 01/11] fix: harden runtime routing and ci --- .changeset/runtime-routing-ci-hardening.md | 5 + .github/workflows/ci.yml | 2 +- .github/workflows/release-beta.yml | 4 + .github/workflows/release-plz.yml | 9 +- .github/workflows/release-pr-integrity.yml | 4 + .github/workflows/release.yml | 2 +- src/agents/hermes/templates/plugin_init.py | 3 +- src/analytics_bridge.rs | 135 +++++++++++++++++++-- src/automation/backend.rs | 17 ++- src/automation/memory_curator.rs | 86 ++++++++++--- src/global_db.rs | 25 ++++ src/sessions/mod.rs | 36 +++++- tests/automation_runner_test/backend.rs | 21 ++++ tests/hermes_suite/lcm_bridge.rs | 19 +++ tests/release_workflow_contract_test.sh | 16 +++ tests/session_suite/structured_backfill.rs | 8 +- 16 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 .changeset/runtime-routing-ci-hardening.md diff --git a/.changeset/runtime-routing-ci-hardening.md b/.changeset/runtime-routing-ci-hardening.md new file mode 100644 index 000000000..12fcd53e0 --- /dev/null +++ b/.changeset/runtime-routing-ci-hardening.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Fix project and user session attribution across modern registry aliases, report analytics from sharded session stores, bound autonomous memory-curator requests and recover from stale oversize failures, expose the Hermes plugin skill through first-turn discovery, and harden CI/release concurrency without disabling Windows coverage. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38ad5fc44..c98a170ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,7 +306,7 @@ jobs: windows-test: name: Test Windows - if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref)) }} + if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref)) }} needs: windows-test-shard runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 0a318cb26..7be60eab6 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -17,6 +17,10 @@ permissions: env: CARGO_TERM_COLOR: always +concurrency: + group: release-beta-${{ github.ref }} + cancel-in-progress: false + jobs: build: name: Build ${{ matrix.name }} diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index ca35bfaf2..327c94098 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -11,6 +11,12 @@ permissions: env: CARGO_TERM_COLOR: always +# Publishing mutates crates.io and GitHub release state. Serialize master +# updates, but never cancel a publication after it has started. +concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + jobs: release-plz-release: name: Publish crate and create GitHub release @@ -92,9 +98,6 @@ jobs: permissions: contents: write pull-requests: write - concurrency: - group: release-plz-${{ github.ref }} - cancel-in-progress: false steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/release-pr-integrity.yml b/.github/workflows/release-pr-integrity.yml index 66d0bf192..8b15b863e 100644 --- a/.github/workflows/release-pr-integrity.yml +++ b/.github/workflows/release-pr-integrity.yml @@ -8,6 +8,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: release-pr-integrity: name: Release PR integrity diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b85c6e8e4..8675d7d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ env: concurrency: group: release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: build: diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index 11d2cc9a4..f6ea2074a 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -435,7 +435,8 @@ def _pre_llm_call(*args, **kwargs): return None return ( "For this codebase request, prefer tracedecay tools for symbol lookup, call graphs, " - "impact analysis, affected files, and architectural navigation before broad file reads." + "impact analysis, affected files, and architectural navigation before broad file reads. " + "For the full workflow, load plugin skill `tracedecay:tracedecay` with `skill_view`." ) _TERMINAL_TOOL_NAMES = frozenset(( diff --git a/src/analytics_bridge.rs b/src/analytics_bridge.rs index 0dbfb7b77..872c0f58f 100644 --- a/src/analytics_bridge.rs +++ b/src/analytics_bridge.rs @@ -7,6 +7,7 @@ //! `analytics_events` so one durable table answers adoption questions, using //! per-file byte cursors in `parse_offsets` to stay idempotent across runs. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -248,6 +249,48 @@ async fn open_global_db() -> crate::errors::Result { }) } +async fn diagnostics_message_count( + global: &GlobalDb, + project_root: Option<&Path>, + all_projects: bool, +) -> i64 { + if all_projects { + let mut session_db_paths = BTreeSet::new(); + if let Some(profile_root) = global.db_path().parent() { + session_db_paths.insert(crate::sessions::user_sessions_db_path(profile_root)); + } + for project_root in crate::sessions::registered_project_roots_from(global) + .await + .unwrap_or_default() + { + if let Some(db_path) = + crate::sessions::cursor::resolved_project_session_db_path(&project_root).await + { + session_db_paths.insert(db_path); + } + } + let mut total = 0; + for db_path in session_db_paths { + if let Some(sessions) = GlobalDb::open_read_only_at(&db_path).await { + total += sessions.session_message_count().await.unwrap_or(0); + } + } + return total; + } + let Some(project_root) = project_root else { + return 0; + }; + let Some(db_path) = + crate::sessions::cursor::resolved_project_session_db_path(project_root).await + else { + return 0; + }; + let Some(sessions) = GlobalDb::open_read_only_at(&db_path).await else { + return 0; + }; + sessions.session_message_count().await.unwrap_or(0) +} + /// `tracedecay analytics sync`: import hook JSONL rows into the durable /// `analytics_events` table and print what happened. pub async fn run_analytics_sync() -> crate::errors::Result<()> { @@ -316,13 +359,8 @@ pub async fn run_analytics_diagnostics( hook_filter_root, ); - let message_count = match project_filter.as_deref() { - Some(project_key) => gdb - .session_message_count_for_project(project_key) - .await - .unwrap_or(0), - None => gdb.session_message_count().await.unwrap_or(0), - }; + let message_count = + diagnostics_message_count(&gdb, project_root.as_deref(), all_projects).await; let durable = if event_rows.is_empty() { None @@ -362,7 +400,46 @@ pub async fn run_analytics_diagnostics( mod tests { use std::path::Path; - use super::hook_row_to_analytics_event; + use super::{diagnostics_message_count, hook_row_to_analytics_event}; + use crate::global_db::GlobalDb; + use crate::sessions::{SessionMessageRecord, SessionRecord}; + + async fn seed_session_message(db: &GlobalDb, project: &Path, id: &str) { + db.upsert_session(&SessionRecord { + provider: "codex".to_string(), + session_id: id.to_string(), + project_key: project.display().to_string(), + project_path: project.display().to_string(), + title: None, + started_at: Some(1), + ended_at: None, + transcript_path: None, + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }) + .await; + assert!( + db.upsert_session_message(&SessionMessageRecord { + provider: "codex".to_string(), + message_id: format!("{id}-message"), + session_id: id.to_string(), + role: "user".to_string(), + timestamp: Some(1), + ordinal: 1, + text: "diagnostics evidence".to_string(), + kind: None, + model: None, + tool_names: None, + source_path: None, + source_offset: None, + metadata_json: None, + }) + .await + ); + } #[test] fn maps_hook_invoked_row_with_attribution() { @@ -412,4 +489,46 @@ mod tests { assert!(hook_row_to_analytics_event("{}", None).is_none()); assert!(hook_row_to_analytics_event("not json", None).is_none()); } + + #[tokio::test] + async fn diagnostics_counts_messages_from_the_project_session_shard() { + let _profile = crate::config::PinnedUserDataDir::new(); + let project = tempfile::tempdir().expect("project tempdir"); + let layout = crate::storage::resolve_layout_for_current_profile(project.path()) + .expect("project layout"); + std::fs::create_dir_all(&layout.data_root).expect("project data root"); + + let global = GlobalDb::open().await.expect("global db"); + let sessions = GlobalDb::open_at(&layout.sessions_db_path) + .await + .expect("project session db"); + seed_session_message(&sessions, project.path(), "project-session").await; + + assert_eq!(global.session_message_count().await.unwrap(), 0); + assert_eq!( + diagnostics_message_count(&global, Some(project.path()), false).await, + 1 + ); + } + + #[tokio::test] + async fn all_project_diagnostics_count_registered_session_shards() { + let _profile = crate::config::PinnedUserDataDir::new(); + let project = tempfile::tempdir().expect("project tempdir"); + let layout = crate::storage::resolve_layout_for_current_profile(project.path()) + .expect("project layout"); + std::fs::create_dir_all(&layout.data_root).expect("project data root"); + + let global = GlobalDb::open().await.expect("global db"); + global + .upsert_code_project("project-shard", project.path(), None, None, Some("main")) + .await; + let sessions = GlobalDb::open_at(&layout.sessions_db_path) + .await + .expect("project session db"); + seed_session_message(&sessions, project.path(), "registered-session").await; + + assert_eq!(global.session_message_count().await.unwrap(), 0); + assert_eq!(diagnostics_message_count(&global, None, true).await, 1); + } } diff --git a/src/automation/backend.rs b/src/automation/backend.rs index fdc48b6ca..dfdf249c3 100644 --- a/src/automation/backend.rs +++ b/src/automation/backend.rs @@ -156,7 +156,16 @@ pub fn agent_task_failure_disposition( error: Option<&str>, ) -> AgentTaskFailureDisposition { let classification = error - .map(classify_agent_task_error_message) + .map(|message| { + if is_oversized_backend_input(message) { + // The next scheduled run rebuilds its request from current + // evidence and code, so an old oversize ledger must not block + // a now-bounded or otherwise changed input forever. + AgentTaskFailureClass::Retryable + } else { + classify_agent_task_error_message(message) + } + }) .or(recorded_classification); let retryable = classification .map(AgentTaskFailureClass::is_retryable) @@ -208,6 +217,12 @@ pub fn classify_agent_task_error_message(message: &str) -> AgentTaskFailureClass AgentTaskFailureClass::Permanent } +fn is_oversized_backend_input(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("input_too_large") + || normalized.contains("input exceeds the maximum length") +} + pub fn agent_task_contract(task: AgentTaskKind) -> AgentTaskContract { AgentTaskContract { task_key: task_key(task).to_string(), diff --git a/src/automation/memory_curator.rs b/src/automation/memory_curator.rs index 578b8d72b..52d79d715 100644 --- a/src/automation/memory_curator.rs +++ b/src/automation/memory_curator.rs @@ -192,13 +192,9 @@ async fn run_memory_curator_for_store( let request = AgentTaskRequest::new( run.run_id.clone(), AgentTaskKind::MemoryCurator, - build_memory_curator_prompt(&llm_review), + build_memory_curator_prompt(), evidence_hash.clone(), - json!({ - "llm_review": llm_review, - "apply": false, - "min_confidence": min_confidence, - }), + memory_curator_backend_context(&llm_review, min_confidence), ); let input_hash = Some(request.input_hash.clone()); let finalizer = run.finalizer(input_hash.clone()); @@ -341,15 +337,22 @@ async fn skipped_run( }) } -fn build_memory_curator_prompt(llm_review: &Value) -> String { - let messages = llm_review - .get("messages") - .cloned() - .unwrap_or_else(|| json!([])); - format!( - "Run TraceDecay memory curation review. Return only the strict JSON object requested by these messages:\n{}", - serde_json::to_string_pretty(&messages).unwrap_or_else(|_| "[]".to_string()) - ) +fn build_memory_curator_prompt() -> String { + "Run TraceDecay memory curation review using context.llm_review.messages. Return only the strict JSON object requested by those messages.".to_string() +} + +fn memory_curator_backend_context(llm_review: &Value, min_confidence: f64) -> Value { + json!({ + "llm_review": { + "status": llm_review.get("status"), + "clusters_reviewed": llm_review.get("clusters_reviewed"), + "allowed_fact_ids": llm_review.get("allowed_fact_ids"), + "min_confidence": llm_review.get("min_confidence"), + "messages": llm_review.get("messages"), + }, + "apply": false, + "min_confidence": min_confidence, + }) } fn memory_curation_apply_policy( @@ -449,3 +452,56 @@ fn default_max_clusters() -> usize { fn default_min_confidence() -> f64 { CURATION_DEFAULT_MIN_CONFIDENCE } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_curator_request_does_not_duplicate_review_messages() { + let marker = "cluster-evidence-that-must-appear-once"; + let review = json!({ + "status": "needs_llm_review", + "messages": [ + { "role": "system", "content": "return strict JSON" }, + { "role": "user", "content": marker }, + ], + }); + + let prompt = build_memory_curator_prompt(); + let request = AgentTaskRequest::new( + "run-1".to_string(), + AgentTaskKind::MemoryCurator, + prompt.clone(), + None, + memory_curator_backend_context(&review, 0.8), + ); + let backend_message = request.backend_message().unwrap(); + + assert!(prompt.contains("TraceDecay memory curation review")); + assert_eq!(backend_message.matches(marker).count(), 1); + } + + #[test] + fn memory_curator_request_stays_below_codex_limit_for_large_review() { + const CODEX_APP_SERVER_MAX_INPUT_CHARS: usize = 1_048_576; + let review = json!({ + "status": "needs_llm_review", + "messages": [ + { "role": "system", "content": "return strict JSON" }, + { "role": "user", "content": "x".repeat(600_000) }, + ], + }); + let request = AgentTaskRequest::new( + "run-1".to_string(), + AgentTaskKind::MemoryCurator, + build_memory_curator_prompt(), + None, + memory_curator_backend_context(&review, 0.8), + ); + + let backend_message = request.backend_message().unwrap(); + + assert!(backend_message.len() < CODEX_APP_SERVER_MAX_INPUT_CHARS); + } +} diff --git a/src/global_db.rs b/src/global_db.rs index 27a03c8e4..82cb45f02 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -2592,6 +2592,31 @@ impl GlobalDb { paths } + /// Returns filesystem aliases from the modern project registry. + /// Synthetic identity aliases (for example `git-common-dir:...`) are + /// intentionally excluded because transcript attribution requires paths. + pub async fn list_project_alias_paths(&self) -> Vec { + let Ok(mut rows) = self + .conn + .query( + "SELECT alias_path FROM project_aliases ORDER BY alias_path", + (), + ) + .await + else { + return Vec::new(); + }; + let mut paths = Vec::new(); + while let Ok(Some(row)) = rows.next().await { + if let Ok(path) = row.get::(0) + && Path::new(&path).is_absolute() + { + paths.push(path); + } + } + paths + } + /// Inserts or replaces a provider session. Returns `false` on any DB error. pub async fn upsert_session(&self, session: &SessionRecord) -> bool { self.conn diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index 4c643d12c..14af2910d 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -62,13 +62,24 @@ async fn try_registered_project_roots_at(profile_root: &Path) -> Option Option> { +pub(crate) async fn registered_project_roots_from(global: &GlobalDb) -> Option> { let mut roots = global .list_project_paths() .await .into_iter() .map(PathBuf::from) .collect::>(); + for project in global.list_code_projects(usize::MAX).await { + roots.push(PathBuf::from(project.canonical_root)); + roots.push(PathBuf::from(project.display_root)); + } + roots.extend( + global + .list_project_alias_paths() + .await + .into_iter() + .map(PathBuf::from), + ); roots.sort(); roots.dedup(); Some(roots) @@ -686,6 +697,29 @@ impl SessionMessageType { mod git_scan_tests { use super::*; + #[tokio::test] + async fn registered_project_roots_include_modern_registry_aliases() { + let temp = tempfile::tempdir().unwrap(); + let canonical = temp.path().join("repo"); + let worktree = temp.path().join("repo-worktree"); + std::fs::create_dir_all(&canonical).unwrap(); + std::fs::create_dir_all(&worktree).unwrap(); + let db = GlobalDb::open_at(&temp.path().join("global.db")) + .await + .unwrap(); + db.upsert_code_project("project-1", &canonical, None, None, None) + .await + .unwrap(); + db.upsert_project_alias(&worktree, "project-1") + .await + .unwrap(); + + let roots = registered_project_roots_from(&db).await.unwrap(); + + assert!(roots.contains(&canonical)); + assert!(roots.contains(&worktree)); + } + #[test] fn provider_scoped_user_catch_up_excludes_unrelated_providers() { assert!(provider_selected( diff --git a/tests/automation_runner_test/backend.rs b/tests/automation_runner_test/backend.rs index c7f8a662b..93362bb0c 100644 --- a/tests/automation_runner_test/backend.rs +++ b/tests/automation_runner_test/backend.rs @@ -256,6 +256,27 @@ fn failure_disposition_heals_stale_recorded_retryability() { assert!(!disposition.is_non_retryable()); } +#[test] +fn oversized_backend_input_is_retryable_after_request_bounding_changes() { + let error = "codex app-server turn failed: input_too_large: Input exceeds the maximum length of 1048576 characters"; + let disposition = agent_task_failure_disposition( + Some(AgentTaskFailureClass::Permanent), + Some(false), + Some(error), + ); + + assert_eq!( + classify_agent_task_error_message(error), + AgentTaskFailureClass::Permanent, + "the same oversized request must not be retried immediately" + ); + assert_eq!( + disposition.classification, + Some(AgentTaskFailureClass::Retryable) + ); + assert_eq!(disposition.retryable, Some(true)); +} + #[test] fn fake_codex_app_server_returns_summary_and_logs_protocol() { let fake = FakeCodexAppServer::new(); diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 22220905d..456f7a462 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -4369,6 +4369,25 @@ assert ctx.skills[0][1].name == "SKILL.md" ); } +/// Stock Hermes keeps plugin skills out of the flat skills index, so the +/// first-turn code nudge must expose the qualified name that skill_view accepts. +#[test] +fn generated_nudge_makes_plugin_skill_discoverable() { + run_generated_plugin_script( + "check_skill_discovery_nudge.py", + r#" +plugin._REGISTERED_TOOL_NAMES.add("tracedecay_search") +text = plugin._pre_llm_call( + is_first_turn=True, + user_message="Find the callers of this Rust function", +) +assert "skill_view" in text, text +assert "tracedecay:tracedecay" in text, text +"#, + "generated nudge must reveal the qualified Hermes plugin skill name", + ); +} + /// Host runtime config may identify a project, but a real session cwd or /// explicit call wins. The plugin-owned config block is filtered separately. #[test] diff --git a/tests/release_workflow_contract_test.sh b/tests/release_workflow_contract_test.sh index 3705fd014..13820e057 100644 --- a/tests/release_workflow_contract_test.sh +++ b/tests/release_workflow_contract_test.sh @@ -3,7 +3,9 @@ set -euo pipefail release_plz=".github/workflows/release-plz.yml" release_workflow=".github/workflows/release.yml" +release_beta=".github/workflows/release-beta.yml" release_pr_integrity=".github/workflows/release-pr-integrity.yml" +ci_workflow=".github/workflows/ci.yml" if grep -q 'GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}' "$release_plz"; then echo "release-plz must not publish releases with GITHUB_TOKEN" >&2 @@ -30,9 +32,22 @@ for name, step in [ raise SystemExit(f"{name} step must use RELEASE_PLZ_TOKEN") PY +grep -Fq "if: \${{ !cancelled() &&" "$ci_workflow" + grep -q 'release:' "$release_workflow" grep -q 'types: \[published\]' "$release_workflow" +python3 - "$release_plz" "$release_workflow" "$release_beta" <<'PY' +import sys + +for path in sys.argv[1:]: + text = open(path, encoding="utf-8").read() + if "concurrency:" not in text: + raise SystemExit(f"{path} must serialize release mutations") + if "cancel-in-progress: false" not in text: + raise SystemExit(f"{path} must never cancel in-progress publication") +PY + python3 - "$release_pr_integrity" <<'PY' import sys @@ -49,6 +64,7 @@ required = [ "git show \"$BASE_SHA:scripts/check-release-pr-integrity.sh\"", "release-extra-files-approved", "scripts/check-release-pr-integrity.sh", + "cancel-in-progress: true", ] for item in required: if item not in text: diff --git a/tests/session_suite/structured_backfill.rs b/tests/session_suite/structured_backfill.rs index abd5a4a17..238f3d0cc 100644 --- a/tests/session_suite/structured_backfill.rs +++ b/tests/session_suite/structured_backfill.rs @@ -729,8 +729,12 @@ async fn structured_backfill_migrates_legacy_global_marker() { /// with the background switch on: it would drop the sweep mid-parse on exit. #[tokio::test] async fn structured_backfill_one_shot_process_never_spawns() { - // Fresh process state (nextest runs each test in its own process): the - // background switch defaults on, but a one-shot process is not long-lived. + // Cargo's default test runner shares this process with tests that disable + // background backfill for deterministic manual sweeps. Restore the + // production default before asserting the independent long-lived gate. + tracedecay::global_db::set_background_structured_backfill_enabled(true); + // In fresh process state (including nextest), the background switch is on, + // but a one-shot process is not long-lived. assert!( !tracedecay::global_db::structured_backfill_will_spawn(), "a one-shot process must not spawn the sweep" From 2af90670f3c37323c362029c8fe2e9e3c10244d6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:00:37 +0000 Subject: [PATCH 02/11] test: isolate orphan manifest reconstruction --- tests/core_cli_suite/cli_non_interactive_test.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index bde71ccaf..e4c04d1b2 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -1456,7 +1456,9 @@ fn list_all_reports_orphan_manifest_reconstructable_store() { .prefix("list-orphan-project-") .tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()) .unwrap(); + git(project.path(), &["init"]); write_profile_sharded_fixture(home.path(), project.path()); + write_repository_identity_marker(project.path(), "proj_cli").unwrap(); write_enrollment_marker( project.path(), &EnrollmentMarker { @@ -1467,6 +1469,17 @@ fn list_all_reports_orphan_manifest_reconstructable_store() { .unwrap(); std::fs::create_dir_all(profile_root(home.path())).unwrap(); + let report = tracedecay::migrate::registry::scan_profile_store_manifests( + &profile_root(home.path()), + tracedecay::tracedecay::current_timestamp(), + ); + assert_eq!(report.plans.len(), 1, "{report:#?}"); + assert_eq!( + report.plans[0].status, + tracedecay::migrate::registry::RegistryReconstructionStatus::Eligible, + "{report:#?}" + ); + let mut command = tracedecay_command(home.path(), project.path()); command.args(["list", "--all"]); let output = run_with_timeout(command, cli_timeout()); From 968e9e402f3e9899e1ca043b221d2413e80665b1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:08:40 +0000 Subject: [PATCH 03/11] test: refresh hermes and identity fixtures --- tests/agent_suite/agent_test.rs | 2 +- tests/storage_suite/profile_storage_migration_test.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 466855e5b..c258721da 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1268,7 +1268,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "89bb095bb94827724521b5fb57238411fbd875c9b6eab45ffe4835f5a42ba9ad", + "52cc1091ed34e5890135bf943602d98f999090796fab6eaa41d180d5dc76fdb3", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } diff --git a/tests/storage_suite/profile_storage_migration_test.rs b/tests/storage_suite/profile_storage_migration_test.rs index a6635ddac..b20bf9271 100644 --- a/tests/storage_suite/profile_storage_migration_test.rs +++ b/tests/storage_suite/profile_storage_migration_test.rs @@ -817,6 +817,7 @@ fn strict_scan_requires_matching_repository_identity_or_enrollment() { let profile_root = dir.path().join("profile"); let project_root = dir.path().join("repo"); write_profile_store_manifest(&profile_root, &project_root); + run_git(&project_root, &["init"]); let unowned = scan_profile_store_manifests(&profile_root, 1); assert_eq!( From 221d42ac9558b51d73624968411cb16b02debb65 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:16:30 +0000 Subject: [PATCH 04/11] test: canonicalize registry alias fixtures --- src/sessions/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index 14af2910d..ab858d4d8 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -704,6 +704,8 @@ mod git_scan_tests { let worktree = temp.path().join("repo-worktree"); std::fs::create_dir_all(&canonical).unwrap(); std::fs::create_dir_all(&worktree).unwrap(); + let canonical = std::fs::canonicalize(canonical).unwrap(); + let worktree = std::fs::canonicalize(worktree).unwrap(); let db = GlobalDb::open_at(&temp.path().join("global.db")) .await .unwrap(); From 46d1017898730f186942fa9e00725e0463027a32 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:19:11 +0000 Subject: [PATCH 05/11] test: restore structured backfill process state --- src/global_db.rs | 7 +++++++ tests/session_suite/structured_backfill.rs | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/global_db.rs b/src/global_db.rs index 82cb45f02..d3446c750 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -902,6 +902,13 @@ pub fn mark_process_long_lived_for_structured_backfill() { STRUCTURED_BACKFILL_LONG_LIVED_PROCESS.store(true, std::sync::atomic::Ordering::Relaxed); } +/// Resets the long-lived-process gate after tests that exercise daemon-only +/// background behavior in a shared test process. +#[doc(hidden)] +pub fn reset_process_long_lived_for_structured_backfill() { + STRUCTURED_BACKFILL_LONG_LIVED_PROCESS.store(false, std::sync::atomic::Ordering::Relaxed); +} + /// Whether [`GlobalDb::spawn_structured_backfill`] will schedule a sweep: the /// background switch is on *and* this process is a long-lived host. This is the /// single predicate the spawn path consults, exposed so tests can assert that a diff --git a/tests/session_suite/structured_backfill.rs b/tests/session_suite/structured_backfill.rs index 238f3d0cc..c613ffdd5 100644 --- a/tests/session_suite/structured_backfill.rs +++ b/tests/session_suite/structured_backfill.rs @@ -745,6 +745,8 @@ async fn structured_backfill_one_shot_process_never_spawns() { tracedecay::global_db::structured_backfill_will_spawn(), "a long-lived host must spawn the sweep" ); + tracedecay::global_db::reset_process_long_lived_for_structured_backfill(); + tracedecay::global_db::set_background_structured_backfill_enabled(false); } /// Two concurrent openers of the same store contend on the sibling lock file: From e91e6ea1864887de0fade7a09bc0fea9fa94ae21 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:19:11 +0000 Subject: [PATCH 06/11] ci: normalize release rebuild identity --- .github/workflows/release-beta.yml | 48 ++++++++++++++++--------- .github/workflows/release.yml | 9 ++--- tests/release_workflow_contract_test.sh | 22 ++++++++++++ 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 7be60eab6..3e58407ab 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -8,6 +8,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + release_tag: + description: "Existing prerelease tag to rebuild and publish, for example v0.1.0-beta.1" + required: true + type: string # Only runs for prerelease tags — stable releases handled by release.yml @@ -16,15 +21,16 @@ permissions: env: CARGO_TERM_COLOR: always + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} concurrency: - group: release-beta-${{ github.ref }} + group: release-beta-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} cancel-in-progress: false jobs: build: name: Build ${{ matrix.name }} - if: vars.BETA_CHANNEL_ENABLED == 'true' && github.event.release.prerelease + if: vars.BETA_CHANNEL_ENABLED == 'true' && (github.event_name == 'workflow_dispatch' || github.event.release.prerelease) runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -50,7 +56,17 @@ jobs: archive: zip steps: + - name: Validate manual prerelease rebuild + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + test "$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq .isPrerelease)" = true + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} - uses: actions/setup-node@v4 with: @@ -97,7 +113,7 @@ jobs: id: version shell: bash run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" # --- Binary archive (all platforms) --- @@ -106,20 +122,20 @@ jobs: if: matrix.archive == 'tar.gz' run: | cd target/${{ matrix.target }}/release - tar czf ../../../tracedecay-beta-${{ github.ref_name }}-${{ matrix.name }}.tar.gz tracedecay + tar czf ../../../tracedecay-beta-${{ env.RELEASE_TAG }}-${{ matrix.name }}.tar.gz tracedecay cd ../../.. - name: Package binary (windows) if: matrix.archive == 'zip' shell: pwsh run: | - Compress-Archive -Path target/${{ matrix.target }}/release/tracedecay.exe -DestinationPath tracedecay-beta-${{ github.ref_name }}-${{ matrix.name }}.zip + Compress-Archive -Path target/${{ matrix.target }}/release/tracedecay.exe -DestinationPath tracedecay-beta-${{ env.RELEASE_TAG }}-${{ matrix.name }}.zip - name: Upload binary archive env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash - run: gh release upload ${{ github.ref_name }} tracedecay-beta-${{ github.ref_name }}-${{ matrix.name }}.${{ matrix.archive }} --clobber + run: gh release upload "$RELEASE_TAG" tracedecay-beta-${{ env.RELEASE_TAG }}-${{ matrix.name }}.${{ matrix.archive }} --clobber # --- Homebrew bottle (only for platforms with bottle_tag) --- @@ -142,7 +158,7 @@ jobs: update-homebrew: name: Update Homebrew tap (beta) - if: vars.BETA_CHANNEL_ENABLED == 'true' && github.event.release.prerelease && needs.build.result == 'success' + if: vars.BETA_CHANNEL_ENABLED == 'true' && needs.build.result == 'success' needs: build runs-on: ubuntu-latest steps: @@ -151,7 +167,7 @@ jobs: - name: Get version id: version run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Download bottle artifacts @@ -165,12 +181,12 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | for f in bottles/tracedecay-beta-*.bottle.tar.gz; do - gh release upload "${GITHUB_REF_NAME}" "$f" --clobber + gh release upload "$RELEASE_TAG" "$f" --clobber done - name: Download source tarball run: | - curl -sL "https://github.com/${{ github.repository }}/archive/refs/tags/${GITHUB_REF_NAME}.tar.gz" -o "source.tar.gz" + curl -sL "https://github.com/${{ github.repository }}/archive/refs/tags/${RELEASE_TAG}.tar.gz" -o "source.tar.gz" - name: Compute SHA256 hashes id: hashes @@ -236,7 +252,7 @@ jobs: update-scoop: name: Update Scoop bucket (beta) - if: vars.BETA_CHANNEL_ENABLED == 'true' && github.event.release.prerelease && needs.build.result == 'success' + if: vars.BETA_CHANNEL_ENABLED == 'true' && needs.build.result == 'success' needs: build runs-on: ubuntu-latest steps: @@ -245,15 +261,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - gh release download "${GITHUB_REF_NAME}" \ + gh release download "${RELEASE_TAG}" \ --repo "${{ github.repository }}" \ - --pattern "tracedecay-beta-${GITHUB_REF_NAME}-x86_64-windows.zip" \ + --pattern "tracedecay-beta-${RELEASE_TAG}-x86_64-windows.zip" \ --dir . - SHA256=$(sha256sum "tracedecay-beta-${GITHUB_REF_NAME}-x86_64-windows.zip" | cut -d' ' -f1) + SHA256=$(sha256sum "tracedecay-beta-${RELEASE_TAG}-x86_64-windows.zip" | cut -d' ' -f1) echo "sha256_win64=${SHA256}" >> "$GITHUB_OUTPUT" # NOTE: the Scoop bucket (ScriptedAlchemy/scoop-bucket) is an external diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8675d7d9c..55e12e674 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: inputs: release_tag: description: "Existing release tag to rebuild and publish, for example v0.0.17" - required: false + required: true type: string # Skip beta/prerelease tags — handled by release-beta.yml @@ -21,7 +21,7 @@ env: CARGO_TERM_COLOR: always concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} cancel-in-progress: false jobs: @@ -114,12 +114,9 @@ jobs: id: version shell: bash run: | - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${{ inputs.release_tag }}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then TAG="${{ inputs.release_tag }}" VERSION="${TAG#v}" - elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - TAG="dry-run-${GITHUB_SHA::7}" - VERSION="${TAG#v}" else TAG="${GITHUB_REF_NAME}" VERSION="${GITHUB_REF_NAME#v}" diff --git a/tests/release_workflow_contract_test.sh b/tests/release_workflow_contract_test.sh index 13820e057..5c05aa777 100644 --- a/tests/release_workflow_contract_test.sh +++ b/tests/release_workflow_contract_test.sh @@ -48,6 +48,28 @@ for path in sys.argv[1:]: raise SystemExit(f"{path} must never cancel in-progress publication") PY +python3 - "$release_workflow" "$release_beta" <<'PY' +import sys + +stable = open(sys.argv[1], encoding="utf-8").read() +beta = open(sys.argv[2], encoding="utf-8").read() + +for name, text in [("stable", stable), ("beta", beta)]: + if "required: true" not in text: + raise SystemExit(f"{name} manual rebuild must require an explicit release tag") + expected = "github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name" + if expected not in text: + raise SystemExit(f"{name} release identity must normalize to the release tag") + +for item in [ + "Validate manual prerelease rebuild", + "gh release view \"$RELEASE_TAG\"", + "ref: ${{ env.RELEASE_TAG }}", +]: + if item not in beta: + raise SystemExit(f"beta manual rebuild contract missing {item!r}") +PY + python3 - "$release_pr_integrity" <<'PY' import sys From b465b8b5d57da7bbb10a125aea76481e28e3194b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:27:06 +0000 Subject: [PATCH 07/11] fix(hermes): route projectless compression to user store --- src/agents/hermes/templates/plugin_init.py | 4 +++ tests/hermes_suite/lcm_bridge.rs | 36 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index f6ea2074a..3fc67d0fa 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -3514,6 +3514,10 @@ def _compress_to_result(self, messages, current_tokens=None, focus_topic=None, * "focus_topic": focus_topic, "summarizer": summarizer, }) + args = _lcm_store_args( + args, + kwargs.get("project_root") or self.project_root, + ) if self.project_root: args["response_handle_project_root"] = self.project_root _apply_lcm_option_overrides(args, kwargs, lcm_option_keys) diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 456f7a462..3137c5659 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -2116,6 +2116,42 @@ assert [call[0] for call in calls] == [ ); } +#[test] +fn projectless_context_engine_compression_uses_user_session_store() { + run_generated_plugin_script( + "check_projectless_compression_user_scope.py", + r#" +import json + +calls = [] + +def fake_call_tracedecay_tool(name, args, **kwargs): + calls.append((name, dict(args), dict(kwargs))) + return json.dumps({"content": [{"type": "text", "text": json.dumps({ + "status": "ok", + "reason": "compressed_backlog", + "replay_messages": [{"role": "user", "content": "compressed"}], + })}]}) + +plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool + +engine = plugin.TraceDecayContextEngine() +engine.initialize(session_id="general-chat") +compressed = engine.compress( + [{"role": "user", "content": "oversized general chat"}], + current_tokens=350000, +) + +assert compressed == [{"role": "user", "content": "compressed"}] +name, args, kwargs = calls.pop() +assert name == "tracedecay_lcm_compress" +assert args["storage_scope"] == "user" +assert "project_root" not in kwargs +"#, + "projectless Hermes compression must use the profile-level user session store", + ); +} + #[test] fn context_engine_rejects_compacted_compression_replay() { run_generated_plugin_script( From 8bdbce9822d9f33a8d023b41aaee13ea2a536837 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:31:19 +0000 Subject: [PATCH 08/11] fix(hermes): resolve session projects through registry --- src/agents/hermes/templates.rs | 6 ++- src/agents/hermes/templates/plugin_init.py | 27 ++++++---- tests/hermes_suite/lcm_bridge.rs | 63 ++++++++++++++++++---- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 1bcef6bfe..a68f181cb 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -142,11 +142,13 @@ def plugin_config_block(hermes_home=None): return block def code_project_root(explicit=None, cwd=None, hermes_home=None): - candidate = explicit or cwd or os.getcwd() + candidate = explicit or cwd if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): candidate = candidate.strip() try: - if os.path.realpath(candidate) == os.path.realpath(hermes_home_dir(hermes_home)): + candidate_real = os.path.realpath(candidate) + hermes_real = os.path.realpath(hermes_home_dir(hermes_home)) + if os.path.commonpath((hermes_real, candidate_real)) == hermes_real: return None except (OSError, TypeError, ValueError): return None diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index 3fc67d0fa..c9da816c4 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -886,23 +886,32 @@ def _project_scope_resolution(project_root, hermes_home=None): return unresolved_state, unresolved_root try: status = call_tracedecay_json( - "tracedecay_active_project", - {}, - **_project_call_kwargs(candidate), + "tracedecay_project_context", + {"project_path": candidate}, ) except Exception: return unresolved_state, unresolved_root if not isinstance(status, dict) or status.get("error"): return unresolved_state, unresolved_root - resolved = status.get("project_root") or status.get("root") + project = status.get("project") if isinstance(status.get("project"), dict) else status + resolved = project.get("project_root") or project.get("canonical_root") or project.get("root") if not resolved: return unresolved_state, unresolved_root if not _code_project_root(explicit=resolved, hermes_home=hermes_home): return "rejected", None try: - resolved_real = os.path.realpath(str(resolved)) candidate_real = os.path.realpath(str(candidate)) - if os.path.commonpath((resolved_real, candidate_real)) != resolved_real: + registered_roots = [resolved] + for alias in status.get("aliases") or []: + if isinstance(alias, dict) and alias.get("alias_path"): + registered_roots.append(alias["alias_path"]) + matched = False + for root in registered_roots: + root_real = os.path.realpath(str(root)) + if os.path.commonpath((root_real, candidate_real)) == root_real: + matched = True + break + if not matched: return unresolved_state, unresolved_root except (OSError, TypeError, ValueError): return unresolved_state, unresolved_root @@ -1269,9 +1278,9 @@ def _code_project_root(explicit=None, cwd=None, configured=None, hermes_home=Non if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): candidate = candidate.strip() try: - if os.path.realpath(candidate) == os.path.realpath( - _resolve_hermes_home(hermes_home=hermes_home) - ): + candidate_real = os.path.realpath(candidate) + hermes_real = os.path.realpath(_resolve_hermes_home(hermes_home=hermes_home)) + if os.path.commonpath((hermes_real, candidate_real)) == hermes_real: return None except (OSError, TypeError, ValueError): return None diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 3137c5659..54d8f75e7 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -2152,6 +2152,49 @@ assert "project_root" not in kwargs ); } +#[test] +fn project_resolution_uses_registry_and_rejects_hermes_descendants() { + run_generated_plugin_script( + "check_registry_project_resolution.py", + r#" +import json +import os +import pathlib +import tempfile + +calls = [] +temp = tempfile.TemporaryDirectory() +base = pathlib.Path(temp.name) +canonical = base / "repo" +alias = base / "repo-feature" +hermes_home = base / "hermes" +for path in (canonical, alias, hermes_home / "hermes-agent"): + path.mkdir(parents=True) + +def fake_call_tracedecay_tool(name, args, **kwargs): + calls.append((name, dict(args), dict(kwargs))) + assert name == "tracedecay_project_context" + assert args == {"project_path": str(alias)} + return json.dumps({"content": [{"type": "text", "text": json.dumps({ + "project": {"project_root": str(canonical)}, + "aliases": [{"alias_path": str(alias)}], + })}]}) + +plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool + +state, root = plugin._project_scope_resolution(str(alias), str(hermes_home)) +assert state == "registered" +assert root == str(canonical) +assert [call[0] for call in calls] == ["tracedecay_project_context"] + +for path in (hermes_home, hermes_home / "hermes-agent"): + assert plugin._code_project_root(explicit=str(path), hermes_home=str(hermes_home)) is None + assert plugin.tools.code_project_root(explicit=str(path), hermes_home=str(hermes_home)) is None +"#, + "Hermes project resolution must use the registry and reject host-owned descendants", + ); +} + #[test] fn context_engine_rejects_compacted_compression_replay() { run_generated_plugin_script( @@ -4526,7 +4569,8 @@ tools = plugin.tools assert not hasattr(tools, "PINNED_PROJECT_ROOT") assert not hasattr(tools, "config_pinned_project_root") -# Default CLI dispatch uses the real process cwd, never the profile pin. +# Unscoped dispatch does not infer a project from the plugin process cwd or +# the legacy profile pin. The routed host wrapper supplies real session cwd. captured = [] class FakeResult: @@ -4542,9 +4586,7 @@ tools.subprocess.run = fake_run tools.call_tracedecay_tool("tracedecay_status", {}) assert captured, "expected a subprocess invocation" argv = captured[-1] -idx = argv.index("--project") -assert argv[idx + 1] == os.getcwd(), argv -assert argv[idx + 1] != "/pinned/project", argv +assert "--project" not in argv, argv # An explicit project still wins over the pin. tools.call_tracedecay_tool("tracedecay_status", {}, project_root="/explicit/root") @@ -4552,22 +4594,21 @@ argv = captured[-1] idx = argv.index("--project") assert argv[idx + 1] == "/explicit/root", argv -# Memory tools follow the same real cwd route. +# Bare memory bridge calls also stay unscoped; the memory provider wrapper +# supplies explicit user/project scope before reaching this transport. tools.call_tracedecay_tool("tracedecay_fact_store", {}) argv = captured[-1] -idx = argv.index("--project") -assert argv[idx + 1] == os.getcwd(), argv +assert "--project" not in argv, argv # A stale MCP `project_root` argument is not translated. The normal cwd route -# still selects the user-profile project store, and the strict CLI rejects the -# unknown argument instead of silently preserving a compatibility protocol. +# stays in the argument payload, and the strict CLI rejects the unknown +# argument instead of silently preserving a compatibility protocol. tools.call_tracedecay_tool( "tracedecay_lcm_status", {"project_root": "/tmp/hermes-profile"}, ) argv = captured[-1] -idx = argv.index("--project") -assert argv[idx + 1] == os.getcwd(), argv +assert "--project" not in argv, argv payload = json.loads(argv[argv.index("--args") + 1]) assert payload["project_root"] == "/tmp/hermes-profile", payload From 3863b45a128a202239fba6babd0b8ef28b9dd98d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 11 Jul 2026 23:58:36 +0000 Subject: [PATCH 09/11] fix(hermes): correlate turns across project scopes --- scripts/hermes_plugin_unit_check.py | 66 ++++++++----- src/agents/hermes/templates.rs | 2 +- src/agents/hermes/templates/plugin_init.py | 78 ++++++++++------ src/mcp/tools/handlers/session.rs | 22 +++-- src/sessions/hermes.rs | 53 +++-------- tests/agent_suite/agent_test.rs | 2 +- tests/hermes_suite/lcm_bridge.rs | 104 +++++++++++++++++---- tests/mcp_suite/mcp_handler_test.rs | 37 ++++++++ tests/transcript_ingest_suite/hermes.rs | 12 +-- 9 files changed, 251 insertions(+), 125 deletions(-) diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index b5ef3ff64..4d09874ed 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -215,10 +215,10 @@ def run_checks(work: Path): (hermes_descendant / "src").mkdir() assert plugin.tools.code_project_root(cwd=str(host_home)) is None assert plugin._code_project_root(cwd=str(host_home), hermes_home=str(host_home)) is None - assert plugin.tools.code_project_root(cwd=str(hermes_descendant)) == str(hermes_descendant) + assert plugin.tools.code_project_root(cwd=str(hermes_descendant)) is None assert plugin._code_project_root( cwd=str(hermes_descendant), hermes_home=str(host_home) - ) == str(hermes_descendant) + ) is None missing_home_child = host_home / "missing-project" assert plugin._project_scope_resolution( str(missing_home_child), str(host_home) @@ -245,7 +245,7 @@ def capture_tool_run(argv, **run_kwargs): plugin.tools.call_tracedecay_tool( "tracedecay_project_search", {"query": "scope"}, cwd=str(hermes_descendant) ) - assert tool_run_kwargs[-1]["cwd"] == str(hermes_descendant) + assert tool_run_kwargs[-1]["cwd"] == os.path.abspath(os.sep) registry_raw = plugin.tools.call_tracedecay_tool( "tracedecay_project_list", {"limit": 3}, cwd=str(host_home) ) @@ -258,17 +258,14 @@ def capture_tool_run(argv, **run_kwargs): active_context_raw = plugin.tools.call_tracedecay_tool( "tracedecay_project_context", {}, cwd=str(hermes_descendant) ) - assert "--project" in tool_argv[-1], (tool_argv[-1], active_context_raw) - assert tool_argv[-1][tool_argv[-1].index("--project") + 1] == str( - hermes_descendant - ) + assert "--project" not in tool_argv[-1], (tool_argv[-1], active_context_raw) + assert tool_run_kwargs[-1]["cwd"] == os.path.abspath(os.sep) assert "tracedecay_project_context" in tool_argv[-1], tool_argv[-1] plugin.tools.call_tracedecay_tool( "tracedecay_status", {"small": True}, cwd=str(hermes_descendant) ) - assert tool_argv[-1][tool_argv[-1].index("--project") + 1] == str( - hermes_descendant - ) + assert "--project" not in tool_argv[-1], tool_argv[-1] + assert tool_run_kwargs[-1]["cwd"] == os.path.abspath(os.sep) for name, args in ( ("tracedecay_fact_store", {"action": "list", "memory_scope": "user"}), ("tracedecay_lcm_status", {"storage_scope": "user"}), @@ -311,7 +308,7 @@ def raise_resolution(*_args, **_kwargs): } assert plugin._resolved_project_scope( str(hermes_descendant / "src"), str(host_home) - ) == str(hermes_descendant) + ) is None finally: plugin.call_tracedecay_json = real_json home_engine = plugin.TraceDecayContextEngine(hermes_home=str(host_home)) @@ -322,7 +319,7 @@ def raise_resolution(*_args, **_kwargs): session_id="home-scope", hermes_home=str(host_home), cwd=str(host_home) ) assert home_provider.project_root is None - ok("Hermes home is user scope while registered descendant repos remain projects") + ok("Hermes home and all descendants remain user scope") # ── 3. Registration split + provider dedup ────────────────────────── # The installer wrote memory.provider: tracedecay into the temp profile @@ -350,7 +347,7 @@ def raise_resolution(*_args, **_kwargs): custom_home.mkdir() custom_descendant = custom_home / "repos" / "unregistered" custom_descendant.mkdir(parents=True) - custom_registered = custom_home / "repos" / "registered" + custom_registered = work / "custom-registered-project" custom_registered.mkdir() custom_ctx = StubCtx() custom_ctx.hermes_home = str(custom_home) @@ -487,9 +484,7 @@ def resolve_receipt(path, *_args): assert pending_threads == [] assert receipt_hook(tool_name="terminal", cwd=str(hermes_descendant)) is None assert resolver_calls == [] - assert len(pending_threads) == 1 - pending_threads.pop(0)() - assert resolver_calls == [str(hermes_descendant)] + assert pending_threads == [] assert notifications == [] assert receipt_hook( tool_name="terminal", @@ -501,10 +496,10 @@ def resolve_receipt(path, *_args): status="success", duration_ms=9, ) is None - assert resolver_calls == [str(hermes_descendant)] + assert resolver_calls == [] assert len(pending_threads) == 1 pending_threads.pop(0)() - assert resolver_calls == [str(hermes_descendant), str(runtime_project)] + assert resolver_calls == [str(runtime_project)] assert len(notifications) == 1 argv, call = notifications[0] assert argv[-1] == "hook-hermes-terminal-receipt" @@ -772,20 +767,39 @@ def call_llm(self, **kwargs): assert kwargs["project_root"] == expected_project_root, kwargs ok("memory tool calls stay bound to the provider's session project") + before = len(calls) provider.sync_turn("u", "a", session_id="other-session", messages=messages) - name, args, kwargs = calls[-1] + turn_calls = calls[before:] + assert len(turn_calls) == 2, turn_calls + user_name, user_args, user_kwargs = turn_calls[0] + assert user_name == "tracedecay_lcm_preflight" + assert user_args["storage_scope"] == "user", user_args + assert "project_root" not in user_kwargs, user_kwargs + name, args, kwargs = turn_calls[1] assert name == "tracedecay_lcm_preflight", calls assert args["session_id"] == "other-session" assert [message["content"] for message in args["messages"]] == ["u", "a"] assert all(message.get("id") for message in args["messages"]) + assert [message["id"] for message in user_args["messages"]] == [ + message["id"] for message in args["messages"] + ] + assert all( + message["associated_project_roots"] == [expected_project_root] + for message in args["messages"] + ) assert args["transcript_projection"] is True assert "project_root" not in args, args assert kwargs["project_root"] == expected_project_root, kwargs - ok("sync_turn projects only the completed turn into project LCM") + ok("sync_turn stores a canonical user turn plus its project projection") + before = len(calls) provider.sync_turn("only user", "and assistant", session_id="s2", messages=None) - name, args, kwargs = calls[-1] + turn_calls = calls[before:] + assert len(turn_calls) == 2, turn_calls + assert turn_calls[0][1]["storage_scope"] == "user", turn_calls + assert "project_root" not in turn_calls[0][2], turn_calls + name, args, kwargs = turn_calls[1] assert name == "tracedecay_lcm_preflight", calls assert args["messages"][0]["content"] == "only user" assert args["messages"][1]["content"] == "and assistant" @@ -823,6 +837,7 @@ def call_llm(self, **kwargs): real_resolver = plugin._resolved_project_scope plugin._resolved_project_scope = lambda path, *_args: expected_project_root + before = len(calls) provider.sync_turn( "project task", "done", @@ -840,9 +855,16 @@ def call_llm(self, **kwargs): }, ], ) - name, args, kwargs = calls[-1] + turn_calls = calls[before:] + assert len(turn_calls) == 2, turn_calls + assert turn_calls[0][1]["storage_scope"] == "user", turn_calls + assert "project_root" not in turn_calls[0][2], turn_calls + name, args, kwargs = turn_calls[1] assert name == "tracedecay_lcm_preflight" assert kwargs["project_root"] == expected_project_root + assert [message["id"] for message in turn_calls[0][1]["messages"]] == [ + message["id"] for message in args["messages"] + ] assert args["transcript_projection"] is True plugin._resolved_project_scope = real_resolver ok("structured tool activity correlates an untethered turn to its project") diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index a68f181cb..b31f977cf 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -142,7 +142,7 @@ def plugin_config_block(hermes_home=None): return block def code_project_root(explicit=None, cwd=None, hermes_home=None): - candidate = explicit or cwd + candidate = explicit or cwd or os.getcwd() if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): candidate = candidate.strip() try: diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index c9da816c4..b4c5aedf7 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -865,9 +865,9 @@ def _project_scope_resolution(project_root, hermes_home=None): if not project_root: return "unregistered", None try: - if os.path.realpath(str(project_root)) == os.path.realpath( - _resolve_hermes_home(hermes_home=hermes_home) - ): + project_real = os.path.realpath(str(project_root)) + hermes_real = os.path.realpath(_resolve_hermes_home(hermes_home=hermes_home)) + if os.path.commonpath((hermes_real, project_real)) == hermes_real: return "rejected", None except (OSError, TypeError, ValueError): return "unregistered", None @@ -978,15 +978,23 @@ def _tool_project_candidates(messages): candidates.extend(_terminal_cd_candidates(arguments.get("command") or arguments.get("cmd"))) return candidates -def _turn_project_root(messages, hermes_home=None): +def _turn_project_roots(messages, hermes_home=None): + roots = [] + seen = set() for candidate in reversed(_tool_project_candidates(messages)): expanded = os.path.abspath(os.path.expanduser(candidate)) if os.path.isfile(expanded): expanded = os.path.dirname(expanded) resolved = _resolved_project_scope(expanded, hermes_home) - if resolved: - return resolved - return None + if resolved and resolved not in seen: + seen.add(resolved) + roots.append(resolved) + roots.reverse() + return roots + +def _turn_project_root(messages, hermes_home=None): + roots = _turn_project_roots(messages, hermes_home) + return roots[-1] if roots else None _UNSCOPED_DIRECT_TOOLS = frozenset(( "tracedecay_project_list", @@ -3830,7 +3838,9 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= same content-cursored path the context engine uses), so the raw store grows every turn instead of only when compression fires. """ - project_root = _turn_project_root(messages, self.hermes_home) or self.project_root + project_roots = _turn_project_roots(messages, self.hermes_home) + if not project_roots and self.project_root: + project_roots = [self.project_root] if not _plugin_toggle("sync_turn", True): return if self.agent_context in ("cron", "flush"): @@ -3854,25 +3864,39 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= role = str(entry.get("role") or "user") entry["id"] = f"tracedecay_sync_{batch_id}_{timestamp_ns}_{idx}_{role}" entry["timestamp"] = timestamp - args = _lcm_store_args({ - "provider": STANDARD_HERMES_LCM_PROVIDER, - "session_id": sid, - "messages": turn_messages, - "transcript_projection": True, - }, project_root) - try: - result = call_tracedecay_json( - "tracedecay_lcm_preflight", - args, - **_project_call_kwargs(project_root), - ) - if not result.get("error"): - _notify_turn_completed(sid, project_root, turn_messages[-1]["id"]) - _notify_turn_ingested(sid, project_root, turn_messages[-1]["id"]) - else: - logger.debug("tracedecay sync_turn ingest rejected: %s", result.get("error")) - except Exception as exc: - logger.debug("tracedecay sync_turn ingest failed: %s", exc) + entry["associated_project_roots"] = list(project_roots) + # The profile-level user store is the canonical Hermes conversation. + # Project shards receive projections with the same stable message IDs, + # so one turn can be searched from every repository it actually touched + # without binding the long-lived host session to any one project. + for project_root in [None, *project_roots]: + args = _lcm_store_args({ + "provider": STANDARD_HERMES_LCM_PROVIDER, + "session_id": sid, + "messages": turn_messages, + "transcript_projection": True, + }, project_root) + try: + result = call_tracedecay_json( + "tracedecay_lcm_preflight", + args, + **_project_call_kwargs(project_root), + ) + if not result.get("error"): + _notify_turn_completed(sid, project_root, turn_messages[-1]["id"]) + _notify_turn_ingested(sid, project_root, turn_messages[-1]["id"]) + else: + logger.debug( + "tracedecay sync_turn ingest rejected for %s: %s", + project_root or "user", + result.get("error"), + ) + except Exception as exc: + logger.debug( + "tracedecay sync_turn ingest failed for %s: %s", + project_root or "user", + exc, + ) def on_memory_write(self, action, target, content, metadata=None): """Mirror built-in memory tool writes into the fact store.""" diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 940eb8e30..d694ae14c 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -3181,6 +3181,18 @@ async fn upsert_live_transcript_projection( if text.trim().is_empty() { continue; } + let mut metadata = json!({ + "source": "lcm_preflight_live", + "project_root": project, + "storage_scope": storage_scope, + "location_provenance": "host_live_route" + }); + if let Some(roots) = message + .get("associated_project_roots") + .filter(|value| value.is_array()) + { + metadata["associated_project_roots"] = roots.clone(); + } projected.push(SessionMessageRecord { provider: provider.to_string(), message_id: message_id.to_string(), @@ -3200,15 +3212,7 @@ async fn upsert_live_transcript_projection( tool_names: (!tool_names.is_empty()).then(|| tool_names.join(",")), source_path: Some(source_path.clone()), source_offset: Some(ordinal as i64), - metadata_json: Some( - json!({ - "source": "lcm_preflight_live", - "project_root": project, - "storage_scope": storage_scope, - "location_provenance": "host_live_route" - }) - .to_string(), - ), + metadata_json: Some(metadata.to_string()), }); } if projected.is_empty() { diff --git a/src/sessions/hermes.rs b/src/sessions/hermes.rs index 528a1257f..f56f747a2 100644 --- a/src/sessions/hermes.rs +++ b/src/sessions/hermes.rs @@ -55,6 +55,7 @@ const HERMES_LOCATION_KEYS: TranscriptLocationMetadataKeys = TranscriptLocationM /// resumes where it stopped. const CHUNK_ROWS: usize = 2000; const CORRELATION_CURSOR_VERSION: &str = "turn-project-v2"; +const USER_CURSOR_VERSION: &str = "user-turn-v2"; /// Ingests Hermes sessions proven to belong to `project_root` into `db`. /// @@ -138,8 +139,9 @@ pub async fn ingest_homes( stats } -/// Ingests historical Hermes turns that have no registered-project -/// attribution into the profile-level user session store. +/// Ingests the canonical historical Hermes conversation into the profile-level +/// user session store. Project ingestion separately projects each turn into +/// every registered project it touched using the same stable message IDs. pub async fn ingest_user_sessions( db: &GlobalDb, registered_roots: &[PathBuf], @@ -637,7 +639,7 @@ async fn try_ingest_user_state_db( let state_db = &source.state_db; let conn = open_read_only_strict(state_db).await?; let path_str = state_db.to_string_lossy().to_string(); - let cursor_path = format!("{path_str}#user-turn-v1"); + let cursor_path = format!("{path_str}#{USER_CURSOR_VERSION}"); let mut cursor = { let prev = db.get_parse_offset(&cursor_path).await.unwrap_or_default(); StoredCursor { @@ -855,11 +857,11 @@ async fn build_user_batches( rows: &[HermesRow], state_db_path: &str, source: &HermesProfileSource, - registered_roots: &[PathBuf], + _registered_roots: &[PathBuf], ) -> Vec { let mut order = Vec::new(); let mut by_session: HashMap = HashMap::new(); - let locations = user_turn_locations(rows, source, registered_roots); + let locations = user_turn_locations(rows, source); for row in rows { if row.role == "session_meta" || row.role.is_empty() || row.active == 0 { continue; @@ -892,26 +894,13 @@ async fn build_user_batches( fn user_turn_locations( rows: &[HermesRow], source: &HermesProfileSource, - registered_roots: &[PathBuf], ) -> HashMap { let mut by_session: HashMap<&str, Vec<&HermesRow>> = HashMap::new(); for row in rows { by_session.entry(&row.session_id).or_default().push(row); } - let belongs_to_registered = |path: &Path| { - registered_roots - .iter() - .any(|root| path_belongs_to_project(path, root)) - }; let mut locations = HashMap::new(); for session_rows in by_session.into_values() { - if source - .legacy_project_pin - .as_deref() - .is_some_and(|pin| belongs_to_registered(pin)) - { - continue; - } let recorded_cwd = session_rows.iter().find_map(|row| { let cwd = PathBuf::from(row.session_cwd.as_deref()?.trim()); cwd.is_absolute().then_some(cwd) @@ -919,29 +908,17 @@ fn user_turn_locations( let fallback = source .legacy_project_pin .clone() - .or_else(|| { - recorded_cwd - .as_ref() - .filter(|cwd| !belongs_to_registered(cwd)) - .cloned() - }) - // Only genuinely locationless sessions fall back to the profile - // directory. A registered cwd must never be relabeled as user. - .or_else(|| { - recorded_cwd - .is_none() - .then(|| source.state_db.parent().map(Path::to_path_buf)) - .flatten() - }); + .or(recorded_cwd) + .or_else(|| source.state_db.parent().map(Path::to_path_buf)); let mut turn = Vec::new(); for row in session_rows { if row.role == "user" && !turn.is_empty() { - assign_user_turn(&turn, fallback.as_deref(), registered_roots, &mut locations); + assign_user_turn(&turn, fallback.as_deref(), &mut locations); turn.clear(); } turn.push(row); } - assign_user_turn(&turn, fallback.as_deref(), registered_roots, &mut locations); + assign_user_turn(&turn, fallback.as_deref(), &mut locations); } locations } @@ -949,20 +926,12 @@ fn user_turn_locations( fn assign_user_turn( rows: &[&HermesRow], fallback: Option<&Path>, - registered_roots: &[PathBuf], locations: &mut HashMap, ) { let explicit = rows .iter() .flat_map(|row| structured_tool_project_paths(row)) .collect::>(); - if explicit.iter().any(|path| { - registered_roots - .iter() - .any(|root| path_belongs_to_project(path, root)) - }) { - return; - } let cwd = explicit .last() .cloned() diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index c258721da..42b368fd1 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1268,7 +1268,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "52cc1091ed34e5890135bf943602d98f999090796fab6eaa41d180d5dc76fdb3", + "d92997d9bef2c033022ac3b8ee559720ce08779099d2d8b58df6e5d849d428df", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 54d8f75e7..bcd593a55 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -2259,16 +2259,21 @@ provider.sync_turn("repeat", "same", session_id="session-1", messages=[]) provider.sync_turn("repeat", "same", session_id="session-1", messages=[]) assert provider.project_root == "/tmp/project" -assert len(calls) == 4 -for name, args, kwargs in calls: - assert name == "tracedecay_lcm_preflight" - assert "project_root" not in args - assert kwargs["project_root"] == "/tmp/project" +assert len(calls) == 8 +for index in range(0, len(calls), 2): + user_call, project_call = calls[index:index + 2] + assert user_call[0] == "tracedecay_lcm_preflight" + assert user_call[1]["storage_scope"] == "user" + assert user_call[2] == {} + assert project_call[0] == "tracedecay_lcm_preflight" + assert "storage_scope" not in project_call[1] + assert project_call[2]["project_root"] == "/tmp/project" + assert user_call[1]["messages"] == project_call[1]["messages"] first_messages = calls[0][1]["messages"] -second_messages = calls[1][1]["messages"] -empty_list_first_messages = calls[2][1]["messages"] -empty_list_second_messages = calls[3][1]["messages"] +second_messages = calls[2][1]["messages"] +empty_list_first_messages = calls[4][1]["messages"] +empty_list_second_messages = calls[6][1]["messages"] assert [message["role"] for message in first_messages] == ["user", "assistant"] assert all(message.get("id") for message in first_messages) assert all(message.get("id") for message in second_messages) @@ -2292,6 +2297,69 @@ assert "project_root" not in calls[-1][2] ); } +#[test] +fn memory_provider_projects_one_turn_to_user_and_every_touched_project() { + run_generated_plugin_script( + "check_sync_turn_multi_project_projection.py", + r#" +calls = [] +completed = [] +ingested = [] + +def fake_call_tracedecay_tool(name, args, **kwargs): + calls.append((name, dict(args), dict(kwargs))) + return '{"content":[{"type":"text","text":"{\\"status\\":\\"ok\\"}"}]}' + +plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool +plugin._project_scope_resolution = lambda path, *_args: ( + ("registered", str(path)) if str(path).startswith("/repos/") else ("unregistered", None) +) +plugin._notify_turn_completed = lambda sid, root, watermark: completed.append((sid, root, watermark)) +plugin._notify_turn_ingested = lambda sid, root, watermark: ingested.append((sid, root, watermark)) + +messages = [ + {"role": "user", "content": "compare both repositories"}, + { + "role": "assistant", + "tool_calls": [ + {"function": {"name": "terminal", "arguments": '{"workdir":"/repos/alpha"}'}}, + {"function": {"name": "terminal", "arguments": '{"workdir":"/repos/beta"}'}}, + ], + }, +] + +provider = plugin.TracedecayMemoryProvider() +provider.initialize(session_id="telegram-dm") +provider.sync_turn( + "compare both repositories", + "done", + session_id="telegram-dm", + messages=messages, +) + +assert len(calls) == 3, calls +user_call, alpha_call, beta_call = calls +assert user_call[1]["storage_scope"] == "user" +assert user_call[2] == {} +assert alpha_call[2] == {"project_root": "/repos/alpha"} +assert beta_call[2] == {"project_root": "/repos/beta"} +assert "storage_scope" not in alpha_call[1] +assert "storage_scope" not in beta_call[1] + +message_sets = [call[1]["messages"] for call in calls] +assert message_sets[0] == message_sets[1] == message_sets[2] +assert message_sets[0][0]["associated_project_roots"] == [ + "/repos/alpha", + "/repos/beta", +] +assert [root for _, root, _ in completed] == [None, "/repos/alpha", "/repos/beta"] +assert ingested == completed +assert provider.project_root is None +"#, + "one Hermes turn must keep a user canonical copy and project into every touched repository", + ); +} + #[test] fn context_engine_records_all_forwarded_native_lcm_tools() { run_generated_plugin_script( @@ -4569,8 +4637,7 @@ tools = plugin.tools assert not hasattr(tools, "PINNED_PROJECT_ROOT") assert not hasattr(tools, "config_pinned_project_root") -# Unscoped dispatch does not infer a project from the plugin process cwd or -# the legacy profile pin. The routed host wrapper supplies real session cwd. +# Default CLI dispatch uses the real process cwd, never the profile pin. captured = [] class FakeResult: @@ -4586,7 +4653,9 @@ tools.subprocess.run = fake_run tools.call_tracedecay_tool("tracedecay_status", {}) assert captured, "expected a subprocess invocation" argv = captured[-1] -assert "--project" not in argv, argv +idx = argv.index("--project") +assert argv[idx + 1] == os.getcwd(), argv +assert argv[idx + 1] != "/pinned/project", argv # An explicit project still wins over the pin. tools.call_tracedecay_tool("tracedecay_status", {}, project_root="/explicit/root") @@ -4594,21 +4663,22 @@ argv = captured[-1] idx = argv.index("--project") assert argv[idx + 1] == "/explicit/root", argv -# Bare memory bridge calls also stay unscoped; the memory provider wrapper -# supplies explicit user/project scope before reaching this transport. +# Memory tools follow the same real cwd route. tools.call_tracedecay_tool("tracedecay_fact_store", {}) argv = captured[-1] -assert "--project" not in argv, argv +idx = argv.index("--project") +assert argv[idx + 1] == os.getcwd(), argv # A stale MCP `project_root` argument is not translated. The normal cwd route -# stays in the argument payload, and the strict CLI rejects the unknown -# argument instead of silently preserving a compatibility protocol. +# still selects the user-profile project store, and the strict CLI rejects the +# unknown argument instead of silently preserving a compatibility protocol. tools.call_tracedecay_tool( "tracedecay_lcm_status", {"project_root": "/tmp/hermes-profile"}, ) argv = captured[-1] -assert "--project" not in argv, argv +idx = argv.index("--project") +assert argv[idx + 1] == os.getcwd(), argv payload = json.loads(argv[argv.index("--args") + 1]) assert payload["project_root"] == "/tmp/hermes-profile", payload diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 28fc53e9f..28ff533c6 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -10597,6 +10597,43 @@ async fn user_scoped_lcm_preflight_ingests_without_a_project() { ); } +#[tokio::test] +async fn user_scoped_lcm_projection_preserves_associated_project_roots() { + let profile = TempDir::new().unwrap(); + let roots = json!(["/work/alpha", "/work/beta"]); + tracedecay::mcp::tools::handle_user_lcm_tool( + "tracedecay_lcm_preflight", + json!({ + "storage_scope": "user", + "provider": "hermes", + "session_id": "multi-project-session", + "messages": [{ + "id": "multi-project-message-1", + "role": "user", + "content": "Update both repositories", + "associated_project_roots": roots + }], + "transcript_projection": true, + "format": "json" + }), + profile.path(), + ) + .await + .unwrap(); + + let db = + GlobalDb::open_read_only_at(&tracedecay::sessions::user_sessions_db_path(profile.path())) + .await + .unwrap(); + let message = db + .get_session_message("hermes", "multi-project-message-1") + .await + .unwrap(); + let metadata: Value = serde_json::from_str(message.metadata_json.as_deref().unwrap()).unwrap(); + assert_eq!(metadata["associated_project_roots"], roots); + assert_eq!(metadata["storage_scope"], "user"); +} + #[tokio::test] async fn lcm_session_handlers_expose_bounded_read_apis_and_placeholders() { let dir = test_temp_dir(); diff --git a/tests/transcript_ingest_suite/hermes.rs b/tests/transcript_ingest_suite/hermes.rs index 481d4a501..eb7028bc6 100644 --- a/tests/transcript_ingest_suite/hermes.rs +++ b/tests/transcript_ingest_suite/hermes.rs @@ -875,7 +875,7 @@ async fn explicit_tool_route_overrides_session_cwd_without_cross_project_duplica } #[tokio::test] -async fn user_sweep_excludes_turns_explicitly_routed_to_registered_projects() { +async fn user_sweep_keeps_canonical_turns_routed_to_registered_projects() { let tmp = TempDir::new().unwrap(); let (hermes_home, registered) = setup(&tmp); let state_db = write_hermes_profile(&hermes_home, "test", None).await; @@ -904,12 +904,12 @@ async fn user_sweep_excludes_turns_explicitly_routed_to_registered_projects() { ) .await; - assert_eq!(stats.messages_upserted, 0); - assert!(user_db.get_session("hermes", SESSION_ID).await.is_none()); + assert_eq!(stats.messages_upserted, 4); + assert!(user_db.get_session("hermes", SESSION_ID).await.is_some()); } #[tokio::test] -async fn user_sweep_excludes_registered_session_cwd_without_tool_routes() { +async fn user_sweep_keeps_registered_session_cwd_as_canonical_history() { let tmp = TempDir::new().unwrap(); let (hermes_home, registered) = setup(&tmp); let state_db = write_hermes_profile(&hermes_home, "test", None).await; @@ -937,6 +937,6 @@ async fn user_sweep_excludes_registered_session_cwd_without_tool_routes() { ) .await; - assert_eq!(stats.messages_upserted, 0); - assert!(user_db.get_session("hermes", SESSION_ID).await.is_none()); + assert_eq!(stats.messages_upserted, 3); + assert!(user_db.get_session("hermes", SESSION_ID).await.is_some()); } From 2f47a21de1e776249436335eb1cabfd8ea49d4b0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 12 Jul 2026 00:14:44 +0000 Subject: [PATCH 10/11] feat(dev): add stable dogfood install workflow --- .cargo/config.toml | 1 + .claude/skills/dogfooding-tracedecay/SKILL.md | 52 +++++++++++++++++++ .codex/skills/dogfooding-tracedecay/SKILL.md | 52 +++++++++++++++++++ .../dogfooding-tracedecay/agents/openai.yaml | 4 ++ .github/workflows/ci.yml | 3 ++ scripts/dogfood.sh | 46 ++++++++++++++++ .../agent_suite/plugin_skill_contract_test.rs | 37 +++++++++++++ tests/dogfood_command_test.sh | 51 ++++++++++++++++++ 8 files changed, 246 insertions(+) create mode 100644 .claude/skills/dogfooding-tracedecay/SKILL.md create mode 100644 .codex/skills/dogfooding-tracedecay/SKILL.md create mode 100644 .codex/skills/dogfooding-tracedecay/agents/openai.yaml create mode 100755 scripts/dogfood.sh create mode 100755 tests/dogfood_command_test.sh diff --git a/.cargo/config.toml b/.cargo/config.toml index d25944472..31a17a753 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -35,3 +35,4 @@ TRACEDECAY_DATA_DIR = { value = "target/test-profile/.tracedecay", force = false # https://nexte.st/docs/installation/pre-built-binaries/ test-all = "nextest run --workspace --no-fail-fast" test-ci = "nextest run --workspace --no-fail-fast" +dogfood = "!scripts/dogfood.sh" diff --git a/.claude/skills/dogfooding-tracedecay/SKILL.md b/.claude/skills/dogfooding-tracedecay/SKILL.md new file mode 100644 index 000000000..edeeb2504 --- /dev/null +++ b/.claude/skills/dogfooding-tracedecay/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dogfooding-tracedecay +description: Use when installing a TraceDecay checkout for live local development, refreshing the globally available binary without a release, or validating agents and the daemon against unmerged TraceDecay changes. +--- + +# Dogfooding TraceDecay + +Use the repository command. Do not run release upgrade or point agents at +`target/`; Cargo may replace or lock that binary during later builds. + +## Install the checkout + +1. Confirm the checkout/branch contains the exact changes to dogfood and that + unrelated worktrees are not being built accidentally. +2. From that checkout's root, run: + + ```bash + cargo dogfood + ``` + +3. Treat any nonzero exit as incomplete deployment. Inspect the printed stage, + post-update, daemon, or doctor failure before retrying. + +The command builds the release binary, copies it outside the repository to +`~/.local/lib/tracedecay/dogfood/tracedecay`, atomically replaces +`~/.local/bin/tracedecay`, refreshes tracked integrations through the normal +post-update lifecycle, restarts the managed daemon, and runs health checks. +Cargo's isolated `target/test-profile/.tracedecay` profile is removed before +the live refresh. + +## Verify live use + +Run these only after `cargo dogfood` succeeds: + +```bash +command -v tracedecay +tracedecay --version +tracedecay daemon status +tracedecay doctor +``` + +Then reproduce the changed host scenario with the ordinary global +`tracedecay` command. Inspect relevant service/host logs. Restart a host only +when its integration is in-process or its plugin module cannot hot-reload. + +## Guardrails + +- Do not run `tracedecay upgrade` for checkout dogfood; it installs a published + release. +- Do not kill host-owned MCP shim processes indiscriminately. +- Do not delete the source worktree until its commits are pushed and merged. +- Re-run `cargo dogfood` after any source change that must reach live agents. diff --git a/.codex/skills/dogfooding-tracedecay/SKILL.md b/.codex/skills/dogfooding-tracedecay/SKILL.md new file mode 100644 index 000000000..edeeb2504 --- /dev/null +++ b/.codex/skills/dogfooding-tracedecay/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dogfooding-tracedecay +description: Use when installing a TraceDecay checkout for live local development, refreshing the globally available binary without a release, or validating agents and the daemon against unmerged TraceDecay changes. +--- + +# Dogfooding TraceDecay + +Use the repository command. Do not run release upgrade or point agents at +`target/`; Cargo may replace or lock that binary during later builds. + +## Install the checkout + +1. Confirm the checkout/branch contains the exact changes to dogfood and that + unrelated worktrees are not being built accidentally. +2. From that checkout's root, run: + + ```bash + cargo dogfood + ``` + +3. Treat any nonzero exit as incomplete deployment. Inspect the printed stage, + post-update, daemon, or doctor failure before retrying. + +The command builds the release binary, copies it outside the repository to +`~/.local/lib/tracedecay/dogfood/tracedecay`, atomically replaces +`~/.local/bin/tracedecay`, refreshes tracked integrations through the normal +post-update lifecycle, restarts the managed daemon, and runs health checks. +Cargo's isolated `target/test-profile/.tracedecay` profile is removed before +the live refresh. + +## Verify live use + +Run these only after `cargo dogfood` succeeds: + +```bash +command -v tracedecay +tracedecay --version +tracedecay daemon status +tracedecay doctor +``` + +Then reproduce the changed host scenario with the ordinary global +`tracedecay` command. Inspect relevant service/host logs. Restart a host only +when its integration is in-process or its plugin module cannot hot-reload. + +## Guardrails + +- Do not run `tracedecay upgrade` for checkout dogfood; it installs a published + release. +- Do not kill host-owned MCP shim processes indiscriminately. +- Do not delete the source worktree until its commits are pushed and merged. +- Re-run `cargo dogfood` after any source change that must reach live agents. diff --git a/.codex/skills/dogfooding-tracedecay/agents/openai.yaml b/.codex/skills/dogfooding-tracedecay/agents/openai.yaml new file mode 100644 index 000000000..fd575742b --- /dev/null +++ b/.codex/skills/dogfooding-tracedecay/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dogfood TraceDecay" + short_description: "Install and verify local TraceDecay builds" + default_prompt: "Use $dogfooding-tracedecay to install this checkout globally and verify the live runtime." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98a170ff..8e9efc567 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,9 @@ jobs: - name: Test release workflow contract run: bash tests/release_workflow_contract_test.sh + - name: Test local dogfood command contract + run: bash tests/dogfood_command_test.sh + - name: Check release version drift run: scripts/check-release-drift.sh diff --git a/scripts/dogfood.sh b/scripts/dogfood.sh new file mode 100755 index 000000000..4ce5b93dc --- /dev/null +++ b/scripts/dogfood.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +target_dir=${CARGO_TARGET_DIR:-"$repo_root/target"} +source_binary="$target_dir/release/tracedecay" +stage_dir=${TRACEDECAY_DOGFOOD_STAGE_DIR:-"$HOME/.local/lib/tracedecay/dogfood"} +install_dir=${TRACEDECAY_DOGFOOD_INSTALL_DIR:-"$HOME/.local/bin"} +staged_binary="$stage_dir/tracedecay" +installed_binary="$install_dir/tracedecay" + +cd "$repo_root" +cargo build --locked --release --bin tracedecay + +if [[ ! -x "$source_binary" ]]; then + printf 'dogfood build did not produce %s\n' "$source_binary" >&2 + exit 1 +fi + +mkdir -p "$stage_dir" "$install_dir" + +install_atomically() { + local source=$1 + local destination=$2 + local temporary + temporary=$(mktemp "${destination}.new.XXXXXX") + trap 'rm -f "$temporary"' RETURN + install -m 0755 "$source" "$temporary" + mv -f "$temporary" "$destination" + trap - RETURN +} + +install_atomically "$source_binary" "$staged_binary" +install_atomically "$staged_binary" "$installed_binary" + +# Cargo-launched commands use an isolated development profile. The staged +# executable must refresh the real user installation instead. +unset TRACEDECAY_DATA_DIR TRACEDECAY_DISABLE_GLOBAL_DB + +"$installed_binary" post-update +"$installed_binary" daemon status +"$installed_binary" doctor +"$installed_binary" --version + +printf 'Dogfood binary installed at %s\n' "$installed_binary" +printf 'Stable staged copy: %s\n' "$staged_binary" diff --git a/tests/agent_suite/plugin_skill_contract_test.rs b/tests/agent_suite/plugin_skill_contract_test.rs index 6720dbfbf..94edd9e48 100644 --- a/tests/agent_suite/plugin_skill_contract_test.rs +++ b/tests/agent_suite/plugin_skill_contract_test.rs @@ -42,6 +42,43 @@ fn codex_plugin_skills_match_codex_skill_creator_quick_validate_rules() { } } +#[test] +fn repo_local_dogfood_skill_matches_codex_skill_creator_quick_validate_rules() { + let skills = load_skill_docs(REPO_LOCAL_SKILL_ROOT); + let dogfood = skills + .iter() + .find(|skill| skill.name == "dogfooding-tracedecay") + .expect("expected repo-local dogfood skill"); + assert_codex_quick_validate_equivalent(dogfood); +} + +#[test] +fn repo_local_dogfood_skill_is_mirrored_and_teaches_live_install_safety() { + let codex = repo_path(".codex/skills/dogfooding-tracedecay/SKILL.md"); + let claude = repo_path(".claude/skills/dogfooding-tracedecay/SKILL.md"); + assert_eq!( + std::fs::read(&codex).expect("read Codex dogfood skill"), + std::fs::read(&claude).expect("read Claude dogfood skill"), + "Codex and Claude must receive the same dogfood workflow" + ); + + let body = std::fs::read_to_string(codex).expect("read dogfood skill text"); + for required in [ + "cargo dogfood", + "target/test-profile/.tracedecay", + "~/.local/lib/tracedecay/dogfood/tracedecay", + "~/.local/bin/tracedecay", + "tracedecay daemon status", + "tracedecay doctor", + "Do not run `tracedecay upgrade`", + ] { + assert!( + body.contains(required), + "dogfood skill should mention {required}" + ); + } +} + #[test] fn generated_codex_plugin_skills_are_byte_copies_of_the_source_bundle() { let home = TempDir::new().expect("temp home"); diff --git a/tests/dogfood_command_test.sh b/tests/dogfood_command_test.sh new file mode 100755 index 000000000..ff3055aa6 --- /dev/null +++ b/tests/dogfood_command_test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT + +fake_target="$fixture/target" +fake_home="$fixture/home" +fake_bin="$fixture/bin" +mkdir -p "$fake_target/release" "$fake_home" "$fake_bin" + +cat >"$fake_target/release/tracedecay" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${TRACEDECAY_DOGFOOD_TEST_LOG:?}" +if [[ "${1:-}" == "--version" ]]; then + printf 'tracedecay 0.0.0-dogfood\n' +fi +EOF +chmod +x "$fake_target/release/tracedecay" + +cat >"$fake_bin/cargo" <<'EOF' +#!/usr/bin/env bash +printf 'cargo %s\n' "$*" >>"${TRACEDECAY_DOGFOOD_TEST_LOG:?}" +EOF +chmod +x "$fake_bin/cargo" + +log="$fixture/actions.log" +PATH="$fake_bin:$PATH" \ +HOME="$fake_home" \ +CARGO_TARGET_DIR="$fake_target" \ +TRACEDECAY_DOGFOOD_TEST_LOG="$log" \ +TRACEDECAY_DOGFOOD_SKIP_SERVICE_MANAGER=1 \ + "$repo_root/scripts/dogfood.sh" + +staged="$fake_home/.local/lib/tracedecay/dogfood/tracedecay" +installed="$fake_home/.local/bin/tracedecay" +test -x "$staged" +test -x "$installed" +cmp "$fake_target/release/tracedecay" "$staged" +cmp "$staged" "$installed" + +grep -Fxq 'cargo build --locked --release --bin tracedecay' "$log" +grep -Fxq 'post-update' "$log" +grep -Fxq 'daemon status' "$log" +grep -Fxq 'doctor' "$log" +grep -Fxq -- '--version' "$log" + +grep -Fq 'dogfood = "!scripts/dogfood.sh"' "$repo_root/.cargo/config.toml" + +echo "dogfood command contract passed" From a85f505e88c6f79c2d4d6b774089a12865e1f268 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 12 Jul 2026 00:26:01 +0000 Subject: [PATCH 11/11] fix(dev): launch dogfood without nested cargo --- .cargo/config.toml | 2 +- scripts/dogfood.sh | 6 ++++-- src/cli.rs | 3 +++ src/main.rs | 5 +++++ src/update_cmd.rs | 22 ++++++++++++++++++++++ tests/dogfood_command_test.sh | 2 +- 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 31a17a753..e16f44ec8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -35,4 +35,4 @@ TRACEDECAY_DATA_DIR = { value = "target/test-profile/.tracedecay", force = false # https://nexte.st/docs/installation/pre-built-binaries/ test-all = "nextest run --workspace --no-fail-fast" test-ci = "nextest run --workspace --no-fail-fast" -dogfood = "!scripts/dogfood.sh" +dogfood = "run --quiet --release --bin tracedecay -- dogfood" diff --git a/scripts/dogfood.sh b/scripts/dogfood.sh index 4ce5b93dc..ed464a9cb 100755 --- a/scripts/dogfood.sh +++ b/scripts/dogfood.sh @@ -3,14 +3,16 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) target_dir=${CARGO_TARGET_DIR:-"$repo_root/target"} -source_binary="$target_dir/release/tracedecay" +source_binary=${TRACEDECAY_DOGFOOD_SOURCE_BINARY:-"$target_dir/release/tracedecay"} stage_dir=${TRACEDECAY_DOGFOOD_STAGE_DIR:-"$HOME/.local/lib/tracedecay/dogfood"} install_dir=${TRACEDECAY_DOGFOOD_INSTALL_DIR:-"$HOME/.local/bin"} staged_binary="$stage_dir/tracedecay" installed_binary="$install_dir/tracedecay" cd "$repo_root" -cargo build --locked --release --bin tracedecay +if [[ -z "${TRACEDECAY_DOGFOOD_SOURCE_BINARY:-}" ]]; then + cargo build --locked --release --bin tracedecay +fi if [[ ! -x "$source_binary" ]]; then printf 'dogfood build did not produce %s\n' "$source_binary" >&2 diff --git a/src/cli.rs b/src/cli.rs index 928548bb4..2ce0fcf3b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -326,6 +326,9 @@ pub enum Commands { #[arg(long)] no_reinstall: bool, }, + /// Install this source-built executable into the live user environment. + #[command(hide = true)] + Dogfood, /// Refresh plugins and daemon after the binary has been updated. #[command(name = "post-update", hide = true)] PostUpdate { diff --git a/src/main.rs b/src/main.rs index e04b97e29..130e74310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -593,6 +593,9 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { } => { update_cmd::run_update_command(no_heal, no_reinstall)?; } + Commands::Dogfood => { + update_cmd::run_dogfood_command()?; + } Commands::PostUpdate { no_heal, no_reinstall, @@ -748,6 +751,7 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::UpdatePlugin | Commands::Upgrade { .. } | Commands::Update { .. } + | Commands::Dogfood | Commands::PostUpdate { .. } | Commands::Uninstall { .. } | Commands::Lsp { .. } @@ -803,6 +807,7 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { | Commands::UpdatePlugin | Commands::Upgrade { .. } | Commands::Update { .. } + | Commands::Dogfood | Commands::PostUpdate { .. } | Commands::Uninstall { .. } | Commands::Lsp { .. } diff --git a/src/update_cmd.rs b/src/update_cmd.rs index 270eb4c07..1e6148142 100644 --- a/src/update_cmd.rs +++ b/src/update_cmd.rs @@ -296,6 +296,28 @@ pub(crate) fn run_upgrade_command( ) } +pub(crate) fn run_dogfood_command() -> tracedecay::errors::Result<()> { + let current_exe = + std::env::current_exe().map_err(|error| tracedecay::errors::TraceDecayError::Config { + message: format!("could not resolve source-built executable: {error}"), + })?; + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/dogfood.sh"); + let status = std::process::Command::new("bash") + .arg(&script) + .env("TRACEDECAY_DOGFOOD_SOURCE_BINARY", ¤t_exe) + .status() + .map_err(|error| tracedecay::errors::TraceDecayError::Config { + message: format!("failed to launch {}: {error}", script.display()), + })?; + if status.success() { + Ok(()) + } else { + Err(tracedecay::errors::TraceDecayError::Config { + message: format!("dogfood installer failed with status: {status}"), + }) + } +} + fn prepare_post_update_lease( lease: tracedecay::lifecycle_lease::LifecycleLease, ) -> Option { diff --git a/tests/dogfood_command_test.sh b/tests/dogfood_command_test.sh index ff3055aa6..40547787f 100755 --- a/tests/dogfood_command_test.sh +++ b/tests/dogfood_command_test.sh @@ -46,6 +46,6 @@ grep -Fxq 'daemon status' "$log" grep -Fxq 'doctor' "$log" grep -Fxq -- '--version' "$log" -grep -Fq 'dogfood = "!scripts/dogfood.sh"' "$repo_root/.cargo/config.toml" +grep -Fq 'dogfood = "run --quiet --release --bin tracedecay -- dogfood"' "$repo_root/.cargo/config.toml" echo "dogfood command contract passed"