Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <SESSION_ID>` restores a headless session with a
fresh budget scope, and headless exits print the exact resume command.
Comment on lines +91 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify that only non-success headless exits print a resume command.

Successful text-mode runs do not print this hint. The current wording conflicts with the release contract and its integration test.

  • README.md#L91-L92: qualify the resume-command statement with “non-success.”
  • site/src/changelog/Changelog.tsx#L79-L80: qualify the English changelog summary with “non-success.”
  • site/src/changelog/Changelog.tsx#L587-L588: qualify the Chinese changelog summary with the equivalent condition.
📍 Affects 2 files
  • README.md#L91-L92 (this comment)
  • site/src/changelog/Changelog.tsx#L79-L80
  • site/src/changelog/Changelog.tsx#L587-L588
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 91 - 92, Update the resume-command wording to state
that only non-success headless exits print the exact resume command: revise
README.md lines 91-92, the English changelog summary in
site/src/changelog/Changelog.tsx lines 79-80, and the equivalent Chinese summary
at lines 587-588; no other behavior changes are needed.

- 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
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 分叉保存的会话
Expand Down
8 changes: 8 additions & 0 deletions crates/orca-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down Expand Up @@ -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",
}
}
Expand Down
28 changes: 21 additions & 7 deletions crates/orca-core/src/event_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
26 changes: 26 additions & 0 deletions crates/orca-runtime/src/command/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct ExecCommandRequest {
pub verifier: Option<String>,
pub max_budget: Option<f64>,
pub resume: Option<String>,
pub resume_at: Option<String>,
pub fork: Option<String>,
pub continue_latest: bool,
pub no_history: bool,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -164,10 +174,26 @@ pub(crate) fn resolve_history_mode(
resume: Option<String>,
fork: Option<String>,
continue_latest: bool,
resume_at: Option<String>,
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())
Expand Down
1 change: 1 addition & 0 deletions crates/orca-runtime/src/command/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub fn prepare_interactive(request: InteractiveLaunchRequest) -> Result<RunConfi
request.resume,
request.fork,
request.continue_latest,
None,
HistoryMode::Record,
);
let mut config_request = RunConfigRequest::new(request.app_version, cwd.clone());
Expand Down
11 changes: 10 additions & 1 deletion crates/orca-runtime/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ impl ThreadTurnCompletion {
task.emit_all(events, sink)?;
}
if request.emit_session_completed() {
sink.emit(events.session_completed(self.status))?;
sink.emit(events.session_completed(self.status, session.session_id()))?;
}
Ok(self.status)
}
Expand Down Expand Up @@ -1135,6 +1135,15 @@ fn run_inner<W: io::Write>(
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)
}

Expand Down
60 changes: 53 additions & 7 deletions crates/orca-runtime/src/runtime_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
);
}
}
Expand Down
7 changes: 4 additions & 3 deletions crates/orca-runtime/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 27 additions & 3 deletions crates/orca-runtime/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,21 +256,45 @@ 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)
.unwrap_or_default()
} 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)
Expand All @@ -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) {
Comment on lines +312 to 315

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve the ResumeAt boundary when reopening the session writer.

SessionWriter::append_to_existing reloads every record from transcript.path. It then appends the continuation after the original tail. The in-memory conversation excludes records after resume_at, but the durable session does not.

A later plain resume can replay the excluded tail and the new continuation. Persist and honor the selected boundary when continuing a ResumeAt session, or rewrite the durable transcript before reopening its writer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/orca-runtime/src/session.rs` around lines 312 - 315, Update the
HistoryMode::ResumeAt branch around SessionWriter::append_to_existing so
reopening the writer preserves the selected resume_at boundary in durable
storage. Either pass and enforce the boundary when appending, or
truncate/rewrite transcript.path to remove records after resume_at before
creating the writer; keep plain Resume behavior unchanged and ensure later
resumes cannot replay the excluded tail.

Expand Down
2 changes: 1 addition & 1 deletion crates/orca-runtime/src/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading