diff --git a/Cargo.lock b/Cargo.lock index c9db95d5..0058eaa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,7 +204,7 @@ checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "blade-deepseek" -version = "0.3.12" +version = "0.3.13" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index e30b8390..2933c2e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ windows-sys = "0.61" [package] name = "blade-deepseek" -version = "0.3.12" +version = "0.3.13" edition = "2024" description = "Orca: a DeepSeek-native coding agent" license = "MIT" diff --git a/README.md b/README.md index 8f404a6d..1f766391 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ export DEEPSEEK_API_KEY=sk-... orca # open the TUI orca exec "fix the failing test" # run headlessly orca exec --verifier "cargo test" "fix it" # verify before finishing +orca exec resume SESSION_ID "continue" # resume a headless session +orca exec resume --last "continue" # resume the most recent session +orca exec resume SID --resume-at MID "continue" # resume up to a message boundary orca --mode=acp # connect an ACP client orca --resume [SESSION_ID] # resume a saved conversation orca --fork SESSION_ID # fork a saved conversation @@ -85,7 +88,8 @@ sandbox permissions. - Gates risky actions with `suggest`, sandboxed `auto-edit`, full-access `full-auto`, and read-only `plan` modes, plus per-folder trust. - Saves local conversations with `--resume` for continuation and `--fork` for - branching. + branching; `orca exec resume ` restores a headless session with a + fresh budget scope, and headless exits print the exact resume command. - Runs persistent goals without a fixed turn ceiling, plus subagents and JavaScript workflows for longer tasks that need continuation or parallel work. - Loads project instructions, skills, plugins, custom tools, MCP tools, and MCP diff --git a/README.zh-CN.md b/README.zh-CN.md index 5fe668d2..3831769c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,6 +51,9 @@ export DEEPSEEK_API_KEY=sk-... orca # 打开 TUI orca exec "修复失败的测试" # 无界面运行 orca exec --verifier "cargo test" "修复它" # 完成前执行验证 +orca exec resume SESSION_ID "继续" # 恢复无界面会话 +orca exec resume --last "继续" # 恢复最近的会话 +orca exec resume SID --resume-at MID "继续" # 恢复到消息边界为止 orca --mode=acp # 连接 ACP 客户端 orca --resume [SESSION_ID] # 恢复保存的会话 orca --fork SESSION_ID # 分叉保存的会话 diff --git a/crates/orca-core/src/config/mod.rs b/crates/orca-core/src/config/mod.rs index 04ef68c2..54330973 100644 --- a/crates/orca-core/src/config/mod.rs +++ b/crates/orca-core/src/config/mod.rs @@ -127,6 +127,13 @@ pub enum HistoryMode { Record, Disabled, Resume(String), + /// Continue a saved conversation but restore only the message log up to a + /// durable message boundary (`resume_at` is a persisted conversation item + /// id). Messages after the boundary are not replayed to the model. + ResumeAt { + selector: String, + resume_at: String, + }, Fork(String), } @@ -813,6 +820,7 @@ fn history_posture(history_mode: &HistoryMode) -> &'static str { HistoryMode::Record => "recording", HistoryMode::Disabled => "disabled", HistoryMode::Resume(_) => "resume", + HistoryMode::ResumeAt { .. } => "resume-at", HistoryMode::Fork(_) => "fork", } } diff --git a/crates/orca-core/src/event_schema.rs b/crates/orca-core/src/event_schema.rs index 124c7a8d..fd1eb38c 100644 --- a/crates/orca-core/src/event_schema.rs +++ b/crates/orca-core/src/event_schema.rs @@ -1199,13 +1199,14 @@ impl EventFactory { ) } - pub fn session_completed(&mut self, status: RunStatus) -> EventDraft { - self.make( - EventType::SessionCompleted, - json!({ - "status": status - }), - ) + pub fn session_completed(&mut self, status: RunStatus, session_id: Option<&str>) -> EventDraft { + let mut payload = json!({ + "status": status + }); + if let Some(session_id) = session_id { + payload["session_id"] = json!(session_id); + } + self.make(EventType::SessionCompleted, payload) } fn make(&mut self, event_type: EventType, payload: Value) -> EventDraft { @@ -1363,6 +1364,19 @@ mod tests { assert!(e.payload["verifier"].is_null()); } + #[test] + fn session_completed_payload_carries_durable_session_id_when_present() { + let mut f = EventFactory::new("run-1".to_string()); + + let with_session = f.session_completed(RunStatus::BudgetExhausted, Some("session-9")); + assert_eq!(with_session.payload["status"], "budget_exhausted"); + assert_eq!(with_session.payload["session_id"], "session-9"); + + let without_session = f.session_completed(RunStatus::Success, None); + assert_eq!(without_session.payload["status"], "success"); + assert!(without_session.payload["session_id"].is_null()); + } + #[test] fn turn_started_with_and_without_prompt() { let mut f = EventFactory::new("run-1".to_string()); diff --git a/crates/orca-runtime/src/command/exec.rs b/crates/orca-runtime/src/command/exec.rs index 8071cc95..8131756c 100644 --- a/crates/orca-runtime/src/command/exec.rs +++ b/crates/orca-runtime/src/command/exec.rs @@ -19,6 +19,7 @@ pub struct ExecCommandRequest { pub verifier: Option, pub max_budget: Option, pub resume: Option, + pub resume_at: Option, pub fork: Option, pub continue_latest: bool, pub no_history: bool, @@ -55,6 +56,14 @@ pub fn run_with_stdin( eprintln!("orca: --resume, --fork, and --continue are mutually exclusive"); return 1; } + if request.resume_at.is_some() && request.fork.is_some() { + eprintln!("orca: --resume-at cannot be combined with --fork"); + return 1; + } + if request.resume_at.is_some() && request.resume.is_none() && !request.continue_latest { + eprintln!("orca: --resume-at requires --resume, --continue, or the resume subcommand"); + return 1; + } let prompt = match resolve_prompt(request.prompt, stdin_is_terminal, stdin) { Ok(prompt) => prompt, @@ -78,6 +87,7 @@ pub fn run_with_stdin( request.resume, request.fork, request.continue_latest, + request.resume_at, fallback, ); let mut config_request = RunConfigRequest::new(request.app_version, config_cwd); @@ -164,10 +174,26 @@ pub(crate) fn resolve_history_mode( resume: Option, fork: Option, continue_latest: bool, + resume_at: Option, fallback: HistoryMode, ) -> HistoryMode { if let Some(selector) = fork { HistoryMode::Fork(selector) + } else if let Some(resume_at) = resume_at { + let selector = resume.or_else(|| { + if continue_latest { + Some("latest".to_string()) + } else { + None + } + }); + match selector { + Some(selector) => HistoryMode::ResumeAt { + selector, + resume_at, + }, + None => fallback, + } } else if let Some(selector) = resume.or_else(|| { if continue_latest { Some("latest".to_string()) diff --git a/crates/orca-runtime/src/command/launch.rs b/crates/orca-runtime/src/command/launch.rs index a5e3e0a6..6e46e1d4 100644 --- a/crates/orca-runtime/src/command/launch.rs +++ b/crates/orca-runtime/src/command/launch.rs @@ -85,6 +85,7 @@ pub fn prepare_interactive(request: InteractiveLaunchRequest) -> Result( if config.desktop_notifications { let _ = crate::notify::notify("Orca", &format!("Session {}", status.as_str())); } + if config.output_format == OutputFormat::Text + && status != RunStatus::Success + && let Some(session_id) = thread.session_id() + { + writeln!( + writer, + "To continue this session, run: orca exec resume {session_id}" + )?; + } Ok(status) } diff --git a/crates/orca-runtime/src/runtime_host.rs b/crates/orca-runtime/src/runtime_host.rs index 98d81708..315c828c 100644 --- a/crates/orca-runtime/src/runtime_host.rs +++ b/crates/orca-runtime/src/runtime_host.rs @@ -2288,7 +2288,7 @@ impl RuntimeThreadStartRequest { self.prepared_record_meta = Some(meta); (thread_id, path) } - HistoryMode::Resume(selector) => { + HistoryMode::Resume(selector) | HistoryMode::ResumeAt { selector, .. } => { let transcript = match self.preloaded.take() { Some(transcript) => transcript, None => SessionStore::new() @@ -2363,7 +2363,10 @@ impl RuntimeThreadStartRequest { message: format!("failed to acquire typed surface owner lease: {error:?}"), })?; let resume_scope_replacement = (self.replace_resume_scope - && matches!(self.config.history_mode, HistoryMode::Resume(_))) + && matches!( + self.config.history_mode, + HistoryMode::Resume(_) | HistoryMode::ResumeAt { .. } + )) .then(|| ResumeScopeReplacement { runtime_workspace_roots: self .config @@ -35094,7 +35097,10 @@ impl ThreadActor { { observe_runtime_event( active.request.event_observer().as_deref(), - result.state.events.session_completed(status), + result.state.events.session_completed( + status, + result.state.thread.session().session_id(), + ), ); } self.state = Some(result.state); @@ -35495,7 +35501,10 @@ impl ThreadActor { }) => { observe_runtime_event( active.request.event_observer().as_deref(), - result.state.events.session_completed(status), + result + .state + .events + .session_completed(status, result.state.thread.session().session_id()), ); OperationOutcome::Completed(status) } @@ -37488,8 +37497,45 @@ fn run_headless_session( ) { sink.emit(events.error(&format!("session_end hook failed: {error}")))?; } - if matches!(outcome, ThreadOperationOutcome::Completed { .. }) { - sink.emit(events.session_completed(status))?; + if let ThreadOperationOutcome::Completed { + end_reason, + background_workflows: _, + .. + } = &outcome + { + // Soft landing: a budget-exhausted headless session persists a typed + // checkpoint before the terminal projection, so the caller can resume + // from the last committed boundary with a fresh budget scope. + if status == RunStatus::BudgetExhausted + && let Some(session_id) = thread.session().session_id().map(str::to_string) + { + let checkpoint = { + let session = thread.session(); + let last_committed_message_id = + session.conversation_records().and_then(|records| { + records.iter().rev().find_map(|record| { + record.item_id.as_ref().map(|id| id.as_str().to_string()) + }) + }); + crate::thread_store::SessionCheckpointRecord { + session_id, + status: status.as_str().to_string(), + reason: Some(end_reason.as_str().to_string()), + budget_consumed: session.aggregate_usage_totals(), + last_committed_message_id, + resumable: true, + task_plan: crate::thread::plan_snapshot(session.conversation()) + .map(str::to_string), + recorded_at: chrono::Utc::now(), + } + }; + if let Some(writer) = thread.session_mut().writer_mut() + && let Err(error) = writer.append_checkpoint(checkpoint) + { + eprintln!("orca: warning: failed to record session checkpoint: {error}"); + } + } + sink.emit(events.session_completed(status, thread.session().session_id()))?; } Ok(outcome) } @@ -37663,7 +37709,7 @@ fn run_provider_background_task( } observe_runtime_event( context.observer.as_deref(), - events.session_completed(status), + events.session_completed(status, None), ); } } diff --git a/crates/orca-runtime/src/server.rs b/crates/orca-runtime/src/server.rs index 0fe8007e..32b7615c 100644 --- a/crates/orca-runtime/src/server.rs +++ b/crates/orca-runtime/src/server.rs @@ -342,9 +342,10 @@ pub fn thread_run_config(config: &RunConfig) -> RunConfig { run_config.output_format = OutputFormat::Jsonl; run_config.history_mode = match run_config.history_mode { HistoryMode::Record => HistoryMode::Record, - HistoryMode::Disabled | HistoryMode::Resume(_) | HistoryMode::Fork(_) => { - HistoryMode::Disabled - } + HistoryMode::Disabled + | HistoryMode::Resume(_) + | HistoryMode::ResumeAt { .. } + | HistoryMode::Fork(_) => HistoryMode::Disabled, }; run_config.show_session_picker = false; run_config.desktop_notifications = false; diff --git a/crates/orca-runtime/src/session.rs b/crates/orca-runtime/src/session.rs index af686954..3e4edac9 100644 --- a/crates/orca-runtime/src/session.rs +++ b/crates/orca-runtime/src/session.rs @@ -256,13 +256,34 @@ impl InteractiveSession { conv.strip_legacy_summary_messages(); (conv, Some(transcript)) } + HistoryMode::ResumeAt { + selector, + resume_at, + } => { + let transcript = match preloaded { + Some(t) => t, + None => store.load_session(selector)?, + }; + // Restore only the durable message boundary: records after the + // requested conversation item id (including uncommitted tool + // calls) are not replayed to the model. + let transcript = + crate::thread_store::truncate_transcript_at_boundary(&transcript, resume_at)?; + let mut conv = store.resume_conversation(&transcript, system_prompt); + conv.strip_legacy_pinned_volatile(); + conv.strip_legacy_summary_messages(); + (conv, Some(transcript)) + } HistoryMode::Record | HistoryMode::Disabled => { let mut conversation = Conversation::new(); conversation.add_system(system_prompt); (conversation, None) } }; - let usage_baseline = if matches!(config.history_mode, HistoryMode::Resume(_)) { + let usage_baseline = if matches!( + config.history_mode, + HistoryMode::Resume(_) | HistoryMode::ResumeAt { .. } + ) { loaded_transcript .as_ref() .and_then(|transcript| transcript.usage) @@ -270,7 +291,10 @@ impl InteractiveSession { } else { UsageTotals::default() }; - let next_event_seq = if matches!(config.history_mode, HistoryMode::Resume(_)) { + let next_event_seq = if matches!( + config.history_mode, + HistoryMode::Resume(_) | HistoryMode::ResumeAt { .. } + ) { loaded_transcript .as_ref() .map(|transcript| transcript.next_event_seq) @@ -285,7 +309,7 @@ impl InteractiveSession { // Resume continues the original thread: keep its session id and // append future items to the existing transcript file. Only Fork // mints a new session id. - HistoryMode::Resume(_) => match loaded_transcript { + HistoryMode::Resume(_) | HistoryMode::ResumeAt { .. } => match loaded_transcript { Some(transcript) => { let thread_session_id = transcript.meta.session_id.clone(); match SessionWriter::append_to_existing(transcript.path) { diff --git a/crates/orca-runtime/src/thread.rs b/crates/orca-runtime/src/thread.rs index eab8d22b..3b5e5c9f 100644 --- a/crates/orca-runtime/src/thread.rs +++ b/crates/orca-runtime/src/thread.rs @@ -731,7 +731,7 @@ pub(crate) fn goal_usage_delta( } } -fn plan_snapshot(conversation: &orca_core::conversation::Conversation) -> Option<&str> { +pub(crate) fn plan_snapshot(conversation: &orca_core::conversation::Conversation) -> Option<&str> { conversation .internal_context .get(orca_core::conversation::PLAN_CONTEXT_FRAGMENT_ID) diff --git a/crates/orca-runtime/src/thread_store.rs b/crates/orca-runtime/src/thread_store.rs index a4113ff2..2ea956f6 100644 --- a/crates/orca-runtime/src/thread_store.rs +++ b/crates/orca-runtime/src/thread_store.rs @@ -28,15 +28,16 @@ pub(crate) use projection::{ pub use session_index::SessionSummaryPage; pub(crate) use types::{ManualCompactionDurableSnapshot, StoredConversationRecord}; pub use types::{ - SessionMeta, SessionSummary, SessionTranscript, SortDirection, StoredThreadItem, - StoredThreadItemPage, StoredThreadProjection, StoredThreadSearchHit, StoredThreadSearchPage, - StoredThreadSummary, StoredThreadSummaryPage, StoredThreadTurn, StoredThreadTurnPage, - ThreadListFilters, ThreadMetadataPatch, ThreadRelationFilter, ThreadSortKey, ThreadStore, - TurnItemsView, + SessionCheckpointRecord, SessionMeta, SessionSummary, SessionTranscript, SortDirection, + StoredThreadItem, StoredThreadItemPage, StoredThreadProjection, StoredThreadSearchHit, + StoredThreadSearchPage, StoredThreadSummary, StoredThreadSummaryPage, StoredThreadTurn, + StoredThreadTurnPage, ThreadListFilters, ThreadMetadataPatch, ThreadRelationFilter, + ThreadSortKey, ThreadStore, TurnItemsView, }; pub use writer::SessionWriter; pub(crate) use writer::{ read_latest_context_tokens, read_manual_compaction_snapshot, redact_sensitive_text, + truncate_transcript_at_boundary, }; pub(crate) fn resume_conversation( diff --git a/crates/orca-runtime/src/thread_store/local.rs b/crates/orca-runtime/src/thread_store/local.rs index 71dbb82e..5e780872 100644 --- a/crates/orca-runtime/src/thread_store/local.rs +++ b/crates/orca-runtime/src/thread_store/local.rs @@ -27,8 +27,8 @@ use super::types::{ }; use super::writer::{ acquire_file_lock, conversation_record_from_semantic_event, open_regular_history_file, - read_history_lines, read_records, read_session_meta, read_transcript, rewrite_records_unlocked, - write_durable_record, + read_history_lines, read_records, read_session_meta, read_transcript, read_transcript_until, + rewrite_records_unlocked, write_durable_record, }; use super::{LiveThread, ORCA_HOME_ENV}; @@ -313,6 +313,14 @@ impl JsonlThreadStore { load_session(selector) } + pub fn load_session_until( + &self, + selector: &str, + boundary_message_id: &str, + ) -> io::Result { + load_session_until(selector, boundary_message_id) + } + pub fn search_sessions( &self, query: &str, @@ -520,6 +528,30 @@ pub fn load_session(selector: &str) -> io::Result { read_transcript(&path) } +/// Like [`load_session`], but restores only the message log up to the +/// persisted conversation item id `boundary_message_id` (inclusive). +pub fn load_session_until( + selector: &str, + boundary_message_id: &str, +) -> io::Result { + let path = if is_latest_selector(selector) { + list_sessions(1)? + .into_iter() + .next() + .map(|s| s.path) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no saved sessions"))? + } else { + find_session_path(selector, true)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("no saved session matches '{selector}'"), + ) + })? + }; + + read_transcript_until(&path, boundary_message_id) +} + pub(crate) fn summarize_session_with_archive_flag( path: &Path, archived: bool, diff --git a/crates/orca-runtime/src/thread_store/types.rs b/crates/orca-runtime/src/thread_store/types.rs index 69498f13..4c9ed18c 100644 --- a/crates/orca-runtime/src/thread_store/types.rs +++ b/crates/orca-runtime/src/thread_store/types.rs @@ -194,6 +194,27 @@ pub(crate) enum SessionRecord { explanation: Option, plan: Vec, }, + /// Typed soft-landing checkpoint written when a session stops against a + /// resumable terminal (for example `budget_exhausted`). It records the + /// durable boundary a continuation should resume from and the budget + /// consumed up to that point; it is audit data, not execution state. + #[serde(rename = "session.checkpoint")] + Checkpoint(SessionCheckpointRecord), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SessionCheckpointRecord { + pub session_id: String, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub budget_consumed: UsageTotals, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_committed_message_id: Option, + pub resumable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_plan: Option, + pub recorded_at: DateTime, } #[derive(Clone, Debug)] diff --git a/crates/orca-runtime/src/thread_store/writer.rs b/crates/orca-runtime/src/thread_store/writer.rs index 5fce2d69..622d66a4 100644 --- a/crates/orca-runtime/src/thread_store/writer.rs +++ b/crates/orca-runtime/src/thread_store/writer.rs @@ -343,7 +343,64 @@ pub(crate) fn read_session_meta(path: &Path) -> io::Result { } pub(crate) fn read_transcript(path: &Path) -> io::Result { + transcript_from_records(path, read_records(path)?) +} + +/// Read a transcript but restore only the message log up to the persisted +/// conversation item id `boundary_message_id` (inclusive). Records after the +/// boundary — including uncommitted tool calls — are not replayed. +pub(crate) fn read_transcript_until( + path: &Path, + boundary_message_id: &str, +) -> io::Result { let records = read_records(path)?; + transcript_from_records( + path, + truncate_records_at_boundary(records, boundary_message_id)?, + ) +} + +/// Keep records through the last `conversation.message` whose item id matches +/// `boundary_message_id`; drop everything after it. +pub(crate) fn truncate_records_at_boundary( + records: Vec, + boundary_message_id: &str, +) -> io::Result> { + let mut boundary_index = None; + for (index, record) in records.iter().enumerate() { + if let SessionRecord::Message { id: Some(id), .. } = record + && id.as_str() == boundary_message_id + { + boundary_index = Some(index); + } + } + let Some(boundary_index) = boundary_index else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no saved message matches '{boundary_message_id}'"), + )); + }; + Ok(records.into_iter().take(boundary_index + 1).collect()) +} + +/// Apply a message boundary to an already-loaded transcript by re-reading the +/// records from its durable path, so preloaded transcripts honor the boundary +/// exactly like freshly loaded ones. +pub(crate) fn truncate_transcript_at_boundary( + transcript: &SessionTranscript, + boundary_message_id: &str, +) -> io::Result { + let records = read_records(&transcript.path)?; + transcript_from_records( + &transcript.path, + truncate_records_at_boundary(records, boundary_message_id)?, + ) +} + +pub(crate) fn transcript_from_records( + path: &Path, + records: Vec, +) -> io::Result { let mut meta = None; let mut messages = Vec::new(); let mut compactions = Vec::new(); @@ -435,6 +492,7 @@ pub(crate) fn read_transcript(path: &Path) -> io::Result { last_plan = Some((explanation, plan)); } } + SessionRecord::Checkpoint(_) => {} } } @@ -564,6 +622,16 @@ fn redact_session_record(record: &SessionRecord) -> SessionRecord { redact_string_in_place(&mut item.step); } } + SessionRecord::Checkpoint(record) => { + redact_string_in_place(&mut record.session_id); + redact_string_in_place(&mut record.status); + if let Some(reason) = &mut record.reason { + redact_string_in_place(reason); + } + if let Some(task_plan) = &mut record.task_plan { + redact_string_in_place(task_plan); + } + } } redacted } @@ -1016,6 +1084,13 @@ impl SessionWriter { ) } + pub(crate) fn append_checkpoint( + &mut self, + checkpoint: super::types::SessionCheckpointRecord, + ) -> io::Result<()> { + write_record(&self.path, &SessionRecord::Checkpoint(checkpoint)) + } + pub fn append_background_task_provider_response( &mut self, task_id: &str, diff --git a/crates/orca-tui/src/app.rs b/crates/orca-tui/src/app.rs index f12aed80..15878311 100644 --- a/crates/orca-tui/src/app.rs +++ b/crates/orca-tui/src/app.rs @@ -8161,7 +8161,9 @@ fn hosted_tui_controller_loop( if typed_history_startup_eligible(&startup_history_mode, &preloaded) { let cfg = config.lock().unwrap().clone(); let selector = match &startup_history_mode { - HistoryMode::Resume(selector) | HistoryMode::Fork(selector) => selector, + HistoryMode::Resume(selector) + | HistoryMode::ResumeAt { selector, .. } + | HistoryMode::Fork(selector) => selector, HistoryMode::Record | HistoryMode::Disabled => unreachable!(), }; let title = format!("Restored session {selector}"); @@ -9363,7 +9365,9 @@ fn switch_saved_hosted_session( ) -> Result { ensure_current_session_switchable(thread.as_ref())?; let selector = match &mode { - HistoryMode::Resume(selector) | HistoryMode::Fork(selector) => selector, + HistoryMode::Resume(selector) + | HistoryMode::ResumeAt { selector, .. } + | HistoryMode::Fork(selector) => selector, HistoryMode::Record | HistoryMode::Disabled => { return Err("saved-session switch requires resume or fork mode".to_string()); } @@ -9372,6 +9376,7 @@ fn switch_saved_hosted_session( .map_err(|error| format!("failed to load saved conversation: {error}"))?; let switch_title = title.unwrap_or_else(|| match mode { HistoryMode::Resume(_) => transcript.meta.title.clone(), + HistoryMode::ResumeAt { .. } => transcript.meta.title.clone(), HistoryMode::Fork(_) => format!("Fork of {}", transcript.meta.title), HistoryMode::Record | HistoryMode::Disabled => unreachable!(), }); diff --git a/docs/harness-contract.md b/docs/harness-contract.md index fe28e29e..deacb3fd 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -162,6 +162,45 @@ The final `session.completed` event contains one of: - `verification_failed` - `budget_exhausted` +When the run recorded history, the same event also carries the durable +`session_id` (the id a subsequent `orca exec resume ` accepts), so +a harness can continue a budget-exhausted or failed session without parsing the +transcript. In text mode, a non-success exit prints the exact resume command: + +```text +To continue this session, run: orca exec resume +``` + +A resumed run appends to the original transcript and owns a fresh budget scope: +the previous invocation's `max_budget` ceiling does not carry over, while its +usage records remain durable. + +To restore only the durable message boundary, pass `--resume-at ` +(a persisted conversation item id) to the `resume` subcommand or to +`--resume`/`--continue`. Messages after the boundary — including uncommitted +tool calls — are not replayed to the model, and an unknown boundary fails +closed before the provider is called: + +```text +orca exec resume --resume-at "continue" +``` + +When a headless session stops at `budget_exhausted`, the runtime also appends +a typed `session.checkpoint` record to the transcript before the terminal +projection: + +```json +{"type":"session.checkpoint","session_id":"...","status":"budget_exhausted", + "reason":"max_inner_turns","budget_consumed":{"input_tokens":120,...}, + "last_committed_message_id":"item_...","resumable":true, + "task_plan":"...","recorded_at":"..."} +``` + +The checkpoint is audit data — execution always resumes from the transcript's +committed messages, and uncommitted side effects are never claimed as +exactly-once (restore repairs them as indeterminate). File rewind is not +promised: Orca does not snapshot external workspace state. + ## Exit Codes - `0`: success diff --git a/docs/production-roadmap.md b/docs/production-roadmap.md index 1cf0af8f..21780020 100644 --- a/docs/production-roadmap.md +++ b/docs/production-roadmap.md @@ -4,6 +4,45 @@ > Reference implementations: Codex CLI, Claude Code, and the current Orca codebase. Last updated: 2026-08-10 + +The 2026-08-10 headless resume slice makes "restore a headless execution" a +first-class CLI capability, following Codex's `exec resume` design while keeping +Orca's typed termination and durable session identity. `orca exec` now accepts +a `resume` subcommand: `orca exec resume [PROMPT]...` continues +the saved conversation by id, prefix, or `latest`, and +`orca exec resume --last [PROMPT]...` picks the most recent recorded session. +The shared run options (`--provider`, `--output-format`, `--cwd`, `--mode`, +`--model`, `--api-key`, `--base-url`, `--verifier`, `--max-budget`) are global +flags that apply in either position, and combining the subcommand with +`--resume`/`--fork`/`--continue` is rejected. The original session record is +immutable: resume appends to the same transcript with a fresh budget scope, so +the previous run's consumption records stay durable while the new invocation +owns its own `max_budget` accounting. On headless exit, `session.completed` +now carries the durable `session_id` when history was recorded (JSONL mode), +and text mode prints `To continue this session, run: orca exec resume +` for every non-success terminal — so a budget-exhausted run (exit +code 4, `status=budget_exhausted`) is visibly distinct from a failure and tells +the caller exactly how to continue. This changes no persisted transcript +schema and no server protocol. + +The same line adds Claude Code's message-boundary restore: `--resume-at +` (on both the `resume` subcommand and `--resume`/`--continue`) +restores the conversation only up to a persisted conversation item id — the +durable boundary — so a later prompt cannot replay uncommitted work past a +chosen point; unknown boundaries fail closed. It also adds Grok-style budget +scope separation at the checkpoint level: when a headless session stops at +`budget_exhausted`, the runtime persists a typed `session.checkpoint` record +(status, reason like `max_inner_turns`/`cost_budget_exhausted`, aggregate +budget consumed, the last committed message id, the current task plan, and +`resumable: true`) before the terminal projection, so the resume command can +start from the last committed boundary with a new budget. Uncommitted tool +calls are still distinguished on restore via the existing indeterminate +compatibility repair — Orca promises resumable, not exactly-once, execution. +Conversation rewind is supported through `--resume-at`; file rewind is +explicitly not promised because Orca does not snapshot external workspace +state (Grok's principle: only restore durable facts, never pretend external +side effects are rewindable). + Current baseline: v0.3.12 adds runtime-owned Side Conversations and strengthens TUI response projection boundaries. This builds on v0.3.8's remaining-context visibility, one-to-one @@ -1131,6 +1170,112 @@ verified before the next phase starts. ### Current Refactor Priorities +#### Evidence-Based Roadmap Reconsideration (2026-08-10) + +This is the current planning baseline. It is based on a fresh inspection of the +source tree, git history, roadmap, and the 2026-08-03 architecture review. It +supersedes the ordering in the historical inventory below; completed work is +recorded as evidence, not repeated as future work. + +#### Evidence: What Is Already Done + +The first review tier is complete: `GoalActor::request` has a bounded +`recv_timeout` (`crates/orca-runtime/src/goal_actor.rs:1636`), supervisor store +access is moved behind `spawn_blocking`, streaming reduction appends with +`push_str` (`crates/orca-runtime/src/runtime_surface/reducer.rs:3678`), the +unused `orca-provider` dependency is gone from `orca-tui`, and usage projection +assigns values by event ordinal (`crates/orca-runtime/src/runtime_surface/commit.rs:3722`). + +The v0.3.1 defect tier is also closed: the session lifecycle contract was +fixed (`959adeedf`), and the runtime-surface validator is executed by +`.github/workflows/runtime-contract.yml`. The larger architecture tier is +mostly closed: production `lib.rs` files no longer use source-layout +`include_str!` assertions (the remaining embeddings are workflow/host assets +and surface-manifest fixtures), `unstable_surface` is closed behind curated +exports, provider no longer depends on tools, and MCP registry ownership is in +runtime (`b81c4d413`). + +ThreadActor has started the intended split. The four existing controllers under +`crates/orca-runtime/src/runtime_actor/` (`background`, `capability`, `commit`, +and `goal`) total 2,834 lines. This is meaningful progress, but it is not the +end state. + +#### Evidence: What Is Still Open + +1. **ThreadActor is still a god object.** At the audited `HEAD`, + `crates/orca-runtime/src/runtime_host.rs` is 51,113 lines. Its + `impl ThreadActor` spans lines 13,438-37,114, contains 254 methods, and the + actor graph still carries eight pending state-machine categories. The four + controller extractions did not reduce the main implementation to the + approximately 8,000-line target. Every new feature therefore continues to + increase the largest structural risk in the repository. +2. **P1.4 task supervision is not implemented.** `TaskRegistry::new_persistent` + (`crates/orca-runtime/src/tasks.rs:286`) provides cross-process record + persistence and interrupted-task recovery, but there is no cross-process + lease, fencing token, stale-owner takeover, or task-wide publication + contract. Detached-worker ownership remains ambiguous at exactly the point + where stop, reattach, and crash recovery must be authoritative. +3. **TUI/runtime protocol drift remains unsliced.** `crates/orca-tui/src/app.rs` + is 10,186 lines and `surface_projection.rs` is 2,281 lines. Renderer-owned + orchestration and projection duplication remain in the code even though the + roadmap matrix has mentioned their convergence in several places. +4. **P2.4 context/cache identity is not a release slice.** DeepSeek usage + already parses `prompt_cache_hit_tokens` (`crates/orca-provider/src/deepseek_http.rs:192`), + but deterministic cache-critical prefixes (stable system prompt, tool + schema, and conversation-prefix ordering), fork isolation, and explicit + checkpoints are not yet specified as one independently verifiable change. +5. **The pending-store deletion gate has not passed.** + `RuntimePendingInteractionStore` remains as a source-compatible shim. Its + retirement spec requires the legacy Goal path to disappear, server and CLI + callers to stop compiling against it, and durable broker recovery evidence; + the implementation plan being checked off does not itself satisfy those + gates. +6. **Five linked branches are cleanup candidates, not new work.** + `codex/auto-memory-governance`, `codex/headless-trajectory-truth`, + `codex/mcp-sse-elicitation`, `codex/network-ask-on-block`, and + `feat/side-conversation` are not ancestors of `main`; their changes have + corresponding rebased commits on `main` (for example `97fa233c4`, + `565b4be92`, `fd75c85bc`, `c69a8a263`, and `8a7ae4584`). They should be + removed only after a provenance check confirms each branch has no unique + uncommitted work or unreplayed patch. + +#### Reordered Release Slices + +Each row is an independent, behaviorally verifiable patch release. The order +follows the project decision priority: lifecycle and ownership, TUI reliability, +architecture boundaries, DeepSeek-native value, compatibility migration, slice +size, then short-term implementation cost. + +| Order | Slice | Priority class | User value | Acceptance evidence | +|------:|-------|----------------|------------|---------------------| +| 1 | **P1.4 task supervision completion**: add `TaskRegistry` lease/fencing/stale-owner takeover and task-wide publication | Lifecycle / ownership | Background subagents can be stopped, reaped, reattached, and recovered after process failure without a detached owner | Cross-process PTY contract, crash-recovery test, stale-commit rejection, and focused/full task-lifecycle gates | +| 2 | **ThreadActor split completion**: finish the four existing controller seams and reduce the main impl toward ~8k lines | Architecture boundary | Future runtime features become cheaper to change and less likely to regress lifecycle behavior | Behavior tests and focused runtime suites; do not make source shape the acceptance oracle | +| 3 | **TUI/runtime protocol convergence**: extract renderer-owned orchestration and make runtime surface state the single projection source | Architecture boundary | Fewer TUI regressions and one authoritative rendering/lifecycle state | Real TUI PTY contracts plus the runtime-surface contract validator in CI | +| 4 | **P2.4 context/cache identity**: deterministic cache-critical prefixes, fork-state isolation, and explicit checkpoints | DeepSeek-native | Stable long sessions can realize prompt-cache savings instead of invalidating the prefix on incidental reorderings | Two real DeepSeek API requests with the same prefix and observed `prompt_cache_hit_tokens`, plus fork/checkpoint behavior tests | +| 5 | **Pending-store deletion gate**: remove the compatibility shim and legacy Goal path only after its stated migration gates pass | Compatibility migration | Eliminate the second interaction fact source without stranding existing callers | Gate each requirement in `docs/superpowers/specs/2026-08-08-runtime-pending-store-retirement.md` and run `cargo-semver-checks` | +| 6 | **Compaction completion and remote-compaction evaluation**: finish `RuntimeCompactionPolicy`, then drive remote work from real waiting behavior | DeepSeek-native | Long conversations retain usable context without silently dropping state | Long-context real-API smoke, interruption/recovery checks, and focused/full compaction gates | +| 7 | **Repository cleanup**: remove the five superseded linked branches and integration residue | Hygiene | One clear source of truth for maintainers and release automation | `git merge-base`, patch/provenance checks, clean worktrees, and branch/worktree verification | + +#### Why This Differs From the Previous Roadmap + +- ThreadActor moves from the old third tier to the front because the evidence + calls it Critical and every additional feature is still paying its cost. +- P2.4 cache identity moves into the first four slices because it is the rare + DeepSeek-native capability with direct user cost impact and a concrete real + API verifier. +- Pending-store retirement becomes an explicit acceptance slice rather than a + vague later cleanup; a defined deletion gate is only useful when the roadmap + schedules its verification. +- Worktree cleanup is now explicit and provenance-first: no branch is deleted + merely because it is not an ancestor, and no unrelated user work is reset or + merged into the release path. + +Execution of slice 1 starts from a freshly fetched `main` in +`.worktrees/p1-4-task-supervision` after its Spec Gate and implementation plan. +This documentation update does not claim that slice 1 has begun. + +#### Historical Refactor Inventory (Superseded) + The July 2026 Codex and package 3 reference pass ranks the remaining architecture work as follows. Codex is the stronger reference for ownership: core thread/session code runs turns against a frozen `TurnContext` plus @@ -1142,8 +1287,8 @@ MCP elicitation queue are good interaction references, but its broad `ToolUseContext` and app-state-coupled orchestration should not be copied into Orca. -The July 11 ownership and recovery pass supersedes the older priority labels -below. The full evidence and dependency graph are recorded in +At the time, the July 11 ownership and recovery pass superseded the older +priority labels below. The full evidence and dependency graph are recorded in [`docs/reports/2026-07-11-codex-package3-runtime-refactor.md`](reports/2026-07-11-codex-package3-runtime-refactor.md). The immediate sequence is: @@ -1161,11 +1306,12 @@ The immediate sequence is: sequencer, then add the interaction broker, tool runtime, and fenced task supervisor before attempting true workflow/subagent/goal resume. -The detailed inventory that follows remains useful implementation history, but -new releases should be ranked against this ownership sequence rather than by -how many additional call-surface bundles they extract. +The detailed inventory that follows remains useful implementation history. New +releases now use the evidence-based sequence above rather than counting +additional call-surface bundles. -The deeper July 9 reference pass changes the next refactor order: +The deeper July 9 reference pass recorded the following refactor order at that +checkpoint: 1. **P0: Stop treating call-surface grouping as the main work once the current tool-turn context family is finished.** The normal, subagent batch, and @@ -2225,7 +2371,7 @@ instruction and capability system. --- -## July 12 Priority Matrix +## Historical July 12 Priority Matrix (Superseded) | Priority | Item | Why Now | Risk | |----------|------|---------|------| diff --git a/docs/releases/v0.3.13.md b/docs/releases/v0.3.13.md new file mode 100644 index 00000000..76caa04f --- /dev/null +++ b/docs/releases/v0.3.13.md @@ -0,0 +1,70 @@ +# Orca v0.3.13 + +Orca v0.3.13 makes "restore a headless execution" a first-class CLI capability +and upgrades honest exit into resumable execution: `orca exec resume` continues +a saved session with a fresh budget scope, the terminal event carries the +durable session id, and a budget-exhausted run persists a typed checkpoint you +can continue from a chosen message boundary. + +## What Changed + +- **`orca exec resume` subcommand.** `orca exec resume [PROMPT]...` + continues a saved conversation by id, prefix, or `latest`; + `orca exec resume --last [PROMPT]...` picks the most recent recorded session. + Run options (`--provider`, `--output-format`, `--cwd`, `--mode`, `--model`, + `--api-key`, `--base-url`, `--verifier`, `--max-budget`) work in either + position, and combining the subcommand with `--resume`/`--fork`/`--continue` + is rejected. The original `--resume`/`--fork`/`--continue` flags keep working. +- **Durable session identity on headless exit.** `session.completed` now + carries `session_id` when history was recorded (JSONL mode), and text mode + prints `To continue this session, run: orca exec resume ` for + every non-success terminal — budget exhaustion (exit code 4, + `status=budget_exhausted`) stays distinct from a plain failure and tells the + caller exactly how to continue. +- **Message-boundary restore.** `--resume-at ` (on the `resume` + subcommand and on `--resume`/`--continue`) restores the conversation only up + to a persisted conversation item id, so uncommitted work past the boundary is + never replayed; unknown boundaries fail closed before the provider is called. +- **Typed budget checkpoint.** When a headless session stops at + `budget_exhausted`, the runtime appends a `session.checkpoint` record + (status, reason, aggregate budget consumed, last committed message id, + current task plan, `resumable: true`) before the terminal projection. Resume + starts a new invocation with a fresh budget scope; the previous run's + consumption records stay durable. Uncommitted tool calls are still + distinguished on restore as indeterminate — Orca promises resumable, not + exactly-once, execution. File rewind is not promised because Orca does not + snapshot external workspace state. + +## Compatibility + +CLI arguments, TUI workflows, server/JSONL and ACP protocols, and SQLite +schemas are unchanged. The persisted transcript format gains one optional, +additive record type (`session.checkpoint`) written only on budget exhaustion; +older readers ignore unknown record types and existing transcripts remain +readable. `HistoryMode` gains a `ResumeAt` variant used only by the new +`--resume-at` path. + +## Verification + +- `cargo nextest run --workspace --all-targets --locked --profile ci --no-fail-fast` +- Headless resume contracts: `exec resume` by id, `--last`, `--resume-at` + boundary restore and fail-closed rejection, budget-scope re-count, typed + checkpoint persistence, `session.completed` session id, and resume-hint text +- Runtime surface and Windows platform contract validators +- `cargo clippy --workspace --all-targets --locked -j 1` +- Version sync, npm staging/public-verifier self-tests, website build, and SEO checks +- `cargo fmt --all -- --check` +- `git diff --check` + +## Upgrade + +```bash +npm install -g @blade-ai/orca@0.3.13 +``` + +macOS and Linux native installer: + +```bash +curl -fsSL https://orcaagent.dev/install.sh | \ + INSTALL_DIR=/usr/local/bin ORCA_VERSION=0.3.13 sh +``` diff --git a/npm/orca/package.json b/npm/orca/package.json index ca6517d2..c63370c9 100644 --- a/npm/orca/package.json +++ b/npm/orca/package.json @@ -1,6 +1,6 @@ { "name": "@blade-ai/orca", - "version": "0.3.12", + "version": "0.3.13", "description": "Orca CLI: a DeepSeek-native coding agent.", "homepage": "https://orcaagent.dev/", "license": "MIT", diff --git a/site/src/changelog/Changelog.tsx b/site/src/changelog/Changelog.tsx index 0a31583d..8dfe153e 100644 --- a/site/src/changelog/Changelog.tsx +++ b/site/src/changelog/Changelog.tsx @@ -76,6 +76,8 @@ const copy = { ], }, summaries: { + "v0.3.13": + "Makes headless resume a first-class CLI capability. orca exec resume continues a saved session with a fresh budget scope, resume --last picks the most recent session, and --resume-at restores only up to a durable message boundary. session.completed now carries the durable session_id, text-mode exits print the exact resume command, and a budget-exhausted run persists a typed session.checkpoint (status, reason, budget consumed, last committed message, task plan, resumable) before the terminal projection — uncommitted tool calls remain indeterminate on restore, so Orca promises resumable, not exactly-once, execution.", "v0.3.12": "Adds runtime-owned Side Conversations for quick questions without disturbing the main task. /side creates a separate disposable child from an atomic parent snapshot; Ctrl+/ switches between parent and Side while the parent keeps running, and Ctrl+C closes and joins only the Side. Side history, memory, goals, and transcript output never merge into the durable parent. TUI response projection also fences provider responses by turn and item identity so older or partial streams cannot overwrite the current response.", "v0.3.11": @@ -582,6 +584,8 @@ const copy = { ], }, summaries: { + "v0.3.13": + "让 headless resume 成为一等 CLI 能力。orca exec resume 以全新预算范围继续保存的会话,resume --last 恢复最近会话,--resume-at 只恢复到持久化消息边界为止。session.completed 现在携带持久的 session_id,文本模式退出时打印确切的 resume 命令,预算耗尽的运行会在 terminal projection 之前持久化类型化 session.checkpoint(status、reason、已消耗预算、最后提交消息、任务计划、resumable)。恢复时未提交的工具调用仍标记为 indeterminate——Orca 承诺可恢复,而非 exactly-once 执行。", "v0.3.12": "新增 runtime-owned Side Conversation,用来临时提问而不打断主任务。/side 会从父会话的原子快照创建独立、可丢弃的 child;Ctrl+/ 在父会话与 Side 之间切换,父任务仍可继续运行,Ctrl+C 只关闭并回收 Side。Side 的历史、memory、Goal 和 transcript 都不会合并回持久化父会话。TUI response projection 也会按 turn 与 item identity 隔离 provider response,避免旧流或部分流覆盖当前回复。", "v0.3.11": diff --git a/site/src/shared.ts b/site/src/shared.ts index ece0973a..0a9102fd 100644 --- a/site/src/shared.ts +++ b/site/src/shared.ts @@ -4,9 +4,14 @@ export const localeStorageKey = "orca-site-locale"; export const canonicalOrigin = "https://orcaagent.dev"; export const socialImageUrl = `${canonicalOrigin}/orca-social.png`; -export const releaseVersion = "v0.3.12"; +export const releaseVersion = "v0.3.13"; export const releases = [ + { + version: "v0.3.13", + date: "2026-08-10", + url: "https://github.com/echoVic/orca-agent/releases/tag/v0.3.13", + }, { version: "v0.3.12", date: "2026-08-10", diff --git a/src/cli.rs b/src/cli.rs index f292340b..2b75684f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -70,43 +70,54 @@ enum Command { } #[derive(Debug, Parser)] +#[command( + override_usage = "orca exec [OPTIONS] [PROMPT]...\n orca exec [OPTIONS] [ARGS]" +)] struct ExecArgs { + /// Resume a saved conversation as a non-interactive session. + #[command(subcommand)] + command: Option, + /// Output format: text (human-readable) or jsonl (machine-readable). - #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text, global = true)] output_format: OutputFormatArg, /// Workspace directory. - #[arg(long)] + #[arg(long, global = true)] cwd: Option, /// Approval policy for tool actions. - #[arg(long = "mode", alias = "approval-mode", value_enum)] + #[arg(long = "mode", alias = "approval-mode", value_enum, global = true)] approval_mode: Option, /// Model to use (overrides config file and DEEPSEEK_MODEL env). - #[arg(long)] + #[arg(long, global = true)] model: Option, /// API key to use (overrides config file and ORCA_API_KEY env). - #[arg(long)] + #[arg(long, global = true)] api_key: Option, /// API base URL (overrides config file and DEEPSEEK_BASE_URL env). - #[arg(long)] + #[arg(long, global = true)] base_url: Option, /// Optional verifier command to run after completion. - #[arg(long)] + #[arg(long, global = true)] verifier: Option, /// Maximum estimated USD budget for this run. - #[arg(long)] + #[arg(long, global = true)] max_budget: Option, /// Resume a saved conversation by ID, prefix, or 'latest'. #[arg(long)] resume: Option, + /// Restore the resumed conversation only up to this persisted message id. + #[arg(long = "resume-at", value_name = "MESSAGE_ID")] + resume_at: Option, + /// Fork a saved conversation by ID, prefix, or 'latest'. #[arg(long, alias = "fork-session")] fork: Option, @@ -124,13 +135,37 @@ struct ExecArgs { save_history: bool, /// Provider implementation (internal, for testing). - #[arg(long, value_enum, default_value_t = ProviderKind::DeepSeek, hide = true)] + #[arg(long, value_enum, default_value_t = ProviderKind::DeepSeek, hide = true, global = true)] provider: ProviderKind, /// Prompt to execute. prompt: Vec, } +#[derive(Debug, Subcommand)] +enum ExecCommand { + /// Resume a saved conversation by ID, prefix, or 'latest'. + Resume(ExecResumeArgs), +} + +#[derive(Debug, Parser)] +struct ExecResumeArgs { + /// Session id, prefix, or 'latest' to resume. Omit with --last to pick the most recent. + #[arg(value_name = "SESSION_ID", required_unless_present = "last")] + session_id: Option, + + /// Continue the most recent recorded session. + #[arg(long)] + last: bool, + + /// Restore the resumed conversation only up to this persisted message id. + #[arg(long = "resume-at", value_name = "MESSAGE_ID")] + resume_at: Option, + + /// Prompt to execute. + prompt: Vec, +} + #[derive(Debug, Parser)] struct WorkflowArgs { #[command(subcommand)] @@ -399,26 +434,76 @@ impl From for OutputFormat { } } -impl From for orca_runtime::command::exec::ExecCommandRequest { - fn from(args: ExecArgs) -> Self { - Self { +impl ExecArgs { + fn into_request(self) -> Result { + let (resume, continue_latest, resume_at, prompt) = match self.command { + Some(ExecCommand::Resume(resume_args)) => { + if self.resume.is_some() || self.fork.is_some() || self.continue_latest { + return Err( + "the 'resume' subcommand cannot be combined with --resume/--fork/--continue" + .to_string(), + ); + } + if self.no_history { + return Err( + "the 'resume' subcommand cannot be combined with --no-history".to_string(), + ); + } + // When --last is used without an explicit prompt, clap cannot + // express the conditional positional meaning, so the first + // positional is reinterpreted as the prompt (Codex-style). + let (selector, prompt) = if resume_args.last && resume_args.prompt.is_empty() { + (None, resume_args.session_id.into_iter().collect()) + } else { + (resume_args.session_id, resume_args.prompt) + }; + let selector = selector.or_else(|| resume_args.last.then(|| "latest".to_string())); + return Ok(orca_runtime::command::exec::ExecCommandRequest { + app_version: env!("CARGO_PKG_VERSION").to_string(), + output_format: self.output_format.into(), + cwd: self.cwd, + approval_mode: self.approval_mode, + model: self.model, + api_key: self.api_key, + base_url: self.base_url, + verifier: self.verifier, + max_budget: self.max_budget, + resume: selector, + resume_at: resume_args.resume_at, + fork: None, + continue_latest: false, + no_history: false, + save_history: false, + provider: self.provider, + prompt, + }); + } + None => ( + self.resume, + self.continue_latest, + self.resume_at, + self.prompt, + ), + }; + Ok(orca_runtime::command::exec::ExecCommandRequest { app_version: env!("CARGO_PKG_VERSION").to_string(), - output_format: args.output_format.into(), - cwd: args.cwd, - approval_mode: args.approval_mode, - model: args.model, - api_key: args.api_key, - base_url: args.base_url, - verifier: args.verifier, - max_budget: args.max_budget, - resume: args.resume, - fork: args.fork, - continue_latest: args.continue_latest, - no_history: args.no_history, - save_history: args.save_history, - provider: args.provider, - prompt: args.prompt, - } + output_format: self.output_format.into(), + cwd: self.cwd, + approval_mode: self.approval_mode, + model: self.model, + api_key: self.api_key, + base_url: self.base_url, + verifier: self.verifier, + max_budget: self.max_budget, + resume, + resume_at, + fork: self.fork, + continue_latest, + no_history: self.no_history, + save_history: self.save_history, + provider: self.provider, + prompt, + }) } } @@ -491,7 +576,13 @@ pub fn run() -> i32 { } match cli.command { - Some(Command::Exec(args)) => orca_runtime::command::exec::run(args.into()), + Some(Command::Exec(args)) => match args.into_request() { + Ok(request) => orca_runtime::command::exec::run(request), + Err(message) => { + eprintln!("orca: {message}"); + 1 + } + }, Some(Command::Workflow(args)) => orca_runtime::workflow::command::run(args.into()), Some(Command::Trust(args)) => orca_runtime::command::trust::run(args.into()), Some(Command::SubagentWorker(args)) => { @@ -540,3 +631,119 @@ fn protocol_request( base_url: cli.base_url, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_exec(args: &[&str]) -> ExecArgs { + let mut argv = vec!["orca", "exec"]; + argv.extend_from_slice(args); + match Cli::try_parse_from(argv) { + Ok(cli) => match cli.command { + Some(Command::Exec(args)) => args, + other => panic!("expected exec command, got {other:?}"), + }, + Err(error) => panic!("parse failed: {error}"), + } + } + + #[test] + fn exec_resume_subcommand_parses_session_id_and_prompt() { + let args = parse_exec(&[ + "resume", + "a11864d0", + "--output-format", + "jsonl", + "continue the work", + ]); + let request = args.into_request().expect("request"); + assert_eq!(request.resume.as_deref(), Some("a11864d0")); + assert!(!request.continue_latest); + assert_eq!(request.prompt, ["continue the work"]); + } + + #[test] + fn exec_resume_subcommand_last_parses_without_session_id() { + let args = parse_exec(&["resume", "--last", "keep going"]); + let request = args.into_request().expect("request"); + assert_eq!(request.resume.as_deref(), Some("latest")); + assert_eq!(request.prompt, ["keep going"]); + } + + #[test] + fn exec_resume_subcommand_requires_session_id_or_last() { + let error = Cli::try_parse_from(["orca", "exec", "resume"]) + .expect_err("resume without selector must fail"); + assert!(error.to_string().contains("SESSION_ID")); + } + + #[test] + fn exec_prompt_positional_still_parses_without_subcommand() { + let args = parse_exec(&["inspect", "the", "repo"]); + let request = args.into_request().expect("request"); + assert!(request.resume.is_none()); + assert_eq!(request.prompt, ["inspect", "the", "repo"]); + } + + #[test] + fn exec_resume_flag_still_parses() { + let args = parse_exec(&["--resume", "latest", "inspect the repo"]); + let request = args.into_request().expect("request"); + assert_eq!(request.resume.as_deref(), Some("latest")); + assert_eq!(request.prompt, ["inspect the repo"]); + } + + #[test] + fn exec_resume_subcommand_rejects_combined_resume_flag() { + let args = parse_exec(&["--resume", "old-id", "resume", "new-id", "prompt"]); + let error = args.into_request().expect_err("combined resume must fail"); + assert!(error.contains("cannot be combined")); + } + + #[test] + fn exec_resume_subcommand_rejects_no_history() { + let args = parse_exec(&["--no-history", "resume", "--last", "prompt"]); + let error = args + .into_request() + .expect_err("no-history resume must fail"); + assert!(error.contains("--no-history")); + } + + #[test] + fn exec_resume_subcommand_forwards_budget_scope_options() { + let args = parse_exec(&["resume", "--last", "--max-budget", "1.5", "prompt"]); + let request = args.into_request().expect("request"); + assert_eq!(request.max_budget, Some(1.5)); + assert_eq!(request.prompt, ["prompt"]); + } + + #[test] + fn exec_resume_subcommand_forwards_message_boundary() { + let args = parse_exec(&[ + "resume", + "a11864d0", + "--resume-at", + "item_019f1234", + "continue", + ]); + let request = args.into_request().expect("request"); + assert_eq!(request.resume.as_deref(), Some("a11864d0")); + assert_eq!(request.resume_at.as_deref(), Some("item_019f1234")); + assert_eq!(request.prompt, ["continue"]); + } + + #[test] + fn exec_resume_flag_forwards_message_boundary() { + let args = parse_exec(&[ + "--resume", + "latest", + "--resume-at", + "item_019f1234", + "continue", + ]); + let request = args.into_request().expect("request"); + assert_eq!(request.resume.as_deref(), Some("latest")); + assert_eq!(request.resume_at.as_deref(), Some("item_019f1234")); + } +} diff --git a/tests/exec_jsonl.rs b/tests/exec_jsonl.rs index 935804f1..34c5f808 100644 --- a/tests/exec_jsonl.rs +++ b/tests/exec_jsonl.rs @@ -443,6 +443,115 @@ fn exec_stops_when_usage_exceeds_max_budget() { ); } +#[test] +fn session_completed_carries_durable_session_id_when_history_is_recorded() { + let home = TempDir::new().expect("temporary ORCA_HOME"); + let output = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--output-format", + "jsonl", + "--save-history", + "--provider", + "mock", + "mock_usage", + ]) + .output() + .expect("run orca"); + + assert_eq!(output.status.code(), Some(0)); + let events = parse_jsonl(&output.stdout); + let terminal = events.last().unwrap(); + assert_eq!(terminal["type"], "session.completed"); + let session_id = terminal["payload"]["session_id"] + .as_str() + .expect("durable session id in terminal event"); + assert!(!session_id.is_empty()); + + let documents = session_documents(home.path()); + assert_eq!(documents.len(), 1); + let meta = documents[0] + .1 + .iter() + .find(|record| record["type"] == "session.meta") + .expect("session metadata"); + assert_eq!(meta["session_id"].as_str(), Some(session_id)); +} + +#[test] +fn session_completed_omits_session_id_when_history_is_disabled() { + let output = Command::new(env!("CARGO_BIN_EXE_orca")) + .args([ + "exec", + "--output-format", + "jsonl", + "--provider", + "mock", + "mock_usage", + ]) + .output() + .expect("run orca"); + + assert_eq!(output.status.code(), Some(0)); + let events = parse_jsonl(&output.stdout); + let terminal = events.last().unwrap(); + assert_eq!(terminal["type"], "session.completed"); + assert!(terminal["payload"]["session_id"].is_null()); +} + +#[test] +fn budget_exhausted_text_mode_prints_resume_hint_with_session_id() { + let home = TempDir::new().expect("temporary ORCA_HOME"); + let output = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--provider", + "mock", + "--max-budget", + "0.000001", + "mock_usage", + ]) + .output() + .expect("run budget-limited fixture"); + + assert_eq!(output.status.code(), Some(4)); + let stdout = String::from_utf8_lossy(&output.stdout); + let documents = session_documents(home.path()); + assert_eq!(documents.len(), 1); + let session_id = documents[0] + .1 + .iter() + .find(|record| record["type"] == "session.meta") + .and_then(|record| record["session_id"].as_str()) + .expect("session metadata") + .to_string(); + assert!( + stdout.contains(&format!( + "To continue this session, run: orca exec resume {session_id}" + )), + "headless exit must surface the exact resume command" + ); +} + +#[test] +fn successful_text_mode_does_not_print_resume_hint() { + let home = TempDir::new().expect("temporary ORCA_HOME"); + let output = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args(["exec", "--provider", "mock", "mock_usage"]) + .output() + .expect("run orca"); + + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("To continue this session, run:"), + "successful runs do not print a resume hint" + ); +} + fn parse_jsonl(stdout: &[u8]) -> Vec { String::from_utf8_lossy(stdout) .lines() @@ -450,6 +559,37 @@ fn parse_jsonl(stdout: &[u8]) -> Vec { .collect() } +fn session_documents(home: &std::path::Path) -> Vec<(std::path::PathBuf, Vec)> { + fn collect(path: &std::path::Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect(&path, files); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") { + files.push(path); + } + } + } + + let mut files = Vec::new(); + collect(&home.join("sessions"), &mut files); + files.sort(); + files + .into_iter() + .map(|path| { + let records = std::fs::read_to_string(&path) + .expect("read saved conversation") + .lines() + .map(|line| serde_json::from_str(line).expect("valid saved conversation record")) + .collect(); + (path, records) + }) + .collect() +} + fn find_event<'a>(events: &'a [Value], event_type: &str) -> &'a Value { events .iter() diff --git a/tests/history_contract.rs b/tests/history_contract.rs index 9934f3e9..c09cf035 100644 --- a/tests/history_contract.rs +++ b/tests/history_contract.rs @@ -76,6 +76,18 @@ fn saved_conversation_text(home: &Path) -> String { .join("\n") } +fn session_id_from_home(home: &Path) -> String { + let documents = session_documents(home); + assert_eq!(documents.len(), 1, "exactly one session document expected"); + documents[0] + .1 + .iter() + .find(|record| record["type"] == "session.meta") + .and_then(|record| record["session_id"].as_str()) + .expect("session metadata with session id") + .to_string() +} + #[test] fn history_subcommand_is_not_exposed() { let output = Command::new(env!("CARGO_BIN_EXE_orca")) @@ -192,6 +204,151 @@ fn exec_resume_injects_prior_conversation() { assert!(text.contains("first prompt | mock_history_echo")); } +#[test] +fn exec_resume_subcommand_continues_session_by_id() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args(["exec", "--provider", "mock", "first prompt"]) + .output() + .expect("run first orca"); + assert_eq!(first.status.code(), Some(0)); + + let session_id = session_id_from_home(home.path()); + + let resumed = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--output-format", + "jsonl", + "--provider", + "mock", + "resume", + &session_id, + "mock_history_echo", + ]) + .output() + .expect("run resumed orca"); + + assert_eq!(resumed.status.code(), Some(0)); + let events = parse_jsonl(&resumed.stdout); + let message = events + .iter() + .find(|event| event["type"] == "assistant.message.delta") + .expect("assistant message"); + let text = message["payload"]["text"].as_str().unwrap_or_default(); + assert!(text.contains("first prompt | mock_history_echo")); +} + +#[test] +fn exec_resume_subcommand_last_continues_latest() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args(["exec", "--provider", "mock", "first prompt"]) + .output() + .expect("run first orca"); + assert_eq!(first.status.code(), Some(0)); + + let resumed = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--output-format", + "jsonl", + "--provider", + "mock", + "resume", + "--last", + "mock_history_echo", + ]) + .output() + .expect("run resumed orca"); + + assert_eq!(resumed.status.code(), Some(0)); + let events = parse_jsonl(&resumed.stdout); + let message = events + .iter() + .find(|event| event["type"] == "assistant.message.delta") + .expect("assistant message"); + let text = message["payload"]["text"].as_str().unwrap_or_default(); + assert!(text.contains("first prompt | mock_history_echo")); +} + +#[test] +fn exec_resume_after_budget_exhaustion_recounts_budget_scope() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--provider", + "mock", + "--max-budget", + "0.000001", + "mock_usage", + ]) + .output() + .expect("run budget-limited orca"); + assert_eq!( + first.status.code(), + Some(4), + "budget exhaustion is typed, not a generic failure" + ); + + let session_id = session_id_from_home(home.path()); + + // The resumed invocation owns a fresh budget scope: the previous run's + // ceiling does not leak into the continuation, while the session and its + // prior consumption records stay durable. + let resumed = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--provider", + "mock", + "resume", + &session_id, + "mock_usage", + ]) + .output() + .expect("run resumed orca"); + assert_eq!(resumed.status.code(), Some(0)); + + let documents = session_documents(home.path()); + assert_eq!( + documents.len(), + 1, + "resume appends to the session, never forks" + ); + let records = &documents[0].1; + let meta_records = records + .iter() + .filter(|record| record["type"] == "session.meta") + .collect::>(); + assert_eq!(meta_records.len(), 1, "one durable session identity"); + assert_eq!( + meta_records[0]["session_id"].as_str(), + Some(session_id.as_str()), + "resumed session keeps its identity" + ); + let user_messages = records + .iter() + .filter(|record| { + record["type"] == "conversation.message" && record["message"]["role"] == "user" + }) + .collect::>(); + assert_eq!( + user_messages.len(), + 2, + "the exhausted run's accepted input and the continuation input both persist" + ); +} + #[test] fn session_fork_copies_history_and_keeps_source_durable() { let home = TempDir::new().expect("temp home"); @@ -521,6 +678,159 @@ fn exec_fork_creates_child_with_parent_metadata() { assert!(child_text.contains("mock_history_echo")); } +#[test] +fn exec_resume_at_restores_conversation_to_message_boundary() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args(["exec", "--provider", "mock", "first prompt"]) + .output() + .expect("run first orca"); + assert_eq!(first.status.code(), Some(0)); + let session_id = session_id_from_home(home.path()); + + let appended = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--provider", + "mock", + "resume", + &session_id, + "second prompt", + ]) + .output() + .expect("append second prompt"); + assert_eq!(appended.status.code(), Some(0)); + + let records = &session_documents(home.path())[0].1; + let first_user_id = records + .iter() + .find(|record| { + record["type"] == "conversation.message" + && record["message"]["role"] == "user" + && record["message"]["content"] == "first prompt" + }) + .and_then(|record| record["id"].as_str()) + .expect("first user message item id") + .to_string(); + + let resumed = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--output-format", + "jsonl", + "--provider", + "mock", + "resume", + &session_id, + "--resume-at", + &first_user_id, + "mock_history_echo", + ]) + .output() + .expect("resume at boundary"); + + assert_eq!(resumed.status.code(), Some(0)); + let events = parse_jsonl(&resumed.stdout); + let message = events + .iter() + .find(|event| event["type"] == "assistant.message.delta") + .expect("assistant message"); + let text = message["payload"]["text"].as_str().unwrap_or_default(); + assert!( + text.contains("first prompt"), + "boundary keeps messages at or before it" + ); + assert!( + !text.contains("second prompt"), + "boundary drops messages after it: {text}" + ); +} + +#[test] +fn exec_resume_at_rejects_unknown_boundary() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args(["exec", "--provider", "mock", "first prompt"]) + .output() + .expect("run first orca"); + assert_eq!(first.status.code(), Some(0)); + let session_id = session_id_from_home(home.path()); + + let resumed = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--output-format", + "jsonl", + "--provider", + "mock", + "resume", + &session_id, + "--resume-at", + "item_00000000-0000-0000-0000-000000000000", + "mock_history_echo", + ]) + .output() + .expect("resume at unknown boundary"); + + assert_eq!(resumed.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&resumed.stderr); + assert!( + stderr.contains("no saved message matches"), + "unknown boundary must fail closed: {stderr}" + ); +} + +#[test] +fn budget_exhausted_session_persists_typed_checkpoint() { + let home = TempDir::new().expect("temp home"); + + let first = Command::new(env!("CARGO_BIN_EXE_orca")) + .env("ORCA_HOME", home.path()) + .args([ + "exec", + "--provider", + "mock", + "--max-budget", + "0.000001", + "mock_usage", + ]) + .output() + .expect("run budget-limited orca"); + assert_eq!(first.status.code(), Some(4)); + + let records = &session_documents(home.path())[0].1; + let checkpoint = records + .iter() + .find(|record| record["type"] == "session.checkpoint") + .expect("typed checkpoint on budget exhaustion"); + assert_eq!(checkpoint["status"], "budget_exhausted"); + assert_eq!(checkpoint["reason"], "cost_budget_exhausted"); + assert_eq!(checkpoint["resumable"], true); + assert_eq!( + checkpoint["budget_consumed"]["input_tokens"], 120, + "checkpoint records the consumption that exhausted the budget" + ); + let checkpoint_index = records + .iter() + .position(|record| record["type"] == "session.checkpoint") + .expect("checkpoint index"); + let completed_index = records + .iter() + .position(|record| record["type"] == "session.completed") + .expect("completed index"); + assert!( + checkpoint_index > completed_index, + "checkpoint lands after the budget terminal and before the projection flush" + ); +} + fn parse_jsonl(stdout: &[u8]) -> Vec { String::from_utf8_lossy(stdout) .lines()