From 504a8954168770a8361dc6cbc2ec66bacf7fac92 Mon Sep 17 00:00:00 2001 From: AkitaOnRails Date: Sat, 25 Jul 2026 14:10:41 -0300 Subject: [PATCH] fix(run): recover orphaned native sessions --- CHANGELOG.md | 7 + README.md | 12 +- crates/ai-memory-cli/src/cli.rs | 7 +- crates/ai-memory-cli/src/commands/run.rs | 198 +++++++++++++++++- crates/ai-memory-workstream/src/harness.rs | 9 +- crates/ai-memory-workstream/src/lib.rs | 4 +- crates/ai-memory-workstream/src/transcript.rs | 102 ++++++++- docs/install.md | 2 +- docs/managed-harness-contributions.md | 18 +- docs/managed-workstreams.md | 27 ++- scripts/managed-workstream-acceptance.sh | 38 ++++ 11 files changed, 396 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4029e043..34ad1866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `access_count` a coarser retention signal. (#239) ### Fixed +- `ai-memory run ` now verifies an ai-memory-injected native resume + target still exists in that harness's read-only session store. A confirmed + orphan starts a fresh native session and repoints the same workstream instead + of retrying the dead id forever. The new wrapper-owned `--fresh` flag forces + the same per-workstream recovery without a resume attempt or adoption prompt; + explicit native resume/session/fork selectors remain authoritative and + cannot be combined with `--fresh`. (#240) - `install-mcp --client claude-desktop` now detects an MSIX-packaged Claude Desktop on Windows and writes to its virtualized `AppData\Local\Packages\Claude_\LocalCache\Roaming\Claude\claude_desktop_config.json` diff --git a/README.md b/README.md index 945131f4..e65b4cd7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ priors are at the [bottom](#influences-and-prior-art). Codex, OpenCode, Pi, Crush, or Kimi Code session for this checkout. On first explicit use, an interactive launcher can adopt a previous session from the same checkout; later switches cannot select unrelated native history. Native - arguments pass through unchanged except the wrapper-owned `--yolo`; direct + arguments pass through unchanged except the wrapper-owned `--yolo` and + `--fresh`; direct commands are unaffected. `kimi-code` and `kimi-cli` are accepted aliases for the installed `kimi` command. - **Per-repository capture exclusions.** A nearest-marker `[capture]` @@ -148,6 +149,9 @@ priors are at the [bottom](#influences-and-prior-art). # Later, omit the name to resume the newest usable managed session here. ai-memory run + + # Start a new Codex session in the same workstream, keeping portable history. + ai-memory run --fresh codex ``` The first explicit run can offer an existing session from this exact checkout @@ -155,7 +159,9 @@ priors are at the [bottom](#influences-and-prior-art). linked to the shared workstream, so an obsolete local session cannot replace newer cross-harness history. After a normal quit, the next launch waits briefly if the previous launcher is still finalizing; handled failures release - the workstream immediately. Managed mode currently covers Claude Code, Codex, + the workstream immediately. If a linked native transcript was deleted, + ai-memory detects the orphan before launch and starts fresh; `--fresh` forces + that recovery for one harness. Managed mode currently covers Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, and OMP; direct harness launches remain unchanged. See [Managed cross-harness workstreams](docs/managed-workstreams.md). - **"Quit at 4 PM, pick up at 9 AM in a different agent."** The @@ -707,7 +713,7 @@ diagram, crate breakdown, schema notes, and invariants. |---|---| | [`docs/install.md`](docs/install.md) | **Installation cookbook.** Every agent CLI, every alternative (curl, source build, no-docker, no-auth), and the server-on-a-different-machine (homelab/LAN) walkthrough. Read after the Quick start if your setup doesn't match the happy path. | | [`docs/usage.md`](docs/usage.md) | Handoffs, proactive memory queries, slim routing snippet + managed Agent Skills, migration from other memory tools, web UI, raw-wiki inspection, and rules-vs-facts workflow. | -| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, and OMP: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. | +| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, OMP, and Grok Build CLI: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. | | [`docs/managed-harness-contributions.md`](docs/managed-harness-contributions.md) | Protocol and acceptance bar for contributors adding managed resume, read-only transcript import, and startup context delivery to another harness. | | [`docs/marker-file.md`](docs/marker-file.md) | `.ai-memory.toml` workspace/project routing for multi-client trees, mono-repos, worktrees, and work/personal separation. | | [`docs/auto-scope.md`](docs/auto-scope.md) | `[auto_scope]` modes for shared servers: default single-slot routing, session-aware isolation, and multi-user `per_actor` behavior. | diff --git a/crates/ai-memory-cli/src/cli.rs b/crates/ai-memory-cli/src/cli.rs index e58f0d32..81f6998b 100644 --- a/crates/ai-memory-cli/src/cli.rs +++ b/crates/ai-memory-cli/src/cli.rs @@ -34,7 +34,8 @@ pub enum Command { /// Print runtime status (counts, paths, version). Status(StatusArgs), /// Launch an agent in an opt-in, cross-harness managed workstream. - /// Every argument after the harness name is forwarded unchanged. + /// Native arguments are forwarded except exact wrapper flags such as + /// `--yolo` and `--fresh`. Run(RunArgs), /// Search the complete visible event ledger for a managed workstream. WorkstreamSearch(WorkstreamSearchArgs), @@ -189,6 +190,10 @@ pub struct RunArgs { /// equivalent dangerous-mode option. #[arg(long)] pub yolo: bool, + /// Start a new native session in the selected workstream instead of + /// resuming or adopting an existing harness session. + #[arg(long)] + pub fresh: bool, /// Agent harness to launch. When omitted, continue the newest managed or /// checkout-local session among the auto-detected harnesses. #[arg(value_enum)] diff --git a/crates/ai-memory-cli/src/commands/run.rs b/crates/ai-memory-cli/src/commands/run.rs index d2e0cb44..aefddadb 100644 --- a/crates/ai-memory-cli/src/commands/run.rs +++ b/crates/ai-memory-cli/src/commands/run.rs @@ -16,7 +16,8 @@ use ai_memory_core::{ use ai_memory_workstream::{ ExportedTranscript, LaunchMode, LaunchPlan, ManagedHarness, NativeSessionCandidate, allows_native_session_adoption, apply_yolo, build_launch_plan, discover_native_session, - export_transcript, inspect_repository, list_native_sessions, wait_for_transcript_flush, + export_transcript, has_native_session_selector, inspect_repository, list_native_sessions, + native_session_exists, wait_for_transcript_flush, }; use anyhow::{Context as _, Result, anyhow}; use tokio::process::Command; @@ -57,6 +58,8 @@ pub async fn run(config: &Config, args: RunArgs) -> Result { let automatic_harness = args.harness.is_none(); let mut native_args = args.native_args; let trailing_yolo = remove_wrapper_yolo(&mut native_args); + let trailing_fresh = remove_wrapper_fresh(&mut native_args); + let force_fresh = args.fresh || trailing_fresh; if automatic_harness && !native_args.is_empty() { return Err(anyhow!( "native harness arguments require an explicit harness; try `ai-memory run codex ...`" @@ -85,7 +88,7 @@ pub async fn run(config: &Config, args: RunArgs) -> Result { let executable = args.executable.map(PathBuf::into_os_string); ensure_executable_available(provisional_harness, executable.as_deref())?; let project = resolve_project_name(config, args.project.as_deref())?; - let may_adopt_native_session = args.new_workstream.is_none(); + let may_adopt_native_session = args.new_workstream.is_none() && !force_fresh; let endpoint = ServerEndpoint::from_config_resolving_auth(config).await; let prepare = PrepareManagedRunRequest { workspace: args.workspace, @@ -151,12 +154,29 @@ pub async fn run(config: &Config, args: RunArgs) -> Result { }; acquired_try!(ensure_executable_available(harness, executable.as_deref())); let native_grok_rules = user_supplied_grok_rules(&native_args); - let mut plan = acquired_try!(build_launch_plan( + let (mut plan, orphaned_session) = acquired_try!(build_preflighted_launch_plan( harness, executable.clone(), native_args.clone(), prepared.native_session_id.as_deref(), + force_fresh, + &home, + &repository.cwd, )); + if let Some(orphaned_session) = orphaned_session { + eprintln!( + "ai-memory: linked {} session {} is missing from its native store; starting fresh and repointing workstream '{}' after the new session is established", + harness.as_str(), + display_session_id(&orphaned_session), + prepared.workstream_name + ); + } else if force_fresh && plan.mode == LaunchMode::Session { + eprintln!( + "ai-memory: starting a fresh {} session in workstream '{}'", + harness.as_str(), + prepared.workstream_name + ); + } if automatic_harness && prepared.native_session_id.is_none() && prepared.may_adopt_existing_session @@ -534,6 +554,63 @@ fn remove_wrapper_yolo(args: &mut Vec) -> bool { args.len() != before } +fn remove_wrapper_fresh(args: &mut Vec) -> bool { + let before = args.len(); + args.retain(|arg| arg != OsStr::new("--fresh")); + args.len() != before +} + +fn build_preflighted_launch_plan( + harness: ManagedHarness, + executable: Option, + native_args: Vec, + linked_session_id: Option<&str>, + force_fresh: bool, + home: &Path, + cwd: &Path, +) -> Result<(LaunchPlan, Option)> { + let explicit_selector = has_native_session_selector(harness, &native_args); + if force_fresh && explicit_selector { + return Err(anyhow!( + "--fresh cannot be combined with a native session, resume, continue, or fork selector" + )); + } + let linked_session_id = if force_fresh { None } else { linked_session_id }; + let plan = build_launch_plan( + harness, + executable.clone(), + native_args.clone(), + linked_session_id, + )?; + let Some(linked_session_id) = linked_session_id else { + return Ok((plan, None)); + }; + if explicit_selector || plan.mode != LaunchMode::Session { + return Ok((plan, None)); + } + match native_session_exists( + harness, + home, + cwd, + plan.session_dir.as_deref(), + linked_session_id, + ) { + Ok(true) => Ok((plan, None)), + Ok(false) => Ok(( + build_launch_plan(harness, executable, native_args, None)?, + Some(linked_session_id.to_string()), + )), + Err(error) => { + eprintln!( + "ai-memory: could not verify linked {} session {} ({error}); preserving native resume. Use --fresh to bypass it", + harness.as_str(), + display_session_id(linked_session_id) + ); + Ok((plan, None)) + } + } +} + fn ensure_executable_available(harness: ManagedHarness, executable: Option<&OsStr>) -> Result<()> { let program = executable.unwrap_or_else(|| OsStr::new(harness.executable())); if executable_available(program) { @@ -1346,6 +1423,121 @@ mod tests { assert_eq!(args, ["resume", "native-id"].map(OsString::from)); } + #[test] + fn wrapper_fresh_parses_before_or_after_the_harness() { + let cli = Cli::try_parse_from(["ai-memory", "run", "--fresh", "codex"]).unwrap(); + let CliCommand::Run(args) = cli.command else { + panic!("expected run command"); + }; + assert!(args.fresh); + assert!(args.native_args.is_empty()); + + let mut trailing = ["--fresh", "--model", "opus"].map(OsString::from).to_vec(); + assert!(remove_wrapper_fresh(&mut trailing)); + assert_eq!(trailing, ["--model", "opus"].map(OsString::from)); + } + + #[test] + fn missing_linked_session_starts_fresh_but_explicit_selectors_win() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + let session_root = temp.path().join("pi-sessions"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&session_root).unwrap(); + let transcript = session_root.join("linked.jsonl"); + std::fs::write( + &transcript, + format!( + "{}\n", + serde_json::json!({"type":"session","id":"linked","cwd":cwd}) + ), + ) + .unwrap(); + let native_args = [ + OsString::from("--session-dir"), + session_root.as_os_str().to_os_string(), + ] + .to_vec(); + + let (resumed, orphaned) = build_preflighted_launch_plan( + ManagedHarness::Pi, + None, + native_args.clone(), + Some("linked"), + false, + temp.path(), + &cwd, + ) + .unwrap(); + assert!(orphaned.is_none()); + assert!(resumed.args.iter().any(|arg| arg == "--session")); + assert!(resumed.args.iter().any(|arg| arg == "linked")); + + std::fs::remove_file(transcript).unwrap(); + let (fresh, orphaned) = build_preflighted_launch_plan( + ManagedHarness::Pi, + None, + native_args.clone(), + Some("linked"), + false, + temp.path(), + &cwd, + ) + .unwrap(); + assert_eq!(orphaned.as_deref(), Some("linked")); + assert!(fresh.args.iter().any(|arg| arg == "--session-id")); + assert!(!fresh.args.iter().any(|arg| arg == "linked")); + + let (explicit, orphaned) = build_preflighted_launch_plan( + ManagedHarness::Pi, + None, + [ + native_args, + [OsString::from("--session"), OsString::from("chosen")].to_vec(), + ] + .concat(), + Some("linked"), + false, + temp.path(), + &cwd, + ) + .unwrap(); + assert!(orphaned.is_none()); + assert_eq!(explicit.expected_session_id.as_deref(), Some("chosen")); + } + + #[test] + fn force_fresh_bypasses_an_existing_link_and_rejects_native_selectors() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&cwd).unwrap(); + let (fresh, orphaned) = build_preflighted_launch_plan( + ManagedHarness::Claude, + None, + Vec::new(), + Some("linked"), + true, + temp.path(), + &cwd, + ) + .unwrap(); + assert!(orphaned.is_none()); + assert!(fresh.args.iter().any(|arg| arg == "--session-id")); + assert!(!fresh.args.iter().any(|arg| arg == "--resume")); + + let error = build_preflighted_launch_plan( + ManagedHarness::Claude, + None, + [OsString::from("--resume"), OsString::from("chosen")].to_vec(), + Some("linked"), + true, + temp.path(), + &cwd, + ) + .unwrap_err(); + assert!(error.to_string().contains("--fresh cannot be combined")); + } + #[tokio::test] async fn utility_launch_does_not_adopt_a_recent_unrelated_session() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/ai-memory-workstream/src/harness.rs b/crates/ai-memory-workstream/src/harness.rs index b3228f55..8d20d367 100644 --- a/crates/ai-memory-workstream/src/harness.rs +++ b/crates/ai-memory-workstream/src/harness.rs @@ -137,7 +137,7 @@ pub fn build_launch_plan( .or_else(|| environment_session_dir(harness)); let mut expected = explicit_session_id(harness, &args); let mode = launch_mode(harness, &args); - if mode == LaunchMode::Session && !has_explicit_session_selector(harness, &args) { + if mode == LaunchMode::Session && !has_native_session_selector(harness, &args) { match harness { ManagedHarness::Claude => { let id = linked_session_id @@ -275,7 +275,7 @@ pub fn apply_yolo(harness: ManagedHarness, args: &mut Vec) { #[must_use] pub fn allows_native_session_adoption(harness: ManagedHarness, native_args: &[OsString]) -> bool { launch_mode(harness, native_args) == LaunchMode::Session - && !has_explicit_session_selector(harness, native_args) + && !has_native_session_selector(harness, native_args) && !noninteractive_invocation(harness, native_args) } @@ -462,7 +462,10 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode { } } -fn has_explicit_session_selector(harness: ManagedHarness, args: &[OsString]) -> bool { +/// Whether the caller supplied a native resume, continue, fork, or session +/// selector. Wrapper recovery must not override an explicit native choice. +#[must_use] +pub fn has_native_session_selector(harness: ManagedHarness, args: &[OsString]) -> bool { match harness { ManagedHarness::Claude => has_flag( args, diff --git a/crates/ai-memory-workstream/src/lib.rs b/crates/ai-memory-workstream/src/lib.rs index 4a544e67..80df779d 100644 --- a/crates/ai-memory-workstream/src/lib.rs +++ b/crates/ai-memory-workstream/src/lib.rs @@ -6,10 +6,10 @@ mod transcript; pub use harness::{ LaunchMode, LaunchPlan, ManagedHarness, allows_native_session_adoption, apply_yolo, - build_launch_plan, + build_launch_plan, has_native_session_selector, }; pub use repository::{RepositoryIdentity, inspect_repository}; pub use transcript::{ ExportedTranscript, NativeSessionCandidate, discover_native_session, export_transcript, - list_native_sessions, wait_for_transcript_flush, + list_native_sessions, native_session_exists, wait_for_transcript_flush, }; diff --git a/crates/ai-memory-workstream/src/transcript.rs b/crates/ai-memory-workstream/src/transcript.rs index 0252d85c..4edcb5ef 100644 --- a/crates/ai-memory-workstream/src/transcript.rs +++ b/crates/ai-memory-workstream/src/transcript.rs @@ -167,6 +167,26 @@ pub async fn list_native_sessions( Ok(sessions) } +/// Check whether one exact native session still exists in the harness's +/// read-only transcript store. `Ok(false)` means the resume target is +/// definitely absent; store access or schema failures remain errors so callers +/// do not mistake an unreadable store for a deleted session. +pub fn native_session_exists( + harness: ManagedHarness, + home: &Path, + cwd: &Path, + session_dir: Option<&Path>, + native_session_id: &str, +) -> Result { + if harness == ManagedHarness::OpenCode { + return Ok(opencode_updated(home, session_dir, native_session_id)?.is_some()); + } + if harness == ManagedHarness::Crush { + return Ok(crush_updated(cwd, session_dir, native_session_id)?.is_some()); + } + Ok(locate_session_file(harness, home, cwd, session_dir, native_session_id)?.is_some()) +} + /// Wait briefly for buffered transcript writers to settle before importing. pub async fn wait_for_transcript_flush( harness: ManagedHarness, @@ -2044,10 +2064,19 @@ mod tests { let sessions = list_native_sessions(harness, temp.path(), &cwd, Some(&root), 8) .await .unwrap(); + let expected_id = format!("{}-id", harness.as_str()); assert_eq!(sessions.len(), 1, "{} candidates", harness.as_str()); - assert_eq!( - sessions[0].native_session_id, - format!("{}-id", harness.as_str()) + assert_eq!(sessions[0].native_session_id, expected_id); + assert!( + native_session_exists(harness, temp.path(), &cwd, Some(&root), &expected_id) + .unwrap(), + "{} existing session", + harness.as_str() + ); + assert!( + !native_session_exists(harness, temp.path(), &cwd, Some(&root), "missing").unwrap(), + "{} missing session", + harness.as_str() ); } } @@ -2097,6 +2126,26 @@ mod tests { .collect::>(), ["newer", "older"] ); + assert!( + native_session_exists( + ManagedHarness::OpenCode, + temp.path(), + &cwd, + Some(&db_root), + "newer" + ) + .unwrap() + ); + assert!( + !native_session_exists( + ManagedHarness::OpenCode, + temp.path(), + &cwd, + Some(&db_root), + "missing" + ) + .unwrap() + ); } #[tokio::test] @@ -2143,6 +2192,13 @@ mod tests { .collect::>(), ["newer", "older"] ); + assert!( + native_session_exists(ManagedHarness::Crush, temp.path(), &cwd, None, "newer").unwrap() + ); + assert!( + !native_session_exists(ManagedHarness::Crush, temp.path(), &cwd, None, "missing") + .unwrap() + ); let first = export_crush(&cwd, None, "newer", None).unwrap(); assert_eq!(first.events.len(), 1); @@ -2487,6 +2543,26 @@ mod tests { ) .unwrap(); assert_eq!(found.as_deref(), Some(wire_b.as_path())); + assert!( + native_session_exists( + ManagedHarness::Kimi, + temp.path(), + &cwd, + Some(root.path()), + "session_aaa" + ) + .unwrap() + ); + assert!( + !native_session_exists( + ManagedHarness::Kimi, + temp.path(), + &cwd, + Some(root.path()), + "missing" + ) + .unwrap() + ); } #[test] @@ -2788,6 +2864,26 @@ mod tests { ) .unwrap(); assert_eq!(found.as_deref(), Some(chat_a.as_path())); + assert!( + native_session_exists( + ManagedHarness::Grok, + temp.path(), + &cwd, + Some(root.path()), + "019f-session-aaa" + ) + .unwrap() + ); + assert!( + !native_session_exists( + ManagedHarness::Grok, + temp.path(), + &cwd, + Some(root.path()), + "missing" + ) + .unwrap() + ); assert!(!transcript_file( ManagedHarness::Grok, &chat_a.parent().unwrap().join("events.jsonl") diff --git a/docs/install.md b/docs/install.md index 738c34fe..979f50ce 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1177,7 +1177,7 @@ docker run --rm akitaonrails/ai-memory:latest --help # full subcommand tree | Subcommand | Pattern | What it does | |---|---|---| | `serve` | `docker compose up -d` (already done) | Run the HTTP MCP server | -| `run [harness] [args...]` | host wrapper or native binary | Opt into one managed cross-harness workstream; omit the harness to resume the newest usable local session, or name Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, OMP, or Grok Build CLI explicitly; exact `--yolo` is wrapper-owned and other native arguments pass through | +| `run [harness] [args...]` | host wrapper or native binary | Opt into one managed cross-harness workstream; omit the harness to resume the newest usable local session, or name Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, OMP, or Grok Build CLI explicitly; exact `--yolo` and `--fresh` flags are wrapper-owned and other native arguments pass through | | `workstream-search [query]` | managed child or thin HTTP client | Search the complete visible managed-workstream ledger; the managed child receives its workstream id automatically | | `status` | `docker exec` | Counts, paths, derived-index diagnostics, and passive LLM/embedding provider health | | `search ""` | `docker exec` | Wiki search with FTS5 + graph/vector RRF | diff --git a/docs/managed-harness-contributions.md b/docs/managed-harness-contributions.md index 6b90a7d5..3c5ec783 100644 --- a/docs/managed-harness-contributions.md +++ b/docs/managed-harness-contributions.md @@ -58,9 +58,10 @@ authoritative for an established workstream. Implement fresh, resume, and explicit-selector behavior in `crates/ai-memory-workstream/src/harness.rs`. Preserve every user argument and -its order except the exact wrapper-owned `--yolo` token. An explicit native -selector always wins over ai-memory's linked session. Help, version, login, -doctor, export, and similar utility commands must not receive session flags. +its order except the exact wrapper-owned `--yolo` and `--fresh` tokens. An +explicit native selector always wins over ai-memory's linked session. Help, +version, login, doctor, export, and similar utility commands must not receive +session flags. Generate a session id only when the native CLI officially accepts a caller provided id. Otherwise let the harness create the session, then discover it by @@ -69,6 +70,12 @@ exact checkout and launch time. Do not infer a session from "newest globally." Map `--yolo` only to a verified native option and avoid duplicates. If the harness has no equivalent, add no flag and document that fact. +Support wrapper `--fresh` by checking the exact linked id in the native store +before injecting a resume selector. A confirmed missing id starts fresh; an +unreadable or malformed store remains an error rather than being treated as +absence. Reject `--fresh` when the user also supplied a native resume, session, +continue, or fork selector. + ## 4. Discover and export read-only Implement candidate discovery and incremental export in @@ -128,8 +135,9 @@ precedence, directory renames, or conflicts. A managed-harness PR should include focused coverage for: -- fresh launch, linked resume, explicit-selector precedence, argv order, - utility passthrough, path overrides, and `--yolo` mapping; +- fresh launch, linked resume, missing-linked-session recovery, + explicit-selector precedence, argv order, utility passthrough, path + overrides, `--yolo` mapping, and wrapper `--fresh`; - candidate ordering, exact-checkout isolation, timestamp handling, read-only access, incremental cursors, stable ids, incomplete records, visible record inclusion, and private record exclusion; diff --git a/docs/managed-workstreams.md b/docs/managed-workstreams.md index c22ee7fd..97d13b4a 100644 --- a/docs/managed-workstreams.md +++ b/docs/managed-workstreams.md @@ -22,12 +22,14 @@ ai-memory run ``` Everything after the harness name is native argv except the wrapper-owned exact -flag `--yolo`. No `--` separator is needed, and ai-memory does not maintain a -second copy of each harness's option schema. Other wrapper options come first: +flags `--yolo` and `--fresh`. No `--` separator is needed, and ai-memory does +not maintain a second copy of each harness's option schema. Other wrapper +options come first: ```text ai-memory run [--workspace NAME] [--project NAME] - [--workstream NAME | --new NAME] [--executable PATH] [--yolo] + [--workstream NAME | --new NAME] [--executable PATH] + [--yolo] [--fresh] [claude|codex|opencode|pi|crush|omp|kimi|grok] [native arguments...] ``` @@ -75,6 +77,15 @@ launches without terminal input skip the chooser and start fresh. A launch that exits before producing either a native session or portable history does not consume the later adoption opportunity. +Before adding an ai-memory-owned resume selector, the launcher checks the exact +linked id in the harness's native store without modifying it. If the transcript +was deleted, cleared, or lost with a sandbox overlay, ai-memory starts a fresh +native session and repoints the same workstream when that session is observed. +An unreadable or malformed store is reported but is not mistaken for a missing +session. Use `ai-memory run --fresh ` to deliberately skip the linked +session and the adoption chooser. `--fresh` cannot be combined with a native +resume, continue, session, or fork selector. + ## What happens on each run 1. The host client resolves the normal workspace/project scope and a stable @@ -318,10 +329,12 @@ seeded with the operator's provider configuration. The deterministic phase also covers first-run adoption, bare-mode selection and empty-directory failure, wrapper `--yolo`, lease exclusion, Crush context cleanup, a fake-mode Kimi store/resume/import round trip, and the established-workstream guard -against obsolete sessions. Native session creation, read-only extraction, -cross-harness injection, and returning resume paths are all exercised. Docker -wrapper host execution and remote URL preservation are covered separately by -the `ai-memory-cli` packaging tests. Set +against obsolete sessions. The fake Kimi round trip also deletes the linked +native session and verifies automatic fresh-session recovery and repointing. +Native session creation, read-only extraction, cross-harness injection, and +returning resume paths are all exercised. Docker wrapper host execution and +remote URL preservation are covered separately by the `ai-memory-cli` +packaging tests. Set `AI_MEMORY_ACCEPTANCE_HARNESSES="kimi-cli codex"` to select a Kimi-to-Codex-to-Kimi round trip (Kimi aliases normalize to the installed `kimi` executable), `AI_MEMORY_ACCEPTANCE_DETERMINISTIC_ONLY=1` to skip model diff --git a/scripts/managed-workstream-acceptance.sh b/scripts/managed-workstream-acceptance.sh index 1209020b..348f6707 100755 --- a/scripts/managed-workstream-acceptance.sh +++ b/scripts/managed-workstream-acceptance.sh @@ -313,6 +313,8 @@ mapfile -t auto_claude_first <"$TMP/auto-claude-first-argv.log" exit 1 } auto_claude_id=${auto_claude_first[1]} +printf '{"sessionId":"%s","cwd":"%s"}\n' "$auto_claude_id" "$REPO" \ + >"$AUTO_CLAUDE_HOME/projects/fixture/$auto_claude_id.jsonl" ( cd "$REPO" HOME="$AUTO_HOME" CODEX_HOME="$AUTO_CODEX_HOME" \ @@ -436,6 +438,42 @@ jq -e \ exit 1 } +# Deleting the linked native store must heal the established workstream instead +# of feeding the dead id back to Kimi forever. +rm -rf "$kimi_session_dir" +( + cd "$REPO" + KIMI_CODE_HOME="$KIMI_FAKE_HOME" \ + AI_MEMORY_ACCEPTANCE_FAKE_MODE=kimi \ + AI_MEMORY_ACCEPTANCE_ARGV_LOG="$TMP/kimi-orphan-argv.log" \ + AI_MEMORY_ACCEPTANCE_SENTINEL="AMWS-FAKE-KIMI-RECOVERED" \ + "$BIN" --data-dir "$DATA" run --workstream edge-kimi --executable "$FAKE" \ + kimi >"$LOGS/edge-kimi-orphan.log" 2>&1 +) +if grep -q . "$TMP/kimi-orphan-argv.log"; then + printf 'orphaned kimi session was not replaced by a fresh launch\n' >&2 + cat "$TMP/kimi-orphan-argv.log" >&2 + exit 1 +fi +grep -q "linked kimi session .* is missing from its native store" \ + "$LOGS/edge-kimi-orphan.log" +kimi_recovered_dir=$(find "$KIMI_FAKE_HOME/sessions" -mindepth 2 -maxdepth 2 -type d -print -quit) +[ -n "$kimi_recovered_dir" ] || { + printf 'orphan recovery did not create a replacement kimi session\n' >&2 + exit 1 +} +kimi_recovered_id=$(basename "$kimi_recovered_dir") +[ "$kimi_recovered_id" != "$kimi_session_id" ] || { + printf 'orphan recovery reused the deleted kimi session id\n' >&2 + exit 1 +} +kimi_current_id=$(sqlite3 "$DATA/db/memory.sqlite" \ + "SELECT native_session_id FROM workstream_native_sessions WHERE workstream_id = x'${kimi_ws_hex}' AND agent_kind = 'kimi-code' AND is_current = 1;") +[ "$kimi_current_id" = "$kimi_recovered_id" ] || { + printf 'orphan recovery did not repoint the kimi workstream\n' >&2 + exit 1 +} + # Grok fake-mode fixture: the fake grok honors the wrapper's `--session-id` # (fresh) and `--resume ` (returning) selectors, writes the native store # layout ($GROK_HOME/sessions///summary.json plus