Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e21c758
WIP: bounded delegation continuation
asteroida123 Sep 8, 2026
67373cf
fix: make delegation continuation durable
asteroida123 Sep 8, 2026
c9168cb
fix: recover variable-based MCP scripts
asteroida123 Sep 8, 2026
9957428
Merge remote-tracking branch 'upstream/main' into codex/delegation-co…
asteroida123 Sep 9, 2026
349ca4b
fix(delegation): reconcile interrupted history and verify restart rec…
asteroida123 Sep 9, 2026
c799055
fix(delegation): reuse app state in history command
asteroida123 Sep 9, 2026
b316ae4
test(acp): allow bounded fixture startup time on Windows
asteroida123 Sep 9, 2026
db3c73b
fix(delegation): wait for DeepSeek history before teardown
asteroida123 Sep 9, 2026
6fe3732
Revert "fix(delegation): wait for DeepSeek history before teardown"
asteroida123 Sep 9, 2026
889a0f7
fix(delegation): preserve interrupted status code
asteroida123 Sep 10, 2026
610836a
fix(delegation): scope release barrier to owned sessions
asteroida123 Sep 10, 2026
aca596b
fix(delegation): reconcile interrupted tasks on startup
asteroida123 Sep 10, 2026
5bd912c
fix(delegation): treat resumed selectors as preferences
asteroida123 Sep 10, 2026
106a52a
fix(delegation): close restart and spawn races
asteroida123 Sep 10, 2026
3194cd3
Merge remote-tracking branch 'upstream/main' into codex/delegation-co…
asteroida123 Sep 11, 2026
27e0ea9
fix(delegation): close small continuation review gaps
asteroida123 Sep 11, 2026
ccebfe5
Merge remote-tracking branch 'upstream/main' into codex/delegation-co…
asteroida123 Sep 11, 2026
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
673 changes: 479 additions & 194 deletions src-tauri/src/acp/connection.rs

Large diffs are not rendered by default.

671 changes: 671 additions & 0 deletions src-tauri/src/acp/connection/continuation_protocol_tests.rs

Large diffs are not rendered by default.

873 changes: 838 additions & 35 deletions src-tauri/src/acp/delegation/broker.rs

Large diffs are not rendered by default.

117 changes: 90 additions & 27 deletions src-tauri/src/acp/delegation/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::RwLock;

use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess};
use crate::acp::delegation::broker::{DelegationBroker, StatusWait};
use crate::acp::delegation::transport::{
read_frame, write_frame, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest,
BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerMessage, BrokerRequest,
BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerResponse,
BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest,
BrokerTaskCompleteRequest, BrokerTaskProgressRequest,
BrokerCommitFeedbackRequest, BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest,
BrokerFeedbackRequest, BrokerMessage, BrokerRequest, BrokerResponse, BrokerResumeTaskRequest,
BrokerSessionRequest, BrokerStatusRequest, BrokerTaskCompleteRequest,
BrokerTaskProgressRequest,
};
use crate::acp::delegation::types::{
DelegationRequest, DelegationTaskReport, ResumeDelegationRequest, TaskStatus,
};
use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess};
use crate::acp::question::{QuestionOutcome, SessionQuestionAccess};
use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess};
use crate::acp::session_info::{SessionInfo, SessionInfoAccess};
use crate::acp::work_task_tools::{TaskReportAck, WorkTaskToolAccess};
use crate::models::AgentType;
Expand All @@ -40,7 +40,6 @@ use serde_json::Value;
/// `wait_ms = 0` opts out of the ceiling and blocks until the task is terminal.
const STATUS_WAIT_MAX_MS: u64 = 60_000;


