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
30 changes: 15 additions & 15 deletions 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 @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "0.1.61"
version = "0.1.62"
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
Expand Down
5 changes: 3 additions & 2 deletions crates/cli-sub-agent/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! - Executor building and tool installation checks
//! - Global slot acquisition with concurrency limits

use anyhow::Result;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tracing::{error, info, warn};

Expand Down Expand Up @@ -760,7 +760,8 @@ pub(crate) async fn execute_with_session_and_meta(
&mut cleanup_guard,
execution_start_time,
)
.await?;
.await
.with_context(|| format!("meta_session_id={}", session.meta_session_id))?;

// Tool execution completed — defuse cleanup guard (preserve artifacts on later errors).
if let Some(ref mut guard) = cleanup_guard {
Expand Down
109 changes: 108 additions & 1 deletion crates/cli-sub-agent/src/run_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use tempfile::TempDir;
use tracing::{info, warn};

use csa_config::{GlobalConfig, ProjectConfig};
use csa_core::types::{OutputFormat, ToolArg, ToolSelectionStrategy};
use csa_core::types::{OutputFormat, ToolArg, ToolName, ToolSelectionStrategy};
use csa_executor::structured_output_instructions_for_fork_call;
use csa_lock::SessionLock;
use csa_lock::slot::{
Expand Down Expand Up @@ -610,6 +610,57 @@ pub(crate) async fn handle_run(
let exec_result = match exec_result {
Ok(result) => result,
Err(e) => {
if let Some(signal_exit_code) = signal_interruption_exit_code(&e) {
cleanup_pre_created_fork_session(
&mut pre_created_fork_session_id,
&project_root,
);
Comment on lines +614 to +617

Choose a reason for hiding this comment

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

P1 Badge Preserve fork session before emitting interruption resume hint

This interruption branch deletes pre_created_fork_session_id before returning the resume guidance, but native fork runs use that pre-created ID as the active session (effective_session_arg) and execute_transport_with_signal persists interruption state into it. In a SIGINT/SIGTERM during a native fork, the code can print a --session <id> hint for the same ID it just deleted, so resume immediately fails because the referenced session no longer exists.

Useful? React with 👍 / 👎.

let interrupted_session_id = extract_meta_session_id_from_error(&e)
.or_else(|| executed_session_id.clone())
.or_else(|| effective_session_arg.clone());
let signal_name = signal_name_from_exit_code(signal_exit_code);

match output_format {
OutputFormat::Text => {
if let Some(ref session_id) = interrupted_session_id {
let resume_hint = build_resume_hint_command(
session_id,
current_tool,
skill.as_deref(),
);
eprintln!(
"csa run interrupted by {} (exit {}). Resume with:\n {}",
signal_name, signal_exit_code, resume_hint
);
} else {
eprintln!(
"csa run interrupted by {} (exit {}). Resume by reusing the interrupted session with `csa run --session <session-id> ...`.",
signal_name, signal_exit_code
);
}
}
OutputFormat::Json => {
let resume_hint = interrupted_session_id.as_ref().map(|session_id| {
build_resume_hint_command(
session_id,
current_tool,
skill.as_deref(),
)
});
let json_error = serde_json::json!({
"error": "interrupted",
"signal": signal_name,
"exit_code": signal_exit_code,
"session_id": interrupted_session_id,
"resume_hint": resume_hint,
"message": e.to_string()
});
println!("{}", serde_json::to_string_pretty(&json_error)?);
}
}

return Ok(signal_exit_code);
}
if runtime_fallback_enabled
&& runtime_fallback_attempts < max_runtime_fallback_attempts
{
Expand Down Expand Up @@ -801,6 +852,62 @@ fn detect_effective_repo(project_root: &Path) -> Option<String> {
Some(trimmed.to_string())
}

fn signal_interruption_exit_code(error: &anyhow::Error) -> Option<i32> {
for cause in error.chain() {
let message = cause.to_string().to_ascii_lowercase();
if message.contains("sigterm") {
return Some(143);
}
if message.contains("sigint") {
return Some(130);
Comment on lines +858 to +862

Choose a reason for hiding this comment

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

P2 Badge Restrict signal detection to real interruption errors

The new classifier treats any error-chain message containing sigterm/sigint as an interruption, which can misclassify non-interruption failures (for example, signal-handler setup errors like Failed to register SIGINT handler from execute_transport_with_signal) as exit 130/143. In that case users get an "interrupted" response and fallback/error handling is skipped even though no OS signal interrupted execution.

Useful? React with 👍 / 👎.

}
}
None
}

fn signal_name_from_exit_code(exit_code: i32) -> &'static str {
match exit_code {
143 => "SIGTERM",
130 => "SIGINT",
_ => "signal",
}
}

fn extract_meta_session_id_from_error(error: &anyhow::Error) -> Option<String> {
const MARKER: &str = "meta_session_id=";
for cause in error.chain() {
let message = cause.to_string();
let Some(idx) = message.find(MARKER) else {
continue;
};
let suffix = &message[idx + MARKER.len()..];
let session_id: String = suffix
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric())
.collect();
if !session_id.is_empty() {
return Some(session_id);
}
}
None
}

fn build_resume_hint_command(session_id: &str, tool: ToolName, skill: Option<&str>) -> String {
match skill {
Some(skill_name) => format!(
"csa run --session {} --tool {} --skill {}",
session_id,
tool.as_str(),
skill_name
),
None => format!(
"csa run --session {} --tool {} <same prompt>",
session_id,
tool.as_str()
),
}
}

#[cfg(test)]
#[path = "run_cmd_tests.rs"]
mod tests;
43 changes: 43 additions & 0 deletions crates/cli-sub-agent/src/run_cmd_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,46 @@ fn test_cli_return_to_requires_fork_call() {
assert!(err.to_string().contains("--return-to"));
assert!(err.to_string().contains("--fork-call"));
}

#[test]
fn signal_interruption_exit_code_detects_sigterm_from_error_chain() {
let err = anyhow::anyhow!("transport failure")
.context("Failed to execute tool via transport")
.context("Execution interrupted by SIGTERM");
assert_eq!(signal_interruption_exit_code(&err), Some(143));
}

#[test]
fn signal_interruption_exit_code_detects_sigint_from_error_chain() {
let err = anyhow::anyhow!("Execution interrupted by SIGINT");
assert_eq!(signal_interruption_exit_code(&err), Some(130));
}

#[test]
fn extract_meta_session_id_from_error_reads_context_marker() {
let err = anyhow::anyhow!("Execution interrupted by SIGTERM")
.context("meta_session_id=01KJTESTSIGTERMABCDE12345");
assert_eq!(
extract_meta_session_id_from_error(&err).as_deref(),
Some("01KJTESTSIGTERMABCDE12345")
);
}

#[test]
fn extract_meta_session_id_from_error_returns_none_without_marker() {
let err = anyhow::anyhow!("Execution interrupted by SIGTERM");
assert_eq!(extract_meta_session_id_from_error(&err), None);
}

#[test]
fn build_resume_hint_command_includes_skill_when_present() {
let command = build_resume_hint_command(
"01KJTESTSIGTERMABCDE12345",
ToolName::Codex,
Some("pr-codex-bot"),
);
assert_eq!(
command,
"csa run --session 01KJTESTSIGTERMABCDE12345 --tool codex --skill pr-codex-bot"
);
}