From 3a3ce596e25a2a7bbb4ed5ef71058cb035fb9b41 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 15:22:39 -0700 Subject: [PATCH 01/19] feat(muse): index Muse Code sessions as a first-class source Add `muse` as a source so Meta's Muse Code CLI sessions reach discovery, sync, targeted hydration and live capture like every other harness. Muse writes one append-only, event-sourced `session.jsonl` per session under `$XDG_DATA_HOME/muse/sessions/YYYY/MM/DD//`. Every record carries its own `recorded_at` (microseconds), so no timestamp is inferred. The parser (`ingest/muse.rs`) reads prompts, prose, readable thinking, tool calls and results, file edits, per-model-step usage, models and lifecycle markers, filters records to the session's own stream (mirrored subagent/reminder task streams are not prompts), and takes tool status from `tool_batch.effect.terminal` plus a non-zero `bash` exit code. `subagent/` child transcripts are not sessions of their own; linking them as delegation is left for a follow-up, so Muse hydration reports `partial`. Usage normalizes as `per-request` with cache reads made exclusive. Grok's history re-attribution is factored into a shared helper both whole-session readers use. Fixtures include a trimmed transcript the real CLI wrote (MIT, xhluca/session-migrate), with snapshots. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 + README.md | 10 +- crates/ai-hist-cli/src/lib.rs | 10 +- crates/ai-hist-napi/src/lib.rs | 4 +- crates/ai-hist/public-api.txt | 2 + crates/ai-hist/src/change_feed.rs | 1 + crates/ai-hist/src/discover.rs | 125 +++ crates/ai-hist/src/ingest.rs | 870 +++++++++++++++++- crates/ai-hist/src/ingest/hydrate.rs | 90 +- crates/ai-hist/src/ingest/muse.rs | 664 +++++++++++++ .../ai-hist/src/ingest/tool_result_facts.rs | 43 + crates/ai-hist/src/paths.rs | 28 +- crates/ai-hist/src/session_store.rs | 11 +- crates/ai-hist/src/store.rs | 7 + crates/ai-hist/src/usage.rs | 77 +- crates/ai-hist/tests/fixture_corpus.rs | 85 ++ crates/ai-hist/tests/fixtures/README.md | 16 + .../session.jsonl | 46 + .../session.jsonl | 29 + .../session.jsonl | 2 + crates/ai-hist/tests/live_capture.rs | 11 +- .../tests/snapshots/muse/cli-capture.json | 452 +++++++++ .../tests/snapshots/muse/tools-session.json | 465 ++++++++++ docs/architecture.md | 2 +- ...9-19-relayhistory-owns-session-sourcing.md | 65 +- docs/session-catalog.md | 100 +- docs/sourcing-sdk.md | 1 + sdk-ts/src/helpers.test.ts | 3 +- sdk-ts/src/mcp-server.ts | 4 +- sdk-ts/src/operations.ts | 2 + sdk-ts/src/sdk-common.ts | 1 + 31 files changed, 3145 insertions(+), 98 deletions(-) create mode 100644 crates/ai-hist/src/ingest/muse.rs create mode 100644 crates/ai-hist/tests/fixtures/muse/cli-capture/.local/share/muse/sessions/2026/09/01/74747474-7474-4747-8747-747474747474/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl create mode 100644 crates/ai-hist/tests/snapshots/muse/cli-capture.json create mode 100644 crates/ai-hist/tests/snapshots/muse/tools-session.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 87958742..6f1ec726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,20 @@ Notable changes to the native `ai-hist` CLI are documented here. ### Added +- Muse Code (Meta's `muse` CLI) is a first-class source, `muse`. Sessions are + read from `$XDG_DATA_HOME/muse/sessions/YYYY/MM/DD//session.jsonl` + (`~/.local/share/muse/sessions` by default) by discovery, `sync`, + targeted hydration and live capture: typed prompts become `history` rows at + the microsecond time Muse recorded them, and each session gets its prose, + readable thinking, tool calls and results (status from Muse's own + `tool_batch.effect.terminal` outcome, and a non-zero `bash` exit code), + `write_file` / `edit_file` edits, per-model-step token usage (normalized as + `per-request`), models, CLI version and lifecycle markers (`turn_end`, + `session_start`, `session_resumed`, `session_end`, `model_switch`, + `encrypted_reasoning`). `subagent/` child transcripts are not catalogued as + sessions, and delegation to them is not linked yet, so a Muse hydration + reports `partial`. `ai-hist resume` prints `muse resume `, and the sync + service forwards `XDG_DATA_HOME`. - Markers reach the SDK, MCP and both CLIs. `getSessionMarkersPage(source, sessionId, {limit, after})`, the `sessionMarkers()` iterator and `getSessionMarkers()` read one source's session on the evidence keyset @@ -184,6 +198,9 @@ Notable changes to the native `ai-hist` CLI are documented here. ### Rust API +- `Source::Muse` and `ProviderRoots::muse` (the Muse Code sessions directory; + `from_env` honours `XDG_DATA_HOME`). Both types are `#[non_exhaustive]`, so + this is additive. - `SessionStore::discover(DiscoveryOptions)` is the shallow catalog sweep: every local provider's sessions from metadata, as `Shallow` rows, hydrating none and taking no `SyncRunLock`. `discover`, `sync` and `hydrate` take an diff --git a/README.md b/README.md index 62e21b3b..ae80887f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Search, resume, and hand off every coding-agent session — across every harness on your machine.** -Local memory for **Claude Code**, **Codex**, **Cursor**, **Grok**, **OpenCode**, and [**Agent Relay**](https://github.com/AgentWorkforce/relay). Your agent sessions are indexed into one local SQLite database. Search them all at once, jump back into a session with its harness's native resume command, and hand a compact context pack to another agent — or to a teammate's agent — to continue the work. +Local memory for **Claude Code**, **Codex**, **Cursor**, **Grok**, **Muse Code**, **OpenCode**, and [**Agent Relay**](https://github.com/AgentWorkforce/relay). Your agent sessions are indexed into one local SQLite database. Search them all at once, jump back into a session with its harness's native resume command, and hand a compact context pack to another agent — or to a teammate's agent — to continue the work. ```sh ai-hist search "auth rewrite" @@ -31,7 +31,7 @@ ai-hist search "the thing i was working on" The bare `ai-hist` command bootstraps a searchable database on first use: it discovers your most recent local sessions and indexes their evidence, then tells you what it found. It leaves an already-populated database alone. Run `ai-hist sync` any time you want a full re-ingest rather than the bounded first-run pass. -RelayHistory follows each harness's configured state directory. Set `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` to relocate Claude Code, Codex, or Grok data; unset or empty values fall back to `~/.claude`, `~/.codex`, and `~/.grok`. OpenCode uses `OPENCODE_DB`, defaulting to `~/.local/share/opencode/opencode.db`. +RelayHistory follows each harness's configured state directory. Set `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` to relocate Claude Code, Codex, or Grok data; unset or empty values fall back to `~/.claude`, `~/.codex`, and `~/.grok`. Muse Code sessions are read from `$XDG_DATA_HOME/muse/sessions` (`~/.local/share/muse/sessions` when `XDG_DATA_HOME` is unset). OpenCode uses `OPENCODE_DB`, defaulting to `~/.local/share/opencode/opencode.db`. Node.js 20 or 22 is required. `npm install` pulls a prebuilt native addon for macOS (arm64, x64), Linux glibc ≥ 2.28 and musl (arm64, x64), and Windows x64 — no Rust toolchain, compiler, or separate binary download. The glibc floor covers Debian 12, Ubuntu 22.04, Amazon Linux 2023, and RHEL/Alma 9; releases are smoke-tested on `node:22-bookworm-slim` and `ubuntu:22.04`. @@ -52,9 +52,9 @@ ai-hist recent 20 # the last N prompts, newes ai-hist stats # how much history is indexed, by source and project ``` -`` is the session's source — `claude`, `codex`, `cursor`, `grok`, `opencode`, or `relay` — and is required alongside the ID, because session IDs collide across providers. `ai-hist sessions list` prints both. +`` is the session's source — `claude`, `codex`, `cursor`, `grok`, `muse`, `opencode`, or `relay` — and is required alongside the ID, because session IDs collide across providers. `ai-hist sessions list` prints both. -`ai-hist resume` prints a native resume command for Claude Code, Codex, Cursor, and Grok sessions. OpenCode and Agent Relay sessions are searchable and packable, but have no native resume command to print, so use `ai-hist pack` to carry that context forward instead. +`ai-hist resume` prints a native resume command for Claude Code, Codex, Cursor, Grok, and Muse Code sessions. OpenCode and Agent Relay sessions are searchable and packable, but have no native resume command to print, so use `ai-hist pack` to carry that context forward instead. OpenCode is read from whichever of its two stores the machine has: the SQLite `opencode.db` that current releases write (`OPENCODE_DB`), or the older JSON @@ -120,7 +120,7 @@ for authentication, durable delivery, readback, and the legacy sharing API. ## Why `ai-hist` -- **Every harness, one search.** Claude Code, Codex, Cursor, Grok, OpenCode, Agent Relay — indexed side-by-side. No per-harness silo. +- **Every harness, one search.** Claude Code, Codex, Cursor, Grok, Muse Code, OpenCode, Agent Relay — indexed side-by-side. No per-harness silo. - **Provider-aware evidence.** Prompts, tool calls, and edits are preserved as raw evidence, not summarized away — as much of it as each harness actually exposes. Hydration reports `full`, `partial`, or `shallow_only` per session, so you can tell thin coverage from a thing that never happened. Where a harness records less than the others, the gap is named. Cursor transcripts carry the assistant's prose and every tool call, but no tool output, model id, token usage or timestamp field — those are reported as unavailable, and a turn whose injected `` tag cannot be read is stamped from the file mtime with `CURSOR_TIMESTAMP_FROM_MTIME`. Grok logs no per-turn billing tokens, so its only token fact is a context-window proxy, and its hydration says so every time. The per-field detail is in [the session catalog](docs/session-catalog.md). - **Local by default.** SQLite on your machine. Export and delivery require an explicit selection; remote acquisition requires an installed source plugin. - **Handoff-native.** `pack` and `resume` are first-class commands, not afterthoughts. diff --git a/crates/ai-hist-cli/src/lib.rs b/crates/ai-hist-cli/src/lib.rs index b6370eaa..56edc79a 100644 --- a/crates/ai-hist-cli/src/lib.rs +++ b/crates/ai-hist-cli/src/lib.rs @@ -408,7 +408,7 @@ enum SessionsAction { /// content blocks, agent lifecycle events. Same `(ts_ms IS NULL, ts_ms, /// id)` keyset as tool calls and file edits; undated markers page last. Markers { - /// Coding-agent source (claude, codex, cursor, grok, relay, opencode). + /// Coding-agent source (claude, codex, cursor, grok, muse, relay, opencode). source: String, /// Native session identifier within that source. session_id: String, @@ -432,7 +432,7 @@ enum SessionsAction { /// and cost is never computed: `reported_cost_usd` appears only when the /// source data carried one. Usage { - /// Coding-agent source (claude, codex, cursor, grok, relay, opencode). + /// Coding-agent source (claude, codex, cursor, grok, muse, relay, opencode). source: String, /// Native session identifier within that source. session_id: String, @@ -470,7 +470,7 @@ enum SessionsAction { enum LearnAction { /// Distill local session history into decision/finding/reflection events. Distill { - /// Only distill sessions from this source (claude, codex, cursor, grok, relay, opencode). + /// Only distill sessions from this source (claude, codex, cursor, grok, muse, relay, opencode). #[arg(long)] source: Option, /// Distill one session id. @@ -2604,11 +2604,13 @@ fn service_command_args(spec: &ServiceSpec, args: &[String]) -> Vec { command } -const PROVIDER_ENV_VARS: [&str; 4] = [ +const PROVIDER_ENV_VARS: [&str; 5] = [ "CLAUDE_CONFIG_DIR", "CODEX_HOME", "GROK_HOME", "OPENCODE_DB", + // Muse Code keeps its sessions under `$XDG_DATA_HOME/muse/sessions`. + "XDG_DATA_HOME", ]; fn scheduler_environment_value(name: &str, value: std::ffi::OsString) -> Result> { diff --git a/crates/ai-hist-napi/src/lib.rs b/crates/ai-hist-napi/src/lib.rs index 7d355d87..33c7d6e5 100644 --- a/crates/ai-hist-napi/src/lib.rs +++ b/crates/ai-hist-napi/src/lib.rs @@ -1733,7 +1733,9 @@ pub async fn hydrate_session(options: HydrateSessionOptions) -> napi::Result> diff --git a/crates/ai-hist/src/change_feed.rs b/crates/ai-hist/src/change_feed.rs index 9bd375d1..f6650a75 100644 --- a/crates/ai-hist/src/change_feed.rs +++ b/crates/ai-hist/src/change_feed.rs @@ -883,6 +883,7 @@ fn parse_source(name: &str) -> Result { "relay" => Ok(Source::Relay), "trajectory" => Ok(Source::Trajectory), "opencode" => Ok(Source::OpenCode), + "muse" => Ok(Source::Muse), other => anyhow::bail!("change feed: unknown source {other:?}"), } } diff --git a/crates/ai-hist/src/discover.rs b/crates/ai-hist/src/discover.rs index 22fe99ce..0e40ec0e 100644 --- a/crates/ai-hist/src/discover.rs +++ b/crates/ai-hist/src/discover.rs @@ -302,6 +302,8 @@ pub struct DiscoveryEnv<'a> { pub codex_home: PathBuf, /// Grok state root. pub grok_home: PathBuf, + /// Muse Code session logs. + pub muse_sessions: PathBuf, /// Path to the opencode database. pub opencode_db: PathBuf, /// Root of OpenCode's legacy `storage/` JSON tree, read only when there @@ -327,6 +329,7 @@ impl<'a> DiscoveryEnv<'a> { claude_config_dir: roots.claude, codex_home: roots.codex, grok_home: roots.grok, + muse_sessions: roots.muse, opencode_db: roots.opencode_db, opencode_storage_dir: roots.opencode_storage_dir, conn, @@ -354,9 +357,11 @@ impl<'a> DiscoveryEnv<'a> { .parent() .map(|parent| parent.join("storage")) .unwrap_or_else(|| home.join(".local/share/opencode/storage")); + let muse = crate::paths::default_muse_sessions_dir(&home); Self::with_provider_roots( conn, crate::ProviderRoots { + muse, home, claude: claude_config_dir, codex: codex_home, @@ -393,6 +398,7 @@ impl<'a> DiscoveryEnv<'a> { claude_config_dir: &self.claude_config_dir, codex_home: &self.codex_home, grok_home: &self.grok_home, + muse_sessions: &self.muse_sessions, opencode_db: &self.opencode_db, opencode_storage_dir: &self.opencode_storage_dir, counters: &self.counters, @@ -434,6 +440,8 @@ pub struct ScanEnv<'a> { pub codex_home: &'a Path, /// Grok state root. pub grok_home: &'a Path, + /// Muse Code session logs. + pub muse_sessions: &'a Path, /// Path to the opencode database. pub opencode_db: &'a Path, /// Root of OpenCode's legacy `storage/` JSON tree. @@ -481,6 +489,8 @@ pub struct ProviderRoots<'a> { pub codex: &'a Path, /// Grok state root. pub grok: &'a Path, + /// Muse Code session logs. + pub muse: &'a Path, /// Path to the opencode database. pub opencode_db: &'a Path, } @@ -990,6 +1000,7 @@ pub fn shallow_providers() -> Vec> { Box::new(CodexProvider), Box::new(CursorProvider), Box::new(GrokProvider), + Box::new(MuseProvider), Box::new(OpencodeProvider::default()), Box::new(RelayProvider), ] @@ -2039,6 +2050,119 @@ fn grok_update_bounds( Ok((first, last)) } +// --------------------------------------------------------------------------- +// muse +// --------------------------------------------------------------------------- + +/// Muse Code: one append-only `session.jsonl` per session under +/// `muse/sessions/YYYY/MM/DD//`. Subagent and reminder children +/// write their own transcripts under `subagent/` beside the parent; those are +/// not sessions of their own and are never enumerated. +struct MuseProvider; + +impl ShallowSessionProvider for MuseProvider { + fn acquire( + &self, + _home: &Path, + _observation: &crate::observations::SessionObservation, + ) -> Result { + Ok(crate::sources::AcquiredEvidence::LocalFiles) + } + fn source(&self) -> &'static str { + "muse" + } + /// The transcript parser writes prompts, events, tool calls and file + /// edits. Delegation is not captured yet, so relationships are not + /// declared and a hydration reports partial rather than claiming them. + fn evidence_kinds(&self) -> &'static [EvidenceKind] { + &[ + EvidenceKind::History, + EvidenceKind::SessionEvent, + EvidenceKind::ToolCall, + EvidenceKind::FileEdit, + ] + } + + fn watch_roots(&self, roots: &ProviderRoots<'_>) -> Vec { + vec![WatchRoot::tree(roots.muse.to_path_buf())] + } + + fn enumerate( + &self, + env: &DiscoveryEnv<'_>, + _requested_limit: Option, + ) -> Result> { + file_candidates( + "muse", + crate::collect_muse_transcripts(&env.muse_sessions)?, + crate::file_stamp_and_modified, + ) + } + + fn read_shallow( + &self, + scan: &ScanEnv<'_>, + _catalog: Option<&Connection>, + candidate: &Candidate, + ) -> Result> { + use crate::ingest::muse; + let path = PathBuf::from(&candidate.locator); + let bounded = read_bounded_jsonl(scan, &path)?; + // A permission frame can precede the metadata record, so the header + // is looked for rather than assumed to be the first line. + let Some(metadata) = bounded + .head_records() + .filter_map(parse_record) + .find_map(|record| muse::parse_metadata(&record)) + else { + return Ok(None); + }; + let own_stream = |record: &Value| { + record.pointer("/stream/id").and_then(Value::as_str) + == Some(metadata.session_id.as_str()) + }; + let mut models = Vec::new(); + push_unique(&mut models, metadata.model_id.as_deref()); + let mut first_prompt = None; + for record in bounded.head_records().filter_map(parse_record) { + if !own_stream(&record) { + continue; + } + match muse::parse_event(&record) { + Some(muse::MuseEvent::Prompt { text }) if first_prompt.is_none() => { + first_prompt = Some(excerpt(&text)); + } + Some(muse::MuseEvent::ModelCompleted { model, .. }) => { + push_unique(&mut models, model.as_deref()); + } + _ => {} + } + if first_prompt.is_some() && models.len() > 1 { + break; + } + } + let last_activity_ms = bounded + .tail_records_rev() + .filter_map(parse_record) + .filter(own_stream) + .find_map(|record| muse::recorded_ms(&record)); + Ok(Some(ShallowSession { + source: "muse".into(), + session_id: metadata.session_id, + cwd: metadata.workspace_root, + first_activity_ms: metadata.recorded_ms, + last_activity_ms: last_activity_ms + .or(metadata.recorded_ms) + .or_else(|| crate::file_modified_ms(&path)), + first_prompt, + models, + agent_version: metadata.cli_version, + raw_path: Some(candidate.locator.clone()), + ..Default::default() + })) + } +} + // --------------------------------------------------------------------------- // opencode // --------------------------------------------------------------------------- @@ -3210,6 +3334,7 @@ pub(crate) fn provider_watch_roots(source: &str, roots: &crate::ProviderRoots) - claude: &roots.claude, codex: &roots.codex, grok: &roots.grok, + muse: &roots.muse, opencode_db: &roots.opencode_db, }, ) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index ce6cef85..02a8f5b8 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -23,6 +23,7 @@ pub(crate) mod hook; pub(crate) mod hydrate; pub(crate) mod incremental; pub(crate) mod jsonl; +pub(crate) mod muse; pub(crate) mod opencode; pub(crate) mod tool_result_facts; pub(crate) mod transcript_cursor; @@ -61,8 +62,8 @@ pub use hydrate::{ }; pub use tool_result_facts::{ content_hash, stable_stringify, ToolResultFacts, ToolResultIndexer, ERROR_SIGNAL_EXIT_CODE, - ERROR_SIGNAL_MCP_ERR, ERROR_SIGNAL_PATCH_APPLY, ERROR_SIGNAL_SUBAGENT_STATUS, - ERROR_SIGNAL_TOOL_RESULT, EVENT_SOURCE_FUNCTION_CALL_OUTPUT, + ERROR_SIGNAL_MCP_ERR, ERROR_SIGNAL_MUSE_TOOL_OUTCOME, ERROR_SIGNAL_PATCH_APPLY, + ERROR_SIGNAL_SUBAGENT_STATUS, ERROR_SIGNAL_TOOL_RESULT, EVENT_SOURCE_FUNCTION_CALL_OUTPUT, EVENT_SOURCE_SUBAGENT_NOTIFICATION, EVENT_SOURCE_TOOL_RESULT, STATUS_COMPLETED, STATUS_ERRORED, STATUS_UNKNOWN, }; @@ -1480,6 +1481,15 @@ fn sync_basic( total_inserted += inserted; checkpoint_sync_state(&state_path, &state); } + capture_progress("muse", 0, None); + check_capture_cancelled()?; + if let Some(inserted) = report.capture( + "muse", + sync_muse_with_coverage(conn, &mut state, &roots.muse, &mut coverage), + ) { + total_inserted += inserted; + checkpoint_sync_state(&state_path, &state); + } capture_progress("trajectory", 0, None); check_capture_cancelled()?; if let Some(inserted) = report.capture( @@ -10372,6 +10382,690 @@ pub(crate) fn decode_cursor_project(name: &str) -> String { format!("/{}", name.replace('-', "/")) } +/// Where plain `sync` remembers the change stamp of each Muse Code transcript +/// it has already read. Renamed on a parser upgrade so unchanged transcripts +/// are read again; see [`GROK_SYNC_STATE_KEY`]. +const MUSE_SYNC_STATE_KEY: &str = "muse_sessions_v1"; + +/// Every top-level Muse Code transcript under `root`, sorted. +/// +/// A transcript under a `subagent/` directory belongs to a subagent or +/// reminder child of the session beside it, not to a session of its own, so +/// it is never returned. +pub(crate) fn collect_muse_transcripts(root: &Path) -> Result> { + Ok( + collect_matching_files(root, muse::SESSION_FILE.trim_end_matches(".jsonl"), "jsonl")? + .into_iter() + .filter(|path| { + path.file_name().and_then(|name| name.to_str()) == Some(muse::SESSION_FILE) + && !muse::is_child_transcript(path, root) + }) + .collect(), + ) +} + +#[cfg(test)] +fn sync_muse(conn: &Connection, state: &mut Map, root: &Path) -> Result { + sync_muse_with_coverage(conn, state, root, &mut SweepCoverage::default()) +} + +/// Index every Muse Code transcript whose stamp changed since the last run. +/// +/// A transcript is append-only, but it is read whole: a session is one file, +/// its records carry their own ids and times, and a whole read is what lets a +/// re-read replace rather than merge. Same state-map discipline as Grok: a +/// stamp is trusted only while the evidence it recorded is still stored, and +/// an unreadable transcript keeps no stamp, so the next run retries it. +fn sync_muse_with_coverage( + conn: &Connection, + state: &mut Map, + root: &Path, + coverage: &mut SweepCoverage, +) -> Result { + if !root.exists() { + sync_note!(" [muse] not found: {} (skipped)", root.display()); + return Ok(0); + } + let mut muse_state = state + .get(MUSE_SYNC_STATE_KEY) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut inserted = 0; + let mut scanned = 0; + let mut sessions = 0; + let mut errors = 0; + let mut accounted = 0; + for transcript in capture_files("muse", collect_muse_transcripts(root)?) { + check_capture_cancelled()?; + let key = transcript.to_string_lossy().to_string(); + let stamp = match file_stamp(&transcript) { + Ok(stamp) => stamp, + Err(error) => { + scanned += 1; + errors += 1; + coverage.note_unread(); + sync_note!( + " [muse] unreadable session {}: {error:#}", + transcript.display() + ); + continue; + } + }; + let recorded = muse_state.get(&key); + if recorded.and_then(grok_state_stamp) == Some(stamp.as_str()) { + // The stamp says unchanged; the database has to agree before + // that is taken as "already indexed". Every Muse ingestion writes + // at least its catalog row, and one that wrote events is checked + // against those. + let still_there = match ( + recorded.and_then(grok_state_session), + recorded.and_then(grok_state_had_evidence), + ) { + (None, _) => true, + (Some(id), Some(false)) => muse_catalog_row_exists(conn, id)?, + (Some(id), _) => muse_evidence_exists(conn, id)?, + }; + if still_there { + accounted += 1; + continue; + } + } + scanned += 1; + match read_muse_transcript(&transcript) { + Ok(Some(parsed)) => { + let session_id = parsed.metadata.session_id.clone(); + let tx = conn.unchecked_transaction()?; + // A re-read replaces the session's prompts wholesale, so what + // it added is the difference, not what it wrote. + let before = muse_history_rows(&tx, &session_id)?; + let outcome = ingest_muse_session(&tx, &parsed, &key)?; + inserted += muse_history_rows(&tx, &session_id)?.saturating_sub(before); + tx.commit()?; + sessions += 1; + accounted += 1; + muse_state.insert( + key, + json!({ + "stamp": stamp, + "session": session_id, + "evidence": outcome.events > 0 || outcome.markers > 0, + }), + ); + } + Ok(None) => { + accounted += 1; + muse_state.insert(key, json!({ "stamp": stamp })); + } + Err(error) => { + if error.is::() { + return Err(error); + } + errors += 1; + coverage.note_unread(); + sync_note!( + " [muse] unreadable session {}: {error:#}", + transcript.display() + ); + } + } + } + state.insert(MUSE_SYNC_STATE_KEY.to_string(), Value::Object(muse_state)); + if scanned > 0 { + let suffix = if errors > 0 { + format!(" ({errors} errors)") + } else { + String::new() + }; + sync_note!(" [muse] +{inserted} rows from {sessions} sessions{suffix}"); + } + if errors > 0 && accounted == 0 { + anyhow::bail!( + "{errors} Muse Code session(s) could not be read and none were indexed; \ + the unreadable transcripts are named above" + ); + } + Ok(inserted) +} + +fn muse_history_rows(conn: &Connection, session_id: &str) -> Result { + let rows: i64 = conn.query_row( + "SELECT COUNT(*) FROM history WHERE source = 'muse' AND session_id = ?", + params![session_id], + |row| row.get(0), + )?; + Ok(rows as usize) +} + +fn muse_evidence_exists(conn: &Connection, session_id: &str) -> Result { + if session_events_exist(conn, "muse", session_id)? { + return Ok(true); + } + let exists: i64 = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM session_markers \ + WHERE source = 'muse' AND session_id = ? LIMIT 1)", + params![session_id], + |row| row.get(0), + )?; + Ok(exists != 0) +} + +fn muse_catalog_row_exists(conn: &Connection, session_id: &str) -> Result { + let exists: i64 = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sessions \ + WHERE source = 'muse' AND session_id = ? LIMIT 1)", + params![session_id], + |row| row.get(0), + )?; + Ok(exists != 0) +} + +/// Read and interpret one transcript. `Ok(None)` when it names no session. +pub(crate) fn read_muse_transcript(path: &Path) -> Result> { + let bytes = + fs::read(path).with_context(|| format!("read Muse transcript {}", path.display()))?; + Ok(muse::parse_transcript(&String::from_utf8_lossy(&bytes))) +} + +/// What indexing one Muse Code transcript produced, and what its records +/// could not establish. +#[derive(Debug, Default, Clone)] +pub(crate) struct MuseIngestOutcome { + pub prompts: usize, + pub events: usize, + pub tool_calls: usize, + pub file_edits: usize, + pub markers: usize, + /// Reasoning Muse kept only as an encrypted trace. + pub encrypted_reasoning: usize, + /// `model_completed` steps whose usage had no assistant record after them + /// in the same run to carry it. + pub unattached_usage: usize, + /// `subagent_spawn` calls. Muse's delegation is not linked yet. + pub subagent_calls: usize, + /// Tool results with no recorded outcome for their call. + pub results_without_outcome: usize, + /// Lines that did not parse, usually a live session's partial tail. + pub unparsed_lines: usize, +} + +/// One model step's usage, waiting for the assistant record it produced. +struct PendingMuseUsage { + run_id: Option, + token_json: String, +} + +/// The usage a model step reported rides on the first assistant row that step +/// committed, in the same run. A step that committed nothing keeps its usage +/// off every row rather than on a neighbour's, and is counted in `orphans`. +fn take_muse_usage( + run_id: Option<&str>, + pending: &mut Option, + orphans: &mut usize, +) -> Option { + match pending.take() { + Some(usage) if usage.run_id.as_deref() == run_id => Some(usage.token_json), + Some(_) => { + *orphans += 1; + None + } + None => None, + } +} + +/// Index one Muse Code transcript: prompts, prose, thinking, tool calls and +/// results, file edits, per-step token usage and lifecycle markers. +/// +/// A whole-transcript read replaces the session's evidence rather than +/// merging into it, so a re-read after the file grew never duplicates a row +/// and one that dropped a record (a rewritten or truncated file) leaves no +/// stale one behind. +pub(crate) fn ingest_muse_session( + conn: &Connection, + transcript: &muse::MuseTranscript, + raw_path: &str, +) -> Result { + const SOURCE: &str = "muse"; + let sid = transcript.metadata.session_id.as_str(); + let project = transcript.metadata.workspace_root.as_deref(); + let version = transcript.metadata.cli_version.as_deref(); + for statement in [ + "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", + "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", + "DELETE FROM file_edits WHERE source = 'muse' AND session_id = ?", + "DELETE FROM session_markers WHERE source = 'muse' AND session_id = ?", + ] { + conn.execute(statement, params![sid])?; + } + replace_session_history(conn, SOURCE, sid)?; + + let mut outcome = MuseIngestOutcome { + unparsed_lines: transcript.unparsed_lines, + ..Default::default() + }; + let outcomes = muse::tool_outcomes(transcript); + let mut model = transcript.metadata.model_id.clone(); + let mut finish_reason: Option = None; + let mut pending_usage: Option = None; + let mut first_prompt: Option = None; + let mut last_assistant_text: Option = None; + let mut results = ToolResultIndexer::default(); + + for (idx, record) in transcript.records.iter().enumerate() { + check_capture_cancelled()?; + let ts = record.ts_ms; + let run_id = record.run_id.as_deref(); + let record_uid = record + .record_id + .clone() + .unwrap_or_else(|| format!("r{}", record.sequence.unwrap_or(idx as i64))); + let facts = RawMessageFacts { + agent_version: version, + turn_id: run_id, + ..Default::default() + }; + match &record.event { + muse::MuseEvent::Prompt { text } => { + if pending_usage.take().is_some() { + outcome.unattached_usage += 1; + } + if first_prompt.is_none() { + first_prompt = Some(crate::discover::excerpt(text)); + } + insert_session_event( + conn, + SOURCE, + sid, + project, + project, + None, + &record_uid, + None, + ts, + "user", + "text", + Some(text), + None, + None, + RequestIdentity::none(), + &record_uid, + None, + facts, + )?; + outcome.events += 1; + outcome.prompts += insert_history( + conn, + &HistoryEntry { + id: 0, + source: SOURCE.into(), + session_id: Some(sid.to_string()), + project: project.map(str::to_string), + prompt_hash: Some(prompt_hash(text)), + prompt: text.clone(), + timestamp_ms: ts, + }, + )?; + } + muse::MuseEvent::ModelCompleted { + model: step_model, + usage, + finish_reason: step_finish, + .. + } => { + if pending_usage.is_some() { + outcome.unattached_usage += 1; + } + if step_model.is_some() { + model = step_model.clone(); + } + finish_reason = step_finish.clone(); + pending_usage = usage.as_ref().map(|usage| PendingMuseUsage { + run_id: record.run_id.clone(), + token_json: usage.to_string(), + }); + } + muse::MuseEvent::Reasoning { + message_id, + text, + encrypted, + } => match text { + Some(text) => { + let token_json = + take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let message_id = message_id.as_deref().unwrap_or(&record_uid); + insert_session_event( + conn, + SOURCE, + sid, + project, + project, + None, + message_id, + None, + ts, + "assistant", + "thinking", + Some(text), + model.as_deref(), + token_json.as_deref(), + RequestIdentity::none(), + &record_uid, + None, + facts, + )?; + outcome.events += 1; + } + None if *encrypted => { + // The trace exists but is opaque: record that Muse thought + // here, never readable thinking for it. + outcome.encrypted_reasoning += 1; + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "encrypted_reasoning", + subkind: Some("reasoning_committed"), + ts_ms: Some(ts), + message_id: message_id.as_deref(), + turn_id: run_id, + ..Default::default() + }, + )?; + } + None => {} + }, + muse::MuseEvent::AssistantText { + message_id, + response_id, + text, + } => { + let token_json = + take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let message_id = message_id.as_deref().unwrap_or(&record_uid); + insert_session_event( + conn, + SOURCE, + sid, + project, + project, + None, + message_id, + None, + ts, + "assistant", + "text", + Some(text), + model.as_deref(), + token_json.as_deref(), + RequestIdentity { + request_id: response_id.as_deref(), + provider_message_id: Some(message_id), + }, + &record_uid, + None, + RawMessageFacts { + request_id: response_id.as_deref(), + stop_reason: finish_reason.as_deref(), + ..facts + }, + )?; + outcome.events += 1; + last_assistant_text = Some(text.clone()); + } + muse::MuseEvent::ToolCalls { + message_id, + response_id, + calls, + } => { + let message_id = message_id.as_deref().unwrap_or(&record_uid); + for call in calls { + let token_json = + take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let target = muse::pick_tool_target(&call.name, &call.arguments); + let uid = format!("tool:{}", call.call_id); + insert_session_event( + conn, + SOURCE, + sid, + project, + project, + None, + message_id, + None, + ts, + "assistant", + "tool_use", + Some(&format_tool_event_text( + &call.name, + target.as_deref(), + &call.arguments, + )), + model.as_deref(), + token_json.as_deref(), + RequestIdentity { + request_id: response_id.as_deref(), + provider_message_id: Some(message_id), + }, + &uid, + None, + RawMessageFacts { + request_id: response_id.as_deref(), + stop_reason: finish_reason.as_deref(), + ..facts + }, + )?; + outcome.events += 1; + insert_tool_call( + conn, + SOURCE, + sid, + message_id, + &call.call_id, + &call.name, + target.as_deref(), + &serde_json::to_string(&call.arguments).unwrap_or_default(), + None, + ts, + )?; + outcome.tool_calls += 1; + if muse::is_subagent_tool(&call.name) { + outcome.subagent_calls += 1; + } + if muse::is_file_edit_tool(&call.name) { + if let Some(path) = target.as_deref() { + upsert_file_edit_from_call( + conn, + SOURCE, + sid, + message_id, + &call.call_id, + path, + &call.name, + ts, + None, + project, + )?; + outcome.file_edits += 1; + } + } + } + } + muse::MuseEvent::ToolResults { results: batch } => { + for result in batch { + let text = result.text.as_deref(); + let mut result_facts = tool_result_facts::muse_tool_result_facts( + text, + &result.call_id, + outcomes.get(result.call_id.as_str()).copied(), + ); + if result_facts.result_status.as_deref() + == Some(tool_result_facts::STATUS_UNKNOWN) + { + outcome.results_without_outcome += 1; + } + let (call_index, event_index) = results.next(&result.call_id); + result_facts = result_facts.with_ordering(call_index, event_index); + let uid = format!("result:{}", result.call_id); + insert_session_event( + conn, + SOURCE, + sid, + project, + project, + None, + &uid, + None, + ts, + "tool_result", + "tool_result", + text, + None, + None, + RequestIdentity::none(), + &uid, + Some(&result_facts), + facts, + )?; + outcome.events += 1; + match result_facts.result_status.as_deref() { + Some(tool_result_facts::STATUS_ERRORED) + | Some(tool_result_facts::STATUS_CANCELLED) => { + set_tool_call_error(conn, SOURCE, sid, &result.call_id, true)? + } + Some(tool_result_facts::STATUS_COMPLETED) => { + set_tool_call_error(conn, SOURCE, sid, &result.call_id, false)? + } + _ => {} + } + } + } + muse::MuseEvent::RunTerminal { status, reason } => { + if pending_usage.take().is_some() { + outcome.unattached_usage += 1; + } + let payload_json = + marker_payload(vec![("terminal", json!(status)), ("reason", json!(reason))]); + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "turn_end", + subkind: Some("terminal"), + ts_ms: Some(ts), + turn_id: run_id, + text: status.as_deref(), + payload_json: payload_json.as_deref(), + ..Default::default() + }, + )?; + } + muse::MuseEvent::SessionOpened { resume } => { + let payload_json = marker_payload(vec![("resume", json!(resume))]); + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "session_start", + subkind: Some("session.opened.observed"), + ts_ms: Some(ts), + payload_json: payload_json.as_deref(), + ..Default::default() + }, + )?; + } + muse::MuseEvent::SessionResumed => { + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "session_resumed", + subkind: Some("session.resumed"), + ts_ms: Some(ts), + ..Default::default() + }, + )?; + } + muse::MuseEvent::SessionEnd { exit_reason } => { + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "session_end", + subkind: Some("session.end"), + ts_ms: Some(ts), + text: exit_reason.as_deref(), + ..Default::default() + }, + )?; + } + muse::MuseEvent::Metadata(metadata) => { + if metadata.model_id.is_some() { + model = metadata.model_id.clone(); + } + outcome.markers += insert_session_marker( + conn, + SOURCE, + sid, + &NewSessionMarker { + marker_uid: &record_uid, + kind: "model_switch", + subkind: Some(muse::METADATA_PAYLOAD_TYPE), + ts_ms: Some(ts), + text: metadata.model_id.as_deref(), + ..Default::default() + }, + )?; + } + // Folded into the result rows above, by call id. + muse::MuseEvent::ToolOutcome { .. } => {} + } + } + if pending_usage.is_some() { + outcome.unattached_usage += 1; + } + + let first_ts = transcript.first_ts().unwrap_or_default(); + let last_ts = transcript.last_ts().unwrap_or(first_ts); + upsert_session( + conn, + sid, + SOURCE, + project, + None, + first_ts, + last_ts, + last_assistant_text.as_deref(), + Some(raw_path), + )?; + let models = transcript.models(); + conn.execute( + "UPDATE sessions SET first_activity_ms = ?, last_activity_ms = ?, \ + last_assistant_text = COALESCE(?, last_assistant_text), models_json = ?, \ + first_prompt = COALESCE(?, first_prompt), agent_version = COALESCE(?, agent_version) \ + WHERE source = 'muse' AND session_id = ?", + params![ + first_ts, + last_ts, + last_assistant_text.as_deref(), + (!models.is_empty()).then(|| serde_json::to_string(&models).unwrap_or_default()), + first_prompt.as_deref(), + version, + sid, + ], + )?; + Ok(outcome) +} + #[cfg(test)] fn sync_grok(conn: &Connection, state: &mut Map, root: &Path) -> Result { sync_grok_with_coverage(conn, state, root, &mut SweepCoverage::default()) @@ -11093,38 +11787,61 @@ fn replace_grok_session_evidence(conn: &Connection, session_id: &str) -> Result< ] { conn.execute(statement, params![session_id])?; } - // `history` is keyed `(source, timestamp_ms, prompt)` and inserted with - // `INSERT OR IGNORE`, so a prompt two sessions both contain is **one row**, - // attributed to whichever session was indexed first. Deleting this - // session's rows outright would therefore delete a prompt that another, - // unchanged session still has -- it would vanish from search with nothing - // to say it had ever been there, and nothing would ever put it back, - // because that session's own files never change again. - // - // So a row this session no longer owns is **re-attributed** rather than - // skipped. Skipping was the other option and is worse: it would leave the - // row filed under a session that no longer contains the prompt, which is - // a false statement about provenance, and would leave it undeletable -- - // the only session that could ever clean it up is the one that no longer - // evidences it. Re-attribution moves the row to a session whose stored - // events actually carry that prompt at that moment, so the row stays true - // and stays owned. + // Every Grok prompt written before this parser carried a timestamp + // synthesized as `created_at + index`; merging would keep the fabricated + // ones forever, so history is replaced, not merged. + replace_session_history(conn, "grok", session_id)?; conn.execute( - "UPDATE history AS h SET session_id = (SELECT e.session_id FROM session_events e WHERE e.source = 'grok' AND e.role = 'user' AND e.kind = 'text' AND e.session_id <> h.session_id AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt ORDER BY e.session_id LIMIT 1), project = (SELECT e.project FROM session_events e WHERE e.source = 'grok' AND e.role = 'user' AND e.kind = 'text' AND e.session_id <> h.session_id AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt ORDER BY e.session_id LIMIT 1) WHERE h.source = 'grok' AND h.session_id = ? AND EXISTS(SELECT 1 FROM session_events e WHERE e.source = 'grok' AND e.role = 'user' AND e.kind = 'text' AND e.session_id <> h.session_id AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt)", - params![session_id], - )?; - for statement in [ - // What is left is this session's alone. A prompt's identity is - // `(source, timestamp_ms, prompt)`, and every Grok prompt written - // before this parser carried a timestamp synthesized as - // `created_at + index`; merging would keep the fabricated ones - // forever. - "DELETE FROM history WHERE source = 'grok' AND session_id = ?", "DELETE FROM session_relationships WHERE source = 'grok' \ AND parent_session_id = ? AND evidence_kind = 'grok_subagent_dir'", - ] { - conn.execute(statement, params![session_id])?; - } + params![session_id], + )?; + Ok(()) +} + +/// Drop the `history` rows one session owns, ahead of a whole-session +/// re-read that writes them again. +/// +/// `history` is keyed `(source, timestamp_ms, prompt)` and inserted with +/// `INSERT OR IGNORE`, so a prompt two sessions both contain is **one row**, +/// attributed to whichever session was indexed first. Deleting this +/// session's rows outright would therefore delete a prompt that another, +/// unchanged session still has -- it would vanish from search with nothing +/// to say it had ever been there, and nothing would ever put it back, +/// because that session's own files never change again. +/// +/// So a row this session no longer owns is **re-attributed** rather than +/// skipped. Skipping was the other option and is worse: it would leave the +/// row filed under a session that no longer contains the prompt, which is +/// a false statement about provenance, and would leave it undeletable -- +/// the only session that could ever clean it up is the one that no longer +/// evidences it. Re-attribution moves the row to a session whose stored +/// events actually carry that prompt at that moment, so the row stays true +/// and stays owned. What is left is this session's alone, and is deleted. +fn replace_session_history(conn: &Connection, source: &str, session_id: &str) -> Result<()> { + conn.execute( + "UPDATE history AS h SET \ + session_id = (SELECT e.session_id FROM session_events e \ + WHERE e.source = h.source AND e.role = 'user' AND e.kind = 'text' \ + AND e.session_id <> h.session_id \ + AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt \ + ORDER BY e.session_id LIMIT 1), \ + project = (SELECT e.project FROM session_events e \ + WHERE e.source = h.source AND e.role = 'user' AND e.kind = 'text' \ + AND e.session_id <> h.session_id \ + AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt \ + ORDER BY e.session_id LIMIT 1) \ + WHERE h.source = ? AND h.session_id = ? \ + AND EXISTS(SELECT 1 FROM session_events e \ + WHERE e.source = h.source AND e.role = 'user' AND e.kind = 'text' \ + AND e.session_id <> h.session_id \ + AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt)", + params![source, session_id], + )?; + conn.execute( + "DELETE FROM history WHERE source = ? AND session_id = ?", + params![source, session_id], + )?; Ok(()) } @@ -13641,6 +14358,97 @@ mod tests { assert_eq!(outcome.unread_update_rows, 0); } + /// Copy the checked-in Muse fixture into `home` and answer with the + /// sessions root and the transcript. + fn muse_fixture(home: &Path) -> (PathBuf, PathBuf) { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/muse/tools-session/.local/share/muse/sessions"); + let root = home.join(".local/share/muse/sessions"); + let day = "2026/09/20/0199a1b2-0000-7000-8000-00000000a001"; + let child = "subagent/0199a1b2-0000-7000-8000-00000000c001"; + fs::create_dir_all(root.join(day).join(child)).unwrap(); + for file in ["session.jsonl", &format!("{child}/session.jsonl")] { + fs::copy(fixture.join(day).join(file), root.join(day).join(file)).unwrap(); + } + let transcript = root.join(day).join("session.jsonl"); + (root, transcript) + } + + fn muse_count(conn: &Connection, table: &str) -> i64 { + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE source = 'muse'"), + [], + |row| row.get(0), + ) + .unwrap() + } + + /// Plain `sync` indexes the transcript once, re-reads it only when it + /// changes, replaces rather than duplicates on that re-read, and never + /// turns the `subagent/` child into a session. + #[test] + fn muse_sync_is_idempotent_and_rereads_a_grown_transcript() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + + assert_eq!(super::sync_muse(&conn, &mut state, &root).unwrap(), 2); + assert_eq!( + muse_count(&conn, "sessions"), + 1, + "the child is not a session" + ); + let events = muse_count(&conn, "session_events"); + assert_eq!(events, 13); + + // Unchanged: nothing is read again and nothing is duplicated. + assert_eq!(super::sync_muse(&conn, &mut state, &root).unwrap(), 0); + assert_eq!(muse_count(&conn, "session_events"), events); + + // The session grows by one turn: the re-read adds exactly that turn. + let mut file = fs::OpenOptions::new() + .append(true) + .open(&transcript) + .unwrap(); + for (sequence, event) in [ + (40, json!({"kind": "started", "prompt": "Fix the test."})), + ( + 41, + json!({"kind": "assistant_message_committed", "message_id": "msg-5", "text": "Done."}), + ), + ] { + let record = json!({ + "id": format!("grown-{sequence}"), + "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, + "sequence": sequence, + "recorded_at": 1_790_337_700_000_000i64 + sequence, + "payload_type": "runtime.session", + "payload": {"kind": "run", "run_id": "run-c", "event": event}, + }); + writeln!(file, "{record}").unwrap(); + } + drop(file); + assert_eq!(super::sync_muse(&conn, &mut state, &root).unwrap(), 1); + assert_eq!(muse_count(&conn, "session_events"), events + 2); + assert_eq!(muse_count(&conn, "history"), 3); + } + + /// `.sync-state.json` outlives a rebuilt database. An unchanged stamp is + /// trusted only while the evidence it recorded is still there. + #[test] + fn muse_sync_reindexes_an_unchanged_transcript_whose_rows_are_gone() { + let home = tempfile::tempdir().unwrap(); + let (root, _) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + + let rebuilt = open_db(&home.path().join("rebuilt.db")).unwrap(); + assert_eq!(super::sync_muse(&rebuilt, &mut state, &root).unwrap(), 2); + assert_eq!(muse_count(&rebuilt, "session_events"), 13); + } + /// A stamp that fails closed has to be *reported*. Round four stopped one /// unreadable directory from failing the whole Grok pass; on its own that /// turned a store whose only session is unreadable into a silent success — diff --git a/crates/ai-hist/src/ingest/hydrate.rs b/crates/ai-hist/src/ingest/hydrate.rs index 3f7bc606..4ac46f17 100644 --- a/crates/ai-hist/src/ingest/hydrate.rs +++ b/crates/ai-hist/src/ingest/hydrate.rs @@ -1340,7 +1340,7 @@ fn validate_options(options: &HydrateSessionOptions) -> Result<()> { } if !matches!( options.source.as_str(), - "claude" | "codex" | "cursor" | "grok" | "relay" | "opencode" + "claude" | "codex" | "cursor" | "grok" | "relay" | "opencode" | "muse" ) { return Err(hydration_error( "INVALID_ARGUMENT", @@ -1889,6 +1889,7 @@ pub(crate) fn validate_provider_path( ], "cursor" => vec![provider_roots.home.join(".cursor/projects")], "grok" => vec![provider_roots.grok.join("sessions")], + "muse" => vec![provider_roots.muse.clone()], _ => Vec::new(), }; let canonical = fs::canonicalize(path)?; @@ -2115,6 +2116,8 @@ fn ingest_selected( } "grok" => ingest_grok(conn, options, path.unwrap()) .map(|diagnostics| (whole_file(), diagnostics, None)), + "muse" => ingest_muse(conn, options, path.unwrap()) + .map(|diagnostics| (whole_file(), diagnostics, None)), "opencode" => { let path = path.unwrap(); // Whichever layout `source_snapshot` validated this locator @@ -2940,6 +2943,91 @@ fn ingest_grok( Ok(grok_diagnostics(&outcome)) } +fn ingest_muse( + conn: &Connection, + options: &HydrateSessionOptions, + path: &Path, +) -> Result> { + let transcript = read_muse_transcript(path)?.ok_or_else(|| { + hydration_error( + "SESSION_SOURCE_MISMATCH", + "Muse Code transcript has no session metadata", + ) + })?; + if transcript.metadata.session_id != options.session_id { + return Err(hydration_error( + "SESSION_SOURCE_MISMATCH", + "Muse Code transcript identity does not match the catalog row", + )); + } + let outcome = ingest_muse_session(conn, &transcript, &path.to_string_lossy())?; + Ok(muse_diagnostics(&outcome)) +} + +/// What a Muse Code transcript could not establish on its own. Every code +/// describes an absence in Muse's records, not a failure of this run. +fn muse_diagnostics(outcome: &MuseIngestOutcome) -> Vec { + let diagnostic = |code: &str, message: String| HydrationDiagnostic { + code: code.to_string(), + message, + duration_ms: None, + source_bytes: None, + records_parsed: None, + }; + let mut diagnostics = Vec::new(); + if outcome.encrypted_reasoning > 0 { + diagnostics.push(diagnostic( + "MUSE_REASONING_ENCRYPTED", + format!( + "{} reasoning record(s) carried only an encrypted trace; each is recorded as \ + an encrypted_reasoning marker rather than as thinking text", + outcome.encrypted_reasoning + ), + )); + } + if outcome.unattached_usage > 0 { + diagnostics.push(diagnostic( + "MUSE_USAGE_UNATTACHED", + format!( + "{} model step(s) reported usage but committed no assistant record in the \ + same run to carry it; that usage is not stored on any event", + outcome.unattached_usage + ), + )); + } + if outcome.subagent_calls > 0 { + diagnostics.push(diagnostic( + "MUSE_SUBAGENT_SPAWN_UNLINKED", + format!( + "{} subagent_spawn call(s) are recorded as tool calls; Muse subagent \ + transcripts are not linked as child sessions yet", + outcome.subagent_calls + ), + )); + } + if outcome.results_without_outcome > 0 { + diagnostics.push(diagnostic( + "MUSE_TOOL_OUTCOME_MISSING", + format!( + "{} tool result(s) had no tool_batch.effect.terminal record for their call; \ + their status is unknown rather than assumed", + outcome.results_without_outcome + ), + )); + } + if outcome.unparsed_lines > 0 { + diagnostics.push(diagnostic( + "MUSE_LINES_UNPARSED", + format!( + "{} line(s) of session.jsonl were not JSON records and were skipped (a live \ + session's partial last line is one)", + outcome.unparsed_lines + ), + )); + } + diagnostics +} + /// What a Grok session directory could not establish on its own. /// /// Every code here describes an absence in Grok's records, not a failure of diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs new file mode 100644 index 00000000..815e38c6 --- /dev/null +++ b/crates/ai-hist/src/ingest/muse.rs @@ -0,0 +1,664 @@ +//! Muse Code (Meta's `muse` CLI) session record parsing. +//! +//! One place for the interpretation of +//! `$XDG_DATA_HOME/muse/sessions/YYYY/MM/DD//session.jsonl` +//! (`~/.local/share/muse/sessions/…` when `XDG_DATA_HOME` is unset), so +//! shallow discovery, full sync and targeted hydration cannot drift apart. +//! +//! ## What Muse actually writes +//! +//! `session.jsonl` is an append-only, event-sourced log. Every line is one +//! envelope: +//! +//! ```json +//! {"schema_version": 1, "id": "", +//! "stream": {"kind": "session", "id": ""}, "sequence": 1, +//! "recorded_at": 1788223110587169, "payload_type": "runtime.session", …, +//! "payload": { … }} +//! ``` +//! +//! * `recorded_at` is **microseconds** since the epoch, on every record, so +//! no timestamp here is ever inferred. +//! * `runtime.session.metadata` (normally the first record, though a +//! permission frame can precede it) names the session: `stream.id` is the +//! session id and `payload.record` carries `workspace_root`, `model_id`, +//! `provider_id` and `build.semver`. +//! * `runtime.session` with `payload.kind == "run"` is the conversation, keyed +//! by `payload.event.kind`: `started` (`prompt`, the typed message), +//! `reasoning_committed`, `assistant_message_committed`, +//! `assistant_tool_calls_committed` (`tool_calls[].args` is a JSON +//! **string**), `tool_result_batch_committed` (results keyed by +//! `tool_call_id`), `model_completed` (per model step: `model`, `usage`, +//! `duration_ms`, `finish_reason`) and `terminal` (run end). +//! `payload.kind == "task"` is execution bookkeeping and is not read. +//! * `tool_batch.effect.terminal` carries the authoritative outcome of each +//! call (`outcome.kind` = `completed` / `failed` / …) keyed by `call_id`. +//! The result text itself carries no error flag. +//! * `session.opened.observed`, `session.resumed` and `session.end` are +//! process lifecycle; they become markers. +//! * Subagent and reminder runs write their own +//! `subagent//session.jsonl` beside the parent, and their task +//! streams are also mirrored into the parent file under a **different** +//! `stream.id`. Only records on the session's own stream are read, and only +//! top-level transcripts are sessions. +//! +//! The shapes were characterized from a transcript captured by the real CLI +//! (Muse 0.2.1, checked in under `tests/fixtures/muse/`) and cross-checked +//! against the published Muse Code SDK and independent readers. + +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; + +/// The `payload_type` of the record that names the session. +pub(crate) const METADATA_PAYLOAD_TYPE: &str = "runtime.session.metadata"; + +/// The directory name a subagent (or reminder child) transcript lives under. +pub(crate) const SUBAGENT_DIR: &str = "subagent"; + +/// The file name every Muse transcript has. +pub(crate) const SESSION_FILE: &str = "session.jsonl"; + +/// What the metadata record says about a session. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MuseMetadata { + pub session_id: String, + pub workspace_root: Option, + pub model_id: Option, + pub provider_id: Option, + pub cli_version: Option, + pub recorded_ms: Option, +} + +/// One tool call as `assistant_tool_calls_committed` records it. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct MuseToolCall { + /// The provider call id results and outcomes join on. + pub call_id: String, + pub name: String, + /// `args` parsed from its JSON string; the raw string when it does not + /// parse, so nothing Muse wrote is dropped. + pub arguments: Value, +} + +/// One tool result as `tool_result_batch_committed` records it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MuseToolResult { + pub call_id: String, + pub text: Option, +} + +/// The conversation-level meaning of one record on the session's own stream. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum MuseEvent { + /// A typed prompt that opened a run. + Prompt { + text: String, + }, + /// Thinking. `text` is `None` when Muse wrote only the encrypted trace. + Reasoning { + message_id: Option, + text: Option, + encrypted: bool, + }, + AssistantText { + message_id: Option, + response_id: Option, + text: String, + }, + ToolCalls { + message_id: Option, + response_id: Option, + calls: Vec, + }, + ToolResults { + results: Vec, + }, + /// One model step finished. `usage` is Muse's own object, verbatim. + ModelCompleted { + model: Option, + usage: Option, + finish_reason: Option, + duration_ms: Option, + }, + /// The run ended. + RunTerminal { + status: Option, + reason: Option, + }, + /// The authoritative outcome of one tool call. + ToolOutcome { + call_id: String, + status: String, + reason: Option, + }, + SessionOpened { + resume: bool, + }, + SessionResumed, + SessionEnd { + exit_reason: Option, + }, + /// A metadata record after the first: a model or provider switch. + Metadata(MuseMetadata), +} + +/// One interpreted record, with the envelope facts every event carries. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct MuseRecord { + /// The envelope `id`: Muse's own, stable record identity. + pub record_id: Option, + pub sequence: Option, + pub ts_ms: i64, + pub run_id: Option, + pub event: MuseEvent, +} + +/// A whole transcript, read. +#[derive(Debug, Clone, Default)] +pub(crate) struct MuseTranscript { + pub metadata: MuseMetadata, + pub records: Vec, + /// Lines that were not JSON objects. A trailing partial line of a live + /// session lands here too. + pub unparsed_lines: usize, + /// Records on another stream (mirrored subagent / reminder tasks). + pub foreign_stream_records: usize, +} + +impl MuseTranscript { + pub fn first_ts(&self) -> Option { + self.metadata + .recorded_ms + .into_iter() + .chain(self.records.iter().map(|record| record.ts_ms)) + .min() + } + + pub fn last_ts(&self) -> Option { + self.metadata + .recorded_ms + .into_iter() + .chain(self.records.iter().map(|record| record.ts_ms)) + .max() + } + + /// Every model the session names, metadata first, in first-seen order. + pub fn models(&self) -> Vec { + let mut models: Vec = Vec::new(); + let mut push = |model: Option<&str>| { + if let Some(model) = model.filter(|model| !model.is_empty()) { + if !models.iter().any(|seen| seen == model) { + models.push(model.to_string()); + } + } + }; + push(self.metadata.model_id.as_deref()); + for record in &self.records { + match &record.event { + MuseEvent::ModelCompleted { model, .. } => push(model.as_deref()), + MuseEvent::Metadata(metadata) => push(metadata.model_id.as_deref()), + _ => {} + } + } + models + } +} + +/// `recorded_at` microseconds as epoch milliseconds. +pub(crate) fn recorded_ms(value: &Value) -> Option { + value + .get("recorded_at") + .and_then(Value::as_i64) + .filter(|micros| *micros > 0) + .map(|micros| micros / 1000) +} + +fn text_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + .map(str::to_string) +} + +fn stream_id(value: &Value) -> Option<&str> { + value.pointer("/stream/id").and_then(Value::as_str) +} + +/// The session a metadata record names, or `None` when the record is not one +/// (or names no session). Identity comes only from `stream.id` on a +/// `session`-kind stream, never from the directory name. +pub(crate) fn parse_metadata(value: &Value) -> Option { + if value.get("payload_type").and_then(Value::as_str) != Some(METADATA_PAYLOAD_TYPE) { + return None; + } + if value.pointer("/stream/kind").and_then(Value::as_str) != Some("session") { + return None; + } + let session_id = stream_id(value).filter(|id| !id.is_empty())?; + let record = value.pointer("/payload/record").unwrap_or(&Value::Null); + Some(MuseMetadata { + session_id: session_id.to_string(), + workspace_root: text_field(record, "workspace_root"), + model_id: text_field(record, "model_id"), + provider_id: text_field(record, "provider_id"), + cli_version: record + .pointer("/build/semver") + .and_then(Value::as_str) + .filter(|version| !version.is_empty()) + .map(str::to_string), + recorded_ms: recorded_ms(value), + }) +} + +/// Interpret one record that is already known to be on the session's stream. +/// `None` for the bookkeeping and diagnostics this reader does not keep. +pub(crate) fn parse_event(value: &Value) -> Option { + let payload = value.get("payload")?; + match value.get("payload_type").and_then(Value::as_str)? { + METADATA_PAYLOAD_TYPE => parse_metadata(value).map(MuseEvent::Metadata), + "runtime.session" => { + if payload.get("kind").and_then(Value::as_str) != Some("run") { + return None; + } + parse_run_event(payload.get("event")?) + } + "tool_batch.effect.terminal" => { + let record = payload.get("record")?; + let call_id = text_field(record, "call_id")?; + let outcome = record.get("outcome")?; + Some(MuseEvent::ToolOutcome { + call_id, + status: text_field(outcome, "kind")?, + reason: text_field(outcome, "reason"), + }) + } + "session.opened.observed" => Some(MuseEvent::SessionOpened { + resume: payload + .pointer("/record/resume") + .and_then(Value::as_bool) + .unwrap_or(false), + }), + "session.resumed" => Some(MuseEvent::SessionResumed), + "session.end" => Some(MuseEvent::SessionEnd { + exit_reason: payload + .get("record") + .and_then(|record| text_field(record, "exit_reason")), + }), + _ => None, + } +} + +fn parse_run_event(event: &Value) -> Option { + match event.get("kind").and_then(Value::as_str)? { + "started" => text_field(event, "prompt").map(|text| MuseEvent::Prompt { text }), + "reasoning_committed" => Some(MuseEvent::Reasoning { + message_id: text_field(event, "message_id"), + text: text_field(event, "text"), + encrypted: event + .get("encrypted_content") + .is_some_and(|content| !content.is_null() && content != ""), + }), + "assistant_message_committed" => Some(MuseEvent::AssistantText { + message_id: text_field(event, "message_id"), + response_id: text_field(event, "response_id"), + text: text_field(event, "text")?, + }), + "assistant_tool_calls_committed" => { + let calls: Vec = event + .get("tool_calls") + .and_then(Value::as_array) + .map(|calls| calls.iter().filter_map(parse_tool_call).collect()) + .unwrap_or_default(); + (!calls.is_empty()).then(|| MuseEvent::ToolCalls { + message_id: text_field(event, "message_id"), + response_id: text_field(event, "response_id"), + calls, + }) + } + "tool_result_batch_committed" => { + let results: Vec = event + .get("results") + .and_then(Value::as_array) + .map(|results| { + results + .iter() + .filter_map(|result| { + Some(MuseToolResult { + call_id: text_field(result, "tool_call_id")?, + text: result + .get("text") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default(); + (!results.is_empty()).then_some(MuseEvent::ToolResults { results }) + } + "model_completed" => Some(MuseEvent::ModelCompleted { + model: text_field(event, "model"), + usage: event + .get("usage") + .filter(|usage| usage.is_object()) + .cloned(), + finish_reason: text_field(event, "finish_reason"), + duration_ms: event.get("duration_ms").and_then(Value::as_i64), + }), + "terminal" => Some(MuseEvent::RunTerminal { + status: text_field(event, "terminal"), + reason: text_field(event, "reason"), + }), + _ => None, + } +} + +fn parse_tool_call(call: &Value) -> Option { + // `call_id` is what results and outcomes name; `id` is the provider's + // response item id and only the fallback. + let call_id = text_field(call, "call_id").or_else(|| text_field(call, "id"))?; + let name = text_field(call, "name")?; + let arguments = match call.get("args") { + Some(Value::String(raw)) => { + serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone())) + } + Some(other) => other.clone(), + None => Value::Null, + }; + Some(MuseToolCall { + call_id, + name, + arguments, + }) +} + +/// Read a whole transcript. `None` when it names no session — no metadata +/// record on a `session` stream — which is "not a session", not an error. +pub(crate) fn parse_transcript(contents: &str) -> Option { + let mut values = Vec::new(); + let mut unparsed_lines = 0; + for line in contents.lines() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(value) if value.is_object() => values.push(value), + _ => unparsed_lines += 1, + } + } + let metadata = values.iter().find_map(parse_metadata)?; + let mut transcript = MuseTranscript { + metadata, + unparsed_lines, + ..Default::default() + }; + let mut seen_metadata = false; + for value in &values { + // Mirrored subagent / reminder task records share the file but not + // the stream. Reading them would present every child objective as a + // prompt the person typed. + if stream_id(value) != Some(transcript.metadata.session_id.as_str()) { + transcript.foreign_stream_records += 1; + continue; + } + let Some(event) = parse_event(value) else { + continue; + }; + if matches!(event, MuseEvent::Metadata(_)) && !seen_metadata { + // The first metadata record is the session's header, not a switch. + seen_metadata = true; + continue; + } + let Some(ts_ms) = recorded_ms(value) else { + continue; + }; + transcript.records.push(MuseRecord { + record_id: text_field(value, "id"), + sequence: value.get("sequence").and_then(Value::as_i64), + ts_ms, + run_id: value + .pointer("/payload/run_id") + .and_then(Value::as_str) + .map(str::to_string), + event, + }); + } + Some(transcript) +} + +/// Every call's authoritative outcome, by call id. +pub(crate) fn tool_outcomes(transcript: &MuseTranscript) -> HashMap<&str, (&str, Option<&str>)> { + transcript + .records + .iter() + .filter_map(|record| match &record.event { + MuseEvent::ToolOutcome { + call_id, + status, + reason, + } => Some((call_id.as_str(), (status.as_str(), reason.as_deref()))), + _ => None, + }) + .collect() +} + +/// Whether a transcript path is a subagent or reminder child rather than a +/// session of its own: any `subagent` directory between it and the root. +pub(crate) fn is_child_transcript(path: &Path, root: &Path) -> bool { + path.strip_prefix(root) + .unwrap_or(path) + .components() + .any(|component| component.as_os_str() == SUBAGENT_DIR) +} + +/// Tools that write a file. +pub(crate) fn is_file_edit_tool(name: &str) -> bool { + matches!(name, "write_file" | "edit_file") +} + +/// Tools that start another agent. +pub(crate) fn is_subagent_tool(name: &str) -> bool { + name == "subagent_spawn" +} + +/// What a tool call acts on: the file, the command, the pattern. +pub(crate) fn pick_tool_target(name: &str, arguments: &Value) -> Option { + let object = arguments.as_object()?; + let get = |key: &str| { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + match name { + "bash" => get("command"), + "bash_input" => get("chars"), + "search" => get("pattern"), + "web_search" => get("query"), + "subagent_spawn" => get("role") + .or_else(|| get("objective")) + .or_else(|| get("prompt")), + _ => get("path") + .or_else(|| get("file_path")) + .or_else(|| get("url")) + .or_else(|| get("query")), + } +} + +/// A `bash` result is a JSON object string; its `exit_code` is the one +/// in-band failure signal Muse writes. +pub(crate) fn bash_exit_code(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("exit_code") + .and_then(Value::as_i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const SID: &str = "74747474-7474-4747-8747-747474747474"; + + fn line(sequence: i64, payload_type: &str, payload: Value) -> String { + json!({ + "schema_version": 1, + "id": format!("rec-{sequence}"), + "stream": {"kind": "session", "id": SID}, + "sequence": sequence, + "recorded_at": 1_788_223_110_000_000i64 + sequence * 1000, + "payload_type": payload_type, + "payload": payload, + }) + .to_string() + } + + fn run(sequence: i64, event: Value) -> String { + line( + sequence, + "runtime.session", + json!({"kind": "run", "run_id": "run-1", "event": event}), + ) + } + + fn metadata() -> String { + line( + 1, + METADATA_PAYLOAD_TYPE, + json!({"kind": "metadata", "record": { + "workspace_root": "/work", "model_id": "meta/muse-spark", + "provider_id": "meta", "build": {"semver": "1.3.0"}}}), + ) + } + + #[test] + fn a_file_without_metadata_is_not_a_session() { + assert!(parse_transcript(&run(2, json!({"kind": "started", "prompt": "hi"}))).is_none()); + assert!(parse_transcript("not json\n").is_none()); + } + + #[test] + fn metadata_may_follow_a_permission_frame() { + let contents = format!( + "{}\n{}\n", + r#"{"retained_frame":"session_permission_transaction","children":[]}"#, + metadata() + ); + let transcript = parse_transcript(&contents).unwrap(); + assert_eq!(transcript.metadata.session_id, SID); + assert_eq!(transcript.metadata.workspace_root.as_deref(), Some("/work")); + assert_eq!(transcript.metadata.cli_version.as_deref(), Some("1.3.0")); + assert_eq!(transcript.metadata.recorded_ms, Some(1_788_223_110_001)); + assert_eq!(transcript.unparsed_lines, 0); + } + + #[test] + fn reads_the_conversation_and_skips_task_bookkeeping() { + let contents = [ + metadata(), + run(2, json!({"kind": "started", "prompt": "fix it"})), + line( + 3, + "runtime.session", + json!({"kind": "task", "run_id": "run-1", "event": {"kind": "started", "task_id": "t"}}), + ), + run( + 4, + json!({"kind": "model_completed", "model": "meta/muse-spark", "duration_ms": 5, + "usage": {"input_tokens": 10, "output_tokens": 2}}), + ), + run( + 5, + json!({"kind": "assistant_tool_calls_committed", "message_id": "m1", + "tool_calls": [{"id": "fc_1", "call_id": "call_1", "name": "write_file", + "args": "{\"path\":\"a.rs\",\"content\":\"x\"}"}]}), + ), + line( + 6, + "tool_batch.effect.terminal", + json!({"kind": "tool_batch_effect", "record": {"call_id": "call_1", + "outcome": {"kind": "failed", "reason": "denied"}}}), + ), + run( + 7, + json!({"kind": "tool_result_batch_committed", + "results": [{"tool_call_id": "call_1", "text": "tool failed: denied"}]}), + ), + run(8, json!({"kind": "terminal", "terminal": "completed"})), + ] + .join("\n"); + let transcript = parse_transcript(&contents).unwrap(); + let kinds: Vec<_> = transcript + .records + .iter() + .map(|record| std::mem::discriminant(&record.event)) + .collect(); + assert_eq!(kinds.len(), 6, "{:#?}", transcript.records); + assert_eq!( + transcript.records[0].event, + MuseEvent::Prompt { + text: "fix it".into() + } + ); + assert_eq!(transcript.records[0].ts_ms, 1_788_223_110_002); + assert_eq!(transcript.records[0].run_id.as_deref(), Some("run-1")); + let MuseEvent::ToolCalls { calls, .. } = &transcript.records[2].event else { + panic!("expected tool calls"); + }; + assert_eq!(calls[0].call_id, "call_1"); + assert_eq!(calls[0].arguments["path"], "a.rs"); + assert_eq!( + pick_tool_target(&calls[0].name, &calls[0].arguments).as_deref(), + Some("a.rs") + ); + assert_eq!( + tool_outcomes(&transcript).get("call_1"), + Some(&("failed", Some("denied"))) + ); + assert_eq!(transcript.models(), vec!["meta/muse-spark".to_string()]); + } + + #[test] + fn records_on_another_stream_are_not_the_sessions() { + let foreign = json!({ + "id": "x", "stream": {"kind": "task", "id": "task-1"}, "recorded_at": 1_788_223_110_000_000i64, + "payload_type": "runtime.session", + "payload": {"kind": "run", "event": {"kind": "started", "prompt": "Role: worker Objective: …"}}, + }) + .to_string(); + let transcript = parse_transcript(&format!("{}\n{foreign}\n", metadata())).unwrap(); + assert!(transcript.records.is_empty()); + assert_eq!(transcript.foreign_stream_records, 1); + } + + #[test] + fn unparseable_arguments_are_kept_as_the_raw_string() { + let call = + parse_tool_call(&json!({"call_id": "c", "name": "bash", "args": "{oops"})).unwrap(); + assert_eq!(call.arguments, json!("{oops")); + } + + #[test] + fn child_transcripts_are_recognized_by_their_subagent_directory() { + let root = Path::new("/data/muse/sessions"); + assert!(is_child_transcript( + &root.join("2026/09/09/p/subagent/c/session.jsonl"), + root + )); + assert!(!is_child_transcript( + &root.join("2026/09/09/p/session.jsonl"), + root + )); + } + + #[test] + fn bash_results_expose_their_exit_code() { + assert_eq!(bash_exit_code(r#"{"exit_code":2,"output":""}"#), Some(2)); + assert_eq!(bash_exit_code("plain text"), None); + } +} diff --git a/crates/ai-hist/src/ingest/tool_result_facts.rs b/crates/ai-hist/src/ingest/tool_result_facts.rs index c06b7b3f..e5f96cd3 100644 --- a/crates/ai-hist/src/ingest/tool_result_facts.rs +++ b/crates/ai-hist/src/ingest/tool_result_facts.rs @@ -52,6 +52,9 @@ pub const ERROR_SIGNAL_EXIT_CODE: &str = "exit_code"; pub const ERROR_SIGNAL_PATCH_APPLY: &str = "patch_apply"; /// A Codex `mcp_tool_call_end` whose `result` carries `Err`. pub const ERROR_SIGNAL_MCP_ERR: &str = "mcp_err"; +/// A Muse Code `tool_batch.effect.terminal` whose `outcome.kind` is not +/// `completed`. +pub const ERROR_SIGNAL_MUSE_TOOL_OUTCOME: &str = "tool_batch.effect"; /// A harness subagent notification reporting a failed or cancelled child. pub const ERROR_SIGNAL_SUBAGENT_STATUS: &str = "subagent_status"; @@ -343,6 +346,46 @@ pub fn codex_output_facts(output: &Value, call_id: &str) -> ToolResultFacts { facts } +/// Facts for one Muse Code tool result. +/// +/// Muse writes no error flag on the result itself. The call's outcome is a +/// separate `tool_batch.effect.terminal` record, passed here as +/// `(outcome.kind, reason)`; without one the status stays `unknown`. A `bash` +/// result that completed as a call but whose command exited non-zero is an +/// error too, the way Codex reads `exit_code`. +pub fn muse_tool_result_facts( + text: Option<&str>, + call_id: &str, + outcome: Option<(&str, Option<&str>)>, +) -> ToolResultFacts { + let payload = text.map_or(Value::Null, |text| Value::String(text.to_string())); + let mut facts = ToolResultFacts::from_payload(&payload); + facts.event_source = Some(EVENT_SOURCE_TOOL_RESULT.to_string()); + facts.tool_use_id = (!call_id.is_empty()).then(|| call_id.to_string()); + let status = match outcome.map(|(kind, _)| kind) { + Some("completed") => STATUS_COMPLETED, + Some("failed") => { + facts.error_signal = Some(ERROR_SIGNAL_MUSE_TOOL_OUTCOME.to_string()); + STATUS_ERRORED + } + Some("cancelled" | "canceled" | "skipped") => { + facts.error_signal = Some(ERROR_SIGNAL_MUSE_TOOL_OUTCOME.to_string()); + STATUS_CANCELLED + } + _ => STATUS_UNKNOWN, + }; + facts.result_status = Some(status.to_string()); + if status == STATUS_COMPLETED + && text + .and_then(crate::ingest::muse::bash_exit_code) + .is_some_and(|code| code != 0) + { + facts.result_status = Some(STATUS_ERRORED.to_string()); + facts.error_signal = Some(ERROR_SIGNAL_EXIT_CODE.to_string()); + } + facts +} + fn first_str(line: &Map, keys: &[&str]) -> Option { keys.iter().find_map(|key| { line.get(*key) diff --git a/crates/ai-hist/src/paths.rs b/crates/ai-hist/src/paths.rs index 6676b7e8..81d5af98 100644 --- a/crates/ai-hist/src/paths.rs +++ b/crates/ai-hist/src/paths.rs @@ -24,6 +24,21 @@ pub fn grok_home(home: &Path) -> PathBuf { env_dir("GROK_HOME").unwrap_or_else(|| home.join(".grok")) } +/// Where Muse Code keeps its session logs: `$XDG_DATA_HOME/muse/sessions`, +/// or `~/.local/share/muse/sessions` when `XDG_DATA_HOME` is unset. Muse uses +/// the same XDG layout on every platform. +pub fn muse_sessions_dir(home: &Path) -> PathBuf { + match env_dir("XDG_DATA_HOME") { + Some(data) => data.join("muse/sessions"), + None => default_muse_sessions_dir(home), + } +} + +/// [`muse_sessions_dir`] with nothing read from the environment. +pub(crate) fn default_muse_sessions_dir(home: &Path) -> PathBuf { + home.join(".local/share/muse/sessions") +} + pub fn opencode_db_path(home: &Path) -> PathBuf { env_dir("OPENCODE_DB") .unwrap_or_else(|| home.join(".local/share/opencode/opencode.db")) @@ -43,7 +58,7 @@ pub fn opencode_storage_dir(home: &Path) -> PathBuf { /// watched, or a session the sweep catalogued cannot be hydrated afterwards. /// Build it with [`ProviderRoots::from_env`] (the CLI's rules: the process /// environment's `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`, -/// `OPENCODE_DB`, `OPENCODE_STORAGE_DIR` and `TRAJECTORY_ROOT` override the +/// `XDG_DATA_HOME` (for Muse Code), `OPENCODE_DB`, `OPENCODE_STORAGE_DIR` and `TRAJECTORY_ROOT` override the /// defaults under `home`) or [`ProviderRoots::from_home`] (the defaults under /// `home`, with nothing read from the environment — what a test or an /// embedder with its own layout wants). The environment is read **once**, @@ -59,6 +74,8 @@ pub struct ProviderRoots { pub codex: PathBuf, /// Grok state root (`~/.grok`). pub grok: PathBuf, + /// Muse Code session logs (`~/.local/share/muse/sessions`). + pub muse: PathBuf, /// The OpenCode SQLite store. pub opencode_db: PathBuf, /// OpenCode's legacy `storage/` JSON tree, read only when there is no @@ -85,6 +102,7 @@ impl ProviderRoots { claude: claude_config_dir(&home), codex: codex_home(&home), grok: grok_home(&home), + muse: muse_sessions_dir(&home), opencode_db: opencode_db_path(&home), opencode_storage_dir: opencode_storage_dir(&home), trajectory_roots: trajectory_roots_from_env(), @@ -104,6 +122,7 @@ impl ProviderRoots { claude: home.join(".claude"), codex: home.join(".codex"), grok: home.join(".grok"), + muse: default_muse_sessions_dir(&home), home, opencode_db, opencode_storage_dir, @@ -152,7 +171,12 @@ mod tests { ("ingest/hydrate.rs", include_str!("ingest/hydrate.rs")), ] { let production = source.split("\n#[cfg(test)]").next().unwrap(); - for literal in ["join(\".claude", "join(\".codex", "join(\".grok"] { + for literal in [ + "join(\".claude", + "join(\".codex", + "join(\".grok", + "join(\".local/share/muse", + ] { assert!( !production.contains(literal), "{name} builds a provider root directly with {literal}" diff --git a/crates/ai-hist/src/session_store.rs b/crates/ai-hist/src/session_store.rs index 91c10ed6..56611b12 100644 --- a/crates/ai-hist/src/session_store.rs +++ b/crates/ai-hist/src/session_store.rs @@ -317,6 +317,7 @@ pub enum Source { Trajectory, #[serde(rename = "opencode")] OpenCode, + Muse, } impl Source { @@ -335,6 +336,7 @@ impl Source { Self::Relay, Self::Trajectory, Self::OpenCode, + Self::Muse, ]; /// Canonical lowercase identifier stored in the ledger. @@ -347,6 +349,7 @@ impl Source { Self::Relay => "relay", Self::Trajectory => "trajectory", Self::OpenCode => "opencode", + Self::Muse => "muse", } } @@ -383,7 +386,7 @@ impl Source { // write them are the ones an embedder needs listed. if matches!( self, - Self::Claude | Self::Codex | Self::Grok | Self::OpenCode + Self::Claude | Self::Codex | Self::Grok | Self::OpenCode | Self::Muse ) && !evidence_kinds.contains(&EvidenceKind::SessionMarker) { evidence_kinds.push(EvidenceKind::SessionMarker); @@ -403,6 +406,9 @@ impl Source { // Cursor: the message `id` when the build wrote one, else // `cursor:{record offset}`. Self::Cursor => MessageIdOrigin::Mixed, + // Muse: the record's own envelope `id` for prose and + // lifecycle, `tool:{call id}` / `result:{call id}` for tools. + Self::Muse => MessageIdOrigin::Mixed, Self::Relay | Self::Trajectory => MessageIdOrigin::None, }, hydrates_by_path: HOOK_HARNESSES.contains(&name), @@ -2689,7 +2695,8 @@ mod tests { | Source::Grok | Source::Relay | Source::Trajectory - | Source::OpenCode => Source::ALL.contains(source), + | Source::OpenCode + | Source::Muse => Source::ALL.contains(source), }; assert!(listed, "{source:?} is missing from Source::ALL"); } diff --git a/crates/ai-hist/src/store.rs b/crates/ai-hist/src/store.rs index b2e39c4e..a4153598 100644 --- a/crates/ai-hist/src/store.rs +++ b/crates/ai-hist/src/store.rs @@ -30,6 +30,7 @@ pub const SOURCE_CHOICES: &[&str] = &[ "relay", "trajectory", "opencode", + "muse", ]; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -4352,6 +4353,12 @@ pub fn resume_command(entry: &HistoryEntry) -> Option { || format!("grok resume {}", shell_quote(sid)), |p| format!("cd {} && grok resume {}", shell_quote(p), shell_quote(sid)), )), + // `muse resume ` finds the session by id; the `cd` puts the + // resumed agent back in the workspace it recorded. + "muse" => Some(entry.project.as_ref().map_or_else( + || format!("muse resume {}", shell_quote(sid)), + |p| format!("cd {} && muse resume {}", shell_quote(p), shell_quote(sid)), + )), _ => None, } } diff --git a/crates/ai-hist/src/usage.rs b/crates/ai-hist/src/usage.rs index bd875b47..83184737 100644 --- a/crates/ai-hist/src/usage.rs +++ b/crates/ai-hist/src/usage.rs @@ -356,7 +356,7 @@ impl std::error::Error for UsageError {} /// Sources this build can normalize. Anything else is an explicit /// [`UsageError::UnknownSource`] rather than a silent zero. -pub const NORMALIZABLE_SOURCES: &[&str] = &["claude", "codex"]; +pub const NORMALIZABLE_SOURCES: &[&str] = &["claude", "codex", "muse"]; /// Whether one model request can be spread across several stored records for /// this source. @@ -380,6 +380,10 @@ pub fn source_accounting(source: &str) -> Option { // Codex reports cumulative `token_count` snapshots, which the parser // differences into per-request deltas before storing them. "codex" => Some(UsageAccounting::CumulativeDelta), + // Muse writes one `model_completed` usage object per model step, and + // the parser stores it once, on the first assistant row that step + // committed. + "muse" => Some(UsageAccounting::PerRequest), _ => None, } } @@ -420,7 +424,13 @@ pub fn normalize_usage( let mut usage = NormalizedUsage::empty(accounting); let output = counter("output_tokens")?; - let reasoning = counter("reasoning_output_tokens")?; + // Muse names the reasoning counter `reasoning_tokens`; Codex spells it + // `reasoning_output_tokens`. Either way it is a subset of output. + let reasoning = counter(if source == "muse" { + "reasoning_tokens" + } else { + "reasoning_output_tokens" + })?; usage.output_tokens = output.unwrap_or(0); usage.reasoning_tokens = reasoning; usage.coverage.has_output_tokens = output.is_some(); @@ -453,6 +463,34 @@ pub fn normalize_usage( usage.coverage.has_cache_read_tokens = cached.is_some(); usage.coverage.has_cache_write_tokens = cache_write.is_some(); } + "muse" => { + // Responses-shaped: `input_tokens` includes the cached prefix, + // which Muse reports as `cache_read_tokens` (and again as + // `cached_tokens`, the same count). Input is emitted exclusive of + // it, as for Codex, so summing the categories counts each token + // once. + let input = counter("input_tokens")?; + let cache_read = match counter("cache_read_tokens")? { + Some(value) => Some(value), + None => counter("cached_tokens")?, + }; + let cache_write = counter("cache_write_tokens")?; + let inclusive = input.unwrap_or(0); + let cached_value = cache_read.unwrap_or(0); + usage.input_tokens = + inclusive + .checked_sub(cached_value) + .ok_or(UsageError::CounterRegressed { + field: "input_tokens", + value: inclusive, + subtracted: cached_value, + })?; + usage.cache_read_tokens = cached_value; + usage.cache_write_tokens = cache_write.unwrap_or(0); + usage.coverage.has_input_tokens = input.is_some(); + usage.coverage.has_cache_read_tokens = cache_read.is_some(); + usage.coverage.has_cache_write_tokens = cache_write.is_some(); + } "claude" => { // Claude's native `message.usage` already excludes cache reads // and writes from `input_tokens`; subtracting them again would @@ -1025,6 +1063,41 @@ mod tests { assert_eq!(usage.reported_cost_usd, None); } + /// Muse's `model_completed` usage, as the real CLI writes it: input + /// inclusive of the cached prefix, reported under two keys. + #[test] + fn muse_input_is_made_cache_exclusive_and_counted_once() { + let usage = normalize_usage( + "muse", + &json!({ + "input_tokens": 26964, + "output_tokens": 379, + "cached_tokens": 5105, + "cache_read_tokens": 5105, + "cache_write_tokens": 0, + "reasoning_tokens": 278, + }), + ) + .unwrap() + .unwrap(); + assert_eq!(usage.input_tokens, 21859); + assert_eq!(usage.cache_read_tokens, 5105); + assert_eq!(usage.cache_write_tokens, 0); + assert_eq!(usage.output_tokens, 379); + assert_eq!(usage.reasoning_tokens, Some(278)); + assert_eq!(usage.accounting, UsageAccounting::PerRequest); + + // An older record that spells the cache read only as `cached_tokens`. + let legacy = normalize_usage( + "muse", + &json!({"input_tokens": 10, "output_tokens": 1, "cached_tokens": 4}), + ) + .unwrap() + .unwrap(); + assert_eq!(legacy.input_tokens, 6); + assert_eq!(legacy.cache_read_tokens, 4); + } + #[test] fn codex_input_is_made_cache_exclusive() { let usage = codex(json!({ diff --git a/crates/ai-hist/tests/fixture_corpus.rs b/crates/ai-hist/tests/fixture_corpus.rs index 67bcd607..d8f5a42e 100644 --- a/crates/ai-hist/tests/fixture_corpus.rs +++ b/crates/ai-hist/tests/fixture_corpus.rs @@ -63,6 +63,9 @@ enum Origin { Burn, /// Authored here, for a log shape burn's corpus does not cover. RelayHistory, + /// Derived from `xhluca/session-migrate`'s native corpus (MIT): a + /// transcript the real harness CLI wrote, kept verbatim line by line. + SessionMigrate, } struct Fixture { @@ -637,6 +640,23 @@ const CORPUS: &[Fixture] = &[ files: &["grok/events-session"], quirk: "documented Grok Build layout: `chat_history.jsonl` with `tool_calls[]`, ACP `updates.jsonl` with real `agentTimestampMs` times, `compaction_checkpoints/`, `subagents/`, `signals.json` and `prompt_context.json`", }, + // -- muse -------------------------------------------------------------- + Fixture { + source: "muse", + name: "cli-capture", + layout: Layout::HomeTree, + origin: Origin::SessionMigrate, + files: &["muse/cli-capture"], + quirk: "a transcript the real `muse` CLI (0.2.1) wrote, trimmed to its conversation, tool and lifecycle records: three runs across two resumes, `read_file` calls with one failed outcome, per-step `model_completed` usage, and mirrored reminder task records", + }, + Fixture { + source: "muse", + name: "tools-session", + layout: Layout::HomeTree, + origin: Origin::RelayHistory, + files: &["muse/tools-session"], + quirk: "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session", + }, // -- opencode ---------------------------------------------------------- Fixture { source: "opencode", @@ -854,6 +874,9 @@ fn capture(fixture: &Fixture, home: &Path) -> Value { std::env::set_var("USERPROFILE", home); std::env::set_var("OPENCODE_DB", &opencode_db); std::env::remove_var("AI_HIST_DB"); + // Muse Code's root follows `XDG_DATA_HOME`; the fixture's own `HOME` + // layout has to win over whatever the machine running the tests sets. + std::env::remove_var("XDG_DATA_HOME"); let db = home.join("ai-history.db"); let mut notes: Vec = Vec::new(); @@ -2298,3 +2321,65 @@ fn grok_events_and_real_timestamps_reach_session_events() { "prompt timestamps come from the record, not from `created_at + index`: {prompts:?}" ); } + +/// A transcript the real Muse CLI wrote: every prompt is a history row at the +/// microsecond time Muse recorded, every read is a tool call joined to its +/// result by call id, and the one call whose `tool_batch.effect.terminal` +/// failed is the one marked as an error. +#[test] +fn muse_cli_capture_reaches_history_tools_and_recorded_times() { + let prompts = rows("muse/cli-capture", "history"); + assert_eq!(prompts.len(), 3, "{prompts:?}"); + assert_eq!( + field(&prompts[0], "timestamp_ms").as_i64(), + Some(1_788_223_110_680), + "the prompt's own `recorded_at`, in milliseconds: {prompts:?}" + ); + let calls = rows("muse/cli-capture", "tool_calls"); + let failed: Vec<&str> = calls + .iter() + .filter(|call| field(call, "is_error").as_i64() == Some(1)) + .map(|call| text(call, "tool_use_id")) + .collect(); + assert_eq!(failed, vec!["call_muse_missing"], "{calls:?}"); + let events = rows("muse/cli-capture", "session_events"); + assert!( + events + .iter() + .filter(|event| text(event, "role") == "user") + .all(|event| !text(event, "text").starts_with("Role:")), + "a mirrored task stream is never a prompt: {events:?}" + ); +} + +/// The authored Muse session: edits reach `file_edits`, a completed `bash` +/// call whose command exited non-zero is an error, usage lands once per model +/// step, and the `subagent/` child transcript is not a session of its own. +#[test] +fn muse_tools_session_records_edits_errors_usage_and_no_child_session() { + let edits = rows("muse/tools-session", "file_edits"); + let mut paths: Vec<&str> = edits.iter().map(|edit| text(edit, "file_path")).collect(); + paths.sort_unstable(); + assert_eq!(paths, vec!["src/http.rs", "tests/retry.rs"], "{edits:?}"); + let calls = rows("muse/tools-session", "tool_calls"); + let bash = calls + .iter() + .find(|call| text(call, "tool_use_id") == "call_bash") + .expect("bash call"); + assert_eq!(field(bash, "is_error").as_i64(), Some(1), "{bash:?}"); + let events = rows("muse/tools-session", "session_events"); + let usage_rows = events + .iter() + .filter(|event| !field(event, "token_json").is_null()) + .count(); + assert_eq!( + usage_rows, 4, + "one row per model_completed step: {events:?}" + ); + let sessions = rows("muse/tools-session", "sessions"); + assert_eq!(sessions.len(), 1, "{sessions:?}"); + assert_eq!( + text(&sessions[0], "models_json"), + r#"["meta/muse-spark-1.3","meta/muse-spark-1.3-contributor"]"# + ); +} diff --git a/crates/ai-hist/tests/fixtures/README.md b/crates/ai-hist/tests/fixtures/README.md index 8b0eed9a..2182535f 100644 --- a/crates/ai-hist/tests/fixtures/README.md +++ b/crates/ai-hist/tests/fixtures/README.md @@ -44,6 +44,15 @@ and the raw-fact assertions among them are ported into `fixture_corpus.rs`. Everything marked **relayhistory** was authored here, for a log shape burn's corpus does not cover. +The fixture marked **sessionmigrate** is derived from the native corpus in +[`xhluca/session-migrate`](https://github.com/xhluca/session-migrate) +(`tests/native_corpus/v1/sources/muse/0.2.1/portable-rich`), licensed **MIT**. +That transcript was written by the real Muse Code 0.2.1 CLI against a +credential-free loopback provider and sanitized upstream (system +instructions and capture paths). Here it is trimmed to the records a parser +reads plus a sample of the rest; every kept line is byte-identical to the +upstream file. + ## Determinism Every staged file's mtime is pinned (base `2026-04-20T00:00:00Z`, plus one @@ -167,6 +176,13 @@ source without deciding which one fails the test. | `grok/full-session` | relayhistory | `grok/full-session` | older Claude-shaped grok directory: per-record timestamps, `tool_use` blocks in `content`, `updates.jsonl` as `file_changed` rows, plus `prompt_context.json`, `signals.json` and `subagents/` | | `grok/events-session` | relayhistory | `grok/events-session` | documented Grok Build layout: `chat_history.jsonl` with `tool_calls[]`, ACP `updates.jsonl` with real `agentTimestampMs` times, `compaction_checkpoints/`, `subagents/`, `signals.json` and `prompt_context.json` | +### `muse` + +| Fixture | Origin | Corpus files | Quirk it encodes | +| --- | --- | --- | --- | +| `muse/cli-capture` | sessionmigrate | `muse/cli-capture` | a transcript the real `muse` CLI (0.2.1) wrote, trimmed to its conversation, tool and lifecycle records: three runs across two resumes, `read_file` calls with one failed outcome, per-step `model_completed` usage, and mirrored reminder task records | +| `muse/tools-session` | relayhistory | `muse/tools-session` | authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session | + ### `opencode` | Fixture | Origin | Corpus files | Quirk it encodes | diff --git a/crates/ai-hist/tests/fixtures/muse/cli-capture/.local/share/muse/sessions/2026/09/01/74747474-7474-4747-8747-747474747474/session.jsonl b/crates/ai-hist/tests/fixtures/muse/cli-capture/.local/share/muse/sessions/2026/09/01/74747474-7474-4747-8747-747474747474/session.jsonl new file mode 100644 index 00000000..220612ae --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/cli-capture/.local/share/muse/sessions/2026/09/01/74747474-7474-4747-8747-747474747474/session.jsonl @@ -0,0 +1,46 @@ +{"schema_version":1,"id":"7016ff17-1131-49a4-860c-e02f9734d074","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":1,"recorded_at":1788223110587169,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session.metadata","payload_schema_version":1,"payload":{"kind":"metadata","record":{"build":{"semver":"0.2.1","sha":"b3170a534f"},"model_id":"meta/muse-glimmer-30b","provider_id":"meta","tool_surface_version":"2","web_search_mode":"client","workspace_root":"/fixture/work"}}} +{"schema_version":1,"id":"b4818b8e-d40f-4b81-a13e-4157ab0f6761","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":2,"recorded_at":1788223110589012,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session.route_facts","payload_schema_version":2,"payload":{"kind":"route_facts","record":{"cwd":"/fixture/work","pid":3392377,"terminal_title_identity":"747474747474"}}} +{"schema_version":1,"id":"8b83613d-78d1-4298-b2a1-8181328e6ec9","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":3,"recorded_at":1788223110669852,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.opened.observed","payload_schema_version":1,"payload":{"kind":"session_opened","record":{"data_dir_free_bytes":2217422848,"resume":false,"schema_version":1,"security_mode":"yolo","session_id":"74747474-7474-4747-8747-747474747474","workspace_free_bytes":2217422848}}} +{"schema_version":1,"id":"e3e35fae-9c8c-40a9-b961-bc81ff200b79","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":10,"recorded_at":1788223110680073,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"started","prompt":"SM_CORPUS_7319 Inspect timeline.py and CORPUS_NOTE.txt with the native file tool. Then try missing-corpus-file.txt so the native log includes a failed tool result. Explain the interval boundary and quote the note marker."},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"5b4e9462-2d01-4fc3-a92e-905ecc88ef05","source_run_record_sequence":1}} +{"schema_version":1,"id":"cd4bd2e0-5e0d-4905-88d1-e2699728fec7","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":17,"recorded_at":1788223110685440,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"display":{"label":"plugin:tbh-reminders:skill-reminder reminder","model":"same-as-main","path":"subagent/962066db-24fe-4e3e-9031-be5b7139678a/session.jsonl","role":"reminder"},"execution_mode":"background","kind":"task_stream_linked","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be","task_stream":{"id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be","kind":"task"}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"91eea925-a942-436a-9972-d8e24b2d587e","source_run_record_sequence":7}} +{"schema_version":1,"id":"a3d106c8-7cdd-4401-8003-8c2020331d62","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":18,"recorded_at":1788223110685796,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"proposed","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be","task_kind":"reminder.agent.plugin:tbh-reminders:skill-reminder"},"kind":"task","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be"}} +{"schema_version":1,"id":"2b803dd4-fe46-44c1-912b-37617f1189e4","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":19,"recorded_at":1788223110686044,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"accepted","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be"},"kind":"task","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be"}} +{"schema_version":1,"id":"7b501901-d80a-4612-8309-c1b59d101bd9","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":20,"recorded_at":1788223110686305,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"child_session_id":"962066db-24fe-4e3e-9031-be5b7139678a","child_session_log_path":"subagent/962066db-24fe-4e3e-9031-be5b7139678a/session.jsonl","generation_id":1,"kind":"memory_reminder_child_session_linked","parent_run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","parent_session_id":"74747474-7474-4747-8747-747474747474","reminder_agent_id":"plugin:tbh-reminders:skill-reminder","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be","task_stream":{"id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be","kind":"task"}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"ff53d032-2fe6-4875-a5b7-a810af1018b4","source_run_record_sequence":8}} +{"schema_version":1,"id":"92a27a4d-2ed8-4354-bd59-189423374b6a","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":21,"recorded_at":1788223110686582,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"started","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be"},"kind":"task","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","task_id":"4a6c4ebc-aac7-4cbf-88ff-055f1ca8b0be"}} +{"schema_version":1,"id":"6cf77b19-1934-47cb-a5d4-630195ce4613","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":22,"recorded_at":1788223110686971,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"display":{"label":"plugin:tbh-reminders:scope-reminder reminder","model":"same-as-main","path":"subagent/cbfd5023-3d9e-44e2-ba65-2feea5d64182/session.jsonl","role":"reminder"},"execution_mode":"background","kind":"task_stream_linked","task_id":"71860a9b-1b61-42cd-9a5d-9eb8c904e1da","task_stream":{"id":"71860a9b-1b61-42cd-9a5d-9eb8c904e1da","kind":"task"}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"43797131-a514-468c-bdea-15b18fc7d8bc","source_run_record_sequence":9}} +{"schema_version":1,"id":"106ac6fc-b12c-493d-b8b2-10e7efb4de19","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":25,"recorded_at":1788223110688365,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"child_session_id":"cbfd5023-3d9e-44e2-ba65-2feea5d64182","child_session_log_path":"subagent/cbfd5023-3d9e-44e2-ba65-2feea5d64182/session.jsonl","generation_id":1,"kind":"memory_reminder_child_session_linked","parent_run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","parent_session_id":"74747474-7474-4747-8747-747474747474","reminder_agent_id":"plugin:tbh-reminders:scope-reminder","task_id":"71860a9b-1b61-42cd-9a5d-9eb8c904e1da","task_stream":{"id":"71860a9b-1b61-42cd-9a5d-9eb8c904e1da","kind":"task"}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"c2f6c229-274b-4050-833e-6130bbd429df","source_run_record_sequence":10}} +{"schema_version":1,"id":"59809fb7-64dc-409c-bc85-de8c3ded5947","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":39,"recorded_at":1788223110715023,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":22,"finish_reason":"tool_calls","kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"1b5fee19-e670-4f75-8ed0-4a93e7164d53","source_run_record_sequence":16}} +{"schema_version":1,"id":"b2b45785-4827-4593-aac7-17692fa8c621","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":41,"recorded_at":1788223110715815,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_tool_calls_committed","message_id":"80749233-420e-4c1c-b567-a9047d47c4be","response_id":"resp_call_muse_timeline","tool_calls":[{"args":"{\"path\":\"timeline.py\"}","call_id":"call_muse_timeline","id":"fc_call_muse_timeline","name":"read_file"}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"1ac33300-ebfe-451e-886e-13a798999c26","source_run_record_sequence":17}} +{"schema_version":1,"id":"f1b6e377-4ba2-4c76-9a0b-c5d01921d7db","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":48,"recorded_at":1788223110725128,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.started","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_timeline","effect_id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","kind":"started","model_call_index":0,"parallel_profile":{"kind":"file_read","subject":"workspace:timeline.py"},"task_id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","task_stream":{"id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","kind":"task"},"tool_name":"read_file"},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"989fcf96-e492-4253-937a-1e7dfdd94dc9","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":49,"recorded_at":1788223110734322,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.terminal","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_timeline","effect_id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","kind":"terminal","model_call_index":0,"outcome":{"kind":"completed","output_ref_count":0,"task_completion":{"kind":"complete"}},"task_id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","task_stream":{"id":"d61d8e5e-6134-4f02-a7a7-ff2b485e5db9","kind":"task"}},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"ed665062-ac23-45dc-bc48-c021bc62e845","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":53,"recorded_at":1788223110736076,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"batch_id":"80749233-420e-4c1c-b567-a9047d47c4be","kind":"tool_result_batch_committed","results":[{"text":"Read text file `timeline.py`.\n1|from dataclasses import dataclass\n2|\n3|\n4|@dataclass(frozen=True)\n5|class Event:\n6| start_ms: int\n7| end_ms: int\n8|\n9|\n10|def touches(previous: Event, current: Event) -> bool:\n11| \"\"\"Return whether two closed intervals overlap or touch.\"\"\"\n12| return current.start_ms < previous.end_ms","tool_call_id":"call_muse_timeline","tool_call_index":0}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"87573844-f5ec-4127-a6b3-9a9668d35351","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":72,"recorded_at":1788223110756121,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":13,"finish_reason":"tool_calls","kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"f793d0ef-2591-423a-b403-861519ab7e6c","source_run_record_sequence":28}} +{"schema_version":1,"id":"c6176039-f878-4842-bc83-d47e1bc4b2e3","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":74,"recorded_at":1788223110756715,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_tool_calls_committed","message_id":"cb7c9354-f80e-410c-8739-89807a688bed","response_id":"resp_call_muse_note","tool_calls":[{"args":"{\"path\":\"CORPUS_NOTE.txt\"}","call_id":"call_muse_note","id":"fc_call_muse_note","name":"read_file"}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"ad593dc6-7c3f-4881-bc31-464b8a24cadb","source_run_record_sequence":29}} +{"schema_version":1,"id":"c26acff4-669b-456d-91b0-fa2f5cf69bb7","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":81,"recorded_at":1788223110759340,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.started","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_note","effect_id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","kind":"started","model_call_index":0,"parallel_profile":{"kind":"file_read","subject":"workspace:CORPUS_NOTE.txt"},"task_id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","task_stream":{"id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","kind":"task"},"tool_name":"read_file"},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"7f3c0eb5-3726-4c56-90cc-fb6fabac470b","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":82,"recorded_at":1788223110760000,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.terminal","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_note","effect_id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","kind":"terminal","model_call_index":0,"outcome":{"kind":"completed","output_ref_count":0,"task_completion":{"kind":"complete"}},"task_id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","task_stream":{"id":"f36e3f72-c5ac-4e7f-b4d3-98d660b8d37d","kind":"task"}},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"18d71b3c-ef85-47d3-a58f-a9ea2ed1aef3","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":86,"recorded_at":1788223110761286,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"batch_id":"cb7c9354-f80e-410c-8739-89807a688bed","kind":"tool_result_batch_committed","results":[{"text":"Read text file `CORPUS_NOTE.txt`.\n1|Native corpus file marker: COPPER_4821\n2|\n3|This file belongs to a disposable session-migrate test workspace. It contains\n4|no credentials, user data, or repository source.","tool_call_id":"call_muse_note","tool_call_index":0}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"941e480a-c553-4976-8e0b-03614d4b4607","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":115,"recorded_at":1788223110798214,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":26,"finish_reason":"tool_calls","kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"3b491522-5317-47aa-87e6-d4bbc73d8d4c","source_run_record_sequence":48}} +{"schema_version":1,"id":"e0ed554b-6119-45d9-9d22-584f0c720419","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":117,"recorded_at":1788223110799120,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_tool_calls_committed","message_id":"d9a98768-7eaf-4496-b382-be2ce4614480","response_id":"resp_call_muse_missing","tool_calls":[{"args":"{\"path\":\"missing-corpus-file.txt\"}","call_id":"call_muse_missing","id":"fc_call_muse_missing","name":"read_file"}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"df1f5f4a-e57e-435d-81de-5528e29c7273","source_run_record_sequence":49}} +{"schema_version":1,"id":"ead09371-c41f-4d02-b1a6-4b05a8115bcc","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":124,"recorded_at":1788223110801824,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.started","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_missing","effect_id":"017f8c0d-b268-4d99-a73b-3391f126516c","kind":"started","model_call_index":0,"parallel_profile":{"kind":"file_read","subject":"workspace:missing-corpus-file.txt"},"task_id":"017f8c0d-b268-4d99-a73b-3391f126516c","task_stream":{"id":"017f8c0d-b268-4d99-a73b-3391f126516c","kind":"task"},"tool_name":"read_file"},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"00e19237-0051-4a9e-b1a2-bfd411d97d6d","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":125,"recorded_at":1788223110809647,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"tool_batch.effect.terminal","payload_schema_version":1,"payload":{"kind":"tool_batch_effect","record":{"call_id":"call_muse_missing","effect_id":"017f8c0d-b268-4d99-a73b-3391f126516c","kind":"terminal","model_call_index":0,"outcome":{"kind":"failed","reason":"No such file or directory (os error 2)"},"task_id":"017f8c0d-b268-4d99-a73b-3391f126516c","task_stream":{"id":"017f8c0d-b268-4d99-a73b-3391f126516c","kind":"task"}},"run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"9d2dfe2a-4215-4112-9f14-e5cd68c0a786","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":128,"recorded_at":1788223110811068,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"batch_id":"d9a98768-7eaf-4496-b382-be2ce4614480","kind":"tool_result_batch_committed","results":[{"text":"tool failed: No such file or directory (os error 2)","tool_call_id":"call_muse_missing","tool_call_index":0}]},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823"}} +{"schema_version":1,"id":"698a9efd-8497-477a-9730-7436a0511a92","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":145,"recorded_at":1788223110828867,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_message_committed","message_id":"3a5a3d69-0f3b-4971-af76-38b3e569c76d","provider_item_id":"msg_resp_muse_summary","response_id":"resp_muse_summary","text":"The boundary comparison is strict, the note marker is COPPER_4821, and the missing-file failure was retained."},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"1d420b0d-e170-4aae-83e5-9e79ffebc93f","source_run_record_sequence":60}} +{"schema_version":1,"id":"3a0f7bf4-093f-4903-96ff-52ee79b624e6","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":148,"recorded_at":1788223110833646,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":14,"kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"5f694ca3-9d3a-448e-8507-2e7c3a077dd0","source_run_record_sequence":62}} +{"schema_version":1,"id":"bcf0bf7c-5a65-4ef3-a337-1e99fc6dec57","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":165,"recorded_at":1788223110922656,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"eot_gate_ms":87,"kind":"terminal","reason":null,"terminal":"completed","time_to_first_token_ms":148,"turn_duration_ms":242},"kind":"run","run_id":"ffa8d311-da04-4cfc-b100-89c18a58f823","source_run_record_id":"c42f3009-b225-4a1b-8a0b-f4bd584355f1","source_run_record_sequence":68}} +{"schema_version":1,"id":"c6f0de74-d6c5-4890-8796-a14b97a7409d","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":166,"recorded_at":1788223110936073,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.end","payload_schema_version":1,"payload":{"kind":"session_end","record":{"exit_reason":"clean","resource_usage":{"cpu_children_ms":2,"cpu_self_ms":231,"data_dir_free_bytes":2216562688,"load_avg_1m_hundredths":3185,"rss_self_peak_bytes":101261312,"rss_tree_peak_bytes":104226816,"session_log_bytes":805849,"threads_live":9,"workspace_free_bytes":2216562688},"schema_version":1,"session_id":"74747474-7474-4747-8747-747474747474","uptime_ms":257}}} +{"schema_version":1,"id":"c1ac942b-c0d6-4e32-b446-96645277a4d4","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":167,"recorded_at":1788223111117411,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session.route_facts","payload_schema_version":2,"payload":{"kind":"route_facts","record":{"cwd":"/fixture/work","pid":3392533,"terminal_title_identity":"747474747474"}}} +{"schema_version":1,"id":"7c4404b5-7678-499d-b77e-dcfa66c0dd04","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":168,"recorded_at":1788223111260389,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.opened.observed","payload_schema_version":1,"payload":{"kind":"session_opened","record":{"data_dir_free_bytes":2216615936,"resume":true,"schema_version":1,"security_mode":"yolo","session_id":"74747474-7474-4747-8747-747474747474","workspace_free_bytes":2216615936}}} +{"schema_version":1,"id":"6c14d49a-a545-4649-a13d-22fc84499c99","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":170,"recorded_at":1788223111261294,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.resumed","payload_schema_version":1,"payload":{"kind":"session_resumed","record":{"prior_turn_count":1,"resumed_from_record_id":"bcf0bf7c-5a65-4ef3-a337-1e99fc6dec57","resumed_from_sequence":165,"schema_version":1,"session_id":"74747474-7474-4747-8747-747474747474"}}} +{"schema_version":1,"id":"13488036-3b77-462c-a65d-d2217076ce71","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":172,"recorded_at":1788223111288863,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"started","prompt":"Inspect the attached image and identify its shape and visible nonce."},"kind":"run","run_id":"28adbeba-47af-4425-b362-0261f207b380","source_run_record_id":"fead892d-ca45-45e5-b2a8-b14e53bb3eeb","source_run_record_sequence":1}} +{"schema_version":1,"id":"d55187fd-db48-4af5-8fb1-6dc1d455fe49","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":198,"recorded_at":1788223111332818,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_message_committed","message_id":"ab7a934d-2bd5-4915-a1ce-daa50835418a","provider_item_id":"msg_resp_muse_image","response_id":"resp_muse_image","text":"The image contains a blue triangle labeled BLUE_TRIANGLE_7319."},"kind":"run","run_id":"28adbeba-47af-4425-b362-0261f207b380","source_run_record_id":"a40a2f8b-69a5-43e7-939c-70f438d481ae","source_run_record_sequence":16}} +{"schema_version":1,"id":"44383b5e-aa7a-443b-a228-d6965b655109","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":201,"recorded_at":1788223111334229,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":25,"kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"28adbeba-47af-4425-b362-0261f207b380","source_run_record_id":"a37bb5c8-dcf3-4d20-ba09-f33171d3d16f","source_run_record_sequence":18}} +{"schema_version":1,"id":"32f03605-c293-454a-a3f8-c19657fa8ddb","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":218,"recorded_at":1788223111458161,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"eot_gate_ms":122,"kind":"terminal","reason":null,"terminal":"completed","time_to_first_token_ms":43,"turn_duration_ms":169},"kind":"run","run_id":"28adbeba-47af-4425-b362-0261f207b380","source_run_record_id":"002b4af6-2f42-4bc9-973b-33f12fbadeed","source_run_record_sequence":24}} +{"schema_version":1,"id":"0ea590be-17c7-490c-ac76-b4de0b5ff0e3","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":219,"recorded_at":1788223111470232,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.end","payload_schema_version":1,"payload":{"kind":"session_end","record":{"exit_reason":"clean","resource_usage":{"cpu_children_ms":1,"cpu_self_ms":354,"data_dir_free_bytes":2215981056,"load_avg_1m_hundredths":3185,"rss_self_peak_bytes":103186432,"rss_tree_peak_bytes":106086400,"session_log_bytes":1341049,"threads_live":10,"workspace_free_bytes":2215981056},"schema_version":1,"session_id":"74747474-7474-4747-8747-747474747474","uptime_ms":187}}} +{"schema_version":1,"id":"84d32ee7-3ded-48cd-ab9c-9da6cbf1abcb","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":220,"recorded_at":1788223111679190,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session.route_facts","payload_schema_version":2,"payload":{"kind":"route_facts","record":{"cwd":"/fixture/work","pid":3392695,"terminal_title_identity":"747474747474"}}} +{"schema_version":1,"id":"65b0a57d-b549-468e-bc86-b3b6cbcb02da","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":221,"recorded_at":1788223111841906,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.opened.observed","payload_schema_version":1,"payload":{"kind":"session_opened","record":{"data_dir_free_bytes":2216005632,"resume":true,"schema_version":1,"security_mode":"yolo","session_id":"74747474-7474-4747-8747-747474747474","workspace_free_bytes":2216005632}}} +{"schema_version":1,"id":"d0a1e421-1b6c-4630-8486-a02081af0847","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":223,"recorded_at":1788223111842797,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.resumed","payload_schema_version":1,"payload":{"kind":"session_resumed","record":{"prior_turn_count":2,"resumed_from_record_id":"32f03605-c293-454a-a3f8-c19657fa8ddb","resumed_from_sequence":218,"schema_version":1,"session_id":"74747474-7474-4747-8747-747474747474"}}} +{"schema_version":1,"id":"123e15fd-ff2f-4065-818a-ae2019998fab","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":228,"recorded_at":1788223111867428,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"started","prompt":"Without tools, recall the task marker, note marker, image marker, and failed filename."},"kind":"run","run_id":"0bbf256f-b87e-4cf9-99bb-3d462715e1d8","source_run_record_id":"7e2ac1a3-bea1-40da-b88d-48c77b9f89e6","source_run_record_sequence":1}} +{"schema_version":1,"id":"ef1c2327-4418-45cd-8259-e4e173983fbf","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":255,"recorded_at":1788223111916704,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"kind":"assistant_message_committed","message_id":"5726f698-6d80-4a72-bf94-ac460d454acd","provider_item_id":"msg_resp_muse_recall","response_id":"resp_muse_recall","text":"Recall: SM_CORPUS_7319, COPPER_4821, BLUE_TRIANGLE_7319, and missing-corpus-file.txt."},"kind":"run","run_id":"0bbf256f-b87e-4cf9-99bb-3d462715e1d8","source_run_record_id":"8dedd56b-5b37-462f-913b-815d110ff272","source_run_record_sequence":16}} +{"schema_version":1,"id":"c1ddabad-cf2c-4995-9957-ba2a1dc54230","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":258,"recorded_at":1788223111925271,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"duration_ms":29,"kind":"model_completed","model":"meta/muse-glimmer-30b","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"input_tokens":32,"output_tokens":8,"reasoning_tokens":0}},"kind":"run","run_id":"0bbf256f-b87e-4cf9-99bb-3d462715e1d8","source_run_record_id":"b401006d-9749-4f27-8125-eff1a81574a4","source_run_record_sequence":18}} +{"schema_version":1,"id":"61dbbf75-ac99-4c6b-aa83-d5317b32e8b3","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":275,"recorded_at":1788223112041092,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"runtime.session","payload_schema_version":1,"payload":{"event":{"eot_gate_ms":114,"kind":"terminal","reason":null,"terminal":"completed","time_to_first_token_ms":47,"turn_duration_ms":173},"kind":"run","run_id":"0bbf256f-b87e-4cf9-99bb-3d462715e1d8","source_run_record_id":"c7fb0944-688d-4ce7-913e-3c4d73d7d853","source_run_record_sequence":24}} +{"schema_version":1,"id":"05e6ada9-1d31-42e9-a030-685d7adef551","stream":{"kind":"session","id":"74747474-7474-4747-8747-747474747474"},"sequence":276,"recorded_at":1788223112057691,"record_type":"event","durability":"durable","causation_id":null,"payload_type":"session.end","payload_schema_version":1,"payload":{"kind":"session_end","record":{"exit_reason":"clean","resource_usage":{"cpu_children_ms":1,"cpu_self_ms":403,"data_dir_free_bytes":2215403520,"load_avg_1m_hundredths":3185,"rss_self_peak_bytes":104079360,"rss_tree_peak_bytes":106979328,"session_log_bytes":1864612,"threads_live":9,"workspace_free_bytes":2215403520},"schema_version":1,"session_id":"74747474-7474-4747-8747-747474747474","uptime_ms":194}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl new file mode 100644 index 00000000..f6c26c5e --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl @@ -0,0 +1,29 @@ +{"retained_frame": "session_permission_transaction", "children": []} +{"schema_version": 1, "id": "0199a1b2-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 1, "recorded_at": 1790337600250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "0199a1b2-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 2, "recorded_at": 1790337600500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": false, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "0199a1b2-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 3, "recorded_at": 1790337600750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "started", "prompt": "Add a retry helper to src/http.rs and run the tests."}}} +{"schema_version": 1, "id": "0199a1b2-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 4, "recorded_at": 1790337601000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 900, "finish_reason": "tool_calls", "usage": {"input_tokens": 1200, "output_tokens": 90, "cached_tokens": 800, "cache_read_tokens": 800, "cache_write_tokens": 0, "reasoning_tokens": 40}}}} +{"schema_version": 1, "id": "0199a1b2-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 5, "recorded_at": 1790337601250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r1", "text": "", "encrypted_content": "gAAAAB-opaque"}}} +{"schema_version": 1, "id": "0199a1b2-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 6, "recorded_at": 1790337601500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r2", "text": "Read the file before editing it."}}} +{"schema_version": 1, "id": "0199a1b2-rec-007", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 7, "recorded_at": 1790337601750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-1", "response_id": "resp-1", "tool_calls": [{"id": "fc_read", "call_id": "call_read", "name": "read_file", "args": "{\"path\": \"src/http.rs\"}"}]}}} +{"schema_version": 1, "id": "0199a1b2-rec-008", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 8, "recorded_at": 1790337602000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_read", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "0199a1b2-rec-009", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 9, "recorded_at": 1790337602250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-1", "results": [{"tool_call_id": "call_read", "tool_call_index": 0, "text": "Read text file `src/http.rs`.\n1|pub fn get() {}"}]}}} +{"schema_version": 1, "id": "0199a1b2-rec-010", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 10, "recorded_at": 1790337602500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 700, "finish_reason": "tool_calls", "usage": {"input_tokens": 1500, "output_tokens": 210, "cached_tokens": 1100, "cache_read_tokens": 1100, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "0199a1b2-rec-011", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 11, "recorded_at": 1790337602750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-2", "response_id": "resp-2", "tool_calls": [{"id": "fc_edit", "call_id": "call_edit", "name": "edit_file", "args": "{\"path\": \"src/http.rs\", \"find\": \"pub fn get() {}\", \"replace\": \"pub fn get() {}\\npub fn retry() {}\"}"}, {"id": "fc_write", "call_id": "call_write", "name": "write_file", "args": "{\"path\": \"tests/retry.rs\", \"content\": \"#[test] fn retries() {}\"}"}, {"id": "fc_bash", "call_id": "call_bash", "name": "bash", "args": "{\"command\": \"cargo test\", \"workdir\": \"/tmp/muse-demo\"}"}]}}} +{"schema_version": 1, "id": "0199a1b2-rec-012", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 12, "recorded_at": 1790337603000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_edit", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "0199a1b2-rec-013", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 13, "recorded_at": 1790337603250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_write", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "0199a1b2-rec-014", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 14, "recorded_at": 1790337603500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_bash", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "0199a1b2-rec-015", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 15, "recorded_at": 1790337603750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-2", "results": [{"tool_call_id": "call_edit", "tool_call_index": 0, "text": "edited \u2014 diff: -pub fn get() {} +pub fn retry() {}"}, {"tool_call_id": "call_write", "tool_call_index": 1, "text": "wrote 22 bytes to tests/retry.rs"}, {"tool_call_id": "call_bash", "tool_call_index": 2, "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}"}]}}} +{"schema_version": 1, "id": "0199a1b2-rec-016", "stream": {"kind": "task", "id": "0199a1b2-0000-7000-8000-00000000d001"}, "sequence": 16, "recorded_at": 1790337604000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "task-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "0199a1b2-rec-017", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 17, "recorded_at": 1790337604250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "task_stream_linked", "execution_mode": "background", "task_id": "0199a1b2-0000-7000-8000-00000000d001", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d001", "kind": "task"}, "display": {"label": "reviewer", "model": "same-as-main", "path": "subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", "role": "worker"}}}} +{"schema_version": 1, "id": "0199a1b2-rec-018", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 18, "recorded_at": 1790337604500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 400, "finish_reason": "stop", "usage": {"input_tokens": 1800, "output_tokens": 35, "cached_tokens": 1500, "cache_read_tokens": 1500, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "0199a1b2-rec-019", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 19, "recorded_at": 1790337604750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_message_committed", "message_id": "msg-3", "response_id": "resp-3", "text": "Added `retry` and a test; `cargo test` fails with exit code 101."}}} +{"schema_version": 1, "id": "0199a1b2-rec-020", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 20, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "terminal", "terminal": "completed", "reason": null, "turn_duration_ms": 2500}}} +{"schema_version": 1, "id": "0199a1b2-rec-021", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 21, "recorded_at": 1790337605250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.end", "payload_schema_version": 1, "payload": {"kind": "session_end", "record": {"exit_reason": "clean", "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "0199a1b2-rec-022", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 22, "recorded_at": 1790337605500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": true, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "0199a1b2-rec-023", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 23, "recorded_at": 1790337605750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.resumed", "payload_schema_version": 1, "payload": {"kind": "session_resumed", "record": {"prior_turn_count": 1, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "0199a1b2-rec-024", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 24, "recorded_at": 1790337606000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3-contributor", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "0199a1b2-rec-025", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 25, "recorded_at": 1790337606250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "started", "prompt": "Why did it fail?"}}} +{"schema_version": 1, "id": "0199a1b2-rec-026", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 26, "recorded_at": 1790337606500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3-contributor", "duration_ms": 300, "finish_reason": "stop", "usage": {"input_tokens": 2000, "output_tokens": 20, "cached_tokens": 1800, "cache_read_tokens": 1800, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "0199a1b2-rec-027", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 27, "recorded_at": 1790337606750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "assistant_message_committed", "message_id": "msg-4", "response_id": "resp-4", "text": "`retry` is declared but the test expects it to return a value."}}} +{"schema_version": 1, "id": "0199a1b2-rec-028", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 28, "recorded_at": 1790337607000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "terminal", "terminal": "interrupted", "reason": "user_cancelled", "turn_duration_ms": 400}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl new file mode 100644 index 00000000..399cc0c0 --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl @@ -0,0 +1,2 @@ +{"schema_version": 1, "id": "child-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 1, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"model_id": "meta/muse-spark-1.3", "provider_id": "meta", "workspace_root": "/tmp/muse-demo", "build": {"semver": "1.3.0"}}}} +{"schema_version": 1, "id": "child-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 2, "recorded_at": 1790337605100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} diff --git a/crates/ai-hist/tests/live_capture.rs b/crates/ai-hist/tests/live_capture.rs index 51eb4eb9..785f034d 100644 --- a/crates/ai-hist/tests/live_capture.rs +++ b/crates/ai-hist/tests/live_capture.rs @@ -32,6 +32,7 @@ struct HomeLayout { claude: PathBuf, codex: PathBuf, grok: PathBuf, + muse: PathBuf, opencode_db: PathBuf, } @@ -43,6 +44,7 @@ impl HomeLayout { claude: home.join(".claude"), codex: home.join(".codex"), grok: home.join(".grok"), + muse: home.join(".local/share/muse/sessions"), opencode_db: home.join(".local/share/opencode/opencode.db"), } } @@ -53,6 +55,7 @@ impl HomeLayout { claude: &self.claude, codex: &self.codex, grok: &self.grok, + muse: &self.muse, opencode_db: &self.opencode_db, } } @@ -535,7 +538,8 @@ fn every_file_backed_provider_contributes_a_watch_root() { /// A relocated provider root moves the watch with it. /// -/// `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `GROK_HOME` already move what a sweep +/// `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME` and (for Muse Code) +/// `XDG_DATA_HOME` already move what a sweep /// reads. A watch root rebuilt from `$HOME` instead would leave live capture /// staring at a directory the provider never writes to: every sweep correct, /// every one of them waiting out the backstop. @@ -547,6 +551,7 @@ fn configured_provider_roots_move_the_watch_roots() { claude: PathBuf::from("/tmp/relayhistory-relocated/claude"), codex: PathBuf::from("/tmp/relayhistory-relocated/codex"), grok: PathBuf::from("/tmp/relayhistory-relocated/grok"), + muse: PathBuf::from("/tmp/relayhistory-relocated/xdg-data/muse/sessions"), opencode_db: PathBuf::from("/tmp/relayhistory-relocated/opencode/opencode.db"), }; let roots = ai_hist::discover::watch_roots(&shallow_providers(), &layout.roots()); @@ -556,6 +561,7 @@ fn configured_provider_roots_move_the_watch_roots() { layout.codex.join("sessions"), layout.codex.join("archived_sessions"), layout.grok.join("sessions"), + layout.muse.clone(), layout .opencode_db .parent() @@ -572,7 +578,8 @@ fn configured_provider_roots_move_the_watch_roots() { .iter() .any(|root| root.path.starts_with(home.join(".claude")) || root.path.starts_with(home.join(".codex")) - || root.path.starts_with(home.join(".grok"))), + || root.path.starts_with(home.join(".grok")) + || root.path.starts_with(home.join(".local/share/muse"))), "a configured root must not leave a $HOME-relative watch behind: {roots:?}" ); } diff --git a/crates/ai-hist/tests/snapshots/muse/cli-capture.json b/crates/ai-hist/tests/snapshots/muse/cli-capture.json new file mode 100644 index 00000000..53dbc9a1 --- /dev/null +++ b/crates/ai-hist/tests/snapshots/muse/cli-capture.json @@ -0,0 +1,452 @@ +{ + "corpus_files": [ + "muse/cli-capture" + ], + "discovery_diagnostics": [], + "file_edits": [], + "fixture": "muse/cli-capture", + "history": [ + { + "project": "/fixture/work", + "prompt": "SM_CORPUS_7319 Inspect timeline.py and CORPUS_NOTE.txt with the native file tool. Then try missing-corpus-file.txt so the native log includes a failed tool result. Explain the interval boundary and quote the note marker.", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "timestamp_ms": 1788223110680 + }, + { + "project": "/fixture/work", + "prompt": "Inspect the attached image and identify its shape and visible nonce.", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "timestamp_ms": 1788223111288 + }, + { + "project": "/fixture/work", + "prompt": "Without tools, recall the task marker, note marker, image marker, and failed filename.", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "timestamp_ms": 1788223111867 + } + ], + "hydration": [ + { + "related_session_ids": [], + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "status": "hydrated" + } + ], + "layout": "HomeTree", + "notes": [], + "origin": "sessionmigrate", + "quirk": "a transcript the real `muse` CLI (0.2.1) wrote, trimmed to its conversation, tool and lifecycle records: three runs across two resumes, `read_file` calls with one failed outcome, per-step `model_completed` usage, and mirrored reminder task records", + "session_events": [ + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "e3e35fae-9c8c-40a9-b961-bc81ff200b79", + "git_branch": null, + "kind": "text", + "message_id": "e3e35fae-9c8c-40a9-b961-bc81ff200b79", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "user", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "SM_CORPUS_7319 Inspect timeline.py and CORPUS_NOTE.txt with the native file tool. Then try missing-corpus-file.txt so the native log includes a failed tool result. Explain the interval boundary and quote the note marker.", + "token_json": null, + "ts_ms": 1788223110680 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "tool:call_muse_timeline", + "git_branch": null, + "kind": "tool_use", + "message_id": "80749233-420e-4c1c-b567-a9047d47c4be", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "read_file timeline.py", + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", + "ts_ms": 1788223110715 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "result:call_muse_timeline", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_muse_timeline", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "tool_result", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "Read text file `timeline.py`.\n1|from dataclasses import dataclass\n2|\n3|\n4|@dataclass(frozen=True)\n5|class Event:\n6| start_ms: int\n7| end_ms: int\n8|\n9|\n10|def touches(previous: Event, current: Event) -> bool:\n11| \"\"\"Return whether two closed intervals overlap or touch.\"\"\"\n12| return current.start_ms < previous.end_ms", + "token_json": null, + "ts_ms": 1788223110736 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "tool:call_muse_note", + "git_branch": null, + "kind": "tool_use", + "message_id": "cb7c9354-f80e-410c-8739-89807a688bed", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "read_file CORPUS_NOTE.txt", + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", + "ts_ms": 1788223110756 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "result:call_muse_note", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_muse_note", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "tool_result", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "Read text file `CORPUS_NOTE.txt`.\n1|Native corpus file marker: COPPER_4821\n2|\n3|This file belongs to a disposable session-migrate test workspace. It contains\n4|no credentials, user data, or repository source.", + "token_json": null, + "ts_ms": 1788223110761 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "tool:call_muse_missing", + "git_branch": null, + "kind": "tool_use", + "message_id": "d9a98768-7eaf-4496-b382-be2ce4614480", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "read_file missing-corpus-file.txt", + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", + "ts_ms": 1788223110799 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "result:call_muse_missing", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_muse_missing", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "tool_result", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "tool failed: No such file or directory (os error 2)", + "token_json": null, + "ts_ms": 1788223110811 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "698a9efd-8497-477a-9730-7436a0511a92", + "git_branch": null, + "kind": "text", + "message_id": "3a5a3d69-0f3b-4971-af76-38b3e569c76d", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "The boundary comparison is strict, the note marker is COPPER_4821, and the missing-file failure was retained.", + "token_json": null, + "ts_ms": 1788223110828 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "13488036-3b77-462c-a65d-d2217076ce71", + "git_branch": null, + "kind": "text", + "message_id": "13488036-3b77-462c-a65d-d2217076ce71", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "user", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "Inspect the attached image and identify its shape and visible nonce.", + "token_json": null, + "ts_ms": 1788223111288 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "d55187fd-db48-4af5-8fb1-6dc1d455fe49", + "git_branch": null, + "kind": "text", + "message_id": "ab7a934d-2bd5-4915-a1ce-daa50835418a", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "The image contains a blue triangle labeled BLUE_TRIANGLE_7319.", + "token_json": null, + "ts_ms": 1788223111332 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "123e15fd-ff2f-4065-818a-ae2019998fab", + "git_branch": null, + "kind": "text", + "message_id": "123e15fd-ff2f-4065-818a-ae2019998fab", + "model": null, + "parent_id": null, + "project": "/fixture/work", + "role": "user", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "Without tools, recall the task marker, note marker, image marker, and failed filename.", + "token_json": null, + "ts_ms": 1788223111867 + }, + { + "control_kind": null, + "cwd": "/fixture/work", + "event_uid": "ef1c2327-4418-45cd-8259-e4e173983fbf", + "git_branch": null, + "kind": "text", + "message_id": "5726f698-6d80-4a72-bf94-ac460d454acd", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/fixture/work", + "role": "assistant", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "text": "Recall: SM_CORPUS_7319, COPPER_4821, BLUE_TRIANGLE_7319, and missing-corpus-file.txt.", + "token_json": null, + "ts_ms": 1788223111916 + } + ], + "session_markers": [ + { + "kind": "session_start", + "marker_uid": "8b83613d-78d1-4298-b2a1-8181328e6ec9", + "message_id": null, + "parent_id": null, + "payload_json": "{\"resume\":false}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.opened.observed", + "text": null, + "ts_ms": 1788223110669, + "turn_id": null + }, + { + "kind": "turn_end", + "marker_uid": "bcf0bf7c-5a65-4ef3-a337-1e99fc6dec57", + "message_id": null, + "parent_id": null, + "payload_json": "{\"terminal\":\"completed\"}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "terminal", + "text": "completed", + "ts_ms": 1788223110922, + "turn_id": "ffa8d311-da04-4cfc-b100-89c18a58f823" + }, + { + "kind": "session_end", + "marker_uid": "c6f0de74-d6c5-4890-8796-a14b97a7409d", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.end", + "text": "clean", + "ts_ms": 1788223110936, + "turn_id": null + }, + { + "kind": "session_start", + "marker_uid": "7c4404b5-7678-499d-b77e-dcfa66c0dd04", + "message_id": null, + "parent_id": null, + "payload_json": "{\"resume\":true}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.opened.observed", + "text": null, + "ts_ms": 1788223111260, + "turn_id": null + }, + { + "kind": "session_resumed", + "marker_uid": "6c14d49a-a545-4649-a13d-22fc84499c99", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.resumed", + "text": null, + "ts_ms": 1788223111261, + "turn_id": null + }, + { + "kind": "turn_end", + "marker_uid": "32f03605-c293-454a-a3f8-c19657fa8ddb", + "message_id": null, + "parent_id": null, + "payload_json": "{\"terminal\":\"completed\"}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "terminal", + "text": "completed", + "ts_ms": 1788223111458, + "turn_id": "28adbeba-47af-4425-b362-0261f207b380" + }, + { + "kind": "session_end", + "marker_uid": "0ea590be-17c7-490c-ac76-b4de0b5ff0e3", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.end", + "text": "clean", + "ts_ms": 1788223111470, + "turn_id": null + }, + { + "kind": "session_start", + "marker_uid": "65b0a57d-b549-468e-bc86-b3b6cbcb02da", + "message_id": null, + "parent_id": null, + "payload_json": "{\"resume\":true}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.opened.observed", + "text": null, + "ts_ms": 1788223111841, + "turn_id": null + }, + { + "kind": "session_resumed", + "marker_uid": "d0a1e421-1b6c-4630-8486-a02081af0847", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.resumed", + "text": null, + "ts_ms": 1788223111842, + "turn_id": null + }, + { + "kind": "turn_end", + "marker_uid": "61dbbf75-ac99-4c6b-aa83-d5317b32e8b3", + "message_id": null, + "parent_id": null, + "payload_json": "{\"terminal\":\"completed\"}", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "terminal", + "text": "completed", + "ts_ms": 1788223112041, + "turn_id": "0bbf256f-b87e-4cf9-99bb-3d462715e1d8" + }, + { + "kind": "session_end", + "marker_uid": "05e6ada9-1d31-42e9-a030-685d7adef551", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "subkind": "session.end", + "text": "clean", + "ts_ms": 1788223112057, + "turn_id": null + } + ], + "session_relationships": [], + "sessions": [ + { + "agent_version": "0.2.1", + "cwd": "/fixture/work", + "discovery_state": "full", + "first_activity_ms": 1788223110587, + "first_prompt": "SM_CORPUS_7319 Inspect timeline.py and CORPUS_NOTE.txt with the native file tool. Then try missing-corpus-file.txt so the native log includes a failed tool result. Explain the interval boundary and quote the note marker.", + "git_branch": null, + "initial_commit": null, + "last_activity_ms": 1788223112057, + "last_assistant_text": "Recall: SM_CORPUS_7319, COPPER_4821, BLUE_TRIANGLE_7319, and missing-corpus-file.txt.", + "models_json": "[\"meta/muse-glimmer-30b\"]", + "originator": null, + "raw_path": "/.local/share/muse/sessions/2026/09/01/74747474-7474-4747-8747-747474747474/session.jsonl", + "repo_url": null, + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "source_stamp": ":31189", + "workspace_roots_json": null + } + ], + "source": "muse", + "tool_calls": [ + { + "args_json": "{\"path\":\"timeline.py\"}", + "is_error": 0, + "message_id": "80749233-420e-4c1c-b567-a9047d47c4be", + "name": "read_file", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "target": "timeline.py", + "tool_use_id": "call_muse_timeline", + "ts_ms": 1788223110715 + }, + { + "args_json": "{\"path\":\"CORPUS_NOTE.txt\"}", + "is_error": 0, + "message_id": "cb7c9354-f80e-410c-8739-89807a688bed", + "name": "read_file", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "target": "CORPUS_NOTE.txt", + "tool_use_id": "call_muse_note", + "ts_ms": 1788223110756 + }, + { + "args_json": "{\"path\":\"missing-corpus-file.txt\"}", + "is_error": 1, + "message_id": "d9a98768-7eaf-4496-b382-be2ce4614480", + "name": "read_file", + "session_id": "74747474-7474-4747-8747-747474747474", + "source": "muse", + "target": "missing-corpus-file.txt", + "tool_use_id": "call_muse_missing", + "ts_ms": 1788223110799 + } + ] +} diff --git a/crates/ai-hist/tests/snapshots/muse/tools-session.json b/crates/ai-hist/tests/snapshots/muse/tools-session.json new file mode 100644 index 00000000..0b8389db --- /dev/null +++ b/crates/ai-hist/tests/snapshots/muse/tools-session.json @@ -0,0 +1,465 @@ +{ + "corpus_files": [ + "muse/tools-session" + ], + "discovery_diagnostics": [], + "file_edits": [ + { + "cwd": "/tmp/muse-demo", + "file_path": "src/http.rs", + "git_branch": null, + "lines_added": null, + "lines_removed": null, + "message_id": "msg-2", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "structured_patch_json": null, + "tool_name": "edit_file", + "tool_use_id": "call_edit", + "ts_ms": 1790337602750, + "user_modified": null + }, + { + "cwd": "/tmp/muse-demo", + "file_path": "tests/retry.rs", + "git_branch": null, + "lines_added": null, + "lines_removed": null, + "message_id": "msg-2", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "structured_patch_json": null, + "tool_name": "write_file", + "tool_use_id": "call_write", + "ts_ms": 1790337602750, + "user_modified": null + } + ], + "fixture": "muse/tools-session", + "history": [ + { + "project": "/tmp/muse-demo", + "prompt": "Add a retry helper to src/http.rs and run the tests.", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "timestamp_ms": 1790337600750 + }, + { + "project": "/tmp/muse-demo", + "prompt": "Why did it fail?", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "timestamp_ms": 1790337606250 + } + ], + "hydration": [ + { + "related_session_ids": [], + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "status": "hydrated" + } + ], + "layout": "HomeTree", + "notes": [], + "origin": "relayhistory", + "quirk": "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session", + "session_events": [ + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "0199a1b2-rec-003", + "git_branch": null, + "kind": "text", + "message_id": "0199a1b2-rec-003", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "user", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "Add a retry helper to src/http.rs and run the tests.", + "token_json": null, + "ts_ms": 1790337600750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "0199a1b2-rec-006", + "git_branch": null, + "kind": "thinking", + "message_id": "msg-r2", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "Read the file before editing it.", + "token_json": "{\"cache_read_tokens\":800,\"cache_write_tokens\":0,\"cached_tokens\":800,\"input_tokens\":1200,\"output_tokens\":90,\"reasoning_tokens\":40}", + "ts_ms": 1790337601500 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "tool:call_read", + "git_branch": null, + "kind": "tool_use", + "message_id": "msg-1", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "read_file src/http.rs", + "token_json": null, + "ts_ms": 1790337601750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "result:call_read", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_read", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "tool_result", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "Read text file `src/http.rs`.\n1|pub fn get() {}", + "token_json": null, + "ts_ms": 1790337602250 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "tool:call_bash", + "git_branch": null, + "kind": "tool_use", + "message_id": "msg-2", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "bash cargo test", + "token_json": null, + "ts_ms": 1790337602750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "tool:call_edit", + "git_branch": null, + "kind": "tool_use", + "message_id": "msg-2", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "edit_file src/http.rs", + "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", + "ts_ms": 1790337602750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "tool:call_write", + "git_branch": null, + "kind": "tool_use", + "message_id": "msg-2", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "write_file tests/retry.rs", + "token_json": null, + "ts_ms": 1790337602750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "result:call_bash", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_bash", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "tool_result", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}", + "token_json": null, + "ts_ms": 1790337603750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "result:call_edit", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_edit", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "tool_result", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "edited — diff: -pub fn get() {} +pub fn retry() {}", + "token_json": null, + "ts_ms": 1790337603750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "result:call_write", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_write", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "tool_result", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "wrote 22 bytes to tests/retry.rs", + "token_json": null, + "ts_ms": 1790337603750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "0199a1b2-rec-019", + "git_branch": null, + "kind": "text", + "message_id": "msg-3", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "Added `retry` and a test; `cargo test` fails with exit code 101.", + "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", + "ts_ms": 1790337604750 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "0199a1b2-rec-025", + "git_branch": null, + "kind": "text", + "message_id": "0199a1b2-rec-025", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "user", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "Why did it fail?", + "token_json": null, + "ts_ms": 1790337606250 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "0199a1b2-rec-027", + "git_branch": null, + "kind": "text", + "message_id": "msg-4", + "model": "meta/muse-spark-1.3-contributor", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "`retry` is declared but the test expects it to return a value.", + "token_json": "{\"cache_read_tokens\":1800,\"cache_write_tokens\":0,\"cached_tokens\":1800,\"input_tokens\":2000,\"output_tokens\":20,\"reasoning_tokens\":0}", + "ts_ms": 1790337606750 + } + ], + "session_markers": [ + { + "kind": "session_start", + "marker_uid": "0199a1b2-rec-002", + "message_id": null, + "parent_id": null, + "payload_json": "{\"resume\":false}", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "session.opened.observed", + "text": null, + "ts_ms": 1790337600500, + "turn_id": null + }, + { + "kind": "encrypted_reasoning", + "marker_uid": "0199a1b2-rec-005", + "message_id": "msg-r1", + "parent_id": null, + "payload_json": null, + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "reasoning_committed", + "text": null, + "ts_ms": 1790337601250, + "turn_id": "run-a" + }, + { + "kind": "turn_end", + "marker_uid": "0199a1b2-rec-020", + "message_id": null, + "parent_id": null, + "payload_json": "{\"terminal\":\"completed\"}", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "terminal", + "text": "completed", + "ts_ms": 1790337605000, + "turn_id": "run-a" + }, + { + "kind": "session_end", + "marker_uid": "0199a1b2-rec-021", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "session.end", + "text": "clean", + "ts_ms": 1790337605250, + "turn_id": null + }, + { + "kind": "session_start", + "marker_uid": "0199a1b2-rec-022", + "message_id": null, + "parent_id": null, + "payload_json": "{\"resume\":true}", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "session.opened.observed", + "text": null, + "ts_ms": 1790337605500, + "turn_id": null + }, + { + "kind": "session_resumed", + "marker_uid": "0199a1b2-rec-023", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "session.resumed", + "text": null, + "ts_ms": 1790337605750, + "turn_id": null + }, + { + "kind": "model_switch", + "marker_uid": "0199a1b2-rec-024", + "message_id": null, + "parent_id": null, + "payload_json": null, + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "runtime.session.metadata", + "text": "meta/muse-spark-1.3-contributor", + "ts_ms": 1790337606000, + "turn_id": null + }, + { + "kind": "turn_end", + "marker_uid": "0199a1b2-rec-028", + "message_id": null, + "parent_id": null, + "payload_json": "{\"reason\":\"user_cancelled\",\"terminal\":\"interrupted\"}", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "subkind": "terminal", + "text": "interrupted", + "ts_ms": 1790337607000, + "turn_id": "run-b" + } + ], + "session_relationships": [], + "sessions": [ + { + "agent_version": "1.3.0", + "cwd": "/tmp/muse-demo", + "discovery_state": "full", + "first_activity_ms": 1790337600250, + "first_prompt": "Add a retry helper to src/http.rs and run the tests.", + "git_branch": null, + "initial_commit": null, + "last_activity_ms": 1790337607000, + "last_assistant_text": "`retry` is declared but the test expects it to return a value.", + "models_json": "[\"meta/muse-spark-1.3\",\"meta/muse-spark-1.3-contributor\"]", + "originator": null, + "raw_path": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl", + "repo_url": null, + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "source_stamp": ":15194", + "workspace_roots_json": null + } + ], + "source": "muse", + "tool_calls": [ + { + "args_json": "{\"path\":\"src/http.rs\"}", + "is_error": 0, + "message_id": "msg-1", + "name": "read_file", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "target": "src/http.rs", + "tool_use_id": "call_read", + "ts_ms": 1790337601750 + }, + { + "args_json": "{\"command\":\"cargo test\",\"workdir\":\"/tmp/muse-demo\"}", + "is_error": 1, + "message_id": "msg-2", + "name": "bash", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "target": "cargo test", + "tool_use_id": "call_bash", + "ts_ms": 1790337602750 + }, + { + "args_json": "{\"find\":\"pub fn get() {}\",\"path\":\"src/http.rs\",\"replace\":\"pub fn get() {}\\npub fn retry() {}\"}", + "is_error": 0, + "message_id": "msg-2", + "name": "edit_file", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "target": "src/http.rs", + "tool_use_id": "call_edit", + "ts_ms": 1790337602750 + }, + { + "args_json": "{\"content\":\"#[test] fn retries() {}\",\"path\":\"tests/retry.rs\"}", + "is_error": 0, + "message_id": "msg-2", + "name": "write_file", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "target": "tests/retry.rs", + "tool_use_id": "call_write", + "ts_ms": 1790337602750 + } + ] +} diff --git a/docs/architecture.md b/docs/architecture.md index 284dc93a..5f87dc52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -247,7 +247,7 @@ so every result reports it: |---|---|---|---|---| | `codex` | always | yes | yes | yes | | `claude` | sometimes (only versions that emit a per-child `agentId`) | yes | yes | yes | -| `cursor`, `grok`, `opencode`, `relay` | never | no | no | no | +| `cursor`, `grok`, `muse`, `opencode`, `relay` | never | no | no | no | A linked child's events are stored under the child's own session id and are never flattened into the parent. The delegated instruction that started a diff --git a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md index fed95a2b..f435c3ef 100644 --- a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md +++ b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md @@ -18,6 +18,7 @@ Two repositories parse the same harness logs today, with no shared code. | OpenCode | `sync_opencode_session` (`src/store.rs`) | `reader/opencode.rs` | | Cursor | `ingest_cursor_transcript` (`src/ingest.rs`) | not supported | | Grok | `ingest_grok_session` (`src/ingest.rs`) | not supported | +| Muse Code | `ingest_muse_session` (`src/ingest.rs`) | not supported | burn has no dependency on relayhistory. relayhistory already defers cost to burn by name (`plugins/relayhistory/rust/src/convergence.rs` — "Input excludes @@ -127,29 +128,29 @@ provider sessions") that lands in the `trajectories` table and is filtered out of `sessions list`. It is listed for completeness; every session-evidence row is `—` for it by construction. -| Record type | claude | codex | cursor | grok | opencode | relay | trajectory | Owner / closes | -| ------------------------------------------------------------------ | ------ | ----- | ------ | ---- | -------- | ----- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Prompt / `history` row | ✓ | ✓ | ✓ | ◐ | ✓ | ✓ | — | grok timestamps: [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) | -| `session_events` — `text` | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) / [#177](https://github.com/AgentWorkforce/relayhistory/issues/177) | -| `session_events` — `thinking` | ✓ | ✓ | — | ✗ | ✗ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| `session_events` — `tool_use` | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| `session_events` — `tool_result` | ✓ | ✓ | — | ✗ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Model, per event | ✓ | ✓ | — | ◐ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Token usage | ◐ | ◐ | — | ✗ | ✓ | ✗ | — | [#172](https://github.com/AgentWorkforce/relayhistory/issues/172) (supersedes #99) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| `request_id` | ✗ | — | — | — | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | -| `stop_reason` | ✗ | — | — | — | ✓ | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Codex `turn_id` | — | ✗ | — | — | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | -| Sidechain / meta flags | ✗ | ✗ | — | — | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | -| Tool calls (`tool_calls`) | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Tool-result fidelity (identity, bytes, truncation, hash, ordering) | ✓ | ✓ | — | ✗ | ✗ | ✗ | — | [#171](https://github.com/AgentWorkforce/relayhistory/issues/171) | -| File edits (`file_edits`) | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Compaction / summary markers | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Control / lifecycle rows | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165), [#180](https://github.com/AgentWorkforce/relayhistory/issues/180) | -| Relationship — delegated | ◐ | ✓ | — | — | ✓ | — | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | -| Relationship — fork / resume / continuation | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) | -| Session metadata (cwd, branch, versions) | ◐ | ✓ | ◐ | ◐ | ◐ | ✗ | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164), [#177](https://github.com/AgentWorkforce/relayhistory/issues/177) | -| Canonical `project_key` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#175](https://github.com/AgentWorkforce/relayhistory/issues/175) | -| Declared hydration `capability` | ◐ | ◐ | ◐ | ◐ | ◐ | ✗ | — | [#169](https://github.com/AgentWorkforce/relayhistory/issues/169) | +| Record type | claude | codex | cursor | grok | muse | opencode | relay | trajectory | Owner / closes | +| ------------------------------------------------------------------ | ------ | ----- | ------ | ---- | ---- | -------- | ----- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prompt / `history` row | ✓ | ✓ | ✓ | ◐ | ✓ | ✓ | ✓ | — | grok timestamps: [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) | +| `session_events` — `text` | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) / [#177](https://github.com/AgentWorkforce/relayhistory/issues/177) | +| `session_events` — `thinking` | ✓ | ✓ | — | ✗ | ◐ | ✗ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| `session_events` — `tool_use` | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| `session_events` — `tool_result` | ✓ | ✓ | — | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Model, per event | ✓ | ✓ | — | ◐ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Token usage | ◐ | ◐ | — | ✗ | ✓ | ✓ | ✗ | — | [#172](https://github.com/AgentWorkforce/relayhistory/issues/172) (supersedes #99) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| `request_id` | ✗ | — | — | — | ✓ | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | +| `stop_reason` | ✗ | — | — | — | ✓ | ✓ | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Codex `turn_id` | — | ✗ | — | — | — | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | +| Sidechain / meta flags | ✗ | ✗ | — | — | — | — | — | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164) | +| Tool calls (`tool_calls`) | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Tool-result fidelity (identity, bytes, truncation, hash, ordering) | ✓ | ✓ | — | ✗ | ✓ | ✗ | ✗ | — | [#171](https://github.com/AgentWorkforce/relayhistory/issues/171) | +| File edits (`file_edits`) | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Compaction / summary markers | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Control / lifecycle rows | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165), [#180](https://github.com/AgentWorkforce/relayhistory/issues/180) | +| Relationship — delegated | ◐ | ✓ | — | — | ✗ | ✓ | — | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Relationship — fork / resume / continuation | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) | +| Session metadata (cwd, branch, versions) | ◐ | ✓ | ◐ | ◐ | ◐ | ◐ | ✗ | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164), [#177](https://github.com/AgentWorkforce/relayhistory/issues/177) | +| Canonical `project_key` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#175](https://github.com/AgentWorkforce/relayhistory/issues/175) | +| Declared hydration `capability` | ◐ | ◐ | ◐ | ◐ | ◐ | ◐ | ✗ | — | [#169](https://github.com/AgentWorkforce/relayhistory/issues/169) | ### Why each non-`✓` cell reads the way it does @@ -186,6 +187,24 @@ different reason — a `Task` block is captured as an ordinary `tool_calls` row, so the spawn is visible, but it names no child transcript, so there is nothing to write a relationship to. +**Muse Code.** `ingest_muse_session` (`src/ingest.rs`, record interpretation +in `src/ingest/muse.rs`) reads `session.jsonl`, an event-sourced log whose +every record carries its own `recorded_at` in microseconds, so no timestamp is +inferred. `thinking` is `◐` because Muse usually keeps reasoning only as +`encrypted_content`; that becomes an `encrypted_reasoning` marker, never +thinking text. Token usage is one `model_completed` object per model step, +stored once on the first assistant row the step committed (`per-request`); +a step that committed nothing is reported as `MUSE_USAGE_UNATTACHED`, not +attached to a neighbour. `request_id` is Muse's `response_id` and +`stop_reason` its `finish_reason`. A tool call's status comes from the +separate `tool_batch.effect.terminal` record, joined by call id, and a +`bash` call that completed with a non-zero `exit_code` is an error. Session +metadata is `◐`: `workspace_root`, model and CLI version, but Muse records no +branch. Delegation is `✗`: `subagent_spawn` is an ordinary `tool_calls` row, +and the `subagent//session.jsonl` children beside a session are neither +catalogued nor linked yet. The fixtures include a transcript the real CLI +wrote (`tests/fixtures/muse/cli-capture`). + **Token usage.** Claude is `◐`: `ingest_claude_transcript_as` serializes `message.usage` once and passes the same `token_json` to every block of the message, so one model request is written N times, once per content block. diff --git a/docs/session-catalog.md b/docs/session-catalog.md index 4e57b183..e6ba01fb 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -367,7 +367,7 @@ contract: | Field | Kind | Notes | |---|---|---| -| `source` | observed | `claude`, `codex`, `cursor`, `grok`, `opencode`, `relay` | +| `source` | observed | `claude`, `codex`, `cursor`, `grok`, `muse`, `opencode`, `relay` | | `session_id` | observed | provider-native; `(source, session_id)` is the primary key, so the same native id under two providers is two rows | | `cwd` | observed | working directory the provider recorded | | `git_branch` | observed | last branch the provider recorded | @@ -463,6 +463,7 @@ which this table must stay consistent with. | **codex** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | **cursor** | ✓ (dir name) | ✓ (decoded path) | – (never) | ✓ (injected ``) | ✓ (injected ``, else mtime) | ✓ | ✓ (if a build writes `message.model`) | – | – | – | – | – | | **grok** | ✓ | ✓ | ✓ | ✓ (`updates.jsonl`, else `summary.json`) | ✓ (`updates.jsonl`, else `summary.json`) | ✓ | ✓ | – | – | – | – | – | +| **muse** | ✓ (metadata `stream.id`) | ✓ (`workspace_root`) | – (never) | ✓ (metadata `recorded_at`) | ✓ (tail `recorded_at`) | ✓ (first `started` prompt) | ✓ (metadata, then `model_completed`) | – | ✓ (`build.semver`) | – | – | – | | **opencode** | ✓ | ✓ (directory / message `path.cwd`) | – | ✓ | ✓ | ✓ | ✓ (`providerID/modelID`) | – | – | – | – | – | | **relay** | ✓ | – (never) | – | ✓ (synced min ts) | ✓ (synced max ts) | ✓ (earliest synced prompt) | – | – | – | – | – | – | @@ -491,25 +492,30 @@ keys would silently drop whatever it adds next, which is the failure this table exists to end. Either way the bound is the promise the column makes, and it holds for every kind. -| `kind` | claude | codex | grok | `subkind` examples | -|---|---|---|---|---| -| `compaction_boundary` | ✓ `type:"system"`, `subtype:"compact_boundary"` | ✓ top-level `compacted`, `context_compacted` | ✓ | `compact_boundary`, `compacted` | -| `summary` | ✓ `type:"summary"` | – | – | `summary` | -| `subagent_notification` | ✓ system rows with `parent_tool_use_id`; tool results carrying `toolUseResult.agentId` | ✓ `subagent_*` | – | `subagent_completed`, `tool_use_result_agent_id`, `subagent_message_complete` | -| `task_started` | – | ✓ | – | `task_started` | -| `task_complete` | – | ✓ | – | `task_complete` | -| `turn_diff` | – | ✓ | – | `turn_diff` | -| `stream_error` | – | ✓ | – | `stream_error` | -| `tool_begin` | – | ✓ any `*_begin` | – | `exec_command_begin`, `patch_apply_begin`, `mcp_tool_call_begin` | -| `review_mode` | – | ✓ | – | `entered_review_mode`, `exited_review_mode` | -| `unsupported_block` | ✓ any content block with no event `kind`, plus thinking signatures | – | – | `image`, `document`, `redacted_thinking`, `server_tool_use`, `thinking_signature` | -| `encrypted_reasoning` | – | ✓ `response_item/reasoning` | ✓ an opaque reasoning trace with no summary | `reasoning` | -| `tool_replacement` | ✓ `_meta.replaces` / `_meta.collapsedCalls` | – | – | `tool_result` | -| `system` | – | – | ✓ a system preamble, in `text` | – | -| `synthetic_turn` | – | – | ✓ a turn the harness wrote, in `text` | – | -| `signals` | – | – | ✓ | – | -| `prompt_context` | – | – | ✓ | – | -| `unknown` | ✓ any unclassified record type, plus a `user`/`assistant` record that produced no event at all | ✓ any unclassified payload type, including `agent_reasoning_raw_content` and `agent_reasoning_section_break` | – | the provider type, verbatim | +| `kind` | claude | codex | grok | muse | `subkind` examples | +|---|---|---|---|---|---| +| `compaction_boundary` | ✓ `type:"system"`, `subtype:"compact_boundary"` | ✓ top-level `compacted`, `context_compacted` | ✓ | – | `compact_boundary`, `compacted` | +| `summary` | ✓ `type:"summary"` | – | – | – | `summary` | +| `subagent_notification` | ✓ system rows with `parent_tool_use_id`; tool results carrying `toolUseResult.agentId` | ✓ `subagent_*` | – | – | `subagent_completed`, `tool_use_result_agent_id`, `subagent_message_complete` | +| `task_started` | – | ✓ | – | – | `task_started` | +| `task_complete` | – | ✓ | – | – | `task_complete` | +| `turn_diff` | – | ✓ | – | – | `turn_diff` | +| `stream_error` | – | ✓ | – | – | `stream_error` | +| `tool_begin` | – | ✓ any `*_begin` | – | – | `exec_command_begin`, `patch_apply_begin`, `mcp_tool_call_begin` | +| `review_mode` | – | ✓ | – | – | `entered_review_mode`, `exited_review_mode` | +| `unsupported_block` | ✓ any content block with no event `kind`, plus thinking signatures | – | – | – | `image`, `document`, `redacted_thinking`, `server_tool_use`, `thinking_signature` | +| `encrypted_reasoning` | – | ✓ `response_item/reasoning` | ✓ an opaque reasoning trace with no summary | ✓ `reasoning_committed` with only `encrypted_content` | `reasoning` | +| `tool_replacement` | ✓ `_meta.replaces` / `_meta.collapsedCalls` | – | – | – | `tool_result` | +| `system` | – | – | ✓ a system preamble, in `text` | – | – | +| `synthetic_turn` | – | – | ✓ a turn the harness wrote, in `text` | – | – | +| `signals` | – | – | ✓ | – | – | +| `prompt_context` | – | – | ✓ | – | – | +| `unknown` | ✓ any unclassified record type, plus a `user`/`assistant` record that produced no event at all | ✓ any unclassified payload type, including `agent_reasoning_raw_content` and `agent_reasoning_section_break` | – | – | the provider type, verbatim | +| `turn_end` | – | – | – | ✓ run `terminal` (`completed`, `interrupted`, …) in `text` | `terminal` | +| `session_start` | – | – | – | ✓ `session.opened.observed`, `resume` in the payload | `session.opened.observed` | +| `session_resumed` | – | – | – | ✓ | `session.resumed` | +| `session_end` | – | – | – | ✓ `exit_reason` in `text` | `session.end` | +| `model_switch` | – | – | – | ✓ a later metadata record, the new model in `text` | `runtime.session.metadata` | `text` is the provider's own readable prose for a marker, and `payload_json` its structure; a marker may carry either, both or neither. Grok records a @@ -592,6 +598,7 @@ status, because a fabricated measurement reads exactly like a real one: |---|---|---|---|---|---|---|---| | **claude** | ✓ (raw `content`) | ✓ (harness markers) | ✓ | ✓ | `tool_result`, `subagent_notification` | `tool_result.is_error`, `subagent_status` | ✓ (system subagent notifications) | | **codex** | ✓ (raw `output`) | ✓ (harness markers) | ✓ | ✓ (settled at `task_complete`) | `function_call_output` | `exit_code`, `patch_apply`, `mcp_err` | – (no notification rail) | +| **muse** | ✓ (result `text`) | ✓ (harness markers) | ✓ | ✓ (`tool_batch.effect.terminal`, joined by call id) | `tool_result` | `tool_batch.effect`, `exit_code` (`bash`) | – | | **cursor**, **grok**, **opencode**, **relay** | – | – | – | – | – | – | – | `payload_bytes` is the raw UTF-8 length of what the provider handed back — @@ -663,6 +670,7 @@ follows. | **codex** | ✓ | ✓ | ✓ | ✓ | ✓ when the bounded child search is complete | `full` or `partial` | | **cursor** | ✓ | ✓ | ✓ | ✓ | – (never: a `Task` block names no child transcript) | `partial` | | **grok** | ✓ | ✓ | ✓ | ✓ | ✓ | `full` | +| **muse** | ✓ | ✓ | ✓ | ✓ | – (not linked yet: `subagent/` children are neither catalogued nor linked) | `partial` | | **opencode** | ✓ | ✓ | ✓ | ✓ | ✓ | `full` | | **relay** | – | – | – | – | – | targeted hydration unsupported | @@ -679,6 +687,7 @@ in flight. | **claude** | ✓ (`requestId`) | ✓ (`message.stop_reason`) | ✓ (`version` / `sourceVersion`) | ✓ (`isSidechain`) | ✓ (`isMeta`) | – | | **codex** | – | – | – | – | – | ✓ (`turn_context.turn_id`, carried to the next `turn_context`) | | **opencode** | – | ✓ (`step-finish.reason`, pending event-level parity) | – | – | – | – | +| **muse** | ✓ (`response_id`) | ✓ (the step's `finish_reason`) | ✓ (`build.semver`) | – | – | ✓ (the run's `run_id`) | | **cursor**, **grok**, **relay** | – | – | – | – | – | – | A null is "the provider did not record it", which is not the same as `false` @@ -731,7 +740,7 @@ Delegation is a separate capability, reported on every relationship result as | **opencode** | always | – | ✓ | ✓ | | **claude** | sometimes | ✓ | ✓ | ✓ | | **grok** | sometimes | ✓ | ✓ | ✓ | -| **cursor**, **relay** | never | – | – | – | +| **cursor**, **muse**, **relay** | never | – | – | – | OpenCode is `always` because a subagent session is a session in its own right and its own record names the parent, in `session.parentID`. Nothing is @@ -1440,6 +1449,55 @@ How each adapter works: `totalTokens` context proxy that decreases on compaction, the models `grok-composer-2.5-fast` and `grok-build`, and the tool-name list. + + +- **muse** — Muse Code (Meta's `muse` CLI) keeps one append-only log per + session at `$XDG_DATA_HOME/muse/sessions/YYYY/MM/DD//session.jsonl` + (`~/.local/share/muse/sessions/…` when `XDG_DATA_HOME` is unset, on every + platform). Record interpretation lives in `crates/ai-hist/src/ingest/muse.rs` + so discovery, sync and hydration read it the same way. + + Every line is an envelope `{id, stream: {kind, id}, sequence, recorded_at, + payload_type, payload}`, and `recorded_at` is **microseconds** on every + record, so no time here is inferred. Identity is the `stream.id` of the + `runtime.session.metadata` record — searched for rather than assumed to be + line one, because a permission frame can precede it — never the directory + name. That record also gives `workspace_root` (the `cwd`), `model_id` and + `build.semver`. + + The conversation is `payload_type: "runtime.session"` with + `payload.kind: "run"`, keyed by `payload.event.kind`: `started` (the typed + prompt, one `history` row), `reasoning_committed`, + `assistant_message_committed`, `assistant_tool_calls_committed` (`args` is a + JSON *string*), `tool_result_batch_committed`, `model_completed` (per model + step: `model`, `usage`, `finish_reason`) and `terminal`. `payload.kind: + "task"` is execution bookkeeping and is not read. A call's outcome is the + separate `tool_batch.effect.terminal` record, joined by call id; the result + text carries no error flag of its own. Only records on the session's own + stream are read: subagent and reminder task streams are mirrored into the + parent file under a different `stream.id`, and reading them would present + every child objective as a prompt somebody typed. + + Usage is recorded as Muse wrote it: each `model_completed.usage` object + (`input_tokens` inclusive of the cached prefix, `cache_read_tokens` / + `cached_tokens`, `cache_write_tokens`, `output_tokens`, `reasoning_tokens`) + goes verbatim into `token_json` on the first assistant row that step + committed, and normalizes as `per-request`. A step that committed nothing + keeps its usage off every row and is reported as `MUSE_USAGE_UNATTACHED`. + Reasoning Muse kept only encrypted is an `encrypted_reasoning` marker + (`MUSE_REASONING_ENCRYPTED`). + + A sync or hydration reads the whole file and **replaces** the session's + evidence, so a re-read after the log grew adds exactly the new turns and + duplicates nothing. `subagent//session.jsonl` transcripts beside a + session are not sessions of their own and are never enumerated; linking them + as delegated children is not done yet (`MUSE_SUBAGENT_SPAWN_UNLINKED` counts + the `subagent_spawn` calls). The shapes were characterized from a transcript + the real CLI wrote (`tests/fixtures/muse/cli-capture`, from + [xhluca/session-migrate](https://github.com/xhluca/session-migrate)) and + cross-checked against the published [Muse Code + SDK](https://github.com/meta-models/muse-code-sdk) and independent readers. + - **opencode** — **two storage layouts**, because both are in the field. RelayHistory prefers `opencode.db` when the host has it and falls back to the legacy JSON tree when it does not; it never reads both, because a host that diff --git a/docs/sourcing-sdk.md b/docs/sourcing-sdk.md index 488adc31..f5aac37d 100644 --- a/docs/sourcing-sdk.md +++ b/docs/sourcing-sdk.md @@ -550,6 +550,7 @@ handle. | codex | ✓ | ✓ | ✓ | ✓ | ✓ | cumulative-delta | | cursor | ✓ | ✓ | ✓ | ✓ | — | none | | grok | ✓ | ✓ | ✓ | ✓ | ✓ | none | +| muse | ✓ | ✓ | ✓ | ✓ | — | per-request | | opencode | ✓ | ✓ | ✓ | ✓ | ✓ | none | | relay | — | — | — | — | — | none | | trajectory | — | — | — | — | — | none | diff --git a/sdk-ts/src/helpers.test.ts b/sdk-ts/src/helpers.test.ts index 0c88e269..f53fd6cc 100644 --- a/sdk-ts/src/helpers.test.ts +++ b/sdk-ts/src/helpers.test.ts @@ -12,7 +12,7 @@ import { } from './index.js'; test('exports immutable, aligned runtime source registries and matching type guards', () => { - assert.deepEqual(SOURCES, ['claude', 'codex', 'cursor', 'grok', 'relay', 'trajectory', 'opencode']); + assert.deepEqual(SOURCES, ['claude', 'codex', 'cursor', 'grok', 'relay', 'trajectory', 'opencode', 'muse']); assert.deepEqual(CATALOG_SOURCES, SOURCES.filter((source) => source !== 'trajectory')); assert.equal(Object.isFrozen(SOURCES), true); assert.equal(Object.isFrozen(CATALOG_SOURCES), true); @@ -49,4 +49,5 @@ test('resumeCommand preserves source and project-aware commands', () => { assert.equal(resumeCommand({ source: 'cursor', sessionId: 's3', project: '/work/app', locations: ['local', 'remote'] }), 'cd /work/app && cursor-agent --resume=s3'); assert.equal(resumeCommand({ source: 'grok', sessionId: 's4', project: null, locations: ['remote'] }), null); assert.equal(resumeCommand({ source: 'relay', sessionId: 's5', project: null, locations: ['local'] }), null); + assert.equal(resumeCommand({ source: 'muse', sessionId: 's6', project: '/work/app', locations: ['local'] }), 'cd /work/app && muse resume s6'); }); diff --git a/sdk-ts/src/mcp-server.ts b/sdk-ts/src/mcp-server.ts index 0390e7c9..cda5da95 100644 --- a/sdk-ts/src/mcp-server.ts +++ b/sdk-ts/src/mcp-server.ts @@ -20,8 +20,8 @@ const READ = { readOnlyHint: true, idempotentHint: true, openWorldHint: false } // Acquisition can reach provider services when a remote scope is requested // (claude.ai/code web sessions, Codex cloud tasks), so it is open-world. const ACQUIRE = { readOnlyHint: false, idempotentHint: true, openWorldHint: true } as const; -const SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'trajectory', 'opencode']); -const CATALOG_SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'opencode']); +const SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'trajectory', 'opencode', 'muse']); +const CATALOG_SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'opencode', 'muse']); const SESSION_SCOPE = z.enum(['local', 'remote', 'all']); const SOURCE_CONNECTORS = z.array(z.string().min(1)).optional().describe('Explicit configured source-plugin IDs; [] disables remote acquisition.'); const packageVersion = JSON.parse( diff --git a/sdk-ts/src/operations.ts b/sdk-ts/src/operations.ts index 63725fab..279d0a35 100644 --- a/sdk-ts/src/operations.ts +++ b/sdk-ts/src/operations.ts @@ -884,6 +884,7 @@ export function formatSessionRow( relay: '↔', opencode: '⌘', trajectory: '↗', + muse: '✧', }; const ageMs = session.lastActivityMs === null @@ -1073,6 +1074,7 @@ export function resumeCommand( if (entry.source === 'codex') return `codex resume ${shellQuote(entry.sessionId)}`; if (entry.source === 'cursor') return `cursor-agent --resume=${shellQuote(entry.sessionId)}`; if (entry.source === 'grok') return `grok resume ${shellQuote(entry.sessionId)}`; + if (entry.source === 'muse') return `muse resume ${shellQuote(entry.sessionId)}`; return null; })(); return resume && entry.project ? `cd ${shellQuote(entry.project)} && ${resume}` : resume; diff --git a/sdk-ts/src/sdk-common.ts b/sdk-ts/src/sdk-common.ts index 525368c5..fbac7ed3 100644 --- a/sdk-ts/src/sdk-common.ts +++ b/sdk-ts/src/sdk-common.ts @@ -21,6 +21,7 @@ export const SOURCES = Object.freeze([ 'relay', 'trajectory', 'opencode', + 'muse', ] as const); export type Source = (typeof SOURCES)[number]; From 6149f372d0d1d9ccfbfed7ec18423036c597d96b Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 20:30:28 -0700 Subject: [PATCH 02/19] Index Muse subagent logs as delegated children --- CHANGELOG.md | 9 +- crates/ai-hist/src/discover.rs | 13 +- crates/ai-hist/src/ingest.rs | 362 +++++++++++++++++- crates/ai-hist/src/ingest/hydrate.rs | 37 +- crates/ai-hist/src/ingest/muse.rs | 80 ++++ crates/ai-hist/src/relationship_graph.rs | 5 + crates/ai-hist/tests/fixture_corpus.rs | 61 ++- crates/ai-hist/tests/fixtures/README.md | 2 +- .../session.jsonl | 58 +-- .../session.jsonl | 8 +- .../session.jsonl | 3 + .../session.jsonl | 3 + .../tests/snapshots/muse/tools-session.json | 269 +++++++++++-- docs/architecture.md | 3 +- ...9-19-relayhistory-owns-session-sourcing.md | 9 +- docs/session-catalog.md | 39 +- docs/sourcing-sdk.md | 2 +- 17 files changed, 843 insertions(+), 120 deletions(-) create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1ec726..854aa2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,12 @@ Notable changes to the native `ai-hist` CLI are documented here. `write_file` / `edit_file` edits, per-model-step token usage (normalized as `per-request`), models, CLI version and lifecycle markers (`turn_end`, `session_start`, `session_resumed`, `session_end`, `model_switch`, - `encrypted_reasoning`). `subagent/` child transcripts are not catalogued as - sessions, and delegation to them is not linked yet, so a Muse hydration - reports `partial`. `ai-hist resume` prints `muse resume `, and the sync + `encrypted_reasoning`). Subagent and reminder logs + (`subagent//session.jsonl`, at any depth) are indexed under the child's + own session id and linked to their parent as `delegated` children, typed + from the parent's `task_stream_linked` record; they are not catalogued as + sessions and their objectives are not history rows. A Muse hydration + reports `full`. `ai-hist resume` prints `muse resume `, and the sync service forwards `XDG_DATA_HOME`. - Markers reach the SDK, MCP and both CLIs. `getSessionMarkersPage(source, sessionId, {limit, after})`, the `sessionMarkers()` iterator and diff --git a/crates/ai-hist/src/discover.rs b/crates/ai-hist/src/discover.rs index 0e40ec0e..59234abf 100644 --- a/crates/ai-hist/src/discover.rs +++ b/crates/ai-hist/src/discover.rs @@ -2071,16 +2071,11 @@ impl ShallowSessionProvider for MuseProvider { fn source(&self) -> &'static str { "muse" } - /// The transcript parser writes prompts, events, tool calls and file - /// edits. Delegation is not captured yet, so relationships are not - /// declared and a hydration reports partial rather than claiming them. + /// The transcript parser writes prompts, events, tool calls, file edits + /// and `delegated` edges to the subagent logs beside the session: every + /// kind a full session is made of. fn evidence_kinds(&self) -> &'static [EvidenceKind] { - &[ - EvidenceKind::History, - EvidenceKind::SessionEvent, - EvidenceKind::ToolCall, - EvidenceKind::FileEdit, - ] + FULL_SESSION_KINDS } fn watch_roots(&self, roots: &ProviderRoots<'_>) -> Vec { diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 02a8f5b8..58fb7377 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10439,7 +10439,7 @@ fn sync_muse_with_coverage( for transcript in capture_files("muse", collect_muse_transcripts(root)?) { check_capture_cancelled()?; let key = transcript.to_string_lossy().to_string(); - let stamp = match file_stamp(&transcript) { + let stamp = match muse_session_stamp(&transcript) { Ok(stamp) => stamp, Err(error) => { scanned += 1; @@ -10479,7 +10479,7 @@ fn sync_muse_with_coverage( // A re-read replaces the session's prompts wholesale, so what // it added is the difference, not what it wrote. let before = muse_history_rows(&tx, &session_id)?; - let outcome = ingest_muse_session(&tx, &parsed, &key)?; + let outcome = ingest_muse_session_tree(&tx, &parsed, &transcript)?; inserted += muse_history_rows(&tx, &session_id)?.saturating_sub(before); tx.commit()?; sessions += 1; @@ -10489,7 +10489,9 @@ fn sync_muse_with_coverage( json!({ "stamp": stamp, "session": session_id, - "evidence": outcome.events > 0 || outcome.markers > 0, + "evidence": outcome.events > 0 + || outcome.markers > 0 + || outcome.relationships > 0, }), ); } @@ -10541,13 +10543,19 @@ fn muse_evidence_exists(conn: &Connection, session_id: &str) -> Result { if session_events_exist(conn, "muse", session_id)? { return Ok(true); } - let exists: i64 = conn.query_row( + for statement in [ "SELECT EXISTS(SELECT 1 FROM session_markers \ WHERE source = 'muse' AND session_id = ? LIMIT 1)", - params![session_id], - |row| row.get(0), - )?; - Ok(exists != 0) + "SELECT EXISTS(SELECT 1 FROM session_relationships \ + WHERE source = 'muse' AND parent_session_id = ? \ + AND evidence_kind = 'muse_subagent_log' LIMIT 1)", + ] { + let exists: i64 = conn.query_row(statement, params![session_id], |row| row.get(0))?; + if exists != 0 { + return Ok(true); + } + } + Ok(false) } fn muse_catalog_row_exists(conn: &Connection, session_id: &str) -> Result { @@ -10560,6 +10568,206 @@ fn muse_catalog_row_exists(conn: &Connection, session_id: &str) -> Result Ok(exists != 0) } +/// The transcript and every subagent log beneath its session directory, +/// parent first, then children depth-first in sorted order. +pub(crate) fn muse_session_files(transcript: &Path) -> Result> { + let mut files = vec![transcript.to_path_buf()]; + if let Some(dir) = transcript.parent() { + collect_muse_child_logs(dir, &mut files)?; + } + Ok(files) +} + +/// The `subagent//session.jsonl` logs under one session directory, and +/// theirs in turn. +fn collect_muse_child_logs(session_dir: &Path, out: &mut Vec) -> Result<()> { + for log in direct_muse_child_logs(session_dir)? { + check_capture_cancelled()?; + out.push(log.clone()); + if let Some(child_dir) = log.parent() { + collect_muse_child_logs(child_dir, out)?; + } + } + Ok(()) +} + +/// The immediate `subagent//session.jsonl` logs of one session +/// directory, sorted. +fn direct_muse_child_logs(session_dir: &Path) -> Result> { + let children = session_dir.join(muse::SUBAGENT_DIR); + let entries = match fs::read_dir(&children) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error) + .with_context(|| format!("list Muse subagents {}", children.display())) + } + }; + let mut logs = Vec::new(); + for entry in entries { + let entry = entry.with_context(|| format!("list Muse subagents {}", children.display()))?; + let log = entry.path().join(muse::SESSION_FILE); + if log.is_file() { + logs.push(log); + } + } + logs.sort(); + Ok(logs) +} + +/// The change stamp of a Muse session: its transcript plus every child log +/// beneath it, because a background subagent keeps writing its own log after +/// the parent's last record, and a stamp over the parent alone would never +/// see that. +pub(crate) fn muse_session_stamp(transcript: &Path) -> Result { + let files = muse_session_files(transcript)?; + let base = transcript.parent().unwrap_or(transcript); + let mut parts = Vec::with_capacity(files.len()); + for (index, file) in files.iter().enumerate() { + let stamp = file_stamp(file)?; + if index == 0 { + parts.push(stamp); + } else { + let relative = file.strip_prefix(base).unwrap_or(file); + parts.push(format!("{}={stamp}", relative.to_string_lossy())); + } + } + Ok(parts.join("|")) +} + +/// Index a Muse session and every subagent it left a log for. +/// +/// Each child is indexed under the session id its **own** metadata record +/// names — never the directory name — and linked to its parent as +/// `delegated`, with the role, label, model and task the parent recorded for +/// it. A child is not a session anybody started: its objective is not a +/// prompt, and it is not a catalog row of its own. So, as for a Codex +/// subagent rollout, its `history` rows and catalog registration are removed +/// and its events stay addressable through the relationship. +pub(crate) fn ingest_muse_session_tree( + conn: &Connection, + transcript: &muse::MuseTranscript, + path: &Path, +) -> Result { + let mut outcome = ingest_muse_session(conn, transcript, &path.to_string_lossy())?; + if let Some(dir) = path.parent() { + let mut visited = HashSet::from([transcript.metadata.session_id.clone()]); + link_muse_subagents(conn, transcript, dir, 1, &mut visited, &mut outcome)?; + } + Ok(outcome) +} + +fn link_muse_subagents( + conn: &Connection, + parent: &muse::MuseTranscript, + parent_dir: &Path, + depth: i64, + visited: &mut HashSet, + outcome: &mut MuseIngestOutcome, +) -> Result<()> { + let parent_id = parent.metadata.session_id.as_str(); + // Replaced, not merged: a child whose log is gone is no longer evidenced. + let previous = muse_linked_children(conn, parent_id)?; + let mut linked = HashSet::new(); + conn.execute( + "DELETE FROM session_relationships WHERE source = 'muse' \ + AND parent_session_id = ? AND evidence_kind = 'muse_subagent_log'", + params![parent_id], + )?; + let links = muse::subagent_links(parent); + for log in direct_muse_child_logs(parent_dir)? { + check_capture_cancelled()?; + let Some(child) = read_muse_transcript(&log)? else { + outcome.unidentified_children += 1; + continue; + }; + let child_id = child.metadata.session_id.clone(); + // A log that names its own parent, or an ancestor, would loop. + if !visited.insert(child_id.clone()) { + continue; + } + linked.insert(child_id.clone()); + let locator = log.to_string_lossy().to_string(); + ingest_muse_session(conn, &child, &locator)?; + conn.execute( + "DELETE FROM history WHERE source = 'muse' AND session_id = ?", + params![child_id], + )?; + cleanup_subagent_registration(conn, "muse", &child_id)?; + if let Some(child_dir) = log.parent() { + link_muse_subagents(conn, &child, child_dir, depth + 1, visited, outcome)?; + } + let relative = log + .strip_prefix(parent_dir) + .map(|relative| muse::normalize_link_path(&relative.to_string_lossy())) + .unwrap_or_default(); + let (linked_at, link) = links.get(&relative).cloned().unwrap_or_default(); + if link.role.as_deref() != Some("reminder") { + outcome.worker_children += 1; + } + let child_model = link + .model + .clone() + .or_else(|| child.metadata.model_id.clone()); + record_relationship( + conn, + &ObservedRelationship { + source: "muse", + parent_session_id: parent_id, + child_session_id: Some(&child_id), + child_agent_type: link.role.as_deref(), + child_agent_name: link.label.as_deref(), + child_model: child_model.as_deref(), + spawn_depth: Some(depth), + evidence_kind: "muse_subagent_log", + evidence_locator: Some(&locator), + evidence_ref: link.task_id.as_deref(), + child_has_events: session_events_exist(conn, "muse", &child_id)?, + spawned_at_ms: (linked_at > 0) + .then_some(linked_at) + .or_else(|| child.first_ts()), + ..ObservedRelationship::default() + }, + )?; + outcome.relationships += 1; + } + for gone in previous.difference(&linked) { + forget_muse_subagent(conn, gone)?; + } + Ok(()) +} + +/// The children a parent's subagent logs linked on the last read. +fn muse_linked_children(conn: &Connection, parent_id: &str) -> Result> { + Ok(conn + .prepare( + "SELECT child_session_id FROM session_relationships \ + WHERE source = 'muse' AND parent_session_id = ? \ + AND evidence_kind = 'muse_subagent_log' AND child_session_id IS NOT NULL", + )? + .query_map(params![parent_id], |row| row.get::<_, String>(0))? + .collect::>()?) +} + +/// Drop the evidence of a child whose log is gone, and of its own children. +/// It was only ever addressable through the edge that no longer exists. +fn forget_muse_subagent(conn: &Connection, child_id: &str) -> Result<()> { + for grandchild in muse_linked_children(conn, child_id)? { + forget_muse_subagent(conn, &grandchild)?; + } + for statement in [ + "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", + "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", + "DELETE FROM file_edits WHERE source = 'muse' AND session_id = ?", + "DELETE FROM session_markers WHERE source = 'muse' AND session_id = ?", + "DELETE FROM session_relationships WHERE source = 'muse' \ + AND parent_session_id = ? AND evidence_kind = 'muse_subagent_log'", + ] { + conn.execute(statement, params![child_id])?; + } + Ok(()) +} + /// Read and interpret one transcript. `Ok(None)` when it names no session. pub(crate) fn read_muse_transcript(path: &Path) -> Result> { let bytes = @@ -10581,8 +10789,15 @@ pub(crate) struct MuseIngestOutcome { /// `model_completed` steps whose usage had no assistant record after them /// in the same run to carry it. pub unattached_usage: usize, - /// `subagent_spawn` calls. Muse's delegation is not linked yet. + /// `subagent_spawn` calls in this session's own transcript. pub subagent_calls: usize, + /// Child transcripts linked as `delegated` children, at any depth. + pub relationships: usize, + /// Of those, the ones the parent typed as something other than a + /// reminder: the children a `subagent_spawn` call accounts for. + pub worker_children: usize, + /// `subagent/` logs that named no session, so there was no child to link. + pub unidentified_children: usize, /// Tool results with no recorded outcome for their call. pub results_without_outcome: usize, /// Lines that did not parse, usually a live session's partial tail. @@ -11028,6 +11243,8 @@ pub(crate) fn ingest_muse_session( } // Folded into the result rows above, by call id. muse::MuseEvent::ToolOutcome { .. } => {} + // Read by `link_muse_subagents`, which owns the edges. + muse::MuseEvent::SubagentLinked(_) => {} } } if pending_usage.is_some() { @@ -14365,15 +14582,28 @@ mod tests { .join("tests/fixtures/muse/tools-session/.local/share/muse/sessions"); let root = home.join(".local/share/muse/sessions"); let day = "2026/09/20/0199a1b2-0000-7000-8000-00000000a001"; - let child = "subagent/0199a1b2-0000-7000-8000-00000000c001"; - fs::create_dir_all(root.join(day).join(child)).unwrap(); - for file in ["session.jsonl", &format!("{child}/session.jsonl")] { - fs::copy(fixture.join(day).join(file), root.join(day).join(file)).unwrap(); + for file in super::muse_session_files(&fixture.join(day).join("session.jsonl")).unwrap() { + let target = root.join(file.strip_prefix(&fixture).unwrap()); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::copy(&file, &target).unwrap(); } let transcript = root.join(day).join("session.jsonl"); (root, transcript) } + /// Rows one session owns in `table`. + fn muse_session_count(conn: &Connection, table: &str, session_id: &str) -> i64 { + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE source = 'muse' AND session_id = ?"), + [session_id], + |row| row.get(0), + ) + .unwrap() + } + + const MUSE_PARENT: &str = "0199a1b2-0000-7000-8000-00000000a001"; + const MUSE_WORKER: &str = "0199a1b2-0000-7000-8000-00000000c001"; + fn muse_count(conn: &Connection, table: &str) -> i64 { conn.query_row( &format!("SELECT COUNT(*) FROM {table} WHERE source = 'muse'"), @@ -14394,13 +14624,10 @@ mod tests { let mut state = Map::new(); assert_eq!(super::sync_muse(&conn, &mut state, &root).unwrap(), 2); - assert_eq!( - muse_count(&conn, "sessions"), - 1, - "the child is not a session" - ); + assert_eq!(muse_count(&conn, "sessions"), 1, "a child is not a session"); + assert_eq!(muse_count(&conn, "session_relationships"), 3); let events = muse_count(&conn, "session_events"); - assert_eq!(events, 13); + assert_eq!(muse_session_count(&conn, "session_events", MUSE_PARENT), 15); // Unchanged: nothing is read again and nothing is duplicated. assert_eq!(super::sync_muse(&conn, &mut state, &root).unwrap(), 0); @@ -14446,7 +14673,102 @@ mod tests { let rebuilt = open_db(&home.path().join("rebuilt.db")).unwrap(); assert_eq!(super::sync_muse(&rebuilt, &mut state, &root).unwrap(), 2); - assert_eq!(muse_count(&rebuilt, "session_events"), 13); + assert_eq!( + muse_session_count(&rebuilt, "session_events", MUSE_PARENT), + 15 + ); + assert_eq!(muse_count(&rebuilt, "session_relationships"), 3); + } + + /// A background subagent keeps writing its own log after the parent's + /// last record. The parent's stamp covers the child logs, so that growth + /// is read even though the parent file never changes again. + #[test] + fn muse_sync_rereads_a_subagent_log_that_grew_on_its_own() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let before = muse_session_count(&conn, "session_events", MUSE_WORKER); + + let child_log = transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}/session.jsonl")); + let record = json!({ + "id": "worker-late", + "stream": {"kind": "session", "id": MUSE_WORKER}, + "sequence": 99, + "recorded_at": 1_790_337_700_000_000i64, + "payload_type": "runtime.session", + "payload": {"kind": "run", "run_id": "child-run", "event": { + "kind": "assistant_message_committed", "message_id": "late", "text": "Also: no docs." + }}, + }); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&child_log) + .unwrap(); + writeln!(file, "{record}").unwrap(); + drop(file); + + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_WORKER), + before + 1 + ); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + assert_eq!( + muse_session_count(&conn, "history", MUSE_WORKER), + 0, + "a child's objective never becomes a typed prompt" + ); + } + + /// A child whose log is gone is no longer evidenced: the edge and the + /// child's events go with it. + #[test] + fn muse_resync_drops_the_edge_to_a_removed_subagent_log() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + fs::remove_dir_all( + transcript + .parent() + .unwrap() + .join("subagent/0199a1b2-0000-7000-8000-00000000e001"), + ) + .unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let children: Vec = conn + .prepare( + "SELECT child_session_id FROM session_relationships \ + WHERE source = 'muse' ORDER BY child_session_id", + ) + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + children, + vec![ + MUSE_WORKER.to_string(), + "0199a1b2-0000-7000-8000-00000000c002".to_string() + ] + ); + assert_eq!( + muse_session_count( + &conn, + "session_events", + "0199a1b2-0000-7000-8000-00000000e001" + ), + 0, + "the removed child's events go with its edge" + ); } /// A stamp that fails closed has to be *reported*. Round four stopped one diff --git a/crates/ai-hist/src/ingest/hydrate.rs b/crates/ai-hist/src/ingest/hydrate.rs index 4ac46f17..d5a9d6e1 100644 --- a/crates/ai-hist/src/ingest/hydrate.rs +++ b/crates/ai-hist/src/ingest/hydrate.rs @@ -1573,6 +1573,21 @@ fn source_snapshot( SnapshotRecords::DeferredGrok(path.clone()), inventory.stamp, ) + } else if options.source == "muse" { + // A Muse session is its transcript plus the subagent logs beside it; + // the stamp and the counts cover all of them, so a child that grew + // after the parent's last record is still a change. + let mut bytes = 0i64; + let mut records = 0i64; + for file in muse_session_files(&path)? { + bytes += file.metadata()?.len() as i64; + records += complete_jsonl_records(&file)?; + } + ( + bytes, + SnapshotRecords::Counted(records), + muse_session_stamp(&path)?, + ) } else if let Some(snapshot) = captured_claude.as_ref() { // The hook already read these bytes; nothing here opens the file. ( @@ -2960,7 +2975,7 @@ fn ingest_muse( "Muse Code transcript identity does not match the catalog row", )); } - let outcome = ingest_muse_session(conn, &transcript, &path.to_string_lossy())?; + let outcome = ingest_muse_session_tree(conn, &transcript, path)?; Ok(muse_diagnostics(&outcome)) } @@ -2995,13 +3010,23 @@ fn muse_diagnostics(outcome: &MuseIngestOutcome) -> Vec { ), )); } - if outcome.subagent_calls > 0 { + if outcome.subagent_calls > outcome.worker_children { diagnostics.push(diagnostic( - "MUSE_SUBAGENT_SPAWN_UNLINKED", + "MUSE_SUBAGENT_LOG_MISSING", format!( - "{} subagent_spawn call(s) are recorded as tool calls; Muse subagent \ - transcripts are not linked as child sessions yet", - outcome.subagent_calls + "{} subagent_spawn call(s) but {} subagent log(s) beside the session; a \ + spawn with no log is visible as a tool call and has no child to link", + outcome.subagent_calls, outcome.worker_children + ), + )); + } + if outcome.unidentified_children > 0 { + diagnostics.push(diagnostic( + "MUSE_SUBAGENT_LOG_UNIDENTIFIED", + format!( + "{} subagent log(s) carried no session metadata, so there is no child \ + session id to link them under", + outcome.unidentified_children ), )); } diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs index 815e38c6..5102c291 100644 --- a/crates/ai-hist/src/ingest/muse.rs +++ b/crates/ai-hist/src/ingest/muse.rs @@ -141,6 +141,28 @@ pub(crate) enum MuseEvent { }, /// A metadata record after the first: a model or provider switch. Metadata(MuseMetadata), + /// The parent linked a child agent's log: a subagent's task stream + /// (`task_stream_linked`) or a reminder child + /// (`memory_reminder_child_session_linked`). Describes the child; its + /// identity is always read from the child's own transcript. + SubagentLinked(MuseSubagentLink), +} + +/// What a parent records about one child agent it started. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MuseSubagentLink { + /// The child's log, relative to the parent's session directory: + /// `subagent//session.jsonl`. + pub path: Option, + /// `worker`, `reminder`, …: what kind of child this is. + pub role: Option, + /// The display label the parent gave it. + pub label: Option, + /// The child's model, when the parent named a concrete one. + pub model: Option, + pub task_id: Option, + /// The child's session id, where the parent states it outright. + pub child_session_id: Option, } /// One interpreted record, with the envelope facts every event carries. @@ -347,6 +369,28 @@ fn parse_run_event(event: &Value) -> Option { finish_reason: text_field(event, "finish_reason"), duration_ms: event.get("duration_ms").and_then(Value::as_i64), }), + "task_stream_linked" => { + let display = event.get("display").unwrap_or(&Value::Null); + Some(MuseEvent::SubagentLinked(MuseSubagentLink { + path: text_field(display, "path"), + role: text_field(display, "role"), + label: text_field(display, "label"), + // `same-as-main` is a policy, not a model name. + model: text_field(display, "model").filter(|model| model != "same-as-main"), + task_id: text_field(event, "task_id"), + child_session_id: None, + })) + } + "memory_reminder_child_session_linked" => { + Some(MuseEvent::SubagentLinked(MuseSubagentLink { + path: text_field(event, "child_session_log_path"), + role: Some("reminder".to_string()), + label: text_field(event, "reminder_agent_id"), + model: None, + task_id: text_field(event, "task_id"), + child_session_id: text_field(event, "child_session_id"), + })) + } "terminal" => Some(MuseEvent::RunTerminal { status: text_field(event, "terminal"), reason: text_field(event, "reason"), @@ -453,6 +497,42 @@ pub(crate) fn is_child_transcript(path: &Path, root: &Path) -> bool { .any(|component| component.as_os_str() == SUBAGENT_DIR) } +/// Everything the parent recorded about its children, keyed by the child +/// log's path relative to the parent's session directory. A child linked by +/// both a task stream and a reminder event keeps every field either named. +pub(crate) fn subagent_links( + transcript: &MuseTranscript, +) -> HashMap { + let mut links: HashMap = HashMap::new(); + for record in &transcript.records { + let MuseEvent::SubagentLinked(link) = &record.event else { + continue; + }; + let Some(path) = link.path.as_deref() else { + continue; + }; + let entry = links + .entry(normalize_link_path(path)) + .or_insert_with(|| (record.ts_ms, MuseSubagentLink::default())); + let merged = &mut entry.1; + merged.path = merged.path.take().or_else(|| link.path.clone()); + merged.role = merged.role.take().or_else(|| link.role.clone()); + merged.label = merged.label.take().or_else(|| link.label.clone()); + merged.model = merged.model.take().or_else(|| link.model.clone()); + merged.task_id = merged.task_id.take().or_else(|| link.task_id.clone()); + merged.child_session_id = merged + .child_session_id + .take() + .or_else(|| link.child_session_id.clone()); + } + links +} + +/// A child log path as a lookup key: `/`-separated, no leading `./`. +pub(crate) fn normalize_link_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_string() +} + /// Tools that write a file. pub(crate) fn is_file_edit_tool(name: &str) -> bool { matches!(name, "write_file" | "edit_file") diff --git a/crates/ai-hist/src/relationship_graph.rs b/crates/ai-hist/src/relationship_graph.rs index 834c906c..9560278b 100644 --- a/crates/ai-hist/src/relationship_graph.rs +++ b/crates/ai-hist/src/relationship_graph.rs @@ -295,6 +295,11 @@ pub fn relationship_capabilities(source: &str) -> RelationshipCapabilities { // the normal sessions tree. The id is never taken from the file name, // so an entry without one stays unlinked evidence. "grok" => ("sometimes", true, true, true), + // A Muse subagent writes its own `subagent//session.jsonl`, whose + // metadata record names the child session, so a linked child's + // identity is always its own. The parent's `task_stream_linked` + // names its role, label and model. + "muse" => ("always", true, true, true), _ => ("never", false, false, false), }; RelationshipCapabilities { diff --git a/crates/ai-hist/tests/fixture_corpus.rs b/crates/ai-hist/tests/fixture_corpus.rs index d8f5a42e..5f5abcf9 100644 --- a/crates/ai-hist/tests/fixture_corpus.rs +++ b/crates/ai-hist/tests/fixture_corpus.rs @@ -655,7 +655,7 @@ const CORPUS: &[Fixture] = &[ layout: Layout::HomeTree, origin: Origin::RelayHistory, files: &["muse/tools-session"], - quirk: "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session", + quirk: "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and `subagent/` logs — a worker with its own nested child, and a reminder — linked as delegated children rather than catalogued as sessions", }, // -- opencode ---------------------------------------------------------- Fixture { @@ -2354,9 +2354,15 @@ fn muse_cli_capture_reaches_history_tools_and_recorded_times() { /// The authored Muse session: edits reach `file_edits`, a completed `bash` /// call whose command exited non-zero is an error, usage lands once per model -/// step, and the `subagent/` child transcript is not a session of its own. +/// step, and every `subagent/` log — a worker, the worker's own child and a +/// reminder — is linked as a `delegated` child under the id its own metadata +/// names, without becoming a catalog row or a typed prompt. #[test] -fn muse_tools_session_records_edits_errors_usage_and_no_child_session() { +fn muse_tools_session_records_edits_errors_usage_and_linked_subagents() { + const PARENT: &str = "0199a1b2-0000-7000-8000-00000000a001"; + const WORKER: &str = "0199a1b2-0000-7000-8000-00000000c001"; + const NESTED: &str = "0199a1b2-0000-7000-8000-00000000c002"; + const REMINDER: &str = "0199a1b2-0000-7000-8000-00000000e001"; let edits = rows("muse/tools-session", "file_edits"); let mut paths: Vec<&str> = edits.iter().map(|edit| text(edit, "file_path")).collect(); paths.sort_unstable(); @@ -2368,18 +2374,61 @@ fn muse_tools_session_records_edits_errors_usage_and_no_child_session() { .expect("bash call"); assert_eq!(field(bash, "is_error").as_i64(), Some(1), "{bash:?}"); let events = rows("muse/tools-session", "session_events"); - let usage_rows = events + let parent_usage_rows = events .iter() + .filter(|event| text(event, "session_id") == PARENT) .filter(|event| !field(event, "token_json").is_null()) .count(); assert_eq!( - usage_rows, 4, + parent_usage_rows, 4, "one row per model_completed step: {events:?}" ); + let sessions = rows("muse/tools-session", "sessions"); - assert_eq!(sessions.len(), 1, "{sessions:?}"); + assert_eq!( + sessions.len(), + 1, + "children are not catalog rows: {sessions:?}" + ); assert_eq!( text(&sessions[0], "models_json"), r#"["meta/muse-spark-1.3","meta/muse-spark-1.3-contributor"]"# ); + let history = rows("muse/tools-session", "history"); + assert!( + history.iter().all(|row| text(row, "session_id") == PARENT), + "a child's objective is not a typed prompt: {history:?}" + ); + + let edges = rows("muse/tools-session", "session_relationships"); + let edge = |child: &str| { + edges + .iter() + .find(|edge| text(edge, "child_session_id") == child) + .unwrap_or_else(|| panic!("no edge to {child}: {edges:?}")) + }; + for (child, parent, agent_type, depth) in [ + (WORKER, PARENT, "worker", 1), + (REMINDER, PARENT, "reminder", 1), + (NESTED, WORKER, "worker", 2), + ] { + let edge = edge(child); + assert_eq!(text(edge, "parent_session_id"), parent, "{edge:?}"); + assert_eq!(text(edge, "relationship"), "delegated", "{edge:?}"); + assert_eq!(text(edge, "child_agent_type"), agent_type, "{edge:?}"); + assert_eq!(field(edge, "spawn_depth").as_i64(), Some(depth), "{edge:?}"); + assert_eq!( + field(edge, "child_has_events").as_i64(), + Some(1), + "{edge:?}" + ); + assert!( + events + .iter() + .any(|event| text(event, "session_id") == child), + "the child's events are stored under its own id: {child}" + ); + } + assert_eq!(text(edge(NESTED), "child_model"), "meta/muse-glimmer-30b"); + assert_eq!(text(edge(WORKER), "child_agent_name"), "reviewer"); } diff --git a/crates/ai-hist/tests/fixtures/README.md b/crates/ai-hist/tests/fixtures/README.md index 2182535f..c94d9f0b 100644 --- a/crates/ai-hist/tests/fixtures/README.md +++ b/crates/ai-hist/tests/fixtures/README.md @@ -181,7 +181,7 @@ source without deciding which one fails the test. | Fixture | Origin | Corpus files | Quirk it encodes | | --- | --- | --- | --- | | `muse/cli-capture` | sessionmigrate | `muse/cli-capture` | a transcript the real `muse` CLI (0.2.1) wrote, trimmed to its conversation, tool and lifecycle records: three runs across two resumes, `read_file` calls with one failed outcome, per-step `model_completed` usage, and mirrored reminder task records | -| `muse/tools-session` | relayhistory | `muse/tools-session` | authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session | +| `muse/tools-session` | relayhistory | `muse/tools-session` | authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and `subagent/` logs — a worker with its own nested child, and a reminder — linked as delegated children rather than catalogued as sessions | ### `opencode` diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl index f6c26c5e..0e7a77c3 100644 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl @@ -1,29 +1,31 @@ {"retained_frame": "session_permission_transaction", "children": []} -{"schema_version": 1, "id": "0199a1b2-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 1, "recorded_at": 1790337600250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "0199a1b2-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 2, "recorded_at": 1790337600500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": false, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "0199a1b2-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 3, "recorded_at": 1790337600750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "started", "prompt": "Add a retry helper to src/http.rs and run the tests."}}} -{"schema_version": 1, "id": "0199a1b2-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 4, "recorded_at": 1790337601000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 900, "finish_reason": "tool_calls", "usage": {"input_tokens": 1200, "output_tokens": 90, "cached_tokens": 800, "cache_read_tokens": 800, "cache_write_tokens": 0, "reasoning_tokens": 40}}}} -{"schema_version": 1, "id": "0199a1b2-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 5, "recorded_at": 1790337601250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r1", "text": "", "encrypted_content": "gAAAAB-opaque"}}} -{"schema_version": 1, "id": "0199a1b2-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 6, "recorded_at": 1790337601500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r2", "text": "Read the file before editing it."}}} -{"schema_version": 1, "id": "0199a1b2-rec-007", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 7, "recorded_at": 1790337601750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-1", "response_id": "resp-1", "tool_calls": [{"id": "fc_read", "call_id": "call_read", "name": "read_file", "args": "{\"path\": \"src/http.rs\"}"}]}}} -{"schema_version": 1, "id": "0199a1b2-rec-008", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 8, "recorded_at": 1790337602000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_read", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "0199a1b2-rec-009", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 9, "recorded_at": 1790337602250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-1", "results": [{"tool_call_id": "call_read", "tool_call_index": 0, "text": "Read text file `src/http.rs`.\n1|pub fn get() {}"}]}}} -{"schema_version": 1, "id": "0199a1b2-rec-010", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 10, "recorded_at": 1790337602500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 700, "finish_reason": "tool_calls", "usage": {"input_tokens": 1500, "output_tokens": 210, "cached_tokens": 1100, "cache_read_tokens": 1100, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "0199a1b2-rec-011", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 11, "recorded_at": 1790337602750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-2", "response_id": "resp-2", "tool_calls": [{"id": "fc_edit", "call_id": "call_edit", "name": "edit_file", "args": "{\"path\": \"src/http.rs\", \"find\": \"pub fn get() {}\", \"replace\": \"pub fn get() {}\\npub fn retry() {}\"}"}, {"id": "fc_write", "call_id": "call_write", "name": "write_file", "args": "{\"path\": \"tests/retry.rs\", \"content\": \"#[test] fn retries() {}\"}"}, {"id": "fc_bash", "call_id": "call_bash", "name": "bash", "args": "{\"command\": \"cargo test\", \"workdir\": \"/tmp/muse-demo\"}"}]}}} -{"schema_version": 1, "id": "0199a1b2-rec-012", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 12, "recorded_at": 1790337603000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_edit", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "0199a1b2-rec-013", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 13, "recorded_at": 1790337603250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_write", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "0199a1b2-rec-014", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 14, "recorded_at": 1790337603500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_bash", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "0199a1b2-rec-015", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 15, "recorded_at": 1790337603750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-2", "results": [{"tool_call_id": "call_edit", "tool_call_index": 0, "text": "edited \u2014 diff: -pub fn get() {} +pub fn retry() {}"}, {"tool_call_id": "call_write", "tool_call_index": 1, "text": "wrote 22 bytes to tests/retry.rs"}, {"tool_call_id": "call_bash", "tool_call_index": 2, "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}"}]}}} -{"schema_version": 1, "id": "0199a1b2-rec-016", "stream": {"kind": "task", "id": "0199a1b2-0000-7000-8000-00000000d001"}, "sequence": 16, "recorded_at": 1790337604000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "task-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} -{"schema_version": 1, "id": "0199a1b2-rec-017", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 17, "recorded_at": 1790337604250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "task_stream_linked", "execution_mode": "background", "task_id": "0199a1b2-0000-7000-8000-00000000d001", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d001", "kind": "task"}, "display": {"label": "reviewer", "model": "same-as-main", "path": "subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", "role": "worker"}}}} -{"schema_version": 1, "id": "0199a1b2-rec-018", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 18, "recorded_at": 1790337604500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 400, "finish_reason": "stop", "usage": {"input_tokens": 1800, "output_tokens": 35, "cached_tokens": 1500, "cache_read_tokens": 1500, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "0199a1b2-rec-019", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 19, "recorded_at": 1790337604750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_message_committed", "message_id": "msg-3", "response_id": "resp-3", "text": "Added `retry` and a test; `cargo test` fails with exit code 101."}}} -{"schema_version": 1, "id": "0199a1b2-rec-020", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 20, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "terminal", "terminal": "completed", "reason": null, "turn_duration_ms": 2500}}} -{"schema_version": 1, "id": "0199a1b2-rec-021", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 21, "recorded_at": 1790337605250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.end", "payload_schema_version": 1, "payload": {"kind": "session_end", "record": {"exit_reason": "clean", "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "0199a1b2-rec-022", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 22, "recorded_at": 1790337605500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": true, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "0199a1b2-rec-023", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 23, "recorded_at": 1790337605750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.resumed", "payload_schema_version": 1, "payload": {"kind": "session_resumed", "record": {"prior_turn_count": 1, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "0199a1b2-rec-024", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 24, "recorded_at": 1790337606000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3-contributor", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "0199a1b2-rec-025", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 25, "recorded_at": 1790337606250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "started", "prompt": "Why did it fail?"}}} -{"schema_version": 1, "id": "0199a1b2-rec-026", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 26, "recorded_at": 1790337606500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3-contributor", "duration_ms": 300, "finish_reason": "stop", "usage": {"input_tokens": 2000, "output_tokens": 20, "cached_tokens": 1800, "cache_read_tokens": 1800, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "0199a1b2-rec-027", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 27, "recorded_at": 1790337606750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "assistant_message_committed", "message_id": "msg-4", "response_id": "resp-4", "text": "`retry` is declared but the test expects it to return a value."}}} -{"schema_version": 1, "id": "0199a1b2-rec-028", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 28, "recorded_at": 1790337607000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "terminal", "terminal": "interrupted", "reason": "user_cancelled", "turn_duration_ms": 400}}} +{"schema_version": 1, "id": "a001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 1, "recorded_at": 1790337600250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "a001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 2, "recorded_at": 1790337600500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": false, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "a001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 3, "recorded_at": 1790337600750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "started", "prompt": "Add a retry helper to src/http.rs and run the tests."}}} +{"schema_version": 1, "id": "a001-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 4, "recorded_at": 1790337601000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 900, "finish_reason": "tool_calls", "usage": {"input_tokens": 1200, "output_tokens": 90, "cached_tokens": 800, "cache_read_tokens": 800, "cache_write_tokens": 0, "reasoning_tokens": 40}}}} +{"schema_version": 1, "id": "a001-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 5, "recorded_at": 1790337601250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r1", "text": "", "encrypted_content": "gAAAAB-opaque"}}} +{"schema_version": 1, "id": "a001-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 6, "recorded_at": 1790337601500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r2", "text": "Read the file before editing it."}}} +{"schema_version": 1, "id": "a001-rec-007", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 7, "recorded_at": 1790337601750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-1", "response_id": "resp-1", "tool_calls": [{"id": "fc_read", "call_id": "call_read", "name": "read_file", "args": "{\"path\": \"src/http.rs\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-008", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 8, "recorded_at": 1790337602000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_read", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-009", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 9, "recorded_at": 1790337602250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-1", "results": [{"tool_call_id": "call_read", "tool_call_index": 0, "text": "Read text file `src/http.rs`.\n1|pub fn get() {}"}]}}} +{"schema_version": 1, "id": "a001-rec-010", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 10, "recorded_at": 1790337602500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 700, "finish_reason": "tool_calls", "usage": {"input_tokens": 1500, "output_tokens": 210, "cached_tokens": 1100, "cache_read_tokens": 1100, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-011", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 11, "recorded_at": 1790337602750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-2", "response_id": "resp-2", "tool_calls": [{"id": "fc_edit", "call_id": "call_edit", "name": "edit_file", "args": "{\"path\": \"src/http.rs\", \"find\": \"pub fn get() {}\", \"replace\": \"pub fn get() {}\\npub fn retry() {}\"}"}, {"id": "fc_write", "call_id": "call_write", "name": "write_file", "args": "{\"path\": \"tests/retry.rs\", \"content\": \"#[test] fn retries() {}\"}"}, {"id": "fc_bash", "call_id": "call_bash", "name": "bash", "args": "{\"command\": \"cargo test\", \"workdir\": \"/tmp/muse-demo\"}"}, {"id": "fc_spawn", "call_id": "call_spawn", "name": "subagent_spawn", "args": "{\"role\": \"reviewer\", \"objective\": \"review src/http.rs\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-012", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 12, "recorded_at": 1790337603000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_edit", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-013", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 13, "recorded_at": 1790337603250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_write", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-014", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 14, "recorded_at": 1790337603500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_bash", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-015", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 15, "recorded_at": 1790337603750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_spawn", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-016", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 16, "recorded_at": 1790337604000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "task_stream_linked", "execution_mode": "background", "task_id": "0199a1b2-0000-7000-8000-00000000d001", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d001", "kind": "task"}, "display": {"label": "reviewer", "model": "same-as-main", "path": "subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", "role": "worker"}}}} +{"schema_version": 1, "id": "a001-rec-017", "stream": {"kind": "task", "id": "0199a1b2-0000-7000-8000-00000000d001"}, "sequence": 17, "recorded_at": 1790337604250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "task-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "a001-rec-018", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 18, "recorded_at": 1790337604500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-2", "results": [{"tool_call_id": "call_edit", "tool_call_index": 0, "text": "edited \u2014 diff: -pub fn get() {} +pub fn retry() {}"}, {"tool_call_id": "call_write", "tool_call_index": 1, "text": "wrote 22 bytes to tests/retry.rs"}, {"tool_call_id": "call_bash", "tool_call_index": 2, "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}"}, {"tool_call_id": "call_spawn", "tool_call_index": 3, "text": "{\"ok\": true, \"task_id\": \"0199a1b2-0000-7000-8000-00000000d001\", \"status\": \"running\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-019", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 19, "recorded_at": 1790337604750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 400, "finish_reason": "stop", "usage": {"input_tokens": 1800, "output_tokens": 35, "cached_tokens": 1500, "cache_read_tokens": 1500, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-020", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 20, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_message_committed", "message_id": "msg-3", "response_id": "resp-3", "text": "Added `retry` and a test; `cargo test` fails with exit code 101."}}} +{"schema_version": 1, "id": "a001-rec-021", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 21, "recorded_at": 1790337605250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "memory_reminder_child_session_linked", "child_session_id": "0199a1b2-0000-7000-8000-00000000e001", "child_session_log_path": "subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl", "generation_id": 1, "parent_run_id": "run-a", "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", "reminder_agent_id": "plugin:tbh-reminders:goal-reminder", "task_id": "0199a1b2-0000-7000-8000-00000000d002", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d002", "kind": "task"}}}} +{"schema_version": 1, "id": "a001-rec-022", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 22, "recorded_at": 1790337605500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "terminal", "terminal": "completed", "reason": null, "turn_duration_ms": 2500}}} +{"schema_version": 1, "id": "a001-rec-023", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 23, "recorded_at": 1790337605750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.end", "payload_schema_version": 1, "payload": {"kind": "session_end", "record": {"exit_reason": "clean", "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "a001-rec-024", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 24, "recorded_at": 1790337606000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": true, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "a001-rec-025", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 25, "recorded_at": 1790337606250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.resumed", "payload_schema_version": 1, "payload": {"kind": "session_resumed", "record": {"prior_turn_count": 1, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} +{"schema_version": 1, "id": "a001-rec-026", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 26, "recorded_at": 1790337606500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3-contributor", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "a001-rec-027", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 27, "recorded_at": 1790337606750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "started", "prompt": "Why did it fail?"}}} +{"schema_version": 1, "id": "a001-rec-028", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 28, "recorded_at": 1790337607000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3-contributor", "duration_ms": 300, "finish_reason": "stop", "usage": {"input_tokens": 2000, "output_tokens": 20, "cached_tokens": 1800, "cache_read_tokens": 1800, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-029", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 29, "recorded_at": 1790337607250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "assistant_message_committed", "message_id": "msg-4", "response_id": "resp-4", "text": "`retry` is declared but the test expects it to return a value."}}} +{"schema_version": 1, "id": "a001-rec-030", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 30, "recorded_at": 1790337607500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "terminal", "terminal": "interrupted", "reason": "user_cancelled", "turn_duration_ms": 400}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl index 399cc0c0..eb5305c6 100644 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl @@ -1,2 +1,6 @@ -{"schema_version": 1, "id": "child-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 1, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"model_id": "meta/muse-spark-1.3", "provider_id": "meta", "workspace_root": "/tmp/muse-demo", "build": {"semver": "1.3.0"}}}} -{"schema_version": 1, "id": "child-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 2, "recorded_at": 1790337605100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "c001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 1, "recorded_at": 1790337604350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "c001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 2, "recorded_at": 1790337604600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "c001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 3, "recorded_at": 1790337604850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 200, "finish_reason": "stop", "usage": {"input_tokens": 600, "output_tokens": 25, "cached_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "c001-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 4, "recorded_at": 1790337605100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "assistant_message_committed", "message_id": "child-msg-1", "text": "`retry` has no body; the test cannot pass."}}} +{"schema_version": 1, "id": "c001-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 5, "recorded_at": 1790337605350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "task_stream_linked", "task_id": "0199a1b2-0000-7000-8000-00000000d003", "display": {"label": "linter", "model": "meta/muse-glimmer-30b", "path": "subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl", "role": "worker"}}}} +{"schema_version": 1, "id": "c001-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 6, "recorded_at": 1790337605600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "terminal", "terminal": "completed"}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl new file mode 100644 index 00000000..5c41515f --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl @@ -0,0 +1,3 @@ +{"schema_version": 1, "id": "c002-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 1, "recorded_at": 1790337605850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-glimmer-30b", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "c002-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 2, "recorded_at": 1790337606100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "started", "prompt": "Role: linter Objective: lint src/http.rs"}}} +{"schema_version": 1, "id": "c002-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 3, "recorded_at": 1790337606350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "assistant_message_committed", "message_id": "grand-msg-1", "text": "No lint findings."}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl new file mode 100644 index 00000000..cc519b4e --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl @@ -0,0 +1,3 @@ +{"schema_version": 1, "id": "e001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 1, "recorded_at": 1790337605650000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "e001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 2, "recorded_at": 1790337605900000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "started", "prompt": "Is the user's task finished?"}}} +{"schema_version": 1, "id": "e001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 3, "recorded_at": 1790337606150000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "assistant_message_committed", "message_id": "remind-msg-1", "text": "No: the test still fails."}}} diff --git a/crates/ai-hist/tests/snapshots/muse/tools-session.json b/crates/ai-hist/tests/snapshots/muse/tools-session.json index 0b8389db..054b5d48 100644 --- a/crates/ai-hist/tests/snapshots/muse/tools-session.json +++ b/crates/ai-hist/tests/snapshots/muse/tools-session.json @@ -49,12 +49,16 @@ "prompt": "Why did it fail?", "session_id": "0199a1b2-0000-7000-8000-00000000a001", "source": "muse", - "timestamp_ms": 1790337606250 + "timestamp_ms": 1790337606750 } ], "hydration": [ { - "related_session_ids": [], + "related_session_ids": [ + "0199a1b2-0000-7000-8000-00000000c001", + "0199a1b2-0000-7000-8000-00000000c002", + "0199a1b2-0000-7000-8000-00000000e001" + ], "session_id": "0199a1b2-0000-7000-8000-00000000a001", "source": "muse", "status": "hydrated" @@ -63,15 +67,15 @@ "layout": "HomeTree", "notes": [], "origin": "relayhistory", - "quirk": "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and a `subagent/` child transcript that must not become a session", + "quirk": "authored from the documented shape: a permission frame before the metadata, encrypted and readable reasoning, `edit_file`/`write_file` edits, a `bash` result that exits 101, a mirrored subagent task stream, a mid-session model switch, and `subagent/` logs — a worker with its own nested child, and a reminder — linked as delegated children rather than catalogued as sessions", "session_events": [ { "control_kind": null, "cwd": "/tmp/muse-demo", - "event_uid": "0199a1b2-rec-003", + "event_uid": "a001-rec-003", "git_branch": null, "kind": "text", - "message_id": "0199a1b2-rec-003", + "message_id": "a001-rec-003", "model": null, "parent_id": null, "project": "/tmp/muse-demo", @@ -85,7 +89,7 @@ { "control_kind": null, "cwd": "/tmp/muse-demo", - "event_uid": "0199a1b2-rec-006", + "event_uid": "a001-rec-006", "git_branch": null, "kind": "thinking", "message_id": "msg-r2", @@ -167,6 +171,23 @@ "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", "ts_ms": 1790337602750 }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "tool:call_spawn", + "git_branch": null, + "kind": "tool_use", + "message_id": "msg-2", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "subagent_spawn reviewer", + "token_json": null, + "ts_ms": 1790337602750 + }, { "control_kind": null, "cwd": "/tmp/muse-demo", @@ -199,7 +220,7 @@ "source": "muse", "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}", "token_json": null, - "ts_ms": 1790337603750 + "ts_ms": 1790337604500 }, { "control_kind": null, @@ -216,7 +237,24 @@ "source": "muse", "text": "edited — diff: -pub fn get() {} +pub fn retry() {}", "token_json": null, - "ts_ms": 1790337603750 + "ts_ms": 1790337604500 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "result:call_spawn", + "git_branch": null, + "kind": "tool_result", + "message_id": "result:call_spawn", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "tool_result", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "text": "{\"ok\": true, \"task_id\": \"0199a1b2-0000-7000-8000-00000000d001\", \"status\": \"running\"}", + "token_json": null, + "ts_ms": 1790337604500 }, { "control_kind": null, @@ -233,12 +271,12 @@ "source": "muse", "text": "wrote 22 bytes to tests/retry.rs", "token_json": null, - "ts_ms": 1790337603750 + "ts_ms": 1790337604500 }, { "control_kind": null, "cwd": "/tmp/muse-demo", - "event_uid": "0199a1b2-rec-019", + "event_uid": "a001-rec-020", "git_branch": null, "kind": "text", "message_id": "msg-3", @@ -250,15 +288,15 @@ "source": "muse", "text": "Added `retry` and a test; `cargo test` fails with exit code 101.", "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", - "ts_ms": 1790337604750 + "ts_ms": 1790337605000 }, { "control_kind": null, "cwd": "/tmp/muse-demo", - "event_uid": "0199a1b2-rec-025", + "event_uid": "a001-rec-027", "git_branch": null, "kind": "text", - "message_id": "0199a1b2-rec-025", + "message_id": "a001-rec-027", "model": null, "parent_id": null, "project": "/tmp/muse-demo", @@ -267,12 +305,12 @@ "source": "muse", "text": "Why did it fail?", "token_json": null, - "ts_ms": 1790337606250 + "ts_ms": 1790337606750 }, { "control_kind": null, "cwd": "/tmp/muse-demo", - "event_uid": "0199a1b2-rec-027", + "event_uid": "a001-rec-029", "git_branch": null, "kind": "text", "message_id": "msg-4", @@ -284,13 +322,115 @@ "source": "muse", "text": "`retry` is declared but the test expects it to return a value.", "token_json": "{\"cache_read_tokens\":1800,\"cache_write_tokens\":0,\"cached_tokens\":1800,\"input_tokens\":2000,\"output_tokens\":20,\"reasoning_tokens\":0}", - "ts_ms": 1790337606750 + "ts_ms": 1790337607250 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "c001-rec-002", + "git_branch": null, + "kind": "text", + "message_id": "c001-rec-002", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "user", + "session_id": "0199a1b2-0000-7000-8000-00000000c001", + "source": "muse", + "text": "Role: reviewer Objective: review src/http.rs", + "token_json": null, + "ts_ms": 1790337604600 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "c001-rec-004", + "git_branch": null, + "kind": "text", + "message_id": "child-msg-1", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000c001", + "source": "muse", + "text": "`retry` has no body; the test cannot pass.", + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":600,\"output_tokens\":25,\"reasoning_tokens\":0}", + "ts_ms": 1790337605100 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "c002-rec-002", + "git_branch": null, + "kind": "text", + "message_id": "c002-rec-002", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "user", + "session_id": "0199a1b2-0000-7000-8000-00000000c002", + "source": "muse", + "text": "Role: linter Objective: lint src/http.rs", + "token_json": null, + "ts_ms": 1790337606100 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "c002-rec-003", + "git_branch": null, + "kind": "text", + "message_id": "grand-msg-1", + "model": "meta/muse-glimmer-30b", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000c002", + "source": "muse", + "text": "No lint findings.", + "token_json": null, + "ts_ms": 1790337606350 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "e001-rec-002", + "git_branch": null, + "kind": "text", + "message_id": "e001-rec-002", + "model": null, + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "user", + "session_id": "0199a1b2-0000-7000-8000-00000000e001", + "source": "muse", + "text": "Is the user's task finished?", + "token_json": null, + "ts_ms": 1790337605900 + }, + { + "control_kind": null, + "cwd": "/tmp/muse-demo", + "event_uid": "e001-rec-003", + "git_branch": null, + "kind": "text", + "message_id": "remind-msg-1", + "model": "meta/muse-spark-1.3", + "parent_id": null, + "project": "/tmp/muse-demo", + "role": "assistant", + "session_id": "0199a1b2-0000-7000-8000-00000000e001", + "source": "muse", + "text": "No: the test still fails.", + "token_json": null, + "ts_ms": 1790337606150 } ], "session_markers": [ { "kind": "session_start", - "marker_uid": "0199a1b2-rec-002", + "marker_uid": "a001-rec-002", "message_id": null, "parent_id": null, "payload_json": "{\"resume\":false}", @@ -303,7 +443,7 @@ }, { "kind": "encrypted_reasoning", - "marker_uid": "0199a1b2-rec-005", + "marker_uid": "a001-rec-005", "message_id": "msg-r1", "parent_id": null, "payload_json": null, @@ -316,7 +456,7 @@ }, { "kind": "turn_end", - "marker_uid": "0199a1b2-rec-020", + "marker_uid": "a001-rec-022", "message_id": null, "parent_id": null, "payload_json": "{\"terminal\":\"completed\"}", @@ -324,12 +464,12 @@ "source": "muse", "subkind": "terminal", "text": "completed", - "ts_ms": 1790337605000, + "ts_ms": 1790337605500, "turn_id": "run-a" }, { "kind": "session_end", - "marker_uid": "0199a1b2-rec-021", + "marker_uid": "a001-rec-023", "message_id": null, "parent_id": null, "payload_json": null, @@ -337,12 +477,12 @@ "source": "muse", "subkind": "session.end", "text": "clean", - "ts_ms": 1790337605250, + "ts_ms": 1790337605750, "turn_id": null }, { "kind": "session_start", - "marker_uid": "0199a1b2-rec-022", + "marker_uid": "a001-rec-024", "message_id": null, "parent_id": null, "payload_json": "{\"resume\":true}", @@ -350,12 +490,12 @@ "source": "muse", "subkind": "session.opened.observed", "text": null, - "ts_ms": 1790337605500, + "ts_ms": 1790337606000, "turn_id": null }, { "kind": "session_resumed", - "marker_uid": "0199a1b2-rec-023", + "marker_uid": "a001-rec-025", "message_id": null, "parent_id": null, "payload_json": null, @@ -363,12 +503,12 @@ "source": "muse", "subkind": "session.resumed", "text": null, - "ts_ms": 1790337605750, + "ts_ms": 1790337606250, "turn_id": null }, { "kind": "model_switch", - "marker_uid": "0199a1b2-rec-024", + "marker_uid": "a001-rec-026", "message_id": null, "parent_id": null, "payload_json": null, @@ -376,12 +516,12 @@ "source": "muse", "subkind": "runtime.session.metadata", "text": "meta/muse-spark-1.3-contributor", - "ts_ms": 1790337606000, + "ts_ms": 1790337606500, "turn_id": null }, { "kind": "turn_end", - "marker_uid": "0199a1b2-rec-028", + "marker_uid": "a001-rec-030", "message_id": null, "parent_id": null, "payload_json": "{\"reason\":\"user_cancelled\",\"terminal\":\"interrupted\"}", @@ -389,11 +529,63 @@ "source": "muse", "subkind": "terminal", "text": "interrupted", - "ts_ms": 1790337607000, + "ts_ms": 1790337607500, "turn_id": "run-b" } ], - "session_relationships": [], + "session_relationships": [ + { + "child_agent_name": "reviewer", + "child_agent_type": "worker", + "child_has_events": 1, + "child_model": "meta/muse-spark-1.3", + "child_session_id": "0199a1b2-0000-7000-8000-00000000c001", + "evidence_kind": "muse_subagent_log", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", + "evidence_ref": "0199a1b2-0000-7000-8000-00000000d001", + "identity_status": "observed", + "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", + "relationship": "delegated", + "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000c001", + "source": "muse", + "spawn_depth": 1, + "spawned_at_ms": 1790337604000 + }, + { + "child_agent_name": "plugin:tbh-reminders:goal-reminder", + "child_agent_type": "reminder", + "child_has_events": 1, + "child_model": "meta/muse-spark-1.3", + "child_session_id": "0199a1b2-0000-7000-8000-00000000e001", + "evidence_kind": "muse_subagent_log", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl", + "evidence_ref": "0199a1b2-0000-7000-8000-00000000d002", + "identity_status": "observed", + "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", + "relationship": "delegated", + "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000e001", + "source": "muse", + "spawn_depth": 1, + "spawned_at_ms": 1790337605250 + }, + { + "child_agent_name": "linter", + "child_agent_type": "worker", + "child_has_events": 1, + "child_model": "meta/muse-glimmer-30b", + "child_session_id": "0199a1b2-0000-7000-8000-00000000c002", + "evidence_kind": "muse_subagent_log", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl", + "evidence_ref": "0199a1b2-0000-7000-8000-00000000d003", + "identity_status": "observed", + "parent_session_id": "0199a1b2-0000-7000-8000-00000000c001", + "relationship": "delegated", + "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000c002", + "source": "muse", + "spawn_depth": 2, + "spawned_at_ms": 1790337605350 + } + ], "sessions": [ { "agent_version": "1.3.0", @@ -403,7 +595,7 @@ "first_prompt": "Add a retry helper to src/http.rs and run the tests.", "git_branch": null, "initial_commit": null, - "last_activity_ms": 1790337607000, + "last_activity_ms": 1790337607500, "last_assistant_text": "`retry` is declared but the test expects it to return a value.", "models_json": "[\"meta/muse-spark-1.3\",\"meta/muse-spark-1.3-contributor\"]", "originator": null, @@ -411,7 +603,7 @@ "repo_url": null, "session_id": "0199a1b2-0000-7000-8000-00000000a001", "source": "muse", - "source_stamp": ":15194", + "source_stamp": ":16712|subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl=:3091|subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl=:1436|subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl=:1433", "workspace_roots_json": null } ], @@ -450,6 +642,17 @@ "tool_use_id": "call_edit", "ts_ms": 1790337602750 }, + { + "args_json": "{\"objective\":\"review src/http.rs\",\"role\":\"reviewer\"}", + "is_error": 0, + "message_id": "msg-2", + "name": "subagent_spawn", + "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "source": "muse", + "target": "reviewer", + "tool_use_id": "call_spawn", + "ts_ms": 1790337602750 + }, { "args_json": "{\"content\":\"#[test] fn retries() {}\",\"path\":\"tests/retry.rs\"}", "is_error": 0, diff --git a/docs/architecture.md b/docs/architecture.md index 5f87dc52..b469c5e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -247,7 +247,8 @@ so every result reports it: |---|---|---|---|---| | `codex` | always | yes | yes | yes | | `claude` | sometimes (only versions that emit a per-child `agentId`) | yes | yes | yes | -| `cursor`, `grok`, `muse`, `opencode`, `relay` | never | no | no | no | +| `muse` | always | yes | yes | yes | +| `cursor`, `grok`, `opencode`, `relay` | never | no | no | no | A linked child's events are stored under the child's own session id and are never flattened into the parent. The delegated instruction that started a diff --git a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md index f435c3ef..f645e1a0 100644 --- a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md +++ b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md @@ -146,7 +146,7 @@ of `sessions list`. It is listed for completeness; every session-evidence row is | File edits (`file_edits`) | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | — | [#166](https://github.com/AgentWorkforce/relayhistory/issues/166) / [#167](https://github.com/AgentWorkforce/relayhistory/issues/167) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | | Compaction / summary markers | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | | Control / lifecycle rows | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | — | [#165](https://github.com/AgentWorkforce/relayhistory/issues/165), [#180](https://github.com/AgentWorkforce/relayhistory/issues/180) | -| Relationship — delegated | ◐ | ✓ | — | — | ✗ | ✓ | — | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | +| Relationship — delegated | ◐ | ✓ | — | — | ✓ | ✓ | — | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) / [#168](https://github.com/AgentWorkforce/relayhistory/issues/168) | | Relationship — fork / resume / continuation | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#170](https://github.com/AgentWorkforce/relayhistory/issues/170) | | Session metadata (cwd, branch, versions) | ◐ | ✓ | ◐ | ◐ | ◐ | ◐ | ✗ | — | [#164](https://github.com/AgentWorkforce/relayhistory/issues/164), [#177](https://github.com/AgentWorkforce/relayhistory/issues/177) | | Canonical `project_key` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | — | [#175](https://github.com/AgentWorkforce/relayhistory/issues/175) | @@ -200,9 +200,10 @@ attached to a neighbour. `request_id` is Muse's `response_id` and separate `tool_batch.effect.terminal` record, joined by call id, and a `bash` call that completed with a non-zero `exit_code` is an error. Session metadata is `◐`: `workspace_root`, model and CLI version, but Muse records no -branch. Delegation is `✗`: `subagent_spawn` is an ordinary `tool_calls` row, -and the `subagent//session.jsonl` children beside a session are neither -catalogued nor linked yet. The fixtures include a transcript the real CLI +branch. Delegation is `✓`: each `subagent//session.jsonl` beside a +session is indexed under the id its own metadata names and linked as +`delegated` (`evidence_kind = "muse_subagent_log"`), typed from the parent's +`task_stream_linked` record, at any nesting depth. The fixtures include a transcript the real CLI wrote (`tests/fixtures/muse/cli-capture`). **Token usage.** Claude is `◐`: `ingest_claude_transcript_as` serializes diff --git a/docs/session-catalog.md b/docs/session-catalog.md index e6ba01fb..c16e51ad 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -670,7 +670,7 @@ follows. | **codex** | ✓ | ✓ | ✓ | ✓ | ✓ when the bounded child search is complete | `full` or `partial` | | **cursor** | ✓ | ✓ | ✓ | ✓ | – (never: a `Task` block names no child transcript) | `partial` | | **grok** | ✓ | ✓ | ✓ | ✓ | ✓ | `full` | -| **muse** | ✓ | ✓ | ✓ | ✓ | – (not linked yet: `subagent/` children are neither catalogued nor linked) | `partial` | +| **muse** | ✓ | ✓ | ✓ | ✓ | ✓ | `full` | | **opencode** | ✓ | ✓ | ✓ | ✓ | ✓ | `full` | | **relay** | – | – | – | – | – | targeted hydration unsupported | @@ -740,7 +740,8 @@ Delegation is a separate capability, reported on every relationship result as | **opencode** | always | – | ✓ | ✓ | | **claude** | sometimes | ✓ | ✓ | ✓ | | **grok** | sometimes | ✓ | ✓ | ✓ | -| **cursor**, **muse**, **relay** | never | – | – | – | +| **muse** | always | ✓ | ✓ | ✓ | +| **cursor**, **relay** | never | – | – | – | OpenCode is `always` because a subagent session is a session in its own right and its own record names the parent, in `session.parentID`. Nothing is @@ -759,6 +760,13 @@ sessions tree, and one that does not is stored as unlinked evidence. The id is never taken from the entry's file name. A `Task` call inside the transcript names no child at all. See ["grok"](#grok). +Muse is `always` because every subagent writes its own +`subagent//session.jsonl` beside the parent, and that log's metadata +record names the child session. The edge is recorded with `evidence_kind = +"muse_subagent_log"`; the agent type, name and model come from the parent's +`task_stream_linked` (or `memory_reminder_child_session_linked`) record for +that log. See ["muse"](#muse). + Cursor is `never` for a different reason from grok, opencode and relay: it does record the spawn — a `Task` / `functions.Subagent` tool call, preserved as a `tool_calls` row — but the block names no child transcript, so there is no @@ -1489,10 +1497,29 @@ How each adapter works: A sync or hydration reads the whole file and **replaces** the session's evidence, so a re-read after the log grew adds exactly the new turns and - duplicates nothing. `subagent//session.jsonl` transcripts beside a - session are not sessions of their own and are never enumerated; linking them - as delegated children is not done yet (`MUSE_SUBAGENT_SPAWN_UNLINKED` counts - the `subagent_spawn` calls). The shapes were characterized from a transcript + duplicates nothing. + + **Subagents.** Every child agent — a `subagent_spawn` worker, or a reminder + child when Muse is set to save those — writes + `subagent//session.jsonl` beside its parent, and may nest its own + `subagent/` below that. These are never enumerated as sessions. Reading a + session reads its whole tree: each child is indexed under the session id + its **own** metadata record names (never the directory name) and linked to + its parent as `delegated`, with `spawn_depth`, the parent's + `task_stream_linked` `display.role` (`worker`, `reminder`, …) as + `child_agent_type`, its `display.label` as `child_agent_name`, a concrete + `display.model` (else the child's own `model_id`) as `child_model`, and the + task id as `evidence_ref`. As for a Codex subagent rollout, a child's + `history` rows and catalog row are removed — its objective is not a prompt + anybody typed — and its events stay addressable through the edge + (`sessions tree`, `related_session_ids`). The change stamp covers every + child log, so a background subagent that keeps writing after the parent's + last record is still re-read; a child whose log disappears loses its edge + and its evidence. A reminder linked by `memory_reminder_child_session_linked` + but saved only in memory (Muse's default for reminders) has no log and no + edge. `MUSE_SUBAGENT_LOG_MISSING` reports `subagent_spawn` calls with no log + to link, and `MUSE_SUBAGENT_LOG_UNIDENTIFIED` a log with no session + metadata. The shapes were characterized from a transcript the real CLI wrote (`tests/fixtures/muse/cli-capture`, from [xhluca/session-migrate](https://github.com/xhluca/session-migrate)) and cross-checked against the published [Muse Code diff --git a/docs/sourcing-sdk.md b/docs/sourcing-sdk.md index f5aac37d..6f8088c7 100644 --- a/docs/sourcing-sdk.md +++ b/docs/sourcing-sdk.md @@ -550,7 +550,7 @@ handle. | codex | ✓ | ✓ | ✓ | ✓ | ✓ | cumulative-delta | | cursor | ✓ | ✓ | ✓ | ✓ | — | none | | grok | ✓ | ✓ | ✓ | ✓ | ✓ | none | -| muse | ✓ | ✓ | ✓ | ✓ | — | per-request | +| muse | ✓ | ✓ | ✓ | ✓ | ✓ | per-request | | opencode | ✓ | ✓ | ✓ | ✓ | ✓ | none | | relay | — | — | — | — | — | none | | trajectory | — | — | — | — | — | none | From 800610ec1afdb7500ca2d022ce43603c4e34786f Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:24:21 -0700 Subject: [PATCH 03/19] =?UTF-8?q?fix(muse):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20usage=20pairing,=20repairs,=20child=20logs,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pair each model step's usage with its assistant record in either order. Muse logs `model_completed` before a tool step's calls but after a prose step's reply, so final replies were losing their usage (every text step in the real capture). Model and finish_reason now belong to the owning record only. - Guard Muse with the destination marker (REPAIRABLE_EVENT_SOURCES), so rows lost under an unchanged stamp are re-read by the next sync, including a parent whose linked subagent is short; a deregistered Muse subagent is not a catalog shortfall. - Include subagent logs in the discovery fingerprint, so a child that grows on its own is not hidden behind the sync fast path. - Read a session's whole tree before writing: an unreadable transcript or child log is that session's failure and the pass continues; a write failure (retention) still ends it. Check capture headroom per session and propagate cancellation from the stamp walk. - Hydration with include_related=false no longer reads or rewrites subagent evidence. - Only newline-terminated records are parsed. - Only a `bash` result's exit_code marks a call failed. - Catalog first_prompt / last_assistant_text are assigned from the transcript, so a rewrite does not keep stale text. - Shorten the authored fixture's session ids: the nested subagent paths exceeded Windows' MAX_PATH and broke checkout on the Windows jobs. Add `muse` to the plugin's SOURCE_CHOICES assertion. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/discover.rs | 13 + crates/ai-hist/src/ingest.rs | 474 ++++++++++++++---- crates/ai-hist/src/ingest/hydrate.rs | 70 ++- crates/ai-hist/src/ingest/muse.rs | 145 +++++- .../ai-hist/src/ingest/tool_result_facts.rs | 20 +- crates/ai-hist/tests/fixture_corpus.rs | 20 +- .../session.jsonl | 31 -- .../session.jsonl | 6 - .../session.jsonl | 3 - .../session.jsonl | 3 - .../2026/09/20/muse-a001/session.jsonl | 31 ++ .../subagent/muse-c001/session.jsonl | 6 + .../subagent/muse-c002/session.jsonl | 3 + .../subagent/muse-e001/session.jsonl | 3 + .../tests/snapshots/muse/cli-capture.json | 6 +- .../tests/snapshots/muse/tools-session.json | 128 ++--- ...9-19-relayhistory-owns-session-sourcing.md | 7 +- docs/session-catalog.md | 14 +- plugins/relayhistory/rust/src/source/tests.rs | 3 +- 19 files changed, 762 insertions(+), 224 deletions(-) delete mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl delete mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl delete mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl delete mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/subagent/muse-c002/session.jsonl create mode 100644 crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-e001/session.jsonl diff --git a/crates/ai-hist/src/discover.rs b/crates/ai-hist/src/discover.rs index 59234abf..483fd49f 100644 --- a/crates/ai-hist/src/discover.rs +++ b/crates/ai-hist/src/discover.rs @@ -2094,6 +2094,19 @@ impl ShallowSessionProvider for MuseProvider { ) } + /// Enumeration is top-level transcripts only, but a subagent log is + /// evidence too: sync stamps it, and a background subagent keeps writing + /// after its parent has stopped. Left out of the fold, a tick whose only + /// change was a child log would sit behind an unchanged fingerprint and + /// never reach the sweep that reads it. + fn fingerprint_inputs(&self, env: &DiscoveryEnv<'_>) -> Result> { + let mut files = Vec::new(); + for transcript in crate::collect_muse_transcripts(&env.muse_sessions)? { + files.extend(crate::muse_session_files(&transcript)?); + } + file_candidates("muse", files, crate::file_stamp_and_modified) + } + fn read_shallow( &self, scan: &ScanEnv<'_>, diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index bdc8cd1c..88d07f4c 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -811,7 +811,11 @@ const DESTINATION_MARKER_VERSION: &str = "v4"; /// sync now re-reads a session indexed as prompts-only, so a loss in one is /// repairable on the same terms. Its rows do not come from a flat log, which /// is what kept it out before. -const REPAIRABLE_EVENT_SOURCES: &[&str] = &["claude", "codex", "opencode"]; +/// +/// Muse Code joined with its parser: every session is a whole-transcript +/// read, and a sweep re-reads a session this marker names short, or the +/// parent whose tree holds a short subagent. +const REPAIRABLE_EVENT_SOURCES: &[&str] = &["claude", "codex", "opencode", "muse"]; /// What one session is expected to hold. /// @@ -1112,11 +1116,18 @@ fn destination_shortfall(conn: &Connection, stored: &str) -> Result 0 + let expected_subagent_deregistration = before.catalog > 0 && current.catalog == 0 - && codex_delegation_recorded(conn, &session_id)? - && !codex_session_has_remote_presence(conn, &session_id)?; + && match source.as_str() { + "codex" => { + codex_delegation_recorded(conn, &session_id)? + && !codex_session_has_remote_presence(conn, &session_id)? + } + // A Muse subagent is deregistered the same way once its log + // is linked to its parent. + "muse" => muse_delegation_recorded(conn, &session_id)?, + _ => false, + }; let covered = current.events >= before.events && current.tool_calls >= before.tool_calls && current.file_edits >= before.file_edits @@ -1576,7 +1587,7 @@ fn sync_basic( if let Some(inserted) = report.capture( conn, "muse", - sync_muse_with_coverage(conn, &mut state, &roots.muse, &mut coverage), + sync_muse_with_coverage(conn, &mut state, &roots.muse, &repairs, &mut coverage), )? { total_inserted += inserted; checkpoint_sync_state(&state_path, &state); @@ -10514,7 +10525,13 @@ pub(crate) fn collect_muse_transcripts(root: &Path) -> Result> { #[cfg(test)] fn sync_muse(conn: &Connection, state: &mut Map, root: &Path) -> Result { - sync_muse_with_coverage(conn, state, root, &mut SweepCoverage::default()) + sync_muse_with_coverage( + conn, + state, + root, + &SweepRepairs::default(), + &mut SweepCoverage::default(), + ) } /// Index every Muse Code transcript whose stamp changed since the last run. @@ -10528,6 +10545,7 @@ fn sync_muse_with_coverage( conn: &Connection, state: &mut Map, root: &Path, + repairs: &SweepRepairs, coverage: &mut SweepCoverage, ) -> Result { if !root.exists() { @@ -10550,6 +10568,9 @@ fn sync_muse_with_coverage( let stamp = match muse_session_stamp(&transcript) { Ok(stamp) => stamp, Err(error) => { + if error.is::() { + return Err(error); + } scanned += 1; errors += 1; coverage.note_unread(); @@ -10571,6 +10592,10 @@ fn sync_muse_with_coverage( recorded.and_then(grok_state_had_evidence), ) { (None, _) => true, + // The destination marker says rows were lost under this + // unchanged stamp — in the session or in a subagent it links. + // Existence cannot see a partial loss; re-reading repairs it. + (Some(id), _) if muse_tree_needs_repair(conn, repairs, id)? => false, (Some(id), Some(false)) => muse_catalog_row_exists(conn, id)?, (Some(id), _) => muse_evidence_exists(conn, id)?, }; @@ -10580,15 +10605,33 @@ fn sync_muse_with_coverage( } } scanned += 1; - match read_muse_transcript(&transcript) { - Ok(Some(parsed)) => { + ensure_capture_headroom(conn)?; + // Everything is read before anything is written. A transcript or a + // subagent log that cannot be read is this session's failure alone: + // it keeps no stamp, so the next run retries it, and the pass goes on. + // A write that fails once the transaction is open is not per-session — + // a retention abort meets every later session too — so it ends the + // pass, as it does for Grok. + match read_muse_tree(&transcript) { + Ok(Some((parsed, children))) => { let session_id = parsed.metadata.session_id.clone(); let tx = conn.unchecked_transaction()?; + let written = muse_history_rows(&tx, &session_id).and_then(|before| { + let outcome = + ingest_muse_session_tree(&tx, &parsed, &transcript, Some(&children))?; + let after = muse_history_rows(&tx, &session_id)?; + Ok((outcome, after.saturating_sub(before))) + }); + let (outcome, added) = match written { + Ok(written) => written, + Err(error) => { + drop(tx); + return Err(annotate_retention_limit(conn, error)); + } + }; // A re-read replaces the session's prompts wholesale, so what // it added is the difference, not what it wrote. - let before = muse_history_rows(&tx, &session_id)?; - let outcome = ingest_muse_session_tree(&tx, &parsed, &transcript)?; - inserted += muse_history_rows(&tx, &session_id)?.saturating_sub(before); + inserted += added; tx.commit()?; sessions += 1; accounted += 1; @@ -10638,6 +10681,51 @@ fn sync_muse_with_coverage( Ok(inserted) } +/// Whether the destination marker names this session, or any subagent its +/// logs link, as having lost rows. +fn muse_tree_needs_repair( + conn: &Connection, + repairs: &SweepRepairs, + session_id: &str, +) -> Result { + if repairs.is_empty() { + return Ok(false); + } + if repairs.contains("muse", session_id) { + return Ok(true); + } + let descendants: Vec = conn + .prepare( + "WITH RECURSIVE tree(id) AS ( \ + SELECT child_session_id FROM session_relationships \ + WHERE source = 'muse' AND parent_session_id = ?1 \ + AND evidence_kind = 'muse_subagent_log' AND child_session_id IS NOT NULL \ + UNION \ + SELECT r.child_session_id FROM session_relationships r JOIN tree \ + ON r.parent_session_id = tree.id \ + WHERE r.source = 'muse' AND r.evidence_kind = 'muse_subagent_log' \ + AND r.child_session_id IS NOT NULL \ + ) SELECT id FROM tree", + )? + .query_map(params![session_id], |row| row.get(0))? + .collect::>()?; + Ok(descendants + .iter() + .any(|child| repairs.contains("muse", child))) +} + +/// Whether a Muse session is a subagent some parent's log links. +fn muse_delegation_recorded(conn: &Connection, child_session_id: &str) -> Result { + let exists: i64 = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM session_relationships \ + WHERE source = 'muse' AND child_session_id = ? \ + AND evidence_kind = 'muse_subagent_log' LIMIT 1)", + params![child_session_id], + |row| row.get(0), + )?; + Ok(exists != 0) +} + fn muse_history_rows(conn: &Connection, session_id: &str) -> Result { let rows: i64 = conn.query_row( "SELECT COUNT(*) FROM history WHERE source = 'muse' AND session_id = ?", @@ -10752,23 +10840,80 @@ pub(crate) fn muse_session_stamp(transcript: &Path) -> Result { /// prompt, and it is not a catalog row of its own. So, as for a Codex /// subagent rollout, its `history` rows and catalog registration are removed /// and its events stay addressable through the relationship. +/// +/// `children` is the tree [`read_muse_children`] read, or `None` to leave the +/// session's children and edges exactly as they are — a hydration that did +/// not ask for related evidence must not rewrite it. pub(crate) fn ingest_muse_session_tree( conn: &Connection, transcript: &muse::MuseTranscript, path: &Path, + children: Option<&[MuseChildLog]>, ) -> Result { let mut outcome = ingest_muse_session(conn, transcript, &path.to_string_lossy())?; - if let Some(dir) = path.parent() { + if let (Some(dir), Some(children)) = (path.parent(), children) { let mut visited = HashSet::from([transcript.metadata.session_id.clone()]); - link_muse_subagents(conn, transcript, dir, 1, &mut visited, &mut outcome)?; + link_muse_subagents( + conn, + transcript, + dir, + children, + 1, + &mut visited, + &mut outcome, + )?; } Ok(outcome) } +/// One subagent log beneath a session, read, with its own children. +pub(crate) struct MuseChildLog { + path: PathBuf, + /// `None` when the log names no session. + transcript: Option, + children: Vec, +} + +/// Read a transcript and every subagent log beneath it. `Ok(None)` when the +/// transcript names no session. +pub(crate) fn read_muse_tree( + transcript: &Path, +) -> Result)>> { + let Some(parsed) = read_muse_transcript(transcript)? else { + return Ok(None); + }; + let children = match transcript.parent() { + Some(dir) => read_muse_children(dir)?, + None => Vec::new(), + }; + Ok(Some((parsed, children))) +} + +/// The subagent logs directly beneath one session directory, each read along +/// with its own children. +pub(crate) fn read_muse_children(session_dir: &Path) -> Result> { + let mut out = Vec::new(); + for path in direct_muse_child_logs(session_dir)? { + check_capture_cancelled()?; + let transcript = read_muse_transcript(&path)?; + let children = match path.parent() { + Some(dir) => read_muse_children(dir)?, + None => Vec::new(), + }; + out.push(MuseChildLog { + path, + transcript, + children, + }); + } + Ok(out) +} + fn link_muse_subagents( conn: &Connection, parent: &muse::MuseTranscript, parent_dir: &Path, + children: &[MuseChildLog], depth: i64, visited: &mut HashSet, outcome: &mut MuseIngestOutcome, @@ -10783,9 +10928,10 @@ fn link_muse_subagents( params![parent_id], )?; let links = muse::subagent_links(parent); - for log in direct_muse_child_logs(parent_dir)? { + for entry in children { check_capture_cancelled()?; - let Some(child) = read_muse_transcript(&log)? else { + let log = &entry.path; + let Some(child) = entry.transcript.as_ref() else { outcome.unidentified_children += 1; continue; }; @@ -10796,14 +10942,22 @@ fn link_muse_subagents( } linked.insert(child_id.clone()); let locator = log.to_string_lossy().to_string(); - ingest_muse_session(conn, &child, &locator)?; + ingest_muse_session(conn, child, &locator)?; conn.execute( "DELETE FROM history WHERE source = 'muse' AND session_id = ?", params![child_id], )?; cleanup_subagent_registration(conn, "muse", &child_id)?; if let Some(child_dir) = log.parent() { - link_muse_subagents(conn, &child, child_dir, depth + 1, visited, outcome)?; + link_muse_subagents( + conn, + child, + child_dir, + &entry.children, + depth + 1, + visited, + outcome, + )?; } let relative = log .strip_prefix(parent_dir) @@ -10912,27 +11066,33 @@ pub(crate) struct MuseIngestOutcome { pub unparsed_lines: usize, } -/// One model step's usage, waiting for the assistant record it produced. -struct PendingMuseUsage { - run_id: Option, - token_json: String, +/// What one model step reported, for the assistant record that step owns: +/// its usage verbatim, its model and its `finish_reason`. +struct MuseStep<'a> { + token_json: Option, + model: Option<&'a str>, + finish_reason: Option<&'a str>, } -/// The usage a model step reported rides on the first assistant row that step -/// committed, in the same run. A step that committed nothing keeps its usage -/// off every row rather than on a neighbour's, and is counted in `orphans`. -fn take_muse_usage( - run_id: Option<&str>, - pending: &mut Option, - orphans: &mut usize, -) -> Option { - match pending.take() { - Some(usage) if usage.run_id.as_deref() == run_id => Some(usage.token_json), - Some(_) => { - *orphans += 1; - None +impl<'a> MuseStep<'a> { + fn of(transcript: &'a muse::MuseTranscript, index: usize) -> Self { + match &transcript.records[index].event { + muse::MuseEvent::ModelCompleted { + model, + usage, + finish_reason, + .. + } => Self { + token_json: usage.as_ref().map(Value::to_string), + model: model.as_deref(), + finish_reason: finish_reason.as_deref(), + }, + _ => Self { + token_json: None, + model: None, + finish_reason: None, + }, } - None => None, } } @@ -10967,9 +11127,18 @@ pub(crate) fn ingest_muse_session( ..Default::default() }; let outcomes = muse::tool_outcomes(transcript); + let tool_names = muse::tool_names(transcript); + let (step_owners, orphan_steps) = muse::pair_model_steps(transcript); + outcome.unattached_usage = orphan_steps; + // The step a record owns, or none: usage, model and finish reason belong + // to that one record, never to its neighbours. + let step_of = |index: usize| { + step_owners + .get(&index) + .map(|step| MuseStep::of(transcript, *step)) + }; + // The model a record ran under when its own step does not name one. let mut model = transcript.metadata.model_id.clone(); - let mut finish_reason: Option = None; - let mut pending_usage: Option = None; let mut first_prompt: Option = None; let mut last_assistant_text: Option = None; let mut results = ToolResultIndexer::default(); @@ -10989,9 +11158,6 @@ pub(crate) fn ingest_muse_session( }; match &record.event { muse::MuseEvent::Prompt { text } => { - if pending_usage.take().is_some() { - outcome.unattached_usage += 1; - } if first_prompt.is_none() { first_prompt = Some(crate::discover::excerpt(text)); } @@ -11029,23 +11195,14 @@ pub(crate) fn ingest_muse_session( }, )?; } + // Paired with its assistant record up front; only the running + // model is tracked here. muse::MuseEvent::ModelCompleted { - model: step_model, - usage, - finish_reason: step_finish, - .. + model: step_model, .. } => { - if pending_usage.is_some() { - outcome.unattached_usage += 1; - } if step_model.is_some() { model = step_model.clone(); } - finish_reason = step_finish.clone(); - pending_usage = usage.as_ref().map(|usage| PendingMuseUsage { - run_id: record.run_id.clone(), - token_json: usage.to_string(), - }); } muse::MuseEvent::Reasoning { message_id, @@ -11053,8 +11210,12 @@ pub(crate) fn ingest_muse_session( encrypted, } => match text { Some(text) => { - let token_json = - take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let step = step_of(idx); + let token_json = step.as_ref().and_then(|step| step.token_json.clone()); + let row_model = step + .as_ref() + .and_then(|step| step.model) + .or(model.as_deref()); let message_id = message_id.as_deref().unwrap_or(&record_uid); insert_session_event( conn, @@ -11069,7 +11230,7 @@ pub(crate) fn ingest_muse_session( "assistant", "thinking", Some(text), - model.as_deref(), + row_model, token_json.as_deref(), RequestIdentity::none(), &record_uid, @@ -11104,8 +11265,13 @@ pub(crate) fn ingest_muse_session( response_id, text, } => { - let token_json = - take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let step = step_of(idx); + let token_json = step.as_ref().and_then(|step| step.token_json.clone()); + let row_model = step + .as_ref() + .and_then(|step| step.model) + .or(model.as_deref()); + let finish_reason = step.as_ref().and_then(|step| step.finish_reason); let message_id = message_id.as_deref().unwrap_or(&record_uid); insert_session_event( conn, @@ -11120,7 +11286,7 @@ pub(crate) fn ingest_muse_session( "assistant", "text", Some(text), - model.as_deref(), + row_model, token_json.as_deref(), RequestIdentity { request_id: response_id.as_deref(), @@ -11130,7 +11296,7 @@ pub(crate) fn ingest_muse_session( None, RawMessageFacts { request_id: response_id.as_deref(), - stop_reason: finish_reason.as_deref(), + stop_reason: finish_reason, ..facts }, )?; @@ -11143,9 +11309,17 @@ pub(crate) fn ingest_muse_session( calls, } => { let message_id = message_id.as_deref().unwrap_or(&record_uid); + let step = step_of(idx); + let row_model = step + .as_ref() + .and_then(|step| step.model) + .or(model.as_deref()); + let finish_reason = step.as_ref().and_then(|step| step.finish_reason); + // One request, several calls: its usage is stored once, on + // the first call's row. + let mut step_usage = step.as_ref().and_then(|step| step.token_json.clone()); for call in calls { - let token_json = - take_muse_usage(run_id, &mut pending_usage, &mut outcome.unattached_usage); + let token_json = step_usage.take(); let target = muse::pick_tool_target(&call.name, &call.arguments); let uid = format!("tool:{}", call.call_id); insert_session_event( @@ -11165,7 +11339,7 @@ pub(crate) fn ingest_muse_session( target.as_deref(), &call.arguments, )), - model.as_deref(), + row_model, token_json.as_deref(), RequestIdentity { request_id: response_id.as_deref(), @@ -11175,7 +11349,7 @@ pub(crate) fn ingest_muse_session( None, RawMessageFacts { request_id: response_id.as_deref(), - stop_reason: finish_reason.as_deref(), + stop_reason: finish_reason, ..facts }, )?; @@ -11221,6 +11395,7 @@ pub(crate) fn ingest_muse_session( let mut result_facts = tool_result_facts::muse_tool_result_facts( text, &result.call_id, + tool_names.get(result.call_id.as_str()).copied(), outcomes.get(result.call_id.as_str()).copied(), ); if result_facts.result_status.as_deref() @@ -11265,9 +11440,6 @@ pub(crate) fn ingest_muse_session( } } muse::MuseEvent::RunTerminal { status, reason } => { - if pending_usage.take().is_some() { - outcome.unattached_usage += 1; - } let payload_json = marker_payload(vec![("terminal", json!(status)), ("reason", json!(reason))]); outcome.markers += insert_session_marker( @@ -11355,9 +11527,6 @@ pub(crate) fn ingest_muse_session( muse::MuseEvent::SubagentLinked(_) => {} } } - if pending_usage.is_some() { - outcome.unattached_usage += 1; - } let first_ts = transcript.first_ts().unwrap_or_default(); let last_ts = transcript.last_ts().unwrap_or(first_ts); @@ -11372,11 +11541,15 @@ pub(crate) fn ingest_muse_session( last_assistant_text.as_deref(), Some(raw_path), )?; + // `upsert_session` merges, which suits an append-only log read in pieces. + // This read is the whole transcript, so the fields it owns are assigned + // from it, empty values included: a rewritten file that no longer has a + // reply or a prompt must not keep quoting the old one. let models = transcript.models(); conn.execute( "UPDATE sessions SET first_activity_ms = ?, last_activity_ms = ?, \ - last_assistant_text = COALESCE(?, last_assistant_text), models_json = ?, \ - first_prompt = COALESCE(?, first_prompt), agent_version = COALESCE(?, agent_version) \ + last_assistant_text = ?, models_json = ?, \ + first_prompt = ?, agent_version = COALESCE(?, agent_version) \ WHERE source = 'muse' AND session_id = ?", params![ first_ts, @@ -14764,7 +14937,7 @@ mod tests { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/muse/tools-session/.local/share/muse/sessions"); let root = home.join(".local/share/muse/sessions"); - let day = "2026/09/20/0199a1b2-0000-7000-8000-00000000a001"; + let day = "2026/09/20/muse-a001"; for file in super::muse_session_files(&fixture.join(day).join("session.jsonl")).unwrap() { let target = root.join(file.strip_prefix(&fixture).unwrap()); fs::create_dir_all(target.parent().unwrap()).unwrap(); @@ -14784,8 +14957,8 @@ mod tests { .unwrap() } - const MUSE_PARENT: &str = "0199a1b2-0000-7000-8000-00000000a001"; - const MUSE_WORKER: &str = "0199a1b2-0000-7000-8000-00000000c001"; + const MUSE_PARENT: &str = "muse-a001"; + const MUSE_WORKER: &str = "muse-c001"; fn muse_count(conn: &Connection, table: &str) -> i64 { conn.query_row( @@ -14830,7 +15003,7 @@ mod tests { ] { let record = json!({ "id": format!("grown-{sequence}"), - "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, + "stream": {"kind": "session", "id": "muse-a001"}, "sequence": sequence, "recorded_at": 1_790_337_700_000_000i64 + sequence, "payload_type": "runtime.session", @@ -14909,6 +15082,136 @@ mod tests { ); } + /// Roots under `home` with nothing read from the environment, for the + /// fingerprint-gated sync path. + fn muse_roots(home: &Path) -> crate::ProviderRoots { + crate::ProviderRoots::from_home(home.to_path_buf(), home.join("opencode.db")) + } + + fn append_muse_record(log: &Path, session_id: &str, uid: &str, text: &str) { + let record = json!({ + "id": uid, + "stream": {"kind": "session", "id": session_id}, + "sequence": 99, + "recorded_at": 1_790_337_700_000_000i64, + "payload_type": "runtime.session", + "payload": {"kind": "run", "run_id": "late", "event": { + "kind": "assistant_message_committed", "message_id": uid, "text": text + }}, + }); + let mut file = fs::OpenOptions::new().append(true).open(log).unwrap(); + writeln!(file, "{record}").unwrap(); + } + + /// A subagent that keeps writing after its parent has stopped changes + /// only its own log. That has to move the source fingerprint, or a + /// plain `sync` takes the fast path and never reads it. + #[test] + fn a_growing_muse_subagent_log_moves_the_sync_fast_path() { + let home = tempfile::tempdir().unwrap(); + let (_, transcript) = muse_fixture(home.path()); + let db = home.path().join("history.db"); + let roots = muse_roots(home.path()); + assert!( + super::sync_exclusive_with_roots(&db, &roots, false) + .unwrap() + .swept + ); + assert!( + !super::sync_exclusive_with_roots(&db, &roots, false) + .unwrap() + .swept, + "an unchanged store takes the fast path" + ); + let conn = open_db(&db).unwrap(); + let before = muse_session_count(&conn, "session_events", MUSE_WORKER); + + let child_log = transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}/session.jsonl")); + append_muse_record(&child_log, MUSE_WORKER, "worker-late", "Also: no docs."); + assert!( + super::sync_exclusive_with_roots(&db, &roots, false) + .unwrap() + .swept + ); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_WORKER), + before + 1 + ); + } + + /// Rows lost under an unchanged stamp — in a session, or in a subagent + /// it links — are put back by the next plain `sync`, not only by a forced + /// one: Muse is a source the destination marker guards. + #[test] + fn lost_muse_evidence_is_repaired_by_the_next_sync() { + let home = tempfile::tempdir().unwrap(); + let _ = muse_fixture(home.path()); + let db = home.path().join("history.db"); + let roots = muse_roots(home.path()); + super::sync_exclusive_with_roots(&db, &roots, false).unwrap(); + let conn = open_db(&db).unwrap(); + let parent_events = muse_session_count(&conn, "session_events", MUSE_PARENT); + let worker_events = muse_session_count(&conn, "session_events", MUSE_WORKER); + + conn.execute( + "DELETE FROM session_events WHERE source = 'muse' AND session_id = ? \ + AND event_uid = 'tool:call_read'", + [MUSE_PARENT], + ) + .unwrap(); + conn.execute( + "DELETE FROM session_events WHERE source = 'muse' AND session_id = ? \ + AND role = 'assistant'", + [MUSE_WORKER], + ) + .unwrap(); + super::sync_exclusive_with_roots(&db, &roots, false).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_PARENT), + parent_events + ); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_WORKER), + worker_events + ); + } + + /// A rewritten transcript that no longer has a reply or a prompt does not + /// keep quoting the old ones in the catalog. + #[test] + fn a_rewritten_muse_transcript_replaces_its_catalog_text() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let catalog = |conn: &Connection| -> (Option, Option) { + conn.query_row( + "SELECT first_prompt, last_assistant_text FROM sessions \ + WHERE source = 'muse' AND session_id = ?", + [MUSE_PARENT], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + }; + let (prompt, reply) = catalog(&conn); + assert!(prompt.is_some() && reply.is_some()); + + // Keep only the permission frame, metadata and the open record. + let head: String = fs::read_to_string(&transcript) + .unwrap() + .lines() + .take(3) + .map(|line| format!("{line}\n")) + .collect(); + fs::write(&transcript, head).unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!(catalog(&conn), (None, None)); + } + /// A child whose log is gone is no longer evidenced: the edge and the /// child's events go with it. #[test] @@ -14918,13 +15221,7 @@ mod tests { let conn = open_db(&home.path().join("history.db")).unwrap(); let mut state = Map::new(); super::sync_muse(&conn, &mut state, &root).unwrap(); - fs::remove_dir_all( - transcript - .parent() - .unwrap() - .join("subagent/0199a1b2-0000-7000-8000-00000000e001"), - ) - .unwrap(); + fs::remove_dir_all(transcript.parent().unwrap().join("subagent/muse-e001")).unwrap(); super::sync_muse(&conn, &mut state, &root).unwrap(); let children: Vec = conn .prepare( @@ -14938,17 +15235,10 @@ mod tests { .unwrap(); assert_eq!( children, - vec![ - MUSE_WORKER.to_string(), - "0199a1b2-0000-7000-8000-00000000c002".to_string() - ] + vec![MUSE_WORKER.to_string(), "muse-c002".to_string()] ); assert_eq!( - muse_session_count( - &conn, - "session_events", - "0199a1b2-0000-7000-8000-00000000e001" - ), + muse_session_count(&conn, "session_events", "muse-e001"), 0, "the removed child's events go with its edge" ); diff --git a/crates/ai-hist/src/ingest/hydrate.rs b/crates/ai-hist/src/ingest/hydrate.rs index 72a337ef..af814f52 100644 --- a/crates/ai-hist/src/ingest/hydrate.rs +++ b/crates/ai-hist/src/ingest/hydrate.rs @@ -1589,10 +1589,11 @@ fn source_snapshot( SnapshotRecords::DeferredGrok(path.clone()), inventory.stamp, ) - } else if options.source == "muse" { - // A Muse session is its transcript plus the subagent logs beside it; - // the stamp and the counts cover all of them, so a child that grew - // after the parent's last record is still a change. + } else if options.source == "muse" && options.include_related { + // With related evidence, a Muse session is its transcript plus the + // subagent logs beside it; the stamp and the counts cover all of them, + // so a child that grew after the parent's last record is still a + // change. Without it, the transcript alone, like any other file. let mut bytes = 0i64; let mut records = 0i64; for file in muse_session_files(&path)? { @@ -2979,7 +2980,14 @@ fn ingest_muse( options: &HydrateSessionOptions, path: &Path, ) -> Result> { - let transcript = read_muse_transcript(path)?.ok_or_else(|| { + // Subagent logs are related evidence: read and linked only when the + // caller asked for it, and otherwise left exactly as they are. + let tree = if options.include_related { + read_muse_tree(path)? + } else { + read_muse_transcript(path)?.map(|transcript| (transcript, Vec::new())) + }; + let (transcript, children) = tree.ok_or_else(|| { hydration_error( "SESSION_SOURCE_MISMATCH", "Muse Code transcript has no session metadata", @@ -2991,7 +2999,12 @@ fn ingest_muse( "Muse Code transcript identity does not match the catalog row", )); } - let outcome = ingest_muse_session_tree(conn, &transcript, path)?; + let outcome = ingest_muse_session_tree( + conn, + &transcript, + path, + options.include_related.then_some(children.as_slice()), + )?; Ok(muse_diagnostics(&outcome)) } @@ -3060,8 +3073,9 @@ fn muse_diagnostics(outcome: &MuseIngestOutcome) -> Vec { diagnostics.push(diagnostic( "MUSE_LINES_UNPARSED", format!( - "{} line(s) of session.jsonl were not JSON records and were skipped (a live \ - session's partial last line is one)", + "{} complete line(s) of session.jsonl were not JSON records and were \ + skipped; a live session's unterminated last line is not counted, it is \ + read once Muse finishes it", outcome.unparsed_lines ), )); @@ -4113,6 +4127,46 @@ mod tests { found.pop().expect("a staged chat_history.jsonl") } + /// A hydration that did not ask for related evidence indexes the session + /// and leaves its subagent logs alone; asking for it links them. + #[test] + fn muse_hydration_links_subagents_only_when_related_evidence_is_asked_for() { + let dir = tempfile::tempdir().unwrap(); + copy_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/muse/tools-session"), + dir.path(), + ); + let transcript = dir + .path() + .join(".local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl"); + let db = dir.path().join("history.db"); + let conn = open_db(&db).unwrap(); + catalog_row(&conn, "muse", "muse-a001", Some(&transcript)); + let counts = |conn: &Connection| -> (i64, i64) { + conn.query_row( + "SELECT (SELECT COUNT(*) FROM session_relationships WHERE source = 'muse'), \ + (SELECT COUNT(*) FROM session_events \ + WHERE source = 'muse' AND session_id <> 'muse-a001')", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + }; + + let mut without = options("muse", "muse-a001"); + without.include_related = false; + let result = hydrate_session_at_with_home(&db, &without, dir.path()).unwrap(); + assert!(result.related_session_ids.is_empty()); + assert_eq!(counts(&conn), (0, 0), "no child evidence was written"); + + let result = + hydrate_session_at_with_home(&db, &options("muse", "muse-a001"), dir.path()).unwrap(); + assert_eq!(result.related_session_ids.len(), 3); + let (edges, child_events) = counts(&conn); + assert_eq!(edges, 3); + assert!(child_events > 0); + } + fn copy_tree(from: &Path, to: &Path) { for entry in fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs index 5102c291..e7338ad7 100644 --- a/crates/ai-hist/src/ingest/muse.rs +++ b/crates/ai-hist/src/ingest/muse.rs @@ -423,7 +423,14 @@ fn parse_tool_call(call: &Value) -> Option { pub(crate) fn parse_transcript(contents: &str) -> Option { let mut values = Vec::new(); let mut unparsed_lines = 0; - for line in contents.lines() { + // Only newline-terminated records are committed. A live session's last + // line may still be mid-write, and a prefix of a record can itself be + // valid JSON, so the unterminated tail is left for the next read. + let complete = match contents.rfind('\n') { + Some(end) => &contents[..=end], + None => "", + }; + for line in complete.lines() { if line.trim().is_empty() { continue; } @@ -472,6 +479,91 @@ pub(crate) fn parse_transcript(contents: &str) -> Option { Some(transcript) } +/// Which assistant record each model step's `model_completed` belongs to. +/// +/// Muse does not write the two in one order. A step that calls tools logs +/// `model_completed` *before* `assistant_tool_calls_committed`; a step that +/// answers in prose logs `assistant_message_committed` *before* its +/// `model_completed` (both are in the real 0.2.1 capture). So a step's usage +/// is paired, within one run, with the first assistant record committed since +/// the previous step closed — earlier or later, whichever the step wrote — +/// and never carried across a prompt or a run's `terminal`. +/// +/// Returns `(owners, orphans)`: `owners` maps an assistant record's index to +/// its step's `model_completed` index, and `orphans` counts steps that +/// reported usage but committed no assistant record to carry it. +pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap, usize) { + #[derive(Default)] + struct Run { + /// A step's first commit, not yet paired with its `model_completed`. + open_commit: Option, + /// A `model_completed` waiting for the commit its step writes next. + pending: Option, + } + let has_usage = |index: usize| { + matches!( + &transcript.records[index].event, + MuseEvent::ModelCompleted { usage: Some(_), .. } + ) + }; + let mut owners = HashMap::new(); + let mut orphans = 0; + let mut runs: HashMap, Run> = HashMap::new(); + for (index, record) in transcript.records.iter().enumerate() { + let run = runs.entry(record.run_id.as_deref()).or_default(); + match &record.event { + MuseEvent::AssistantText { .. } + | MuseEvent::ToolCalls { .. } + | MuseEvent::Reasoning { text: Some(_), .. } => { + if let Some(step) = run.pending.take() { + owners.insert(index, step); + run.open_commit = None; + } else if run.open_commit.is_none() { + run.open_commit = Some(index); + } + } + MuseEvent::ModelCompleted { .. } => { + if let Some(step) = run.pending.take() { + orphans += usize::from(has_usage(step)); + } + match run.open_commit.take() { + Some(commit) => { + owners.insert(commit, index); + } + None => run.pending = Some(index), + } + } + MuseEvent::Prompt { .. } | MuseEvent::RunTerminal { .. } => { + if let Some(step) = run.pending.take() { + orphans += usize::from(has_usage(step)); + } + run.open_commit = None; + } + _ => {} + } + } + orphans += runs + .values() + .filter_map(|run| run.pending) + .filter(|step| has_usage(*step)) + .count(); + (owners, orphans) +} + +/// Every call's tool name, by call id. +pub(crate) fn tool_names(transcript: &MuseTranscript) -> HashMap<&str, &str> { + transcript + .records + .iter() + .filter_map(|record| match &record.event { + MuseEvent::ToolCalls { calls, .. } => Some(calls), + _ => None, + }) + .flatten() + .map(|call| (call.call_id.as_str(), call.name.as_str())) + .collect() +} + /// Every call's authoritative outcome, by call id. pub(crate) fn tool_outcomes(transcript: &MuseTranscript) -> HashMap<&str, (&str, Option<&str>)> { transcript @@ -671,7 +763,8 @@ mod tests { ), run(8, json!({"kind": "terminal", "terminal": "completed"})), ] - .join("\n"); + .join("\n") + + "\n"; let transcript = parse_transcript(&contents).unwrap(); let kinds: Vec<_> = transcript .records @@ -703,6 +796,54 @@ mod tests { assert_eq!(transcript.models(), vec!["meta/muse-spark".to_string()]); } + /// Tool steps log usage before their calls; prose steps after their + /// reply. Both pair with the right record, and neither leaks into the + /// next step or the next run. + #[test] + fn model_steps_pair_with_their_commit_in_either_order() { + let usage = json!({"input_tokens": 1, "output_tokens": 1}); + let contents = [ + metadata(), + run(2, json!({"kind": "started", "prompt": "go"})), + run(3, json!({"kind": "model_completed", "usage": usage})), + run( + 4, + json!({"kind": "assistant_tool_calls_committed", + "tool_calls": [{"call_id": "c", "name": "bash", "args": "{}"}]}), + ), + run( + 5, + json!({"kind": "assistant_message_committed", "text": "done"}), + ), + run(6, json!({"kind": "model_completed", "usage": usage})), + run(7, json!({"kind": "model_completed", "usage": usage})), + run(8, json!({"kind": "terminal", "terminal": "completed"})), + ] + .join("\n") + + "\n"; + let transcript = parse_transcript(&contents).unwrap(); + let (owners, orphans) = pair_model_steps(&transcript); + // Record indexes: 0 prompt, 1 usage, 2 calls, 3 reply, 4 usage, + // 5 usage with nothing to carry it, 6 terminal. + assert_eq!(owners.get(&2), Some(&1)); + assert_eq!(owners.get(&3), Some(&4)); + assert_eq!(owners.len(), 2); + assert_eq!(orphans, 1); + } + + #[test] + fn an_unterminated_last_line_is_not_read_yet() { + let contents = format!( + "{}\n{}", + metadata(), + run(2, json!({"kind": "started", "prompt": "hi"})) + ); + let transcript = parse_transcript(&contents).unwrap(); + assert!(transcript.records.is_empty(), "{:?}", transcript.records); + let transcript = parse_transcript(&format!("{contents}\n")).unwrap(); + assert_eq!(transcript.records.len(), 1); + } + #[test] fn records_on_another_stream_are_not_the_sessions() { let foreign = json!({ diff --git a/crates/ai-hist/src/ingest/tool_result_facts.rs b/crates/ai-hist/src/ingest/tool_result_facts.rs index e5f96cd3..61741032 100644 --- a/crates/ai-hist/src/ingest/tool_result_facts.rs +++ b/crates/ai-hist/src/ingest/tool_result_facts.rs @@ -352,10 +352,13 @@ pub fn codex_output_facts(output: &Value, call_id: &str) -> ToolResultFacts { /// separate `tool_batch.effect.terminal` record, passed here as /// `(outcome.kind, reason)`; without one the status stays `unknown`. A `bash` /// result that completed as a call but whose command exited non-zero is an -/// error too, the way Codex reads `exit_code`. +/// error too, the way Codex reads `exit_code`. Only a `bash` result: any +/// other tool's output is the tool's data, and a file that happens to hold +/// `{"exit_code": 1}` is not a failed read. pub fn muse_tool_result_facts( text: Option<&str>, call_id: &str, + tool_name: Option<&str>, outcome: Option<(&str, Option<&str>)>, ) -> ToolResultFacts { let payload = text.map_or(Value::Null, |text| Value::String(text.to_string())); @@ -376,6 +379,7 @@ pub fn muse_tool_result_facts( }; facts.result_status = Some(status.to_string()); if status == STATUS_COMPLETED + && tool_name == Some("bash") && text .and_then(crate::ingest::muse::bash_exit_code) .is_some_and(|code| code != 0) @@ -400,6 +404,20 @@ mod tests { use super::*; use serde_json::json; + /// Only a `bash` result's `exit_code` is a failure signal; any other + /// tool's output is data that may happen to contain the same key. + #[test] + fn muse_exit_codes_fail_only_bash_results() { + let text = Some(r#"{"exit_code":101}"#); + let completed = Some(("completed", None)); + let bash = muse_tool_result_facts(text, "c1", Some("bash"), completed); + assert_eq!(bash.result_status.as_deref(), Some(STATUS_ERRORED)); + assert_eq!(bash.error_signal.as_deref(), Some(ERROR_SIGNAL_EXIT_CODE)); + let read = muse_tool_result_facts(text, "c2", Some("read_file"), completed); + assert_eq!(read.result_status.as_deref(), Some(STATUS_COMPLETED)); + assert_eq!(read.error_signal, None); + } + #[test] fn stable_stringify_sorts_object_keys_and_keeps_array_order() { let value = json!({ "b": 1, "a": 2, "c": [3, { "y": 1, "x": 2 }] }); diff --git a/crates/ai-hist/tests/fixture_corpus.rs b/crates/ai-hist/tests/fixture_corpus.rs index 5f5abcf9..e834668a 100644 --- a/crates/ai-hist/tests/fixture_corpus.rs +++ b/crates/ai-hist/tests/fixture_corpus.rs @@ -2350,6 +2350,18 @@ fn muse_cli_capture_reaches_history_tools_and_recorded_times() { .all(|event| !text(event, "text").starts_with("Role:")), "a mirrored task stream is never a prompt: {events:?}" ); + // Tool steps log `model_completed` before their calls and prose steps + // after their reply; every one of the six steps keeps its usage. + let with_usage: Vec<&str> = events + .iter() + .filter(|event| !field(event, "token_json").is_null()) + .map(|event| text(event, "kind")) + .collect(); + assert_eq!( + with_usage, + vec!["tool_use", "tool_use", "tool_use", "text", "text", "text"], + "{events:?}" + ); } /// The authored Muse session: edits reach `file_edits`, a completed `bash` @@ -2359,10 +2371,10 @@ fn muse_cli_capture_reaches_history_tools_and_recorded_times() { /// names, without becoming a catalog row or a typed prompt. #[test] fn muse_tools_session_records_edits_errors_usage_and_linked_subagents() { - const PARENT: &str = "0199a1b2-0000-7000-8000-00000000a001"; - const WORKER: &str = "0199a1b2-0000-7000-8000-00000000c001"; - const NESTED: &str = "0199a1b2-0000-7000-8000-00000000c002"; - const REMINDER: &str = "0199a1b2-0000-7000-8000-00000000e001"; + const PARENT: &str = "muse-a001"; + const WORKER: &str = "muse-c001"; + const NESTED: &str = "muse-c002"; + const REMINDER: &str = "muse-e001"; let edits = rows("muse/tools-session", "file_edits"); let mut paths: Vec<&str> = edits.iter().map(|edit| text(edit, "file_path")).collect(); paths.sort_unstable(); diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl deleted file mode 100644 index 0e7a77c3..00000000 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl +++ /dev/null @@ -1,31 +0,0 @@ -{"retained_frame": "session_permission_transaction", "children": []} -{"schema_version": 1, "id": "a001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 1, "recorded_at": 1790337600250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "a001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 2, "recorded_at": 1790337600500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": false, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "a001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 3, "recorded_at": 1790337600750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "started", "prompt": "Add a retry helper to src/http.rs and run the tests."}}} -{"schema_version": 1, "id": "a001-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 4, "recorded_at": 1790337601000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 900, "finish_reason": "tool_calls", "usage": {"input_tokens": 1200, "output_tokens": 90, "cached_tokens": 800, "cache_read_tokens": 800, "cache_write_tokens": 0, "reasoning_tokens": 40}}}} -{"schema_version": 1, "id": "a001-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 5, "recorded_at": 1790337601250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r1", "text": "", "encrypted_content": "gAAAAB-opaque"}}} -{"schema_version": 1, "id": "a001-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 6, "recorded_at": 1790337601500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r2", "text": "Read the file before editing it."}}} -{"schema_version": 1, "id": "a001-rec-007", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 7, "recorded_at": 1790337601750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-1", "response_id": "resp-1", "tool_calls": [{"id": "fc_read", "call_id": "call_read", "name": "read_file", "args": "{\"path\": \"src/http.rs\"}"}]}}} -{"schema_version": 1, "id": "a001-rec-008", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 8, "recorded_at": 1790337602000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_read", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "a001-rec-009", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 9, "recorded_at": 1790337602250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-1", "results": [{"tool_call_id": "call_read", "tool_call_index": 0, "text": "Read text file `src/http.rs`.\n1|pub fn get() {}"}]}}} -{"schema_version": 1, "id": "a001-rec-010", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 10, "recorded_at": 1790337602500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 700, "finish_reason": "tool_calls", "usage": {"input_tokens": 1500, "output_tokens": 210, "cached_tokens": 1100, "cache_read_tokens": 1100, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "a001-rec-011", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 11, "recorded_at": 1790337602750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-2", "response_id": "resp-2", "tool_calls": [{"id": "fc_edit", "call_id": "call_edit", "name": "edit_file", "args": "{\"path\": \"src/http.rs\", \"find\": \"pub fn get() {}\", \"replace\": \"pub fn get() {}\\npub fn retry() {}\"}"}, {"id": "fc_write", "call_id": "call_write", "name": "write_file", "args": "{\"path\": \"tests/retry.rs\", \"content\": \"#[test] fn retries() {}\"}"}, {"id": "fc_bash", "call_id": "call_bash", "name": "bash", "args": "{\"command\": \"cargo test\", \"workdir\": \"/tmp/muse-demo\"}"}, {"id": "fc_spawn", "call_id": "call_spawn", "name": "subagent_spawn", "args": "{\"role\": \"reviewer\", \"objective\": \"review src/http.rs\"}"}]}}} -{"schema_version": 1, "id": "a001-rec-012", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 12, "recorded_at": 1790337603000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_edit", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "a001-rec-013", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 13, "recorded_at": 1790337603250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_write", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "a001-rec-014", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 14, "recorded_at": 1790337603500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_bash", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "a001-rec-015", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 15, "recorded_at": 1790337603750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_spawn", "kind": "terminal", "outcome": {"kind": "completed"}}}} -{"schema_version": 1, "id": "a001-rec-016", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 16, "recorded_at": 1790337604000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "task_stream_linked", "execution_mode": "background", "task_id": "0199a1b2-0000-7000-8000-00000000d001", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d001", "kind": "task"}, "display": {"label": "reviewer", "model": "same-as-main", "path": "subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", "role": "worker"}}}} -{"schema_version": 1, "id": "a001-rec-017", "stream": {"kind": "task", "id": "0199a1b2-0000-7000-8000-00000000d001"}, "sequence": 17, "recorded_at": 1790337604250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "task-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} -{"schema_version": 1, "id": "a001-rec-018", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 18, "recorded_at": 1790337604500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-2", "results": [{"tool_call_id": "call_edit", "tool_call_index": 0, "text": "edited \u2014 diff: -pub fn get() {} +pub fn retry() {}"}, {"tool_call_id": "call_write", "tool_call_index": 1, "text": "wrote 22 bytes to tests/retry.rs"}, {"tool_call_id": "call_bash", "tool_call_index": 2, "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}"}, {"tool_call_id": "call_spawn", "tool_call_index": 3, "text": "{\"ok\": true, \"task_id\": \"0199a1b2-0000-7000-8000-00000000d001\", \"status\": \"running\"}"}]}}} -{"schema_version": 1, "id": "a001-rec-019", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 19, "recorded_at": 1790337604750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 400, "finish_reason": "stop", "usage": {"input_tokens": 1800, "output_tokens": 35, "cached_tokens": 1500, "cache_read_tokens": 1500, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "a001-rec-020", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 20, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_message_committed", "message_id": "msg-3", "response_id": "resp-3", "text": "Added `retry` and a test; `cargo test` fails with exit code 101."}}} -{"schema_version": 1, "id": "a001-rec-021", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 21, "recorded_at": 1790337605250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "memory_reminder_child_session_linked", "child_session_id": "0199a1b2-0000-7000-8000-00000000e001", "child_session_log_path": "subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl", "generation_id": 1, "parent_run_id": "run-a", "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", "reminder_agent_id": "plugin:tbh-reminders:goal-reminder", "task_id": "0199a1b2-0000-7000-8000-00000000d002", "task_stream": {"id": "0199a1b2-0000-7000-8000-00000000d002", "kind": "task"}}}} -{"schema_version": 1, "id": "a001-rec-022", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 22, "recorded_at": 1790337605500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "terminal", "terminal": "completed", "reason": null, "turn_duration_ms": 2500}}} -{"schema_version": 1, "id": "a001-rec-023", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 23, "recorded_at": 1790337605750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.end", "payload_schema_version": 1, "payload": {"kind": "session_end", "record": {"exit_reason": "clean", "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "a001-rec-024", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 24, "recorded_at": 1790337606000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": true, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "a001-rec-025", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 25, "recorded_at": 1790337606250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.resumed", "payload_schema_version": 1, "payload": {"kind": "session_resumed", "record": {"prior_turn_count": 1, "session_id": "0199a1b2-0000-7000-8000-00000000a001"}}} -{"schema_version": 1, "id": "a001-rec-026", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 26, "recorded_at": 1790337606500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3-contributor", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "a001-rec-027", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 27, "recorded_at": 1790337606750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "started", "prompt": "Why did it fail?"}}} -{"schema_version": 1, "id": "a001-rec-028", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 28, "recorded_at": 1790337607000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3-contributor", "duration_ms": 300, "finish_reason": "stop", "usage": {"input_tokens": 2000, "output_tokens": 20, "cached_tokens": 1800, "cache_read_tokens": 1800, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "a001-rec-029", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 29, "recorded_at": 1790337607250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "assistant_message_committed", "message_id": "msg-4", "response_id": "resp-4", "text": "`retry` is declared but the test expects it to return a value."}}} -{"schema_version": 1, "id": "a001-rec-030", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000a001"}, "sequence": 30, "recorded_at": 1790337607500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "terminal", "terminal": "interrupted", "reason": "user_cancelled", "turn_duration_ms": 400}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl deleted file mode 100644 index eb5305c6..00000000 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"schema_version": 1, "id": "c001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 1, "recorded_at": 1790337604350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "c001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 2, "recorded_at": 1790337604600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} -{"schema_version": 1, "id": "c001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 3, "recorded_at": 1790337604850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 200, "finish_reason": "stop", "usage": {"input_tokens": 600, "output_tokens": 25, "cached_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} -{"schema_version": 1, "id": "c001-rec-004", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 4, "recorded_at": 1790337605100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "assistant_message_committed", "message_id": "child-msg-1", "text": "`retry` has no body; the test cannot pass."}}} -{"schema_version": 1, "id": "c001-rec-005", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 5, "recorded_at": 1790337605350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "task_stream_linked", "task_id": "0199a1b2-0000-7000-8000-00000000d003", "display": {"label": "linter", "model": "meta/muse-glimmer-30b", "path": "subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl", "role": "worker"}}}} -{"schema_version": 1, "id": "c001-rec-006", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c001"}, "sequence": 6, "recorded_at": 1790337605600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "terminal", "terminal": "completed"}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl deleted file mode 100644 index 5c41515f..00000000 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"schema_version": 1, "id": "c002-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 1, "recorded_at": 1790337605850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-glimmer-30b", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "c002-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 2, "recorded_at": 1790337606100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "started", "prompt": "Role: linter Objective: lint src/http.rs"}}} -{"schema_version": 1, "id": "c002-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000c002"}, "sequence": 3, "recorded_at": 1790337606350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "assistant_message_committed", "message_id": "grand-msg-1", "text": "No lint findings."}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl deleted file mode 100644 index cc519b4e..00000000 --- a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"schema_version": 1, "id": "e001-rec-001", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 1, "recorded_at": 1790337605650000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} -{"schema_version": 1, "id": "e001-rec-002", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 2, "recorded_at": 1790337605900000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "started", "prompt": "Is the user's task finished?"}}} -{"schema_version": 1, "id": "e001-rec-003", "stream": {"kind": "session", "id": "0199a1b2-0000-7000-8000-00000000e001"}, "sequence": 3, "recorded_at": 1790337606150000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "assistant_message_committed", "message_id": "remind-msg-1", "text": "No: the test still fails."}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl new file mode 100644 index 00000000..7749f0c5 --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl @@ -0,0 +1,31 @@ +{"retained_frame": "session_permission_transaction", "children": []} +{"schema_version": 1, "id": "a001-rec-001", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 1, "recorded_at": 1790337600250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "a001-rec-002", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 2, "recorded_at": 1790337600500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": false, "session_id": "muse-a001"}}} +{"schema_version": 1, "id": "a001-rec-003", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 3, "recorded_at": 1790337600750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "started", "prompt": "Add a retry helper to src/http.rs and run the tests."}}} +{"schema_version": 1, "id": "a001-rec-004", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 4, "recorded_at": 1790337601000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 900, "finish_reason": "tool_calls", "usage": {"input_tokens": 1200, "output_tokens": 90, "cached_tokens": 800, "cache_read_tokens": 800, "cache_write_tokens": 0, "reasoning_tokens": 40}}}} +{"schema_version": 1, "id": "a001-rec-005", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 5, "recorded_at": 1790337601250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r1", "text": "", "encrypted_content": "gAAAAB-opaque"}}} +{"schema_version": 1, "id": "a001-rec-006", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 6, "recorded_at": 1790337601500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "reasoning_committed", "message_id": "msg-r2", "text": "Read the file before editing it."}}} +{"schema_version": 1, "id": "a001-rec-007", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 7, "recorded_at": 1790337601750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-1", "response_id": "resp-1", "tool_calls": [{"id": "fc_read", "call_id": "call_read", "name": "read_file", "args": "{\"path\": \"src/http.rs\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-008", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 8, "recorded_at": 1790337602000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_read", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-009", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 9, "recorded_at": 1790337602250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-1", "results": [{"tool_call_id": "call_read", "tool_call_index": 0, "text": "Read text file `src/http.rs`.\n1|pub fn get() {}"}]}}} +{"schema_version": 1, "id": "a001-rec-010", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 10, "recorded_at": 1790337602500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 700, "finish_reason": "tool_calls", "usage": {"input_tokens": 1500, "output_tokens": 210, "cached_tokens": 1100, "cache_read_tokens": 1100, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-011", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 11, "recorded_at": 1790337602750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_tool_calls_committed", "message_id": "msg-2", "response_id": "resp-2", "tool_calls": [{"id": "fc_edit", "call_id": "call_edit", "name": "edit_file", "args": "{\"path\": \"src/http.rs\", \"find\": \"pub fn get() {}\", \"replace\": \"pub fn get() {}\\npub fn retry() {}\"}"}, {"id": "fc_write", "call_id": "call_write", "name": "write_file", "args": "{\"path\": \"tests/retry.rs\", \"content\": \"#[test] fn retries() {}\"}"}, {"id": "fc_bash", "call_id": "call_bash", "name": "bash", "args": "{\"command\": \"cargo test\", \"workdir\": \"/tmp/muse-demo\"}"}, {"id": "fc_spawn", "call_id": "call_spawn", "name": "subagent_spawn", "args": "{\"role\": \"reviewer\", \"objective\": \"review src/http.rs\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-012", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 12, "recorded_at": 1790337603000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_edit", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-013", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 13, "recorded_at": 1790337603250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_write", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-014", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 14, "recorded_at": 1790337603500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_bash", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-015", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 15, "recorded_at": 1790337603750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "tool_batch.effect.terminal", "payload_schema_version": 1, "payload": {"kind": "tool_batch_effect", "run_id": "run-a", "record": {"call_id": "call_spawn", "kind": "terminal", "outcome": {"kind": "completed"}}}} +{"schema_version": 1, "id": "a001-rec-016", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 16, "recorded_at": 1790337604000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "task_stream_linked", "execution_mode": "background", "task_id": "muse-task-d001", "task_stream": {"id": "muse-task-d001", "kind": "task"}, "display": {"label": "reviewer", "model": "same-as-main", "path": "subagent/muse-c001/session.jsonl", "role": "worker"}}}} +{"schema_version": 1, "id": "a001-rec-017", "stream": {"kind": "task", "id": "muse-task-d001"}, "sequence": 17, "recorded_at": 1790337604250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "task-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "a001-rec-018", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 18, "recorded_at": 1790337604500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "tool_result_batch_committed", "batch_id": "msg-2", "results": [{"tool_call_id": "call_edit", "tool_call_index": 0, "text": "edited \u2014 diff: -pub fn get() {} +pub fn retry() {}"}, {"tool_call_id": "call_write", "tool_call_index": 1, "text": "wrote 22 bytes to tests/retry.rs"}, {"tool_call_id": "call_bash", "tool_call_index": 2, "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}"}, {"tool_call_id": "call_spawn", "tool_call_index": 3, "text": "{\"ok\": true, \"task_id\": \"muse-task-d001\", \"status\": \"running\"}"}]}}} +{"schema_version": 1, "id": "a001-rec-019", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 19, "recorded_at": 1790337604750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 400, "finish_reason": "stop", "usage": {"input_tokens": 1800, "output_tokens": 35, "cached_tokens": 1500, "cache_read_tokens": 1500, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-020", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 20, "recorded_at": 1790337605000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "assistant_message_committed", "message_id": "msg-3", "response_id": "resp-3", "text": "Added `retry` and a test; `cargo test` fails with exit code 101."}}} +{"schema_version": 1, "id": "a001-rec-021", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 21, "recorded_at": 1790337605250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "memory_reminder_child_session_linked", "child_session_id": "muse-e001", "child_session_log_path": "subagent/muse-e001/session.jsonl", "generation_id": 1, "parent_run_id": "run-a", "parent_session_id": "muse-a001", "reminder_agent_id": "plugin:tbh-reminders:goal-reminder", "task_id": "muse-task-d002", "task_stream": {"id": "muse-task-d002", "kind": "task"}}}} +{"schema_version": 1, "id": "a001-rec-022", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 22, "recorded_at": 1790337605500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-a", "event": {"kind": "terminal", "terminal": "completed", "reason": null, "turn_duration_ms": 2500}}} +{"schema_version": 1, "id": "a001-rec-023", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 23, "recorded_at": 1790337605750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.end", "payload_schema_version": 1, "payload": {"kind": "session_end", "record": {"exit_reason": "clean", "session_id": "muse-a001"}}} +{"schema_version": 1, "id": "a001-rec-024", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 24, "recorded_at": 1790337606000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.opened.observed", "payload_schema_version": 1, "payload": {"kind": "session_opened", "record": {"resume": true, "session_id": "muse-a001"}}} +{"schema_version": 1, "id": "a001-rec-025", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 25, "recorded_at": 1790337606250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "session.resumed", "payload_schema_version": 1, "payload": {"kind": "session_resumed", "record": {"prior_turn_count": 1, "session_id": "muse-a001"}}} +{"schema_version": 1, "id": "a001-rec-026", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 26, "recorded_at": 1790337606500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3-contributor", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "a001-rec-027", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 27, "recorded_at": 1790337606750000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "started", "prompt": "Why did it fail?"}}} +{"schema_version": 1, "id": "a001-rec-028", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 28, "recorded_at": 1790337607000000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3-contributor", "duration_ms": 300, "finish_reason": "stop", "usage": {"input_tokens": 2000, "output_tokens": 20, "cached_tokens": 1800, "cache_read_tokens": 1800, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "a001-rec-029", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 29, "recorded_at": 1790337607250000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "assistant_message_committed", "message_id": "msg-4", "response_id": "resp-4", "text": "`retry` is declared but the test expects it to return a value."}}} +{"schema_version": 1, "id": "a001-rec-030", "stream": {"kind": "session", "id": "muse-a001"}, "sequence": 30, "recorded_at": 1790337607500000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "run-b", "event": {"kind": "terminal", "terminal": "interrupted", "reason": "user_cancelled", "turn_duration_ms": 400}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/session.jsonl new file mode 100644 index 00000000..284a0015 --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/session.jsonl @@ -0,0 +1,6 @@ +{"schema_version": 1, "id": "c001-rec-001", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 1, "recorded_at": 1790337604350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "c001-rec-002", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 2, "recorded_at": 1790337604600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "started", "prompt": "Role: reviewer Objective: review src/http.rs"}}} +{"schema_version": 1, "id": "c001-rec-003", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 3, "recorded_at": 1790337604850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "model_completed", "model": "meta/muse-spark-1.3", "duration_ms": 200, "finish_reason": "stop", "usage": {"input_tokens": 600, "output_tokens": 25, "cached_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "reasoning_tokens": 0}}}} +{"schema_version": 1, "id": "c001-rec-004", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 4, "recorded_at": 1790337605100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "assistant_message_committed", "message_id": "child-msg-1", "text": "`retry` has no body; the test cannot pass."}}} +{"schema_version": 1, "id": "c001-rec-005", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 5, "recorded_at": 1790337605350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "task_stream_linked", "task_id": "muse-task-d003", "display": {"label": "linter", "model": "meta/muse-glimmer-30b", "path": "subagent/muse-c002/session.jsonl", "role": "worker"}}}} +{"schema_version": 1, "id": "c001-rec-006", "stream": {"kind": "session", "id": "muse-c001"}, "sequence": 6, "recorded_at": 1790337605600000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "child-run", "event": {"kind": "terminal", "terminal": "completed"}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/subagent/muse-c002/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/subagent/muse-c002/session.jsonl new file mode 100644 index 00000000..6a206cbb --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/subagent/muse-c002/session.jsonl @@ -0,0 +1,3 @@ +{"schema_version": 1, "id": "c002-rec-001", "stream": {"kind": "session", "id": "muse-c002"}, "sequence": 1, "recorded_at": 1790337605850000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-glimmer-30b", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "c002-rec-002", "stream": {"kind": "session", "id": "muse-c002"}, "sequence": 2, "recorded_at": 1790337606100000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "started", "prompt": "Role: linter Objective: lint src/http.rs"}}} +{"schema_version": 1, "id": "c002-rec-003", "stream": {"kind": "session", "id": "muse-c002"}, "sequence": 3, "recorded_at": 1790337606350000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "grand-run", "event": {"kind": "assistant_message_committed", "message_id": "grand-msg-1", "text": "No lint findings."}}} diff --git a/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-e001/session.jsonl b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-e001/session.jsonl new file mode 100644 index 00000000..fe0f31fc --- /dev/null +++ b/crates/ai-hist/tests/fixtures/muse/tools-session/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-e001/session.jsonl @@ -0,0 +1,3 @@ +{"schema_version": 1, "id": "e001-rec-001", "stream": {"kind": "session", "id": "muse-e001"}, "sequence": 1, "recorded_at": 1790337605650000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session.metadata", "payload_schema_version": 1, "payload": {"kind": "metadata", "record": {"build": {"semver": "1.3.0", "sha": "0000000000"}, "model_id": "meta/muse-spark-1.3", "provider_id": "meta", "tool_surface_version": "2", "workspace_root": "/tmp/muse-demo"}}} +{"schema_version": 1, "id": "e001-rec-002", "stream": {"kind": "session", "id": "muse-e001"}, "sequence": 2, "recorded_at": 1790337605900000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "started", "prompt": "Is the user's task finished?"}}} +{"schema_version": 1, "id": "e001-rec-003", "stream": {"kind": "session", "id": "muse-e001"}, "sequence": 3, "recorded_at": 1790337606150000, "record_type": "event", "durability": "durable", "causation_id": null, "payload_type": "runtime.session", "payload_schema_version": 1, "payload": {"kind": "run", "run_id": "remind-run", "event": {"kind": "assistant_message_committed", "message_id": "remind-msg-1", "text": "No: the test still fails."}}} diff --git a/crates/ai-hist/tests/snapshots/muse/cli-capture.json b/crates/ai-hist/tests/snapshots/muse/cli-capture.json index 53dbc9a1..2ea68dd3 100644 --- a/crates/ai-hist/tests/snapshots/muse/cli-capture.json +++ b/crates/ai-hist/tests/snapshots/muse/cli-capture.json @@ -174,7 +174,7 @@ "session_id": "74747474-7474-4747-8747-747474747474", "source": "muse", "text": "The boundary comparison is strict, the note marker is COPPER_4821, and the missing-file failure was retained.", - "token_json": null, + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", "ts_ms": 1788223110828 }, { @@ -208,7 +208,7 @@ "session_id": "74747474-7474-4747-8747-747474747474", "source": "muse", "text": "The image contains a blue triangle labeled BLUE_TRIANGLE_7319.", - "token_json": null, + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", "ts_ms": 1788223111332 }, { @@ -242,7 +242,7 @@ "session_id": "74747474-7474-4747-8747-747474747474", "source": "muse", "text": "Recall: SM_CORPUS_7319, COPPER_4821, BLUE_TRIANGLE_7319, and missing-corpus-file.txt.", - "token_json": null, + "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":32,\"output_tokens\":8,\"reasoning_tokens\":0}", "ts_ms": 1788223111916 } ], diff --git a/crates/ai-hist/tests/snapshots/muse/tools-session.json b/crates/ai-hist/tests/snapshots/muse/tools-session.json index 054b5d48..c9ca338e 100644 --- a/crates/ai-hist/tests/snapshots/muse/tools-session.json +++ b/crates/ai-hist/tests/snapshots/muse/tools-session.json @@ -11,7 +11,7 @@ "lines_added": null, "lines_removed": null, "message_id": "msg-2", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "structured_patch_json": null, "tool_name": "edit_file", @@ -26,7 +26,7 @@ "lines_added": null, "lines_removed": null, "message_id": "msg-2", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "structured_patch_json": null, "tool_name": "write_file", @@ -40,14 +40,14 @@ { "project": "/tmp/muse-demo", "prompt": "Add a retry helper to src/http.rs and run the tests.", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "timestamp_ms": 1790337600750 }, { "project": "/tmp/muse-demo", "prompt": "Why did it fail?", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "timestamp_ms": 1790337606750 } @@ -55,11 +55,11 @@ "hydration": [ { "related_session_ids": [ - "0199a1b2-0000-7000-8000-00000000c001", - "0199a1b2-0000-7000-8000-00000000c002", - "0199a1b2-0000-7000-8000-00000000e001" + "muse-c001", + "muse-c002", + "muse-e001" ], - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "status": "hydrated" } @@ -80,7 +80,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "user", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "Add a retry helper to src/http.rs and run the tests.", "token_json": null, @@ -97,7 +97,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "Read the file before editing it.", "token_json": "{\"cache_read_tokens\":800,\"cache_write_tokens\":0,\"cached_tokens\":800,\"input_tokens\":1200,\"output_tokens\":90,\"reasoning_tokens\":40}", @@ -114,10 +114,10 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "read_file src/http.rs", - "token_json": null, + "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", "ts_ms": 1790337601750 }, { @@ -131,7 +131,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "tool_result", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "Read text file `src/http.rs`.\n1|pub fn get() {}", "token_json": null, @@ -148,7 +148,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "bash cargo test", "token_json": null, @@ -165,10 +165,10 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "edit_file src/http.rs", - "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", + "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", "ts_ms": 1790337602750 }, { @@ -182,7 +182,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "subagent_spawn reviewer", "token_json": null, @@ -199,7 +199,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "write_file tests/retry.rs", "token_json": null, @@ -216,7 +216,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "tool_result", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "{\"command\": \"cargo test\", \"description\": null, \"exit_code\": 101, \"terminal_status\": \"exited\", \"output\": \"test retries ... FAILED\", \"truncated\": false}", "token_json": null, @@ -233,7 +233,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "tool_result", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "edited — diff: -pub fn get() {} +pub fn retry() {}", "token_json": null, @@ -250,9 +250,9 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "tool_result", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", - "text": "{\"ok\": true, \"task_id\": \"0199a1b2-0000-7000-8000-00000000d001\", \"status\": \"running\"}", + "text": "{\"ok\": true, \"task_id\": \"muse-task-d001\", \"status\": \"running\"}", "token_json": null, "ts_ms": 1790337604500 }, @@ -267,7 +267,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "tool_result", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "wrote 22 bytes to tests/retry.rs", "token_json": null, @@ -284,10 +284,10 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "Added `retry` and a test; `cargo test` fails with exit code 101.", - "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", + "token_json": null, "ts_ms": 1790337605000 }, { @@ -301,7 +301,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "user", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "Why did it fail?", "token_json": null, @@ -318,7 +318,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "text": "`retry` is declared but the test expects it to return a value.", "token_json": "{\"cache_read_tokens\":1800,\"cache_write_tokens\":0,\"cached_tokens\":1800,\"input_tokens\":2000,\"output_tokens\":20,\"reasoning_tokens\":0}", @@ -335,7 +335,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "user", - "session_id": "0199a1b2-0000-7000-8000-00000000c001", + "session_id": "muse-c001", "source": "muse", "text": "Role: reviewer Objective: review src/http.rs", "token_json": null, @@ -352,7 +352,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000c001", + "session_id": "muse-c001", "source": "muse", "text": "`retry` has no body; the test cannot pass.", "token_json": "{\"cache_read_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0,\"input_tokens\":600,\"output_tokens\":25,\"reasoning_tokens\":0}", @@ -369,7 +369,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "user", - "session_id": "0199a1b2-0000-7000-8000-00000000c002", + "session_id": "muse-c002", "source": "muse", "text": "Role: linter Objective: lint src/http.rs", "token_json": null, @@ -386,7 +386,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000c002", + "session_id": "muse-c002", "source": "muse", "text": "No lint findings.", "token_json": null, @@ -403,7 +403,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "user", - "session_id": "0199a1b2-0000-7000-8000-00000000e001", + "session_id": "muse-e001", "source": "muse", "text": "Is the user's task finished?", "token_json": null, @@ -420,7 +420,7 @@ "parent_id": null, "project": "/tmp/muse-demo", "role": "assistant", - "session_id": "0199a1b2-0000-7000-8000-00000000e001", + "session_id": "muse-e001", "source": "muse", "text": "No: the test still fails.", "token_json": null, @@ -434,7 +434,7 @@ "message_id": null, "parent_id": null, "payload_json": "{\"resume\":false}", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "session.opened.observed", "text": null, @@ -447,7 +447,7 @@ "message_id": "msg-r1", "parent_id": null, "payload_json": null, - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "reasoning_committed", "text": null, @@ -460,7 +460,7 @@ "message_id": null, "parent_id": null, "payload_json": "{\"terminal\":\"completed\"}", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "terminal", "text": "completed", @@ -473,7 +473,7 @@ "message_id": null, "parent_id": null, "payload_json": null, - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "session.end", "text": "clean", @@ -486,7 +486,7 @@ "message_id": null, "parent_id": null, "payload_json": "{\"resume\":true}", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "session.opened.observed", "text": null, @@ -499,7 +499,7 @@ "message_id": null, "parent_id": null, "payload_json": null, - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "session.resumed", "text": null, @@ -512,7 +512,7 @@ "message_id": null, "parent_id": null, "payload_json": null, - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "runtime.session.metadata", "text": "meta/muse-spark-1.3-contributor", @@ -525,7 +525,7 @@ "message_id": null, "parent_id": null, "payload_json": "{\"reason\":\"user_cancelled\",\"terminal\":\"interrupted\"}", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "subkind": "terminal", "text": "interrupted", @@ -539,14 +539,14 @@ "child_agent_type": "worker", "child_has_events": 1, "child_model": "meta/muse-spark-1.3", - "child_session_id": "0199a1b2-0000-7000-8000-00000000c001", + "child_session_id": "muse-c001", "evidence_kind": "muse_subagent_log", - "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl", - "evidence_ref": "0199a1b2-0000-7000-8000-00000000d001", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/session.jsonl", + "evidence_ref": "muse-task-d001", "identity_status": "observed", - "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", + "parent_session_id": "muse-a001", "relationship": "delegated", - "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000c001", + "relationship_uid": "child:muse-c001", "source": "muse", "spawn_depth": 1, "spawned_at_ms": 1790337604000 @@ -556,14 +556,14 @@ "child_agent_type": "reminder", "child_has_events": 1, "child_model": "meta/muse-spark-1.3", - "child_session_id": "0199a1b2-0000-7000-8000-00000000e001", + "child_session_id": "muse-e001", "evidence_kind": "muse_subagent_log", - "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl", - "evidence_ref": "0199a1b2-0000-7000-8000-00000000d002", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-e001/session.jsonl", + "evidence_ref": "muse-task-d002", "identity_status": "observed", - "parent_session_id": "0199a1b2-0000-7000-8000-00000000a001", + "parent_session_id": "muse-a001", "relationship": "delegated", - "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000e001", + "relationship_uid": "child:muse-e001", "source": "muse", "spawn_depth": 1, "spawned_at_ms": 1790337605250 @@ -573,14 +573,14 @@ "child_agent_type": "worker", "child_has_events": 1, "child_model": "meta/muse-glimmer-30b", - "child_session_id": "0199a1b2-0000-7000-8000-00000000c002", + "child_session_id": "muse-c002", "evidence_kind": "muse_subagent_log", - "evidence_locator": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl", - "evidence_ref": "0199a1b2-0000-7000-8000-00000000d003", + "evidence_locator": "/.local/share/muse/sessions/2026/09/20/muse-a001/subagent/muse-c001/subagent/muse-c002/session.jsonl", + "evidence_ref": "muse-task-d003", "identity_status": "observed", - "parent_session_id": "0199a1b2-0000-7000-8000-00000000c001", + "parent_session_id": "muse-c001", "relationship": "delegated", - "relationship_uid": "child:0199a1b2-0000-7000-8000-00000000c002", + "relationship_uid": "child:muse-c002", "source": "muse", "spawn_depth": 2, "spawned_at_ms": 1790337605350 @@ -599,11 +599,11 @@ "last_assistant_text": "`retry` is declared but the test expects it to return a value.", "models_json": "[\"meta/muse-spark-1.3\",\"meta/muse-spark-1.3-contributor\"]", "originator": null, - "raw_path": "/.local/share/muse/sessions/2026/09/20/0199a1b2-0000-7000-8000-00000000a001/session.jsonl", + "raw_path": "/.local/share/muse/sessions/2026/09/20/muse-a001/session.jsonl", "repo_url": null, - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", - "source_stamp": ":16712|subagent/0199a1b2-0000-7000-8000-00000000c001/session.jsonl=:3091|subagent/0199a1b2-0000-7000-8000-00000000c001/subagent/0199a1b2-0000-7000-8000-00000000c002/session.jsonl=:1436|subagent/0199a1b2-0000-7000-8000-00000000e001/session.jsonl=:1433", + "source_stamp": ":15581|subagent/muse-c001/session.jsonl=:2880|subagent/muse-c001/subagent/muse-c002/session.jsonl=:1355|subagent/muse-e001/session.jsonl=:1352", "workspace_roots_json": null } ], @@ -614,7 +614,7 @@ "is_error": 0, "message_id": "msg-1", "name": "read_file", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "target": "src/http.rs", "tool_use_id": "call_read", @@ -625,7 +625,7 @@ "is_error": 1, "message_id": "msg-2", "name": "bash", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "target": "cargo test", "tool_use_id": "call_bash", @@ -636,7 +636,7 @@ "is_error": 0, "message_id": "msg-2", "name": "edit_file", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "target": "src/http.rs", "tool_use_id": "call_edit", @@ -647,7 +647,7 @@ "is_error": 0, "message_id": "msg-2", "name": "subagent_spawn", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "target": "reviewer", "tool_use_id": "call_spawn", @@ -658,7 +658,7 @@ "is_error": 0, "message_id": "msg-2", "name": "write_file", - "session_id": "0199a1b2-0000-7000-8000-00000000a001", + "session_id": "muse-a001", "source": "muse", "target": "tests/retry.rs", "tool_use_id": "call_write", diff --git a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md index f645e1a0..c61dcee4 100644 --- a/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md +++ b/docs/decisions/2026-09-19-relayhistory-owns-session-sourcing.md @@ -193,9 +193,10 @@ every record carries its own `recorded_at` in microseconds, so no timestamp is inferred. `thinking` is `◐` because Muse usually keeps reasoning only as `encrypted_content`; that becomes an `encrypted_reasoning` marker, never thinking text. Token usage is one `model_completed` object per model step, -stored once on the first assistant row the step committed (`per-request`); -a step that committed nothing is reported as `MUSE_USAGE_UNATTACHED`, not -attached to a neighbour. `request_id` is Muse's `response_id` and +stored once on the assistant row that step committed (`per-request`). Tool +steps log it before their calls and prose steps after their reply, so the +pairing works in both directions within a run; a step that committed nothing +is reported as `MUSE_USAGE_UNATTACHED`, not attached to a neighbour. `request_id` is Muse's `response_id` and `stop_reason` its `finish_reason`. A tool call's status comes from the separate `tool_batch.effect.terminal` record, joined by call id, and a `bash` call that completed with a non-zero `exit_code` is an error. Session diff --git a/docs/session-catalog.md b/docs/session-catalog.md index c16e51ad..446198cb 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -1489,9 +1489,17 @@ How each adapter works: Usage is recorded as Muse wrote it: each `model_completed.usage` object (`input_tokens` inclusive of the cached prefix, `cache_read_tokens` / `cached_tokens`, `cache_write_tokens`, `output_tokens`, `reasoning_tokens`) - goes verbatim into `token_json` on the first assistant row that step - committed, and normalizes as `per-request`. A step that committed nothing - keeps its usage off every row and is reported as `MUSE_USAGE_UNATTACHED`. + goes verbatim into `token_json` on the assistant row that step committed, + with its `model` and `finish_reason`, and normalizes as `per-request`. Muse + does not write the two in one order — a step that calls tools logs + `model_completed` *before* its calls, a step that answers in prose logs it + *after* the reply — so each step is paired, within its run, with its first + commit in whichever direction the step wrote it. A step that committed + nothing keeps its usage off every row and is reported as + `MUSE_USAGE_UNATTACHED`. Only newline-terminated records are read, so a + live session's half-written last line waits for the next read, and Muse is + one of the sources the sweep's destination marker guards: rows lost under + an unchanged stamp are restored by the next `sync`. Reasoning Muse kept only encrypted is an `encrypted_reasoning` marker (`MUSE_REASONING_ENCRYPTED`). diff --git a/plugins/relayhistory/rust/src/source/tests.rs b/plugins/relayhistory/rust/src/source/tests.rs index d9f6417d..0986c94b 100644 --- a/plugins/relayhistory/rust/src/source/tests.rs +++ b/plugins/relayhistory/rust/src/source/tests.rs @@ -447,7 +447,8 @@ fn cloud_all_sources_fan_out_without_widening_source_choices() { "grok", "relay", "trajectory", - "opencode" + "opencode", + "muse" ] ); assert!( From 2d07c35c5a651a458a2d5264b82f333db481f31c Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:40:05 -0700 Subject: [PATCH 04/19] fix(muse): keep remote history on local re-reads; tighten spawn diagnostic - A Muse session also known remotely shares its history identity with that evidence. A whole-transcript re-read no longer replaces its history, a linked subagent's objective is removed only when nothing remote stands behind the id (as for Codex), a removed child with a remote presence keeps its evidence, and the destination marker's deregistration exemption requires no remote presence. - MUSE_SUBAGENT_LOG_MISSING compares the session's own subagent_spawn calls with its direct children that it typed as workers, instead of workers at every depth plus logs it never described. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 110 +++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 88d07f4c..a87b5026 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -1125,7 +1125,10 @@ fn destination_shortfall(conn: &Connection, stored: &str) -> Result muse_delegation_recorded(conn, &session_id)?, + "muse" => { + muse_delegation_recorded(conn, &session_id)? + && !session_has_remote_presence(conn, "muse", &session_id)? + } _ => false, }; let covered = current.events >= before.events @@ -3258,12 +3261,19 @@ fn cleanup_codex_subagent_registration(conn: &Connection, session_id: &str) -> R } fn codex_session_has_remote_presence(conn: &Connection, session_id: &str) -> Result { + session_has_remote_presence(conn, "codex", session_id) +} + +/// Whether a session is also known remotely. Local and remote evidence share +/// one `(source, session_id)` identity, so a local re-read must not delete +/// rows a remote observation put there. +fn session_has_remote_presence(conn: &Connection, source: &str, session_id: &str) -> Result { conn.query_row( "SELECT EXISTS(\ SELECT 1 FROM session_presences \ - WHERE source = 'codex' AND session_id = ? AND location = 'remote'\ + WHERE source = ? AND session_id = ? AND location = 'remote'\ )", - [session_id], + params![source, session_id], |row| row.get(0), ) .map_err(Into::into) @@ -10943,10 +10953,15 @@ fn link_muse_subagents( linked.insert(child_id.clone()); let locator = log.to_string_lossy().to_string(); ingest_muse_session(conn, child, &locator)?; - conn.execute( - "DELETE FROM history WHERE source = 'muse' AND session_id = ?", - params![child_id], - )?; + // A child's objective is not a typed prompt — unless the child is also + // known remotely, whose history is shared evidence and stays, as for + // a Codex subagent. + if !session_has_remote_presence(conn, "muse", &child_id)? { + conn.execute( + "DELETE FROM history WHERE source = 'muse' AND session_id = ?", + params![child_id], + )?; + } cleanup_subagent_registration(conn, "muse", &child_id)?; if let Some(child_dir) = log.parent() { link_muse_subagents( @@ -10964,7 +10979,11 @@ fn link_muse_subagents( .map(|relative| muse::normalize_link_path(&relative.to_string_lossy())) .unwrap_or_default(); let (linked_at, link) = links.get(&relative).cloned().unwrap_or_default(); - if link.role.as_deref() != Some("reminder") { + // Only this session's own spawns are compared with its direct + // children, and only a child it typed as a worker answers one: a + // grandchild answers its own parent's spawn, and a log the parent + // never described is not evidence that a spawn succeeded. + if depth == 1 && link.role.as_deref().is_some_and(|role| role != "reminder") { outcome.worker_children += 1; } let child_model = link @@ -11013,7 +11032,13 @@ fn muse_linked_children(conn: &Connection, parent_id: &str) -> Result Result<()> { + if session_has_remote_presence(conn, "muse", child_id)? { + return Ok(()); + } for grandchild in muse_linked_children(conn, child_id)? { forget_muse_subagent(conn, &grandchild)?; } @@ -11055,8 +11080,8 @@ pub(crate) struct MuseIngestOutcome { pub subagent_calls: usize, /// Child transcripts linked as `delegated` children, at any depth. pub relationships: usize, - /// Of those, the ones the parent typed as something other than a - /// reminder: the children a `subagent_spawn` call accounts for. + /// The session's direct children that it typed as something other than + /// a reminder: the ones its own `subagent_spawn` calls account for. pub worker_children: usize, /// `subagent/` logs that named no session, so there was no child to link. pub unidentified_children: usize, @@ -11120,7 +11145,12 @@ pub(crate) fn ingest_muse_session( ] { conn.execute(statement, params![sid])?; } - replace_session_history(conn, SOURCE, sid)?; + // A session also known remotely shares its `history` identity with that + // evidence; replacing it here would delete prompts this transcript never + // wrote. Its local prompts are merged in instead (`INSERT OR IGNORE`). + if !session_has_remote_presence(conn, SOURCE, sid)? { + replace_session_history(conn, SOURCE, sid)?; + } let mut outcome = MuseIngestOutcome { unparsed_lines: transcript.unparsed_lines, @@ -15179,6 +15209,64 @@ mod tests { ); } + /// Only the parent's own direct children that it typed as workers answer + /// its `subagent_spawn` calls; the worker's nested child does not. + #[test] + fn muse_worker_children_count_only_direct_typed_workers() { + let home = tempfile::tempdir().unwrap(); + let (_, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let (parsed, children) = super::read_muse_tree(&transcript).unwrap().unwrap(); + let outcome = + super::ingest_muse_session_tree(&conn, &parsed, &transcript, Some(&children)).unwrap(); + assert_eq!(outcome.subagent_calls, 1); + assert_eq!(outcome.worker_children, 1, "{outcome:?}"); + assert_eq!(outcome.relationships, 3); + } + + /// A subagent also known remotely shares its `history` identity with that + /// evidence. A local re-read removes the child's local objective only + /// when nothing remote stands behind the id. + #[test] + fn a_remote_muse_childs_history_survives_a_local_reread() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + crate::mark_session_presence(&conn, "muse", MUSE_WORKER, super::SessionLocation::Remote) + .unwrap(); + insert_history( + &conn, + &HistoryEntry { + id: 0, + source: "muse".into(), + session_id: Some(MUSE_WORKER.to_string()), + project: None, + prompt: "remote prompt".into(), + prompt_hash: Some(prompt_hash("remote prompt")), + timestamp_ms: 42, + }, + ) + .unwrap(); + + let child_log = transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}/session.jsonl")); + append_muse_record(&child_log, MUSE_WORKER, "worker-late", "Also: no docs."); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let remote: i64 = conn + .query_row( + "SELECT COUNT(*) FROM history WHERE source = 'muse' AND session_id = ? \ + AND prompt = 'remote prompt'", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remote, 1, "a local re-read does not erase remote history"); + } + /// A rewritten transcript that no longer has a reply or a prompt does not /// keep quoting the old ones in the catalog. #[test] From 096edc6a540c3c834be72cf240a90462bd5bcc89 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 22:50:07 -0700 Subject: [PATCH 05/19] fix(muse): own exactly the history a transcript wrote; recurse on removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A re-read removes the history rows its previous read wrote — the ones its stored prompt events name — when the session is also known remotely, instead of skipping cleanup. A prompt the transcript dropped stops being searchable; remote-only prompts stay. Sessions with no remote presence keep the whole-identity replacement. - A subagent's run-start prompt is ingested as an event only (MuseRole::Subagent), never as history, whether or not the child is known remotely. - Forgetting a removed child always forgets the children it linked from the missing directory and drops its edges to them; only the child's own events are kept when it is known remotely. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 203 +++++++++++++++++++++++++++++------ 1 file changed, 169 insertions(+), 34 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index a87b5026..c0215b87 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10860,7 +10860,8 @@ pub(crate) fn ingest_muse_session_tree( path: &Path, children: Option<&[MuseChildLog]>, ) -> Result { - let mut outcome = ingest_muse_session(conn, transcript, &path.to_string_lossy())?; + let mut outcome = + ingest_muse_session(conn, transcript, &path.to_string_lossy(), MuseRole::Session)?; if let (Some(dir), Some(children)) = (path.parent(), children) { let mut visited = HashSet::from([transcript.metadata.session_id.clone()]); link_muse_subagents( @@ -10876,6 +10877,15 @@ pub(crate) fn ingest_muse_session_tree( Ok(outcome) } +/// Whose transcript an ingestion is reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MuseRole { + /// A session someone started: its prompts were typed. + Session, + /// A subagent's log: its prompt is the objective its parent assigned. + Subagent, +} + /// One subagent log beneath a session, read, with its own children. pub(crate) struct MuseChildLog { path: PathBuf, @@ -10952,16 +10962,11 @@ fn link_muse_subagents( } linked.insert(child_id.clone()); let locator = log.to_string_lossy().to_string(); - ingest_muse_session(conn, child, &locator)?; - // A child's objective is not a typed prompt — unless the child is also - // known remotely, whose history is shared evidence and stays, as for - // a Codex subagent. - if !session_has_remote_presence(conn, "muse", &child_id)? { - conn.execute( - "DELETE FROM history WHERE source = 'muse' AND session_id = ?", - params![child_id], - )?; - } + // Read as a subagent: its objective is an event, never `history`, and + // any history it held before is cleared the same way a session's is + // — all of it, or only this log's own rows where the child is also + // known remotely. + ingest_muse_session(conn, child, &locator, MuseRole::Subagent)?; cleanup_subagent_registration(conn, "muse", &child_id)?; if let Some(child_dir) = log.parent() { link_muse_subagents( @@ -11033,15 +11038,23 @@ fn muse_linked_children(conn: &Connection, parent_id: &str) -> Result Result<()> { - if session_has_remote_presence(conn, "muse", child_id)? { - return Ok(()); - } + // Its own children were linked from the same, now missing, directory, + // whatever the child itself is. for grandchild in muse_linked_children(conn, child_id)? { forget_muse_subagent(conn, &grandchild)?; } + conn.execute( + "DELETE FROM session_relationships WHERE source = 'muse' \ + AND parent_session_id = ? AND evidence_kind = 'muse_subagent_log'", + params![child_id], + )?; + if session_has_remote_presence(conn, "muse", child_id)? { + return Ok(()); + } for statement in [ "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", @@ -11128,15 +11141,39 @@ impl<'a> MuseStep<'a> { /// merging into it, so a re-read after the file grew never duplicates a row /// and one that dropped a record (a rewritten or truncated file) leaves no /// stale one behind. +/// +/// `role` says whose transcript this is. A [`MuseRole::Session`]'s prompts +/// were typed and become `history`; a [`MuseRole::Subagent`]'s run-start +/// prompt is the objective its parent assigned, which is an event but never +/// a prompt anybody typed. pub(crate) fn ingest_muse_session( conn: &Connection, transcript: &muse::MuseTranscript, raw_path: &str, + role: MuseRole, ) -> Result { const SOURCE: &str = "muse"; let sid = transcript.metadata.session_id.as_str(); let project = transcript.metadata.workspace_root.as_deref(); let version = transcript.metadata.cli_version.as_deref(); + // History is replaced, not merged, so a prompt the transcript no longer + // has stops being searchable. A session also known remotely shares its + // `history` identity with that evidence, so there only the rows this + // transcript wrote last time — the ones its stored prompt events name — + // are removed; replacing the whole identity would delete prompts it never + // wrote. Either way this runs before the events it reads are replaced. + if session_has_remote_presence(conn, SOURCE, sid)? { + conn.execute( + "DELETE FROM history WHERE source = 'muse' AND session_id = ?1 \ + AND EXISTS (SELECT 1 FROM session_events e \ + WHERE e.source = 'muse' AND e.session_id = ?1 \ + AND e.role = 'user' AND e.kind = 'text' \ + AND e.ts_ms = history.timestamp_ms AND e.text = history.prompt)", + params![sid], + )?; + } else { + replace_session_history(conn, SOURCE, sid)?; + } for statement in [ "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", @@ -11145,12 +11182,6 @@ pub(crate) fn ingest_muse_session( ] { conn.execute(statement, params![sid])?; } - // A session also known remotely shares its `history` identity with that - // evidence; replacing it here would delete prompts this transcript never - // wrote. Its local prompts are merged in instead (`INSERT OR IGNORE`). - if !session_has_remote_presence(conn, SOURCE, sid)? { - replace_session_history(conn, SOURCE, sid)?; - } let mut outcome = MuseIngestOutcome { unparsed_lines: transcript.unparsed_lines, @@ -11212,18 +11243,20 @@ pub(crate) fn ingest_muse_session( facts, )?; outcome.events += 1; - outcome.prompts += insert_history( - conn, - &HistoryEntry { - id: 0, - source: SOURCE.into(), - session_id: Some(sid.to_string()), - project: project.map(str::to_string), - prompt_hash: Some(prompt_hash(text)), - prompt: text.clone(), - timestamp_ms: ts, - }, - )?; + if role == MuseRole::Session { + outcome.prompts += insert_history( + conn, + &HistoryEntry { + id: 0, + source: SOURCE.into(), + session_id: Some(sid.to_string()), + project: project.map(str::to_string), + prompt_hash: Some(prompt_hash(text)), + prompt: text.clone(), + timestamp_ms: ts, + }, + )?; + } } // Paired with its assistant record up front; only the running // model is tracked here. @@ -15265,6 +15298,108 @@ mod tests { ) .unwrap(); assert_eq!(remote, 1, "a local re-read does not erase remote history"); + let objectives: i64 = conn + .query_row( + "SELECT COUNT(*) FROM history WHERE source = 'muse' AND session_id = ? \ + AND prompt LIKE 'Role:%'", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + objectives, 0, + "a subagent's objective is never a typed prompt" + ); + } + + /// A remotely known session whose transcript drops a prompt stops + /// offering it, while the prompts only the remote side holds stay. + #[test] + fn a_remote_muse_session_drops_prompts_its_transcript_no_longer_has() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + crate::mark_session_presence(&conn, "muse", MUSE_PARENT, super::SessionLocation::Remote) + .unwrap(); + insert_history( + &conn, + &HistoryEntry { + id: 0, + source: "muse".into(), + session_id: Some(MUSE_PARENT.to_string()), + project: None, + prompt: "remote prompt".into(), + prompt_hash: Some(prompt_hash("remote prompt")), + timestamp_ms: 42, + }, + ) + .unwrap(); + + // Drop the second typed prompt from the transcript. + let kept: String = fs::read_to_string(&transcript) + .unwrap() + .lines() + .filter(|line| !line.contains("Why did it fail?")) + .map(|line| format!("{line}\n")) + .collect(); + fs::write(&transcript, kept).unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let prompts: Vec = conn + .prepare( + "SELECT prompt FROM history WHERE source = 'muse' AND session_id = ? \ + ORDER BY timestamp_ms", + ) + .unwrap() + .query_map([MUSE_PARENT], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + prompts, + vec![ + "remote prompt".to_string(), + "Add a retry helper to src/http.rs and run the tests.".to_string() + ] + ); + } + + /// A removed child that is also known remotely keeps its own evidence, + /// but the local-only children it linked from the missing directory do + /// not linger, and neither do its edges to them. + #[test] + fn a_removed_remote_muse_child_still_forgets_its_local_children() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + crate::mark_session_presence(&conn, "muse", MUSE_WORKER, super::SessionLocation::Remote) + .unwrap(); + let worker_events = muse_session_count(&conn, "session_events", MUSE_WORKER); + fs::remove_dir_all( + transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}")), + ) + .unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_WORKER), + worker_events + ); + assert_eq!(muse_session_count(&conn, "session_events", "muse-c002"), 0); + let edges_from_worker: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_relationships \ + WHERE source = 'muse' AND parent_session_id = ?", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(edges_from_worker, 0); } /// A rewritten transcript that no longer has a reply or a prompt does not From 56e8d5daadf3552c1b91a1af5fa60f045ea75a5b Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 23:06:17 -0700 Subject: [PATCH 06/19] fix(muse): re-attribute shared prompts, repair erased subagents, child role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The remote-scoped history cleanup now goes through replace_session_history_scoped, so a shared prompt row another session still evidences is re-attributed instead of deleted. - A subagent with no catalog row and no events left is named for repair through its parent's muse_subagent_log edge, so the next plain sync re-reads the parent's tree and restores it. - A transcript at …/subagent//session.jsonl is always read as a subagent, including when a remotely known child is hydrated directly, so its objective never becomes history. - A removed child that is known remotely loses its local presence. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 187 ++++++++++++++++++++++++++---- crates/ai-hist/src/ingest/muse.rs | 18 +++ 2 files changed, 183 insertions(+), 22 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index c0215b87..e670c7ee 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -1094,12 +1094,20 @@ fn destination_shortfall(conn: &Connection, stored: &str) -> Result, ) -> Result { - let mut outcome = - ingest_muse_session(conn, transcript, &path.to_string_lossy(), MuseRole::Session)?; + // A subagent's log is read as one wherever the read starts — a child + // also known remotely keeps a catalog row, and hydrating it directly must + // not turn its objective into a typed prompt that the parent's next sweep + // would take back out. + let role = if muse::is_subagent_log(path) { + MuseRole::Subagent + } else { + MuseRole::Session + }; + let mut outcome = ingest_muse_session(conn, transcript, &path.to_string_lossy(), role)?; if let (Some(dir), Some(children)) = (path.parent(), children) { let mut visited = HashSet::from([transcript.metadata.session_id.clone()]); link_muse_subagents( @@ -11053,6 +11069,13 @@ fn forget_muse_subagent(conn: &Connection, child_id: &str) -> Result<()> { params![child_id], )?; if session_has_remote_presence(conn, "muse", child_id)? { + // Its log is gone, so it is no longer present locally; the remote + // presence, and the catalog row that stands on it, stay. + conn.execute( + "DELETE FROM session_presences \ + WHERE source = 'muse' AND session_id = ? AND location = 'local'", + params![child_id], + )?; return Ok(()); } for statement in [ @@ -11162,18 +11185,12 @@ pub(crate) fn ingest_muse_session( // transcript wrote last time — the ones its stored prompt events name — // are removed; replacing the whole identity would delete prompts it never // wrote. Either way this runs before the events it reads are replaced. - if session_has_remote_presence(conn, SOURCE, sid)? { - conn.execute( - "DELETE FROM history WHERE source = 'muse' AND session_id = ?1 \ - AND EXISTS (SELECT 1 FROM session_events e \ - WHERE e.source = 'muse' AND e.session_id = ?1 \ - AND e.role = 'user' AND e.kind = 'text' \ - AND e.ts_ms = history.timestamp_ms AND e.text = history.prompt)", - params![sid], - )?; + let scope = if session_has_remote_presence(conn, SOURCE, sid)? { + HistoryScope::OwnPromptEvents } else { - replace_session_history(conn, SOURCE, sid)?; - } + HistoryScope::Session + }; + replace_session_history_scoped(conn, SOURCE, sid, scope)?; for statement in [ "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", @@ -12390,7 +12407,39 @@ fn replace_grok_session_evidence(conn: &Connection, session_id: &str) -> Result< /// events actually carry that prompt at that moment, so the row stays true /// and stays owned. What is left is this session's alone, and is deleted. fn replace_session_history(conn: &Connection, source: &str, session_id: &str) -> Result<()> { - conn.execute( + replace_session_history_scoped(conn, source, session_id, HistoryScope::Session) +} + +/// Which of a session's `history` rows a re-read owns. +#[derive(Clone, Copy, PartialEq, Eq)] +enum HistoryScope { + /// Every row filed under the session. + Session, + /// Only the rows its own stored prompt events name — what the previous + /// read of the same transcript wrote. For a session whose identity it + /// shares with remote evidence, whose rows it did not write. Must run + /// before those events are replaced. + OwnPromptEvents, +} + +/// [`replace_session_history`] over the rows `scope` names: each is +/// re-attributed to another session that still evidences it, or deleted. +fn replace_session_history_scoped( + conn: &Connection, + source: &str, + session_id: &str, + scope: HistoryScope, +) -> Result<()> { + let owned = match scope { + HistoryScope::Session => "", + HistoryScope::OwnPromptEvents => { + " AND EXISTS(SELECT 1 FROM session_events own \ + WHERE own.source = h.source AND own.session_id = h.session_id \ + AND own.role = 'user' AND own.kind = 'text' \ + AND own.ts_ms = h.timestamp_ms AND own.text = h.prompt)" + } + }; + let update = format!( "UPDATE history AS h SET \ session_id = (SELECT e.session_id FROM session_events e \ WHERE e.source = h.source AND e.role = 'user' AND e.kind = 'text' \ @@ -12406,13 +12455,11 @@ fn replace_session_history(conn: &Connection, source: &str, session_id: &str) -> AND EXISTS(SELECT 1 FROM session_events e \ WHERE e.source = h.source AND e.role = 'user' AND e.kind = 'text' \ AND e.session_id <> h.session_id \ - AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt)", - params![source, session_id], - )?; - conn.execute( - "DELETE FROM history WHERE source = ? AND session_id = ?", - params![source, session_id], - )?; + AND e.ts_ms = h.timestamp_ms AND e.text = h.prompt){owned}", + ); + conn.execute(&update, params![source, session_id])?; + let delete = format!("DELETE FROM history AS h WHERE h.source = ? AND h.session_id = ?{owned}"); + conn.execute(&delete, params![source, session_id])?; Ok(()) } @@ -15400,6 +15447,102 @@ mod tests { ) .unwrap(); assert_eq!(edges_from_worker, 0); + let local: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_presences \ + WHERE source = 'muse' AND session_id = ? AND location = 'local'", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(local, 0, "a child whose log is gone is not present locally"); + } + + /// A subagent with no catalog row and no events left can still be named + /// for repair — by its parent's edge — so the next plain `sync` re-reads + /// the parent's tree and restores it. + #[test] + fn a_muse_subagent_that_lost_every_event_is_repaired() { + let home = tempfile::tempdir().unwrap(); + let _ = muse_fixture(home.path()); + let db = home.path().join("history.db"); + let roots = muse_roots(home.path()); + super::sync_exclusive_with_roots(&db, &roots, false).unwrap(); + let conn = open_db(&db).unwrap(); + let worker_events = muse_session_count(&conn, "session_events", MUSE_WORKER); + conn.execute( + "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", + [MUSE_WORKER], + ) + .unwrap(); + super::sync_exclusive_with_roots(&db, &roots, false).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", MUSE_WORKER), + worker_events + ); + } + + /// Hydrating a subagent's log directly reads it as a subagent: its + /// objective is an event, never history. + #[test] + fn a_muse_subagent_log_read_directly_writes_no_history() { + let home = tempfile::tempdir().unwrap(); + let (_, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let log = transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}/session.jsonl")); + let child = super::read_muse_transcript(&log).unwrap().unwrap(); + super::ingest_muse_session_tree(&conn, &child, &log, None).unwrap(); + assert!(muse_session_count(&conn, "session_events", MUSE_WORKER) > 0); + assert_eq!(muse_session_count(&conn, "history", MUSE_WORKER), 0); + } + + /// Scoped to its own prompt events, a remote session's history cleanup + /// still re-attributes a row another session evidences rather than + /// deleting it, and leaves rows it never wrote alone. + #[test] + fn scoped_history_cleanup_reattributes_shared_prompts() { + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + for (session, text) in [("a", "shared"), ("b", "shared"), ("a", "only a")] { + conn.execute( + "INSERT INTO session_events (source, session_id, ts_ms, role, kind, text, event_uid) \ + VALUES ('muse', ?, 7, 'user', 'text', ?, ?)", + params![session, text, format!("{session}:{text}")], + ) + .unwrap(); + } + for (session, text) in [("a", "shared"), ("a", "only a"), ("a", "remote")] { + conn.execute( + "INSERT INTO history (source, session_id, prompt, timestamp_ms) \ + VALUES ('muse', ?, ?, 7)", + params![session, text], + ) + .unwrap(); + } + super::replace_session_history_scoped( + &conn, + "muse", + "a", + super::HistoryScope::OwnPromptEvents, + ) + .unwrap(); + let rows: Vec<(String, String)> = conn + .prepare("SELECT session_id, prompt FROM history ORDER BY prompt") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + rows, + vec![ + ("a".to_string(), "remote".to_string()), + ("b".to_string(), "shared".to_string()), + ] + ); } /// A rewritten transcript that no longer has a reply or a prompt does not diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs index e7338ad7..2e577149 100644 --- a/crates/ai-hist/src/ingest/muse.rs +++ b/crates/ai-hist/src/ingest/muse.rs @@ -625,6 +625,16 @@ pub(crate) fn normalize_link_path(path: &str) -> String { path.replace('\\', "/").trim_start_matches("./").to_string() } +/// Whether a transcript is a subagent's own log by where it sits: +/// `…/subagent//session.jsonl`. A session's transcript sits under its +/// date directories (`…/YYYY/MM/DD//session.jsonl`), never there. +pub(crate) fn is_subagent_log(path: &Path) -> bool { + path.parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .is_some_and(|name| name == SUBAGENT_DIR) +} + /// Tools that write a file. pub(crate) fn is_file_edit_tool(name: &str) -> bool { matches!(name, "write_file" | "edit_file") @@ -877,6 +887,14 @@ mod tests { )); } + #[test] + fn a_subagent_log_is_recognized_by_where_it_sits() { + assert!(is_subagent_log(Path::new( + "/s/2026/09/09/p/subagent/c/session.jsonl" + ))); + assert!(!is_subagent_log(Path::new("/s/2026/09/09/p/session.jsonl"))); + } + #[test] fn bash_results_expose_their_exit_code() { assert_eq!(bash_exit_code(r#"{"exit_code":2,"output":""}"#), Some(2)); From d0a4b96e5cae331f692fc20f71c2c729b6e00892 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 23:11:56 -0700 Subject: [PATCH 07/19] fix(muse): a subagent log is never a session on any path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MuseProvider::read_shallow returns no row for a log at …/subagent//session.jsonl, so a watch hit or by-path read cannot catalogue a child the enumeration already skips. - A subagent log read directly (hydrating a remote child's raw_path) is deregistered from the catalog like a linked child, unless a remote presence stands behind it. - Forgetting a removed child also re-attributes or deletes history still filed under it, unless it is known remotely. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/discover.rs | 6 ++++ crates/ai-hist/src/discover/tests.rs | 39 ++++++++++++++++++++++ crates/ai-hist/src/ingest.rs | 48 +++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/ai-hist/src/discover.rs b/crates/ai-hist/src/discover.rs index 483fd49f..1508c05b 100644 --- a/crates/ai-hist/src/discover.rs +++ b/crates/ai-hist/src/discover.rs @@ -2115,6 +2115,12 @@ impl ShallowSessionProvider for MuseProvider { ) -> Result> { use crate::ingest::muse; let path = PathBuf::from(&candidate.locator); + // A subagent's log is never a session of its own, however the + // candidate arrived — enumeration skips them, a watch hit or a + // by-path read need not. + if muse::is_subagent_log(&path) { + return Ok(None); + } let bounded = read_bounded_jsonl(scan, &path)?; // A permission frame can precede the metadata record, so the header // is looked for rather than assumed to be the first line. diff --git a/crates/ai-hist/src/discover/tests.rs b/crates/ai-hist/src/discover/tests.rs index ff261622..dff435ae 100644 --- a/crates/ai-hist/src/discover/tests.rs +++ b/crates/ai-hist/src/discover/tests.rs @@ -3866,3 +3866,42 @@ fn cancellation_during_row_emission_preserves_the_committed_window() { assert_eq!(resumed.summary.discovered, 0); } } + +/// A Muse subagent's log is never a session of its own, however it reaches +/// the shallow reader — enumeration skips it, a by-path read must too. +#[test] +fn muse_shallow_read_refuses_a_subagent_log() { + let home = tempfile::tempdir().unwrap(); + let conn = catalog(); + let session = home + .path() + .join(".local/share/muse/sessions/2026/09/20/parent"); + let header = |id: &str| { + format!( + "{{\"id\":\"r1\",\"stream\":{{\"kind\":\"session\",\"id\":\"{id}\"}},\ + \"recorded_at\":1790337600000000,\"payload_type\":\"runtime.session.metadata\",\ + \"payload\":{{\"kind\":\"metadata\",\"record\":{{\"workspace_root\":\"/w\"}}}}}}\n" + ) + }; + write(&session.join("session.jsonl"), &header("parent")); + let child = session.join("subagent/child/session.jsonl"); + write(&child, &header("child")); + + let env = env_at(&conn, home.path()); + let candidates = MuseProvider.enumerate(&env, None).unwrap(); + assert_eq!(candidates.len(), 1, "only the parent is enumerated"); + let mut by_path = candidates[0].clone(); + by_path.locator = child.to_string_lossy().into_owned(); + assert!(MuseProvider + .read_shallow(&env.scan(), None, &by_path) + .unwrap() + .is_none()); + assert_eq!( + MuseProvider + .read_shallow(&env.scan(), None, &candidates[0]) + .unwrap() + .unwrap() + .session_id, + "parent" + ); +} diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index e670c7ee..2893bae0 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10878,6 +10878,11 @@ pub(crate) fn ingest_muse_session_tree( MuseRole::Session }; let mut outcome = ingest_muse_session(conn, transcript, &path.to_string_lossy(), role)?; + if role == MuseRole::Subagent { + // Not a catalog row of its own either, unless a remote presence + // stands behind it. + cleanup_subagent_registration(conn, "muse", &transcript.metadata.session_id)?; + } if let (Some(dir), Some(children)) = (path.parent(), children) { let mut visited = HashSet::from([transcript.metadata.session_id.clone()]); link_muse_subagents( @@ -11070,7 +11075,7 @@ fn forget_muse_subagent(conn: &Connection, child_id: &str) -> Result<()> { )?; if session_has_remote_presence(conn, "muse", child_id)? { // Its log is gone, so it is no longer present locally; the remote - // presence, and the catalog row that stands on it, stay. + // presence, the catalog row that stands on it and its history stay. conn.execute( "DELETE FROM session_presences \ WHERE source = 'muse' AND session_id = ? AND location = 'local'", @@ -11078,6 +11083,9 @@ fn forget_muse_subagent(conn: &Connection, child_id: &str) -> Result<()> { )?; return Ok(()); } + // Any history still filed under it — from before it was linked as a + // subagent — goes too, or moves to a session that still evidences it. + replace_session_history(conn, "muse", child_id)?; for statement in [ "DELETE FROM session_events WHERE source = 'muse' AND session_id = ?", "DELETE FROM tool_calls WHERE source = 'muse' AND session_id = ?", @@ -15482,6 +15490,39 @@ mod tests { ); } + /// A removed child's leftover history — filed under it before it was + /// linked as a subagent — goes with the rest of its evidence. + #[test] + fn a_removed_muse_child_takes_its_leftover_history_with_it() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + insert_history( + &conn, + &HistoryEntry { + id: 0, + source: "muse".into(), + session_id: Some(MUSE_WORKER.to_string()), + project: None, + prompt: "indexed as a session once".into(), + prompt_hash: Some(prompt_hash("indexed as a session once")), + timestamp_ms: 42, + }, + ) + .unwrap(); + fs::remove_dir_all( + transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}")), + ) + .unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!(muse_session_count(&conn, "history", MUSE_WORKER), 0); + } + /// Hydrating a subagent's log directly reads it as a subagent: its /// objective is an event, never history. #[test] @@ -15497,6 +15538,11 @@ mod tests { super::ingest_muse_session_tree(&conn, &child, &log, None).unwrap(); assert!(muse_session_count(&conn, "session_events", MUSE_WORKER) > 0); assert_eq!(muse_session_count(&conn, "history", MUSE_WORKER), 0); + assert_eq!( + muse_session_count(&conn, "sessions", MUSE_WORKER), + 0, + "nor a catalog row of its own" + ); } /// Scoped to its own prompt events, a remote session's history cleanup From 2b7b58bf1add516226dbb7763df8c8ec18c29b12 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 23:46:40 -0700 Subject: [PATCH 08/19] fix(muse): delimit model steps by tool results, not by commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step can commit readable reasoning and then its tool calls. The previous pairing reopened a new step at the second commit, so every later step's usage shifted one record forward and the last reply lost its own (visible in the authored fixture). Each run is now cut into segments at `started`, `tool_result_batch_committed` and `terminal` — the points after which the model is called again — and a segment's first assistant record owns its `model_completed`, in either order. A second usage-bearing completion in one segment is counted as unattached. Hydration with related evidence also reports the subagent logs it read in `bytes_read`. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest/hydrate.rs | 16 +- crates/ai-hist/src/ingest/muse.rs | 139 +++++++++++------- crates/ai-hist/tests/fixture_corpus.rs | 24 +++ .../tests/snapshots/muse/tools-session.json | 6 +- docs/session-catalog.md | 7 +- 5 files changed, 134 insertions(+), 58 deletions(-) diff --git a/crates/ai-hist/src/ingest/hydrate.rs b/crates/ai-hist/src/ingest/hydrate.rs index af814f52..37482c49 100644 --- a/crates/ai-hist/src/ingest/hydrate.rs +++ b/crates/ai-hist/src/ingest/hydrate.rs @@ -2148,8 +2148,20 @@ fn ingest_selected( } "grok" => ingest_grok(conn, options, path.unwrap()) .map(|diagnostics| (whole_file(), diagnostics, None)), - "muse" => ingest_muse(conn, options, path.unwrap()) - .map(|diagnostics| (whole_file(), diagnostics, None)), + "muse" => { + let path = path.unwrap(); + let diagnostics = ingest_muse(conn, options, path)?; + let mut outcome = whole_file(); + // With related evidence the read covers every subagent log too. + if options.include_related { + outcome.bytes_read = muse_session_files(path)? + .iter() + .filter_map(|file| file.metadata().ok()) + .map(|metadata| metadata.len() as i64) + .sum(); + } + Ok((outcome, diagnostics, None)) + } "opencode" => { let path = path.unwrap(); // Whichever layout `source_snapshot` validated this locator diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs index 2e577149..4a2d6bc5 100644 --- a/crates/ai-hist/src/ingest/muse.rs +++ b/crates/ai-hist/src/ingest/muse.rs @@ -484,21 +484,27 @@ pub(crate) fn parse_transcript(contents: &str) -> Option { /// Muse does not write the two in one order. A step that calls tools logs /// `model_completed` *before* `assistant_tool_calls_committed`; a step that /// answers in prose logs `assistant_message_committed` *before* its -/// `model_completed` (both are in the real 0.2.1 capture). So a step's usage -/// is paired, within one run, with the first assistant record committed since -/// the previous step closed — earlier or later, whichever the step wrote — -/// and never carried across a prompt or a run's `terminal`. +/// `model_completed` (both are in the real 0.2.1 capture). A step can also +/// commit several records — readable reasoning and then its tool calls. +/// +/// What does delimit a step is what the model is called *after*: a typed +/// prompt, or the tool results it asked for. So each run is cut into +/// segments at `started`, `tool_result_batch_committed` and `terminal`. A +/// segment is one model call: its first assistant record owns that call's +/// `model_completed`, whichever of the two came first, and every other +/// record of the step is part of it and owns no usage of its own. A second +/// usage-bearing `model_completed` in one segment (a retried call) has no +/// record of its own to carry it, and is counted rather than attached to a +/// neighbour. /// /// Returns `(owners, orphans)`: `owners` maps an assistant record's index to /// its step's `model_completed` index, and `orphans` counts steps that /// reported usage but committed no assistant record to carry it. pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap, usize) { #[derive(Default)] - struct Run { - /// A step's first commit, not yet paired with its `model_completed`. - open_commit: Option, - /// A `model_completed` waiting for the commit its step writes next. - pending: Option, + struct Segment { + first_commit: Option, + steps: Vec, } let has_usage = |index: usize| { matches!( @@ -508,45 +514,57 @@ pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap, Run> = HashMap::new(); + let mut close = |segment: Segment| { + let owner = segment + .steps + .iter() + .copied() + .find(|step| has_usage(*step)) + .or_else(|| segment.steps.first().copied()); + match (segment.first_commit, owner) { + (Some(commit), Some(step)) => { + owners.insert(commit, step); + orphans += segment + .steps + .iter() + .filter(|other| **other != step && has_usage(**other)) + .count(); + } + _ => { + orphans += segment + .steps + .iter() + .filter(|step| has_usage(**step)) + .count(); + } + } + }; + let mut runs: HashMap, Segment> = HashMap::new(); for (index, record) in transcript.records.iter().enumerate() { - let run = runs.entry(record.run_id.as_deref()).or_default(); + let run = record.run_id.as_deref(); match &record.event { MuseEvent::AssistantText { .. } | MuseEvent::ToolCalls { .. } | MuseEvent::Reasoning { text: Some(_), .. } => { - if let Some(step) = run.pending.take() { - owners.insert(index, step); - run.open_commit = None; - } else if run.open_commit.is_none() { - run.open_commit = Some(index); - } + runs.entry(run) + .or_default() + .first_commit + .get_or_insert(index); } - MuseEvent::ModelCompleted { .. } => { - if let Some(step) = run.pending.take() { - orphans += usize::from(has_usage(step)); - } - match run.open_commit.take() { - Some(commit) => { - owners.insert(commit, index); - } - None => run.pending = Some(index), + MuseEvent::ModelCompleted { .. } => runs.entry(run).or_default().steps.push(index), + MuseEvent::Prompt { .. } + | MuseEvent::ToolResults { .. } + | MuseEvent::RunTerminal { .. } => { + if let Some(segment) = runs.remove(&run) { + close(segment); } } - MuseEvent::Prompt { .. } | MuseEvent::RunTerminal { .. } => { - if let Some(step) = run.pending.take() { - orphans += usize::from(has_usage(step)); - } - run.open_commit = None; - } _ => {} } } - orphans += runs - .values() - .filter_map(|run| run.pending) - .filter(|step| has_usage(*step)) - .count(); + for (_, segment) in runs.drain() { + close(segment); + } (owners, orphans) } @@ -812,32 +830,51 @@ mod tests { #[test] fn model_steps_pair_with_their_commit_in_either_order() { let usage = json!({"input_tokens": 1, "output_tokens": 1}); + let calls = |id: &str| { + json!({"kind": "assistant_tool_calls_committed", + "tool_calls": [{"call_id": id, "name": "bash", "args": "{}"}]}) + }; + let results = |id: &str| { + json!({"kind": "tool_result_batch_committed", + "results": [{"tool_call_id": id, "text": "ok"}]}) + }; let contents = [ metadata(), run(2, json!({"kind": "started", "prompt": "go"})), + // Step 1: usage first, then readable reasoning *and* tool calls. run(3, json!({"kind": "model_completed", "usage": usage})), + run(4, json!({"kind": "reasoning_committed", "text": "think"})), + run(5, calls("c1")), + run(6, results("c1")), + // Step 2: usage first, tool calls. + run(7, json!({"kind": "model_completed", "usage": usage})), + run(8, calls("c2")), + run(9, results("c2")), + // Step 3: the reply first, then its usage. run( - 4, - json!({"kind": "assistant_tool_calls_committed", - "tool_calls": [{"call_id": "c", "name": "bash", "args": "{}"}]}), - ), - run( - 5, + 10, json!({"kind": "assistant_message_committed", "text": "done"}), ), - run(6, json!({"kind": "model_completed", "usage": usage})), - run(7, json!({"kind": "model_completed", "usage": usage})), - run(8, json!({"kind": "terminal", "terminal": "completed"})), + run(11, json!({"kind": "model_completed", "usage": usage})), + // A second usage in the same step has no record to carry it. + run(12, json!({"kind": "model_completed", "usage": usage})), + run(13, json!({"kind": "terminal", "terminal": "completed"})), ] .join("\n") + "\n"; let transcript = parse_transcript(&contents).unwrap(); let (owners, orphans) = pair_model_steps(&transcript); - // Record indexes: 0 prompt, 1 usage, 2 calls, 3 reply, 4 usage, - // 5 usage with nothing to carry it, 6 terminal. - assert_eq!(owners.get(&2), Some(&1)); - assert_eq!(owners.get(&3), Some(&4)); - assert_eq!(owners.len(), 2); + // Record indexes: 0 prompt, 1 usage, 2 reasoning, 3 calls, 4 results, + // 5 usage, 6 calls, 7 results, 8 reply, 9 usage, 10 usage, 11 terminal. + assert_eq!( + owners.get(&2), + Some(&1), + "step 1 is owned by its first commit" + ); + assert_eq!(owners.get(&3), None, "the rest of step 1 owns nothing"); + assert_eq!(owners.get(&6), Some(&5)); + assert_eq!(owners.get(&8), Some(&9)); + assert_eq!(owners.len(), 3); assert_eq!(orphans, 1); } diff --git a/crates/ai-hist/tests/fixture_corpus.rs b/crates/ai-hist/tests/fixture_corpus.rs index e834668a..82b0b11e 100644 --- a/crates/ai-hist/tests/fixture_corpus.rs +++ b/crates/ai-hist/tests/fixture_corpus.rs @@ -2395,6 +2395,30 @@ fn muse_tools_session_records_edits_errors_usage_and_linked_subagents() { parent_usage_rows, 4, "one row per model_completed step: {events:?}" ); + // Each step's usage sits on that step's first record: step 1's on its + // readable reasoning (not on the tool calls the same step committed), + // step 2's on its first call, the replies' on the replies. + let owners: Vec<(String, i64)> = events + .iter() + .filter(|event| text(event, "session_id") == PARENT) + .filter_map(|event| { + let usage: serde_json::Value = + serde_json::from_str(field(event, "token_json").as_str()?).ok()?; + Some(( + text(event, "event_uid").to_string(), + usage["input_tokens"].as_i64()?, + )) + }) + .collect(); + assert_eq!( + owners, + vec![ + ("a001-rec-006".to_string(), 1200), + ("tool:call_edit".to_string(), 1500), + ("a001-rec-020".to_string(), 1800), + ("a001-rec-029".to_string(), 2000), + ] + ); let sessions = rows("muse/tools-session", "sessions"); assert_eq!( diff --git a/crates/ai-hist/tests/snapshots/muse/tools-session.json b/crates/ai-hist/tests/snapshots/muse/tools-session.json index c9ca338e..2c330fd7 100644 --- a/crates/ai-hist/tests/snapshots/muse/tools-session.json +++ b/crates/ai-hist/tests/snapshots/muse/tools-session.json @@ -117,7 +117,7 @@ "session_id": "muse-a001", "source": "muse", "text": "read_file src/http.rs", - "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", + "token_json": null, "ts_ms": 1790337601750 }, { @@ -168,7 +168,7 @@ "session_id": "muse-a001", "source": "muse", "text": "edit_file src/http.rs", - "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", + "token_json": "{\"cache_read_tokens\":1100,\"cache_write_tokens\":0,\"cached_tokens\":1100,\"input_tokens\":1500,\"output_tokens\":210,\"reasoning_tokens\":0}", "ts_ms": 1790337602750 }, { @@ -287,7 +287,7 @@ "session_id": "muse-a001", "source": "muse", "text": "Added `retry` and a test; `cargo test` fails with exit code 101.", - "token_json": null, + "token_json": "{\"cache_read_tokens\":1500,\"cache_write_tokens\":0,\"cached_tokens\":1500,\"input_tokens\":1800,\"output_tokens\":35,\"reasoning_tokens\":0}", "ts_ms": 1790337605000 }, { diff --git a/docs/session-catalog.md b/docs/session-catalog.md index 446198cb..bd2ffaa0 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -1493,8 +1493,11 @@ How each adapter works: with its `model` and `finish_reason`, and normalizes as `per-request`. Muse does not write the two in one order — a step that calls tools logs `model_completed` *before* its calls, a step that answers in prose logs it - *after* the reply — so each step is paired, within its run, with its first - commit in whichever direction the step wrote it. A step that committed + *after* the reply, and one step can commit readable reasoning and then its + tool calls. A step is what lies between the model being called and the + next `started`, `tool_result_batch_committed` or `terminal` in its run; its + first assistant record owns its usage, in whichever direction the step + wrote the two. A step that committed nothing keeps its usage off every row and is reported as `MUSE_USAGE_UNATTACHED`. Only newline-terminated records are read, so a live session's half-written last line waits for the next read, and Muse is From f93c8323f824e1ac158a9a2aa3079b094cdb927e Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Thu, 24 Sep 2026 23:56:29 -0700 Subject: [PATCH 09/19] fix(muse): an unstattable subagent log is not a deleted one direct_muse_child_logs used `is_file()`, which reads any stat error as "not a file". A child log that exists but cannot be statted (permission denied, I/O error) dropped out of the tree, the changed stamp re-read the parent, and forget_muse_subagent deleted the child's evidence and edge. Only NotFound / NotADirectory now mean absent; any other error fails that session's read, which keeps no stamp and is retried. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 58 ++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 2893bae0..9b930fdb 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10821,8 +10821,21 @@ fn direct_muse_child_logs(session_dir: &Path) -> Result> { for entry in entries { let entry = entry.with_context(|| format!("list Muse subagents {}", children.display()))?; let log = entry.path().join(muse::SESSION_FILE); - if log.is_file() { - logs.push(log); + // Only "not there" means absent. A log that cannot be statted is + // still there, and treating it as gone would forget its evidence; + // the error fails this session's read instead, so it is retried. + match fs::metadata(&log) { + Ok(metadata) if metadata.is_file() => logs.push(log), + Ok(_) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => { + return Err(error) + .with_context(|| format!("stat Muse subagent log {}", log.display())) + } } } logs.sort(); @@ -15490,6 +15503,47 @@ mod tests { ); } + /// A subagent log that is there but cannot be read is not a log that is + /// gone: the session's read fails and is retried, and the child keeps + /// its evidence and its edge. + #[cfg(unix)] + #[test] + fn an_unreadable_muse_subagent_log_is_not_forgotten() { + use std::os::unix::fs::PermissionsExt; + const REMINDER: &str = "muse-e001"; + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let reminder_events = muse_session_count(&conn, "session_events", REMINDER); + assert!(reminder_events > 0); + + // The parent changes, so its tree is read again; the reminder's + // directory stays listable but its log can no longer be statted. + append_muse_record(&transcript, MUSE_PARENT, "parent-late", "More."); + let reminder_dir = transcript + .parent() + .unwrap() + .join(format!("subagent/{REMINDER}")); + fs::set_permissions(&reminder_dir, fs::Permissions::from_mode(0o644)).unwrap(); + let stat_blocked = fs::metadata(reminder_dir.join("session.jsonl")).is_err(); + let result = super::sync_muse(&conn, &mut state, &root); + fs::set_permissions(&reminder_dir, fs::Permissions::from_mode(0o755)).unwrap(); + if !stat_blocked { + // Running as a user permissions do not bind (root in a + // container): there is nothing unreadable to test. + return; + } + assert!(result.is_err(), "the only session could not be read"); + assert_eq!( + muse_session_count(&conn, "session_events", REMINDER), + reminder_events, + "an unreadable log is not a deleted one" + ); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + } + /// A removed child's leftover history — filed under it before it was /// linked as a subagent — goes with the rest of its evidence. #[test] From a7e352c174c28f093459674daebc451b7e4813da Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 10:17:59 -0700 Subject: [PATCH 10/19] fix(muse): keep unidentified child logs; share step facts; isolate fingerprint errors - A child log that names no session yet (Muse still writing its header) no longer reads as a removed child. link_muse_subagents upserts edges as it links children and only afterwards retires the ones left over, skipping any whose previous log is still present but unidentifiable, so their evidence and edge survive until the log can be read. - pair_model_steps also reports every assistant record of a step. All of them carry the step's model and finish_reason; only the first owns its usage. A step that thinks before calling tools no longer drops stop_reason from the thinking row and the calls. - MuseProvider::fingerprint_inputs falls back to a session's own transcript when one of its child logs cannot be statted, instead of failing the whole fold; cancellation still propagates. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/discover.rs | 10 +- crates/ai-hist/src/discover/tests.rs | 52 ++++++++++ crates/ai-hist/src/ingest.rs | 142 ++++++++++++++++++++++++--- crates/ai-hist/src/ingest/muse.rs | 52 ++++++---- 4 files changed, 222 insertions(+), 34 deletions(-) diff --git a/crates/ai-hist/src/discover.rs b/crates/ai-hist/src/discover.rs index 1508c05b..f82a334f 100644 --- a/crates/ai-hist/src/discover.rs +++ b/crates/ai-hist/src/discover.rs @@ -2102,7 +2102,15 @@ impl ShallowSessionProvider for MuseProvider { fn fingerprint_inputs(&self, env: &DiscoveryEnv<'_>) -> Result> { let mut files = Vec::new(); for transcript in crate::collect_muse_transcripts(&env.muse_sessions)? { - files.extend(crate::muse_session_files(&transcript)?); + match crate::muse_session_files(&transcript) { + Ok(tree) => files.extend(tree), + Err(error) if error.is::() => return Err(error), + // One session's child log that cannot be statted must not fail + // the whole fold. Its transcript still counts; the sweep reads + // that session, fails it alone, and — having left a source + // unread — does not cache this fingerprint, so it is retried. + Err(_) => files.push(transcript), + } } file_candidates("muse", files, crate::file_stamp_and_modified) } diff --git a/crates/ai-hist/src/discover/tests.rs b/crates/ai-hist/src/discover/tests.rs index dff435ae..b48e5f37 100644 --- a/crates/ai-hist/src/discover/tests.rs +++ b/crates/ai-hist/src/discover/tests.rs @@ -3905,3 +3905,55 @@ fn muse_shallow_read_refuses_a_subagent_log() { "parent" ); } + +/// One session's child log that cannot be statted leaves that session's +/// transcript in the fingerprint and every other session's tree untouched, +/// rather than failing the fold. +#[cfg(unix)] +#[test] +fn a_muse_child_log_stat_error_does_not_fail_the_fingerprint() { + use std::os::unix::fs::PermissionsExt; + let home = tempfile::tempdir().unwrap(); + let conn = catalog(); + let sessions = home.path().join(".local/share/muse/sessions/2026/09/20"); + let header = |id: &str| { + format!( + "{{\"id\":\"r1\",\"stream\":{{\"kind\":\"session\",\"id\":\"{id}\"}},\ + \"recorded_at\":1790337600000000,\"payload_type\":\"runtime.session.metadata\",\ + \"payload\":{{\"kind\":\"metadata\",\"record\":{{\"workspace_root\":\"/w\"}}}}}}\n" + ) + }; + write(&sessions.join("a/session.jsonl"), &header("a")); + write(&sessions.join("a/subagent/c/session.jsonl"), &header("c")); + write(&sessions.join("b/session.jsonl"), &header("b")); + write(&sessions.join("b/subagent/d/session.jsonl"), &header("d")); + let blocked = sessions.join("a/subagent/c"); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o644)).unwrap(); + let stat_blocked = fs::metadata(blocked.join("session.jsonl")).is_err(); + + let env = env_at(&conn, home.path()); + let inputs = MuseProvider.fingerprint_inputs(&env); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).unwrap(); + if !stat_blocked { + return; + } + let mut locators: Vec = inputs + .unwrap() + .into_iter() + .map(|candidate| { + candidate + .locator + .trim_start_matches(&*sessions.to_string_lossy()) + .to_string() + }) + .collect(); + locators.sort(); + assert_eq!( + locators, + vec![ + "/a/session.jsonl".to_string(), + "/b/session.jsonl".to_string(), + "/b/subagent/d/session.jsonl".to_string(), + ] + ); +} diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 9b930fdb..9c6ec24e 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10974,19 +10974,21 @@ fn link_muse_subagents( ) -> Result<()> { let parent_id = parent.metadata.session_id.as_str(); // Replaced, not merged: a child whose log is gone is no longer evidenced. - let previous = muse_linked_children(conn, parent_id)?; + // The edges are upserted as the children are linked and only the ones + // left over are retired afterwards, so a child whose log is still there + // is never dropped in between. + let previous = muse_child_locators(conn, parent_id)?; let mut linked = HashSet::new(); - conn.execute( - "DELETE FROM session_relationships WHERE source = 'muse' \ - AND parent_session_id = ? AND evidence_kind = 'muse_subagent_log'", - params![parent_id], - )?; + let mut unidentified_logs = HashSet::new(); let links = muse::subagent_links(parent); for entry in children { check_capture_cancelled()?; let log = &entry.path; let Some(child) = entry.transcript.as_ref() else { + // A log that names no session yet — Muse is still writing its + // header — is not a log that is gone. outcome.unidentified_children += 1; + unidentified_logs.insert(log.to_string_lossy().to_string()); continue; }; let child_id = child.metadata.session_id.clone(); @@ -11051,12 +11053,44 @@ fn link_muse_subagents( )?; outcome.relationships += 1; } - for gone in previous.difference(&linked) { - forget_muse_subagent(conn, gone)?; + for (child, locator) in &previous { + if linked.contains(child) { + continue; + } + // Still on disk, just not identifiable yet: keep what it had. + if locator + .as_deref() + .is_some_and(|locator| unidentified_logs.contains(locator)) + { + continue; + } + conn.execute( + "DELETE FROM session_relationships WHERE source = 'muse' \ + AND parent_session_id = ? AND child_session_id = ? \ + AND evidence_kind = 'muse_subagent_log'", + params![parent_id, child], + )?; + forget_muse_subagent(conn, child)?; } Ok(()) } +/// The children a parent's subagent logs linked on the last read, with the +/// log each was read from. +fn muse_child_locators( + conn: &Connection, + parent_id: &str, +) -> Result>> { + Ok(conn + .prepare( + "SELECT child_session_id, evidence_locator FROM session_relationships \ + WHERE source = 'muse' AND parent_session_id = ? \ + AND evidence_kind = 'muse_subagent_log' AND child_session_id IS NOT NULL", + )? + .query_map(params![parent_id], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?) +} + /// The children a parent's subagent logs linked on the last read. fn muse_linked_children(conn: &Connection, parent_id: &str) -> Result> { Ok(conn @@ -11227,14 +11261,19 @@ pub(crate) fn ingest_muse_session( }; let outcomes = muse::tool_outcomes(transcript); let tool_names = muse::tool_names(transcript); - let (step_owners, orphan_steps) = muse::pair_model_steps(transcript); - outcome.unattached_usage = orphan_steps; - // The step a record owns, or none: usage, model and finish reason belong - // to that one record, never to its neighbours. + let steps = muse::pair_model_steps(transcript); + outcome.unattached_usage = steps.orphans; + // The step a record belongs to: its model and finish reason are every one + // of its records', and its usage is its first record's alone, never a + // neighbour's. let step_of = |index: usize| { - step_owners - .get(&index) - .map(|step| MuseStep::of(transcript, *step)) + steps.members.get(&index).map(|step| { + let mut step_facts = MuseStep::of(transcript, *step); + if !steps.owners.contains_key(&index) { + step_facts.token_json = None; + } + step_facts + }) }; // The model a record ran under when its own step does not name one. let mut model = transcript.metadata.model_id.clone(); @@ -11336,7 +11375,10 @@ pub(crate) fn ingest_muse_session( RequestIdentity::none(), &record_uid, None, - facts, + RawMessageFacts { + stop_reason: step.as_ref().and_then(|step| step.finish_reason), + ..facts + }, )?; outcome.events += 1; } @@ -15503,6 +15545,74 @@ mod tests { ); } + /// A child log that names no session yet — Muse is still writing its + /// header — is not a child that is gone: its evidence and edge stay, and + /// it is linked again once it can be read. + #[test] + fn an_unidentified_muse_child_log_keeps_its_evidence() { + const REMINDER: &str = "muse-e001"; + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let reminder_events = muse_session_count(&conn, "session_events", REMINDER); + assert!(reminder_events > 0); + + let log = transcript + .parent() + .unwrap() + .join(format!("subagent/{REMINDER}/session.jsonl")); + let complete = fs::read_to_string(&log).unwrap(); + // Mid-write: the header record is not finished. + fs::write(&log, &complete[..complete.find('\n').unwrap() / 2]).unwrap(); + append_muse_record(&transcript, MUSE_PARENT, "parent-late", "More."); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", REMINDER), + reminder_events + ); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + + fs::write(&log, &complete).unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!( + muse_session_count(&conn, "session_events", REMINDER), + reminder_events + ); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + } + + /// Every record of a model step shares its model and finish reason; only + /// its usage is the first record's alone. A step that thinks before it + /// calls tools keeps `finish_reason` on the thinking row and on the calls. + #[test] + fn every_record_of_a_muse_step_carries_its_finish_reason() { + let home = tempfile::tempdir().unwrap(); + let (root, _) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let row = |uid: &str| -> (Option, Option) { + conn.query_row( + "SELECT stop_reason, token_json FROM session_events \ + WHERE source = 'muse' AND session_id = ? AND event_uid = ?", + params![MUSE_PARENT, uid], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + }; + let (thinking_stop, thinking_usage) = row("a001-rec-006"); + assert_eq!(thinking_stop.as_deref(), Some("tool_calls")); + assert!( + thinking_usage.is_some(), + "the step's first record owns its usage" + ); + let (call_stop, call_usage) = row("tool:call_read"); + assert_eq!(call_stop.as_deref(), Some("tool_calls")); + assert!(call_usage.is_none(), "…and only the first"); + } + /// A subagent log that is there but cannot be read is not a log that is /// gone: the session's read fails and is retried, and the child keeps /// its evidence and its edge. diff --git a/crates/ai-hist/src/ingest/muse.rs b/crates/ai-hist/src/ingest/muse.rs index 4a2d6bc5..3c237a26 100644 --- a/crates/ai-hist/src/ingest/muse.rs +++ b/crates/ai-hist/src/ingest/muse.rs @@ -497,13 +497,11 @@ pub(crate) fn parse_transcript(contents: &str) -> Option { /// record of its own to carry it, and is counted rather than attached to a /// neighbour. /// -/// Returns `(owners, orphans)`: `owners` maps an assistant record's index to -/// its step's `model_completed` index, and `orphans` counts steps that -/// reported usage but committed no assistant record to carry it. -pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap, usize) { +/// Indexes are into `transcript.records`. +pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> ModelSteps { #[derive(Default)] struct Segment { - first_commit: Option, + commits: Vec, steps: Vec, } let has_usage = |index: usize| { @@ -512,8 +510,7 @@ pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap (HashMap { - owners.insert(commit, step); - orphans += segment + paired.owners.insert(commit, step); + for member in &segment.commits { + paired.members.insert(*member, step); + } + paired.orphans += segment .steps .iter() .filter(|other| **other != step && has_usage(**other)) .count(); } _ => { - orphans += segment + paired.orphans += segment .steps .iter() .filter(|step| has_usage(**step)) @@ -546,10 +546,7 @@ pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap { - runs.entry(run) - .or_default() - .first_commit - .get_or_insert(index); + runs.entry(run).or_default().commits.push(index); } MuseEvent::ModelCompleted { .. } => runs.entry(run).or_default().steps.push(index), MuseEvent::Prompt { .. } @@ -565,7 +562,22 @@ pub(crate) fn pair_model_steps(transcript: &MuseTranscript) -> (HashMap, + /// Every assistant record of a step — its owner and the rest (reasoning + /// before tool calls) — mapped to the step's `model_completed`, whose + /// model and `finish_reason` all of them share. + pub members: HashMap, + /// Steps that reported usage but committed no assistant record to carry + /// it. + pub orphans: usize, } /// Every call's tool name, by call id. @@ -863,7 +875,8 @@ mod tests { .join("\n") + "\n"; let transcript = parse_transcript(&contents).unwrap(); - let (owners, orphans) = pair_model_steps(&transcript); + let steps = pair_model_steps(&transcript); + let (owners, orphans) = (&steps.owners, steps.orphans); // Record indexes: 0 prompt, 1 usage, 2 reasoning, 3 calls, 4 results, // 5 usage, 6 calls, 7 results, 8 reply, 9 usage, 10 usage, 11 terminal. assert_eq!( @@ -872,6 +885,11 @@ mod tests { "step 1 is owned by its first commit" ); assert_eq!(owners.get(&3), None, "the rest of step 1 owns nothing"); + assert_eq!( + steps.members.get(&3), + Some(&1), + "…but still belongs to step 1, whose model and finish reason it shares" + ); assert_eq!(owners.get(&6), Some(&5)); assert_eq!(owners.get(&8), Some(&9)); assert_eq!(owners.len(), 3); From 34e1cf5ef6a97d79c357909afc3574d406dcb1bb Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 10:31:15 -0700 Subject: [PATCH 11/19] fix(muse): a subagent read never sets its catalog title A subagent's run-start prompt is the objective its parent assigned. ingest_muse_session took it as `first_prompt` whatever the role, and the catalog UPDATE assigned it, so a remotely known child (which keeps its catalog row) had its title replaced by the objective, or cleared. Only a MuseRole::Session read now owns `first_prompt`; a subagent read leaves the column as it is. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 50 +++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 9c6ec24e..36e646f2 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -11296,7 +11296,9 @@ pub(crate) fn ingest_muse_session( }; match &record.event { muse::MuseEvent::Prompt { text } => { - if first_prompt.is_none() { + // A subagent's run-start prompt is its assigned objective, + // not a title anybody typed. + if role == MuseRole::Session && first_prompt.is_none() { first_prompt = Some(crate::discover::excerpt(text)); } insert_session_event( @@ -11687,18 +11689,22 @@ pub(crate) fn ingest_muse_session( // `upsert_session` merges, which suits an append-only log read in pieces. // This read is the whole transcript, so the fields it owns are assigned // from it, empty values included: a rewritten file that no longer has a - // reply or a prompt must not keep quoting the old one. + // reply or a prompt must not keep quoting the old one. A subagent has no + // typed prompt to own, so its read leaves `first_prompt` as it is — a + // remotely known child keeps the title the remote side gave it. let models = transcript.models(); conn.execute( "UPDATE sessions SET first_activity_ms = ?, last_activity_ms = ?, \ last_assistant_text = ?, models_json = ?, \ - first_prompt = ?, agent_version = COALESCE(?, agent_version) \ + first_prompt = CASE WHEN ? THEN ? ELSE first_prompt END, \ + agent_version = COALESCE(?, agent_version) \ WHERE source = 'muse' AND session_id = ?", params![ first_ts, last_ts, last_assistant_text.as_deref(), (!models.is_empty()).then(|| serde_json::to_string(&models).unwrap_or_default()), + role == MuseRole::Session, first_prompt.as_deref(), version, sid, @@ -15583,6 +15589,44 @@ mod tests { assert_eq!(muse_count(&conn, "session_relationships"), 3); } + /// A remotely known subagent keeps its catalog row; a local read of its + /// log must not turn its assigned objective into that row's typed title, + /// nor clear the title the remote side gave it. + #[test] + fn a_muse_subagent_read_leaves_its_catalog_title_alone() { + let home = tempfile::tempdir().unwrap(); + let (_, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + crate::mark_session_presence(&conn, "muse", MUSE_WORKER, super::SessionLocation::Remote) + .unwrap(); + conn.execute( + "INSERT OR IGNORE INTO sessions (source, session_id, first_prompt) \ + VALUES ('muse', ?, 'remote title')", + [MUSE_WORKER], + ) + .unwrap(); + conn.execute( + "UPDATE sessions SET first_prompt = 'remote title' \ + WHERE source = 'muse' AND session_id = ?", + [MUSE_WORKER], + ) + .unwrap(); + let log = transcript + .parent() + .unwrap() + .join(format!("subagent/{MUSE_WORKER}/session.jsonl")); + let child = super::read_muse_transcript(&log).unwrap().unwrap(); + super::ingest_muse_session_tree(&conn, &child, &log, None).unwrap(); + let title: Option = conn + .query_row( + "SELECT first_prompt FROM sessions WHERE source = 'muse' AND session_id = ?", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(title.as_deref(), Some("remote title")); + } + /// Every record of a model step shares its model and finish reason; only /// its usage is the first record's alone. A step that thinks before it /// calls tools keeps `finish_reason` on the thinking row and on the calls. From 2fbf37f7d6ca5cb34a2bd251e9634517a7ae55b5 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 10:36:56 -0700 Subject: [PATCH 12/19] fix(muse): read child logs only inside their session directory direct_muse_child_logs followed a symlinked `subagent/` or `session.jsonl` wherever it pointed, so sync and hydration could read and index a transcript outside the Muse root. A child log is now used only when its resolved path is inside the resolved parent session directory; anything else is not the session's evidence and is never stamped, fingerprinted, read or linked. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 47 +++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 36e646f2..7279352b 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10817,6 +10817,12 @@ fn direct_muse_child_logs(session_dir: &Path) -> Result> { .with_context(|| format!("list Muse subagents {}", children.display())) } }; + // A child log is read only where it resolves inside its parent's own + // session directory. A `subagent/` or `session.jsonl` that is a + // symlink pointing anywhere else is not this session's evidence, and + // following it would index a file outside the Muse root. + let within = fs::canonicalize(session_dir) + .with_context(|| format!("resolve Muse session {}", session_dir.display()))?; let mut logs = Vec::new(); for entry in entries { let entry = entry.with_context(|| format!("list Muse subagents {}", children.display()))?; @@ -10825,7 +10831,13 @@ fn direct_muse_child_logs(session_dir: &Path) -> Result> { // still there, and treating it as gone would forget its evidence; // the error fails this session's read instead, so it is retried. match fs::metadata(&log) { - Ok(metadata) if metadata.is_file() => logs.push(log), + Ok(metadata) if metadata.is_file() => { + let resolved = fs::canonicalize(&log) + .with_context(|| format!("resolve Muse subagent log {}", log.display()))?; + if resolved.starts_with(&within) { + logs.push(log); + } + } Ok(_) => {} Err(error) if matches!( @@ -15551,6 +15563,39 @@ mod tests { ); } + /// A `subagent/` entry that is a symlink out of the session directory is + /// not the session's evidence: it is never read, stamped or linked. + #[cfg(unix)] + #[test] + fn a_muse_child_log_symlinked_outside_its_session_is_not_read() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let outside = home.path().join("elsewhere/secret"); + fs::create_dir_all(&outside).unwrap(); + fs::write( + outside.join("session.jsonl"), + "{\"id\":\"x\",\"stream\":{\"kind\":\"session\",\"id\":\"escaped\"},\ + \"recorded_at\":1790337600000000,\"payload_type\":\"runtime.session.metadata\",\ + \"payload\":{\"kind\":\"metadata\",\"record\":{\"workspace_root\":\"/w\"}}}\n", + ) + .unwrap(); + let session_dir = transcript.parent().unwrap(); + std::os::unix::fs::symlink(&outside, session_dir.join("subagent/escaped")).unwrap(); + + let files = super::muse_session_files(&transcript).unwrap(); + assert!( + files + .iter() + .all(|file| !file.to_string_lossy().contains("escaped")), + "{files:?}" + ); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!(muse_session_count(&conn, "session_events", "escaped"), 0); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + } + /// A child log that names no session yet — Muse is still writing its /// header — is not a child that is gone: its evidence and edge stay, and /// it is linked again once it can be read. From d5435328d6f5f23db946fea52f3a067d8124f80b Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 13:08:01 -0700 Subject: [PATCH 13/19] fix(muse): rebuild each relinked delegation edge Upserting a relinked child's edge went through record_relationship, which keeps an optional field (label, model, task) the new observation leaves empty, so a detail the parent no longer records stayed on the edge. Each relinked child's edge is now deleted and recorded again in the same transaction, so it reflects the parent's current record and is never missing in between. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 7279352b..7a0a4adf 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -11043,6 +11043,17 @@ fn link_muse_subagents( .model .clone() .or_else(|| child.metadata.model_id.clone()); + // Rebuilt, not merged: `record_relationship` keeps an optional field + // the new observation leaves empty, so a label, model or task the + // parent no longer records would otherwise stay on the edge. This one + // child's edge is replaced in place, in the same transaction, so it is + // never missing in between. + conn.execute( + "DELETE FROM session_relationships WHERE source = 'muse' \ + AND parent_session_id = ? AND child_session_id = ? \ + AND evidence_kind = 'muse_subagent_log'", + params![parent_id, child_id], + )?; record_relationship( conn, &ObservedRelationship { @@ -15596,6 +15607,34 @@ mod tests { assert_eq!(muse_count(&conn, "session_relationships"), 3); } + /// An edge is rebuilt from what the parent records now: a label the + /// parent no longer gives its child does not linger on the delegation. + #[test] + fn a_muse_delegation_drops_details_the_parent_no_longer_records() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let label = |conn: &Connection| -> Option { + conn.query_row( + "SELECT child_agent_name FROM session_relationships \ + WHERE source = 'muse' AND child_session_id = ?", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(label(&conn).as_deref(), Some("reviewer")); + + let rewritten = fs::read_to_string(&transcript) + .unwrap() + .replace("\"label\": \"reviewer\", ", ""); + fs::write(&transcript, rewritten).unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!(label(&conn), None); + } + /// A child log that names no session yet — Muse is still writing its /// header — is not a child that is gone: its evidence and edge stay, and /// it is linked again once it can be read. From f09490c7ec2dc2cff35ae316ce2da26c7195b405 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 13:11:50 -0700 Subject: [PATCH 14/19] fix(muse): a call that ended without a result keeps its outcome `tool_batch.effect.terminal` outcomes were applied only while writing a matching tool result, so a call that was cancelled or failed before producing one left tool_calls.is_error unset. After the transcript is read, every call with a recorded outcome and no result now takes its status from that outcome; calls with a result keep the status settled with it (including a failing `bash` exit code). Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 7a0a4adf..bc764670 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -11284,6 +11284,8 @@ pub(crate) fn ingest_muse_session( }; let outcomes = muse::tool_outcomes(transcript); let tool_names = muse::tool_names(transcript); + // Calls whose result this transcript recorded. + let mut answered: HashSet = HashSet::new(); let steps = muse::pair_model_steps(transcript); outcome.unattached_usage = steps.orphans; // The step a record belongs to: its model and finish reason are every one @@ -11559,6 +11561,7 @@ pub(crate) fn ingest_muse_session( } muse::MuseEvent::ToolResults { results: batch } => { for result in batch { + answered.insert(result.call_id.clone()); let text = result.text.as_deref(); let mut result_facts = tool_result_facts::muse_tool_result_facts( text, @@ -11695,6 +11698,21 @@ pub(crate) fn ingest_muse_session( muse::MuseEvent::SubagentLinked(_) => {} } } + // A call can end without a result record — cancelled, or failed before + // producing output. Its outcome is still Muse's word on how it ended, so + // it sets the call's status; a call that has a result was settled above, + // where the result's own evidence (a `bash` exit code) is weighed too. + for (call_id, (status, _)) in &outcomes { + if answered.contains(*call_id) { + continue; + } + let failed = match *status { + "completed" => false, + "failed" | "cancelled" | "canceled" | "skipped" => true, + _ => continue, + }; + set_tool_call_error(conn, SOURCE, sid, call_id, failed)?; + } let first_ts = transcript.first_ts().unwrap_or_default(); let last_ts = transcript.last_ts().unwrap_or(first_ts); @@ -15607,6 +15625,44 @@ mod tests { assert_eq!(muse_count(&conn, "session_relationships"), 3); } + /// A call that failed or was cancelled before any result was recorded + /// still takes its status from Muse's recorded outcome. + #[test] + fn a_muse_call_that_ended_without_a_result_keeps_its_outcome() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + // Drop the result record that answered the four calls of step 2, + // keeping their `tool_batch.effect.terminal` outcomes; make the + // write call's outcome a cancellation. + let rewritten: String = fs::read_to_string(&transcript) + .unwrap() + .lines() + .filter(|line| !line.contains("\"batch_id\": \"msg-2\"")) + .map(|line| { + if line.contains("\"call_id\": \"call_write\"") && line.contains("tool_batch") { + line.replace("\"kind\": \"completed\"", "\"kind\": \"cancelled\"") + } else { + line.to_string() + } + }) + .map(|line| format!("{line}\n")) + .collect(); + fs::write(&transcript, rewritten).unwrap(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let status = |call: &str| -> Option { + conn.query_row( + "SELECT is_error FROM tool_calls WHERE source = 'muse' AND tool_use_id = ?", + [call], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(status("call_write"), Some(1), "cancelled without a result"); + assert_eq!(status("call_edit"), Some(0), "completed without a result"); + } + /// An edge is rebuilt from what the parent records now: a label the /// parent no longer gives its child does not linger on the delegation. #[test] From 0a2e9af234a03a97ffe85f6d2ee36eaba64bb8e2 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 13:15:37 -0700 Subject: [PATCH 15/19] fix(muse): keep a delegation's first-seen time when its details change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a relinked child's edge before recording it again turned every re-read into a fresh insert, resetting `created_ms` to now. The edge is upserted again (keeping `created_ms`) and the fields the parent's record owns — agent type, name, model, task reference, locator, depth and spawn time — are then assigned from it, empty values included, so a detail the parent dropped still does not linger. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 62 ++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index bc764670..b8860d0a 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -11043,17 +11043,9 @@ fn link_muse_subagents( .model .clone() .or_else(|| child.metadata.model_id.clone()); - // Rebuilt, not merged: `record_relationship` keeps an optional field - // the new observation leaves empty, so a label, model or task the - // parent no longer records would otherwise stay on the edge. This one - // child's edge is replaced in place, in the same transaction, so it is - // never missing in between. - conn.execute( - "DELETE FROM session_relationships WHERE source = 'muse' \ - AND parent_session_id = ? AND child_session_id = ? \ - AND evidence_kind = 'muse_subagent_log'", - params![parent_id, child_id], - )?; + let spawned_at_ms = (linked_at > 0) + .then_some(linked_at) + .or_else(|| child.first_ts()); record_relationship( conn, &ObservedRelationship { @@ -11068,12 +11060,33 @@ fn link_muse_subagents( evidence_locator: Some(&locator), evidence_ref: link.task_id.as_deref(), child_has_events: session_events_exist(conn, "muse", &child_id)?, - spawned_at_ms: (linked_at > 0) - .then_some(linked_at) - .or_else(|| child.first_ts()), + spawned_at_ms, ..ObservedRelationship::default() }, )?; + // The upsert keeps an optional field the new observation leaves empty, + // so a label, model or task the parent no longer records would stay + // on the edge. What the parent's record owns is assigned from it, + // empty values included; the edge itself — and its first-seen + // `created_ms` — stays in place. + conn.execute( + "UPDATE session_relationships SET child_agent_type = ?, child_agent_name = ?, \ + child_model = ?, evidence_ref = ?, evidence_locator = ?, spawn_depth = ?, \ + spawned_at_ms = ? \ + WHERE source = 'muse' AND parent_session_id = ? AND child_session_id = ? \ + AND evidence_kind = 'muse_subagent_log'", + params![ + link.role.as_deref(), + link.label.as_deref(), + child_model.as_deref(), + link.task_id.as_deref(), + locator, + depth, + spawned_at_ms, + parent_id, + child_id, + ], + )?; outcome.relationships += 1; } for (child, locator) in &previous { @@ -15663,8 +15676,9 @@ mod tests { assert_eq!(status("call_edit"), Some(0), "completed without a result"); } - /// An edge is rebuilt from what the parent records now: a label the - /// parent no longer gives its child does not linger on the delegation. + /// An edge reflects what the parent records now — a label the parent no + /// longer gives its child does not linger — while keeping the time it + /// was first observed. #[test] fn a_muse_delegation_drops_details_the_parent_no_longer_records() { let home = tempfile::tempdir().unwrap(); @@ -15686,9 +15700,25 @@ mod tests { let rewritten = fs::read_to_string(&transcript) .unwrap() .replace("\"label\": \"reviewer\", ", ""); + let created = |conn: &Connection| -> i64 { + conn.query_row( + "SELECT created_ms FROM session_relationships \ + WHERE source = 'muse' AND child_session_id = ?", + [MUSE_WORKER], + |row| row.get(0), + ) + .unwrap() + }; + let first_seen = created(&conn); + std::thread::sleep(std::time::Duration::from_millis(5)); fs::write(&transcript, rewritten).unwrap(); super::sync_muse(&conn, &mut state, &root).unwrap(); assert_eq!(label(&conn), None); + assert_eq!( + created(&conn), + first_seen, + "the edge keeps the time it was first observed" + ); } /// A child log that names no session yet — Muse is still writing its From d1d60385bae822e78950ccad1f73e1c685bd6bcb Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 13:33:19 -0700 Subject: [PATCH 16/19] fix(muse): a subagent symlink back into its session is not followed The containment check accepted any child log resolving inside the session directory, so `subagent/loop -> ..` read the parent's own transcript as its child and recursed forever through muse_session_files / read_muse_children, overflowing the stack on every sync. A child log must now resolve strictly beneath that level's own `subagent/` directory, which makes each level strictly deeper in the real filesystem and every walk finite; the walks are also capped at 32 levels. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 75 ++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index b8860d0a..ed50cba8 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10795,16 +10795,32 @@ pub(crate) fn muse_session_files(transcript: &Path) -> Result> { /// The `subagent//session.jsonl` logs under one session directory, and /// theirs in turn. fn collect_muse_child_logs(session_dir: &Path, out: &mut Vec) -> Result<()> { + collect_muse_child_logs_at(session_dir, 0, out) +} + +fn collect_muse_child_logs_at( + session_dir: &Path, + depth: usize, + out: &mut Vec, +) -> Result<()> { + if depth >= MAX_MUSE_SUBAGENT_DEPTH { + return Ok(()); + } for log in direct_muse_child_logs(session_dir)? { check_capture_cancelled()?; out.push(log.clone()); if let Some(child_dir) = log.parent() { - collect_muse_child_logs(child_dir, out)?; + collect_muse_child_logs_at(child_dir, depth + 1, out)?; } } Ok(()) } +/// How deep subagent logs are followed. The containment rule in +/// [`direct_muse_child_logs`] already makes every level strictly deeper in the +/// real filesystem, so a walk always ends; this bounds it regardless. +const MAX_MUSE_SUBAGENT_DEPTH: usize = 32; + /// The immediate `subagent//session.jsonl` logs of one session /// directory, sorted. fn direct_muse_child_logs(session_dir: &Path) -> Result> { @@ -10817,12 +10833,16 @@ fn direct_muse_child_logs(session_dir: &Path) -> Result> { .with_context(|| format!("list Muse subagents {}", children.display())) } }; - // A child log is read only where it resolves inside its parent's own - // session directory. A `subagent/` or `session.jsonl` that is a - // symlink pointing anywhere else is not this session's evidence, and - // following it would index a file outside the Muse root. - let within = fs::canonicalize(session_dir) - .with_context(|| format!("resolve Muse session {}", session_dir.display()))?; + // A child log is read only where it resolves inside this session's own + // `subagent/` directory. A `subagent/` or `session.jsonl` that is a + // symlink pointing anywhere else is not this session's evidence: + // outside the Muse root it would index a stranger's file, and back up + // into the session (`subagent/loop -> ..`) it would read the parent's own + // transcript as its child and recurse forever. Requiring each level to + // resolve strictly beneath the previous one's `subagent/` is what makes + // every walk of the tree finite. + let within = fs::canonicalize(&children) + .with_context(|| format!("resolve Muse subagents {}", children.display()))?; let mut logs = Vec::new(); for entry in entries { let entry = entry.with_context(|| format!("list Muse subagents {}", children.display()))?; @@ -10958,12 +10978,19 @@ pub(crate) fn read_muse_tree( /// The subagent logs directly beneath one session directory, each read along /// with its own children. pub(crate) fn read_muse_children(session_dir: &Path) -> Result> { + read_muse_children_at(session_dir, 0) +} + +fn read_muse_children_at(session_dir: &Path, depth: usize) -> Result> { + if depth >= MAX_MUSE_SUBAGENT_DEPTH { + return Ok(Vec::new()); + } let mut out = Vec::new(); for path in direct_muse_child_logs(session_dir)? { check_capture_cancelled()?; let transcript = read_muse_transcript(&path)?; let children = match path.parent() { - Some(dir) => read_muse_children(dir)?, + Some(dir) => read_muse_children_at(dir, depth + 1)?, None => Vec::new(), }; out.push(MuseChildLog { @@ -15721,6 +15748,38 @@ mod tests { ); } + /// A `subagent/` entry that points back up into its own session is not a + /// child: it is never read as one, and the walk ends rather than + /// recursing through it forever. + #[cfg(unix)] + #[test] + fn a_muse_subagent_symlink_back_into_its_session_is_not_followed() { + let home = tempfile::tempdir().unwrap(); + let (root, transcript) = muse_fixture(home.path()); + let session_dir = transcript.parent().unwrap(); + std::os::unix::fs::symlink("..", session_dir.join("subagent/loop")).unwrap(); + + let files = super::muse_session_files(&transcript).unwrap(); + assert_eq!( + files.len(), + 4, + "the parent and its three real child logs: {files:?}" + ); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + assert_eq!(muse_count(&conn, "session_relationships"), 3); + let self_edges: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_relationships \ + WHERE source = 'muse' AND child_session_id = ?", + [MUSE_PARENT], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(self_edges, 0, "the parent is never its own child"); + } + /// A child log that names no session yet — Muse is still writing its /// header — is not a child that is gone: its evidence and edge stay, and /// it is linked again once it can be read. From 1acca700c5d6a209bee1e3a0f740243aa7eed88f Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 13:40:50 -0700 Subject: [PATCH 17/19] fix(muse): measure subagent containment against the real session dir The containment boundary was the resolved `subagent/` directory, so a `subagent` that is itself a symlink moved the boundary with it: logs under its target were read as children, and `subagent -> .` read the parent transcript as its own child again. The boundary is now the session's resolved directory plus a literal `subagent` component, so a symlinked `subagent/` is measured against where it should be. With that rule every level resolves strictly deeper in the real filesystem and adds at least `/subagent/` to a path the OS bounds, so every walk is finite without a depth limit. The 32-level cap, which silently dropped deeper logs, is removed. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 90 ++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index ed50cba8..49526063 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -10794,33 +10794,25 @@ pub(crate) fn muse_session_files(transcript: &Path) -> Result> { /// The `subagent//session.jsonl` logs under one session directory, and /// theirs in turn. +/// +/// No depth limit is needed, and none is imposed — a limit would silently drop +/// the logs beneath it. [`direct_muse_child_logs`] accepts a child only where +/// it resolves beneath the session's *real* directory plus a literal +/// `subagent/` component, so each level is strictly deeper in the real +/// filesystem than the one before and adds at least `/subagent/` to its +/// path; the operating system's own path-length limit therefore bounds every +/// walk, symlinks and all. fn collect_muse_child_logs(session_dir: &Path, out: &mut Vec) -> Result<()> { - collect_muse_child_logs_at(session_dir, 0, out) -} - -fn collect_muse_child_logs_at( - session_dir: &Path, - depth: usize, - out: &mut Vec, -) -> Result<()> { - if depth >= MAX_MUSE_SUBAGENT_DEPTH { - return Ok(()); - } for log in direct_muse_child_logs(session_dir)? { check_capture_cancelled()?; out.push(log.clone()); if let Some(child_dir) = log.parent() { - collect_muse_child_logs_at(child_dir, depth + 1, out)?; + collect_muse_child_logs(child_dir, out)?; } } Ok(()) } -/// How deep subagent logs are followed. The containment rule in -/// [`direct_muse_child_logs`] already makes every level strictly deeper in the -/// real filesystem, so a walk always ends; this bounds it regardless. -const MAX_MUSE_SUBAGENT_DEPTH: usize = 32; - /// The immediate `subagent//session.jsonl` logs of one session /// directory, sorted. fn direct_muse_child_logs(session_dir: &Path) -> Result> { @@ -10833,16 +10825,19 @@ fn direct_muse_child_logs(session_dir: &Path) -> Result> { .with_context(|| format!("list Muse subagents {}", children.display())) } }; - // A child log is read only where it resolves inside this session's own - // `subagent/` directory. A `subagent/` or `session.jsonl` that is a - // symlink pointing anywhere else is not this session's evidence: - // outside the Muse root it would index a stranger's file, and back up - // into the session (`subagent/loop -> ..`) it would read the parent's own - // transcript as its child and recurse forever. Requiring each level to - // resolve strictly beneath the previous one's `subagent/` is what makes - // every walk of the tree finite. - let within = fs::canonicalize(&children) - .with_context(|| format!("resolve Muse subagents {}", children.display()))?; + // A child log is read only where it resolves beneath the session's real + // directory plus a literal `subagent/` component. The session directory + // is resolved first and `subagent` joined after, so a `subagent/` that is + // itself a symlink is measured against where it *should* be, not where + // it points. Anything else is not this session's evidence: outside the + // Muse root it would index a stranger's file, and back up into the + // session (`subagent -> .`, `subagent/loop -> ..`) it would read the + // parent's own transcript as its child and recurse. Requiring each level + // to resolve strictly beneath the previous one's real `subagent/` is what + // makes every walk of the tree finite. + let within = fs::canonicalize(session_dir) + .with_context(|| format!("resolve Muse session {}", session_dir.display()))? + .join(muse::SUBAGENT_DIR); let mut logs = Vec::new(); for entry in entries { let entry = entry.with_context(|| format!("list Muse subagents {}", children.display()))?; @@ -10977,20 +10972,14 @@ pub(crate) fn read_muse_tree( /// The subagent logs directly beneath one session directory, each read along /// with its own children. +/// Finite for the reason [`collect_muse_child_logs`] is. pub(crate) fn read_muse_children(session_dir: &Path) -> Result> { - read_muse_children_at(session_dir, 0) -} - -fn read_muse_children_at(session_dir: &Path, depth: usize) -> Result> { - if depth >= MAX_MUSE_SUBAGENT_DEPTH { - return Ok(Vec::new()); - } let mut out = Vec::new(); for path in direct_muse_child_logs(session_dir)? { check_capture_cancelled()?; let transcript = read_muse_transcript(&path)?; let children = match path.parent() { - Some(dir) => read_muse_children_at(dir, depth + 1)?, + Some(dir) => read_muse_children(dir)?, None => Vec::new(), }; out.push(MuseChildLog { @@ -15780,6 +15769,37 @@ mod tests { assert_eq!(self_edges, 0, "the parent is never its own child"); } + /// A `subagent/` directory that is itself a symlink — back into its own + /// session, or out of it — is not the session's subagents: nothing under + /// it is read, and the walk ends. + #[cfg(unix)] + #[test] + fn a_symlinked_muse_subagent_directory_is_not_followed() { + let home = tempfile::tempdir().unwrap(); + let (_, transcript) = muse_fixture(home.path()); + let session_dir = transcript.parent().unwrap(); + + // Back into the session. + let real = session_dir.join("subagent"); + let moved = session_dir.join("subagent-real"); + fs::rename(&real, &moved).unwrap(); + std::os::unix::fs::symlink(".", &real).unwrap(); + assert_eq!( + super::muse_session_files(&transcript).unwrap(), + vec![transcript.clone()] + ); + + // Out of the session, to a real subagent tree somewhere else. + fs::remove_file(&real).unwrap(); + let elsewhere = home.path().join("elsewhere"); + fs::rename(&moved, &elsewhere).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &real).unwrap(); + assert_eq!( + super::muse_session_files(&transcript).unwrap(), + vec![transcript.clone()] + ); + } + /// A child log that names no session yet — Muse is still writing its /// header — is not a child that is gone: its evidence and edge stay, and /// it is linked again once it can be read. From ddf8af72b7df26ee6011928cec4c3caab53ee62c Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Sun, 27 Sep 2026 15:26:28 -0700 Subject: [PATCH 18/19] fix(muse): adapt the sync pass to the retired export journal The merge of main (6291cf0) was committed without this change, so the Muse pass still called the retention gate main removed with the export journal (report.ensure_headroom, ensure_capture_headroom, annotate_retention_limit) and the three-argument report.capture. It now uses main's two-argument report.capture like every other source; a write failure inside a session's transaction still ends the pass. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index 3762bf1e..c13aef0e 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -1513,12 +1513,10 @@ fn sync_basic( } capture_progress("muse", 0, None); check_capture_cancelled()?; - report.ensure_headroom(conn, "muse")?; if let Some(inserted) = report.capture( - conn, "muse", sync_muse_with_coverage(conn, &mut state, &roots.muse, &repairs, &mut coverage), - )? { + ) { total_inserted += inserted; checkpoint_sync_state(&state_path, &state); } @@ -10523,13 +10521,12 @@ fn sync_muse_with_coverage( } } scanned += 1; - ensure_capture_headroom(conn)?; // Everything is read before anything is written. A transcript or a // subagent log that cannot be read is this session's failure alone: // it keeps no stamp, so the next run retries it, and the pass goes on. - // A write that fails once the transaction is open is not per-session — - // a retention abort meets every later session too — so it ends the - // pass, as it does for Grok. + // A write that fails once the transaction is open is a database + // failure, not this session's, so it ends the pass, as it does for + // Grok. match read_muse_tree(&transcript) { Ok(Some((parsed, children))) => { let session_id = parsed.metadata.session_id.clone(); @@ -10544,7 +10541,7 @@ fn sync_muse_with_coverage( Ok(written) => written, Err(error) => { drop(tx); - return Err(annotate_retention_limit(conn, error)); + return Err(error); } }; // A re-read replaces the session's prompts wholesale, so what From 6b0c88b27cbe8927edf8b805a02c78b90fb3ffef Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Tue, 29 Sep 2026 02:18:06 -0700 Subject: [PATCH 19/19] fix(muse): tool results are standalone outputs, not user-message blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muse commits tool results in their own tool_result_batch_committed record, never inside a user message, but muse_tool_result_facts labelled them event_source = 'tool_result' — the label for a block embedded in a user message. The user-turn view counts exactly those rows, so a Muse prompt followed by a large read_file result was reported as a user turn carrying the tool output. They are now 'function_call_output', like Grok's and OpenCode's standalone results; a Muse user turn is the prompt alone. The Muse snapshots change only in that label. Co-Authored-By: Claude Opus 5.5 --- crates/ai-hist/src/ingest.rs | 31 +++++++++++++++++++ .../ai-hist/src/ingest/tool_result_facts.rs | 5 ++- .../tests/snapshots/muse/cli-capture.json | 6 ++-- .../tests/snapshots/muse/tools-session.json | 10 +++--- docs/session-catalog.md | 2 +- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/ai-hist/src/ingest.rs b/crates/ai-hist/src/ingest.rs index d0260e3a..8e12ee88 100644 --- a/crates/ai-hist/src/ingest.rs +++ b/crates/ai-hist/src/ingest.rs @@ -16677,6 +16677,37 @@ mod tests { assert_eq!(muse_count(&conn, "session_relationships"), 3); } + /// Muse commits tool results in their own records, not inside a user + /// message, so a user turn is the typed prompt alone: no tool output is + /// counted among its blocks. + #[test] + fn muse_user_turns_are_the_prompts_alone() { + let home = tempfile::tempdir().unwrap(); + let (root, _) = muse_fixture(home.path()); + let conn = open_db(&home.path().join("history.db")).unwrap(); + let mut state = Map::new(); + super::sync_muse(&conn, &mut state, &root).unwrap(); + let page = crate::session_user_turns_page(&conn, "muse", MUSE_PARENT, 100, None).unwrap(); + assert_eq!(page.user_turns.len(), 2, "{:?}", page.user_turns); + for turn in &page.user_turns { + assert!( + turn.blocks.iter().all(|block| block.kind != "tool_result"), + "{turn:?}" + ); + } + let sources: Vec = conn + .prepare( + "SELECT DISTINCT event_source FROM session_events \ + WHERE source = 'muse' AND role = 'tool_result'", + ) + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(sources, vec!["function_call_output".to_string()]); + } + /// A call that failed or was cancelled before any result was recorded /// still takes its status from Muse's recorded outcome. #[test] diff --git a/crates/ai-hist/src/ingest/tool_result_facts.rs b/crates/ai-hist/src/ingest/tool_result_facts.rs index 040c67c7..5a8bec26 100644 --- a/crates/ai-hist/src/ingest/tool_result_facts.rs +++ b/crates/ai-hist/src/ingest/tool_result_facts.rs @@ -374,7 +374,10 @@ pub fn muse_tool_result_facts( ) -> ToolResultFacts { let payload = text.map_or(Value::Null, |text| Value::String(text.to_string())); let mut facts = ToolResultFacts::from_payload(&payload); - facts.event_source = Some(EVENT_SOURCE_TOOL_RESULT.to_string()); + // Muse commits results in their own `tool_result_batch_committed` + // record, not inside a user message, so they are standalone outputs — a + // user turn is the prompt alone, as for Grok and OpenCode. + facts.event_source = Some(EVENT_SOURCE_FUNCTION_CALL_OUTPUT.to_string()); facts.tool_use_id = (!call_id.is_empty()).then(|| call_id.to_string()); let status = match outcome.map(|(kind, _)| kind) { Some("completed") => STATUS_COMPLETED, diff --git a/crates/ai-hist/tests/snapshots/muse/cli-capture.json b/crates/ai-hist/tests/snapshots/muse/cli-capture.json index 48b7300f..35597720 100644 --- a/crates/ai-hist/tests/snapshots/muse/cli-capture.json +++ b/crates/ai-hist/tests/snapshots/muse/cli-capture.json @@ -104,7 +104,7 @@ "cwd": "/fixture/work", "error_signal": null, "event_index": 0, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_muse_timeline", "git_branch": null, "kind": "tool_result", @@ -160,7 +160,7 @@ "cwd": "/fixture/work", "error_signal": null, "event_index": 1, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_muse_note", "git_branch": null, "kind": "tool_result", @@ -216,7 +216,7 @@ "cwd": "/fixture/work", "error_signal": "tool_batch.effect", "event_index": 2, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_muse_missing", "git_branch": null, "kind": "tool_result", diff --git a/crates/ai-hist/tests/snapshots/muse/tools-session.json b/crates/ai-hist/tests/snapshots/muse/tools-session.json index 4eb1d24b..b469de4a 100644 --- a/crates/ai-hist/tests/snapshots/muse/tools-session.json +++ b/crates/ai-hist/tests/snapshots/muse/tools-session.json @@ -160,7 +160,7 @@ "cwd": "/tmp/muse-demo", "error_signal": null, "event_index": 0, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_read", "git_branch": null, "kind": "tool_result", @@ -300,7 +300,7 @@ "cwd": "/tmp/muse-demo", "error_signal": "exit_code", "event_index": 3, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_bash", "git_branch": null, "kind": "tool_result", @@ -328,7 +328,7 @@ "cwd": "/tmp/muse-demo", "error_signal": null, "event_index": 1, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_edit", "git_branch": null, "kind": "tool_result", @@ -356,7 +356,7 @@ "cwd": "/tmp/muse-demo", "error_signal": null, "event_index": 4, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_spawn", "git_branch": null, "kind": "tool_result", @@ -384,7 +384,7 @@ "cwd": "/tmp/muse-demo", "error_signal": null, "event_index": 2, - "event_source": "tool_result", + "event_source": "function_call_output", "event_uid": "result:call_write", "git_branch": null, "kind": "tool_result", diff --git a/docs/session-catalog.md b/docs/session-catalog.md index fdb3f0de..42bebe25 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -602,7 +602,7 @@ status, because a fabricated measurement reads exactly like a real one: | **cursor** | ✓ (raw block `content`) | ✓ (harness markers) | ✓ | ✓ | `tool_result` | `tool_result.is_error` | – (no notification rail) | | **grok** | ✓ (raw line `content`) | ✓ (harness markers) | ✓ | ✓ (`unknown` with neither signal) | `function_call_output` | `tool_result.is_error`, `tool_status` | – (no notification rail) | | **opencode** | ✓ (raw part `output`) | ✓ (harness markers) | ✓ | ✓ | `function_call_output` | `exit_code`, `tool_status` | – (no notification rail) | -| **muse** | ✓ (result `text`) | ✓ (harness markers) | ✓ | ✓ (`tool_batch.effect.terminal`, joined by call id) | `tool_result` | `tool_batch.effect`, `exit_code` (`bash`) | – | +| **muse** | ✓ (result `text`) | ✓ (harness markers) | ✓ | ✓ (`tool_batch.effect.terminal`, joined by call id) | `function_call_output` | `tool_batch.effect`, `exit_code` (`bash`) | – | | **relay** | – | – | – | – | – | – | – | `payload_bytes` is the raw UTF-8 length of what the provider handed back —