/// The bound-but-not-yet-served socket handed from [`DelegationListener::bind`]
/// to [`DelegationListener::accept_loop`]. A UDS listener on unix; on Windows,
/// the first named-pipe server instance (the loop creates each subsequent one
Expand Down Expand Up @@ -242,11 +241,7 @@ impl DelegationListener {
#[cfg(unix)]
fn staging_socket_path(socket_path: &Path) -> PathBuf {
let salt = uuid::Uuid::new_v4().simple().to_string();
socket_path.with_file_name(format!(
".stg-{}-{}",
std::process::id(),
&salt[..8]
))
socket_path.with_file_name(format!(".stg-{}-{}", std::process::id(), &salt[..8]))
}

#[cfg(windows)]
Expand Down Expand Up @@ -373,10 +368,7 @@ impl DelegationListener {
write_frame(conn, &feedback_response(&[])?).await?;
}
Some(parent_conn_id) => {
let pending = self
.feedback
.read_pending_feedback(&parent_conn_id)
.await;
let pending = self.feedback.read_pending_feedback(&parent_conn_id).await;
// Read-only: the response carries the note ids
// (`_commit_ids`); delivery is committed LATER, by the
// companion's `CommitFeedback` once it actually returns
Expand Down Expand Up @@ -813,6 +805,19 @@ impl DelegationListener {
let working_dir = requested_working_dir
.clone()
.or_else(|| Some(entry.working_dir.to_string_lossy().to_string()));
let continue_from_task_id = match req.input.get("continue_from_task_id") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(value)) if !value.trim().is_empty() => {
Some(value.trim().to_string())
}
Some(serde_json::Value::String(_)) => None,
Some(_) => {
return report_failed(
"continuation_invalid",
"continue_from_task_id must be a string or null",
);
}
};

let delegation_req = DelegationRequest {
parent_connection_id: req.parent_connection_id,
Expand All @@ -822,6 +827,7 @@ impl DelegationListener {
task,
working_dir,
requested_working_dir,
continue_from_task_id,
external_handle: req.external_handle,
};
self.broker.start_delegation(delegation_req).await
Expand Down Expand Up @@ -1047,10 +1053,7 @@ mod tests {
}
#[async_trait]
impl SessionFeedbackAccess for StubFeedback {
async fn read_pending_feedback(
&self,
parent_connection_id: &str,
) -> Vec<PendingFeedback> {
async fn read_pending_feedback(&self, parent_connection_id: &str) -> Vec<PendingFeedback> {
*self.read_conn.lock().await = Some(parent_connection_id.to_string());
self.items.lock().await.clone()
}
Expand All @@ -1070,9 +1073,7 @@ mod tests {
#[derive(Default)]
struct StubQuestion {
pending: tokio::sync::Mutex<HashMap<String, oneshot::Sender<QuestionOutcome>>>,
registered: tokio::sync::Mutex<
Vec<(String, Vec<crate::acp::question::QuestionSpec>)>,
>,
registered: tokio::sync::Mutex<Vec<(String, Vec<crate::acp::question::QuestionSpec>)>>,
canceled: tokio::sync::Mutex<Vec<String>>,
}
#[async_trait]
Expand Down Expand Up @@ -1522,6 +1523,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
continue_from_task_id: None,
external_handle: None,
})
.await;
Expand Down Expand Up @@ -1675,6 +1677,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
continue_from_task_id: None,
external_handle: None,
})
.await
Expand Down Expand Up @@ -1777,6 +1780,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
continue_from_task_id: None,
external_handle: None,
})
.await;
Expand Down Expand Up @@ -1828,6 +1832,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
continue_from_task_id: None,
external_handle: Some("h-1".into()),
};
broker.handle_request(req).await
Expand Down Expand Up @@ -1900,7 +1905,8 @@ mod tests {
}

let mock = Arc::new(MockSpawner::new());
mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await;
mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2")))
.await;
mock.queue_resume_send(Ok(())).await;
let broker = Arc::new(
DelegationBroker::new(
Expand Down Expand Up @@ -2057,6 +2063,57 @@ mod tests {
assert_eq!(report.error_code.as_deref(), Some("spawn_failed"));
}

#[tokio::test]
async fn continuation_id_validation_rejects_types_and_defaults_empty_values() {
let mock = Arc::new(MockSpawner::new());
let broker = make_broker(mock.clone()).await;
let tokens = Arc::new(TokenRegistry::default());
tokens
.register(
"tok".into(),
TokenEntry {
parent_connection_id: "parent-conn".into(),
working_dir: PathBuf::from("/tmp"),
},
)
.await;
let listener = make_listener(broker, tokens, Some(1));
for invalid in [json!(7), json!([])] {
let report = listener
.process(
make_request(json!({
"agent_type": "codex",
"task": "next",
"continue_from_task_id": invalid,
}))
.await,
)
.await;
assert_eq!(report.error_code.as_deref(), Some("continuation_invalid"));
}
assert!(mock.spawn_args.lock().await.is_empty());

mock.queue_spawn(Err(SpawnerError::Spawn("stop-null".into())))
.await;
mock.queue_spawn(Err(SpawnerError::Spawn("stop-blank".into())))
.await;
for value in [serde_json::Value::Null, json!(" ")] {
let report = listener
.process(
make_request(json!({
"agent_type": "codex",
"task": "fresh",
"continue_from_task_id": value,
}))
.await,
)
.await;
assert_eq!(report.error_code.as_deref(), Some("spawn_failed"));
}

assert_eq!(mock.spawn_args.lock().await.len(), 2);
}

// --- check_user_feedback over the listener -----------------------------

use crate::acp::feedback::PendingFeedback;
Expand Down Expand Up @@ -2149,7 +2206,10 @@ mod tests {
let commit_ids = resp.outcome["_commit_ids"].as_array().unwrap();
assert_eq!(commit_ids, &vec!["f1", "f2"]);
// Read was scoped to the token's parent connection id.
assert_eq!(feedback.read_conn.lock().await.as_deref(), Some("parent-conn"));
assert_eq!(
feedback.read_conn.lock().await.as_deref(),
Some("parent-conn")
);
// The Feedback arm is READ-ONLY — it does NOT commit (delivery is
// committed later, by the companion's CommitFeedback).
assert!(feedback.committed.lock().await.is_empty());
Expand Down Expand Up @@ -2593,15 +2653,19 @@ mod tests {
.await
.expect("serve_one must return after peer close");
result.unwrap().unwrap();
assert_eq!(questions.canceled.lock().await.as_slice(), &["q-1".to_string()]);
assert_eq!(
questions.canceled.lock().await.as_slice(),
&["q-1".to_string()]
);
}

/// An invalid token never registers a question and returns a `declined`
/// outcome (the LLM proceeds with its own judgment).
#[tokio::test]
async fn ask_invalid_token_declined() {
let questions = Arc::new(StubQuestion::default());
let listener = make_question_listener(Arc::new(TokenRegistry::default()), questions.clone());
let listener =
make_question_listener(Arc::new(TokenRegistry::default()), questions.clone());
let (mut client, mut server) = duplex(8 * 1024);
let server_task = tokio::spawn(async move {
listener.serve_one(&mut server).await.unwrap();
Expand All @@ -2614,5 +2678,4 @@ mod tests {
assert_eq!(resp.outcome["declined"], true);
assert!(questions.registered.lock().await.is_empty());
}

}
29 changes: 29 additions & 0 deletions src-tauri/src/acp/delegation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,32 @@ pub const DELEGATE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__delegate_to_agent";
pub const STATUS_TOOL_REWRITE_TITLE: &str = "codeg-mcp__get_delegation_status";
pub const CANCEL_TOOL_REWRITE_TITLE: &str = "codeg-mcp__cancel_delegation";
pub const RESUME_TOOL_REWRITE_TITLE: &str = "codeg-mcp__resume_delegation";

/// Byte cap shared by every fallback task label written to delegation-card
/// metadata. The full task remains available in the child session.
pub(crate) const TASK_PREVIEW_CAP: usize = 2 * 1024;

/// Return a bounded task label without splitting a UTF-8 code point.
pub(crate) fn task_preview(task: &str) -> String {
if task.len() <= TASK_PREVIEW_CAP {
return task.to_string();
}
const ELLIPSIS: &str = "…";
let mut end = TASK_PREVIEW_CAP.saturating_sub(ELLIPSIS.len());
while end > 0 && !task.is_char_boundary(end) {
end -= 1;
}
format!("{}{}", &task[..end], ELLIPSIS)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn task_preview_is_utf8_safe_and_within_cap() {
let preview = task_preview(&"界".repeat(TASK_PREVIEW_CAP));
assert!(preview.len() <= TASK_PREVIEW_CAP);
assert!(preview.ends_with('…'));
}
}
Loading
Loading