fix: restore console input and attachment workflows

Restore durable busy Submit queues and TUI Enter delivery, expose Web Queue beside Notify, and separate the Composer toolbar from its editor without persistent success notices.

Support pasted image attachments and restore canonical Worker Session uploaded-file operations, including retention, binding, cleanup and authenticated upload context.

Validated Worker, Protocol and Session Store suites, 278 TUI tests, 59 Web tests and Svelte check. Runtime suite could not complete due to external Workdir transport timeouts.
This commit is contained in:
2026-09-30 05:16:57 +09:00
parent 677c7cfed9
commit 2006b390b4
20 changed files with 1478 additions and 339 deletions
+124 -111
View File
@@ -1472,51 +1472,6 @@ where
Ok(workdir_for_view)
}
const SUBMIT_REQUIRES_IDLE_MESSAGE: &str =
"Submit requires an idle Worker; use Notify for work already in progress";
fn reject_submit_while_not_idle(
submission_request_id: String,
working_event_tx: &broadcast::Sender<Event>,
) {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id,
message: SUBMIT_REQUIRES_IDLE_MESSAGE.into(),
});
}
fn replay_or_reject_submit_while_not_idle<St>(
pending_submissions: &PendingSubmissionHandle<St>,
submission_request_id: String,
input: Vec<protocol::Segment>,
source_namespace: String,
working_event_tx: &broadcast::Sender<Event>,
) where
St: session_store::Store + Clone,
{
let request_id = submission_request_id.clone();
match pending_submissions.replay_acceptance_from_source(
submission_request_id,
input,
source_namespace,
) {
Ok(Some(acceptance)) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
submission_request_id: acceptance.submission_request_id,
submission_id: acceptance.submission_id,
disposition: acceptance.disposition,
});
}
Ok(None) => reject_submit_while_not_idle(request_id, working_event_tx),
Err(error) => {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: request_id,
message: error.to_string(),
});
}
}
}
fn durably_accept_method_while_busy<St>(
method: Method,
pending_submissions: &PendingSubmissionHandle<St>,
@@ -1530,13 +1485,28 @@ where
submission_request_id,
input,
} => {
replay_or_reject_submit_while_not_idle(
pending_submissions,
let request_id = submission_request_id.clone();
match pending_submissions.accept_from_source(
submission_request_id,
input,
pending_submissions.direct_client_namespace(),
working_event_tx,
);
session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
false,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
submission_request_id: acceptance.submission_request_id,
submission_id: acceptance.submission_id,
disposition: acceptance.disposition,
});
}
Err(error) => {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: request_id,
message: error.to_string(),
});
}
}
None
}
Method::SubmitTracked {
@@ -1544,14 +1514,30 @@ where
input,
source,
} => {
let (source_namespace, _) = resolved_input_source(pending_submissions, &source);
replay_or_reject_submit_while_not_idle(
pending_submissions,
let request_id = submission_request_id.clone();
let (source_namespace, provenance) =
resolved_input_source(pending_submissions, &source);
match pending_submissions.accept_from_source(
submission_request_id,
input,
source_namespace,
working_event_tx,
);
provenance,
false,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
submission_request_id: acceptance.submission_request_id,
submission_id: acceptance.submission_id,
disposition: acceptance.disposition,
});
}
Err(error) => {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: request_id,
message: error.to_string(),
});
}
}
None
}
Method::Notify {
@@ -1964,22 +1950,13 @@ async fn controller_loop<C, St>(
submission_request_id,
input,
} => {
if shared_state.catalog_status() != WorkerStatus::Idle {
replay_or_reject_submit_while_not_idle(
&pending_submissions,
submission_request_id,
input,
pending_submissions.direct_client_namespace(),
&working_event_tx,
);
continue;
}
let request_id = submission_request_id.clone();
match pending_submissions.accept_idle_from_source(
match pending_submissions.accept_from_source(
submission_request_id,
input,
pending_submissions.direct_client_namespace(),
session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
shared_state.catalog_status() == WorkerStatus::Idle,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
@@ -2004,24 +1981,15 @@ async fn controller_loop<C, St>(
input,
source,
} => {
let request_id = submission_request_id.clone();
let (source_namespace, provenance) =
resolved_input_source(&pending_submissions, &source);
if shared_state.catalog_status() != WorkerStatus::Idle {
replay_or_reject_submit_while_not_idle(
&pending_submissions,
submission_request_id,
input,
source_namespace,
&working_event_tx,
);
continue;
}
let request_id = submission_request_id.clone();
match pending_submissions.accept_idle_from_source(
match pending_submissions.accept_from_source(
submission_request_id,
input,
source_namespace,
provenance,
shared_state.catalog_status() == WorkerStatus::Idle,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
@@ -2978,28 +2946,64 @@ where
submission_request_id,
input,
}) => {
replay_or_reject_submit_while_not_idle(
pending_submissions,
let request_id = submission_request_id.clone();
match pending_submissions.accept_from_source(
submission_request_id,
input,
pending_submissions.direct_client_namespace(),
working_event_tx,
);
session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
false,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
submission_request_id: acceptance.submission_request_id,
submission_id: acceptance.submission_id,
disposition: acceptance.disposition,
});
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_submissions.snapshot(),
});
}
Err(error) => {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: request_id,
message: error.to_string(),
});
}
}
}
Some(Method::SubmitTracked {
submission_request_id,
input,
source,
}) => {
let (source_namespace, _) =
let request_id = submission_request_id.clone();
let (source_namespace, provenance) =
resolved_input_source(pending_submissions, &source);
replay_or_reject_submit_while_not_idle(
pending_submissions,
match pending_submissions.accept_from_source(
submission_request_id,
input,
source_namespace,
working_event_tx,
);
provenance,
false,
) {
Ok(acceptance) => {
let _ = working_event_tx.send(Event::SubmissionAccepted {
submission_request_id: acceptance.submission_request_id,
submission_id: acceptance.submission_id,
disposition: acceptance.disposition,
});
let _ = working_event_tx.send(Event::PendingSubmissionsChanged {
pending: pending_submissions.snapshot(),
});
}
Err(error) => {
let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: request_id,
message: error.to_string(),
});
}
}
}
Some(Method::Resume { command }) => {
if let Err(disposition) =
@@ -3560,35 +3564,44 @@ mod tests {
}
#[test]
fn busy_submit_is_rejected_without_queueing() {
fn busy_submit_is_durable_and_idempotent_before_shutdown() {
let dir = tempfile::tempdir().unwrap();
let pending = PendingSubmissionHandle::for_test(dir.path());
let (event_tx, mut event_rx) = broadcast::channel(4);
assert!(
durably_accept_method_while_busy(
Method::SubmitTracked {
submission_request_id: "request-1".into(),
input: vec![protocol::Segment::text("follow-up")],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: "operation-1".into(),
},
let (event_tx, mut event_rx) = broadcast::channel(8);
for method in [
Method::submit_text("direct-request", "next direct turn"),
Method::SubmitTracked {
submission_request_id: "tracked-request".into(),
input: vec![protocol::Segment::text("next tracked turn")],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: "operation-1".into(),
},
&pending,
&event_tx,
)
.is_none()
},
] {
for _ in 0..2 {
assert!(
durably_accept_method_while_busy(method.clone(), &pending, &event_tx).is_none()
);
assert!(matches!(
event_rx.try_recv().unwrap(),
Event::SubmissionAccepted {
disposition: protocol::SubmissionDisposition::Queued,
..
}
));
}
}
assert_eq!(pending.snapshot().submissions.len(), 2);
let entries = pending.persisted_entries_for_test();
assert_eq!(
entries
.iter()
.filter(|entry| matches!(entry,
LogEntry::Extension { domain, .. } if domain == "worker.pending_activations.v1"
))
.count(),
2
);
assert!(matches!(
event_rx.try_recv().unwrap(),
Event::SubmissionRejected {
submission_request_id,
message,
} if submission_request_id == "request-1"
&& message == SUBMIT_REQUIRES_IDLE_MESSAGE
));
assert!(pending.snapshot().submissions.is_empty());
assert!(pending.persisted_entries_for_test().is_empty());
}
#[test]
@@ -850,7 +850,7 @@ impl WorkerOperation {
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
}
Self::SendInput => {
"Start a fresh turn by sending user input to a known Idle Worker. Running or Paused targets reject Submit; use WorkerNotify for advisory information during work already in progress."
"Send user input to a known Runtime Worker. Idle Workers can start a fresh turn; Running or Paused Workers durably queue it for a later turn. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers require Idle."
}
Self::Notify => {
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
+2 -1
View File
@@ -914,7 +914,8 @@ mod tests {
"missing canonical tool {name}"
);
}
assert!(orchestration.contains("Idle-only Submit"));
assert!(orchestration.contains("durably queue it for a later turn"));
assert!(orchestration.contains("Direct SubWorkers still require Idle"));
assert!(orchestration.contains("use `WorkerNotify` instead"));
assert!(orchestration.contains("do not create a queued Submit"));
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
+1 -54
View File
@@ -1562,60 +1562,7 @@ where
)
}
pub(crate) fn accept_idle_from_source(
&self,
submission_request_id: String,
input: Vec<Segment>,
source_namespace: String,
provenance: WorkerHistoryProvenance,
) -> Result<SubmissionAcceptance, PendingSubmissionError> {
self.accept_from_source(
submission_request_id,
input,
source_namespace,
provenance,
true,
)
}
pub(crate) fn replay_acceptance_from_source(
&self,
submission_request_id: String,
input: Vec<Segment>,
source_namespace: String,
) -> Result<Option<SubmissionAcceptance>, PendingSubmissionError> {
if submission_request_id.trim().is_empty() {
return Err(PendingSubmissionError::EmptyRequestId);
}
if submission_request_id.len() > MAX_ACTIVATION_REQUEST_ID_BYTES {
return Err(PendingSubmissionError::RequestIdLimit);
}
if input.is_empty() {
return Err(PendingSubmissionError::EmptyInput);
}
let payload_digest = submission_payload_digest(&input);
let current = self
.state
.lock()
.expect("pending activation state poisoned");
let Some(receipt) = current.receipts.iter().find(|receipt| {
receipt.submission_request_id == submission_request_id
&& receipt.source_namespace == source_namespace
}) else {
return Ok(None);
};
if receipt.payload_digest != payload_digest {
return Err(PendingSubmissionError::IdempotencyConflict);
}
Ok(Some(SubmissionAcceptance {
submission_request_id,
submission_id: receipt.submission_id.clone(),
disposition: receipt.disposition,
activation: None,
}))
}
fn accept_from_source(
pub(crate) fn accept_from_source(
&self,
submission_request_id: String,
input: Vec<Segment>,
+225 -24
View File
@@ -318,6 +318,95 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
handle
}
#[tokio::test]
async fn canonical_worker_handle_uploads_and_submits_attachment() {
let root = tempfile::tempdir().unwrap();
let manifest = WorkerManifest::from_toml(MANIFEST_TOML).unwrap();
let session = session_store::WorkerSessionStore::new(root.path().join("session")).unwrap();
let store = CombinedStore::new(
session.clone(),
session_store::WorkerAggregateStore::new(root.path(), manifest.worker.name.clone())
.unwrap(),
);
let client = MockClient::new(simple_text_events());
let engine = Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
client.clone(),
);
let worker = Worker::new(
manifest,
engine,
store,
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::local(root.path().to_owned(), root.path().to_owned()),
manifest::Scope::writable(root.path()).unwrap(),
)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(
worker,
&root.path().join("runtime"),
&root.path().join("bash-output"),
)
.await
.unwrap();
let mut rx = handle.subscribe();
let file = handle
.upload_file_with_context(
"notes.txt",
"text/plain",
b"attached context",
&session_store::UploadedFileUploadContext {
upload_id: "upload-1".into(),
principal_id: "account-1".into(),
workspace_id: "workspace-1".into(),
runtime_id: "runtime-1".into(),
worker_id: "worker-1".into(),
},
)
.unwrap();
handle
.send(Method::Submit {
submission_request_id: "attachment-submit".into(),
input: vec![protocol::Segment::UploadedFile { file: file.clone() }],
})
.await
.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
matches!(
event,
Event::RunEnd {
result: protocol::RunResult::Finished
}
)
})
.await
);
let requests = client.captured_requests();
assert_eq!(requests.len(), 1);
assert!(requests[0].items.iter().any(|item| {
item.as_text()
.is_some_and(|text| text.contains("notes.txt") && text.contains(&file.artifact_id))
}));
assert_eq!(handle.delete_uncommitted_uploaded_files().unwrap(), 0);
use session_store::Store;
let (bound, content) = session
.read_uploaded_file_by_id(session.session_id().unwrap().unwrap(), &file.artifact_id)
.unwrap();
assert!(bound.source_entry_id.is_some());
assert_eq!(content, b"attached context");
handle
.send(Method::Shutdown {
command: worker_command(&handle),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_rx)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
@@ -1446,7 +1535,7 @@ async fn events_are_broadcast() {
}
#[tokio::test]
async fn submit_requires_an_idle_worker() {
async fn submit_while_running_or_paused_is_durably_queued() {
async fn wait_for_rejection(
rx: &mut tokio::sync::broadcast::Receiver<Event>,
expected_request_id: &str,
@@ -1492,7 +1581,13 @@ async fn submit_requires_an_idle_worker() {
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "slow..."),
];
let client = MockClient::sequential(vec![MockResponse::Hang(events)]);
let client = MockClient::sequential(vec![
MockResponse::Hang(events),
MockResponse::Complete(simple_text_events()),
MockResponse::Complete(simple_text_events()),
MockResponse::Complete(simple_text_events()),
]);
let captured = client.clone();
let worker = make_worker(client).await;
let handle = spawn_controller(worker).await;
let mut rx = handle.subscribe();
@@ -1517,9 +1612,27 @@ async fn submit_requires_an_idle_worker() {
.await
.unwrap();
let running_rejection = wait_for_rejection(&mut rx, "request-running").await;
assert!(running_rejection.contains("requires an idle Worker"));
assert!(running_rejection.contains("use Notify"));
assert_eq!(
wait_for_acceptance(&mut rx, "request-running").await,
protocol::SubmissionDisposition::Queued
);
handle
.send(Method::submit_text("request-running", "second"))
.await
.unwrap();
assert_eq!(
wait_for_acceptance(&mut rx, "request-running").await,
protocol::SubmissionDisposition::Queued
);
handle
.send(Method::submit_text("request-running", "conflict"))
.await
.unwrap();
assert!(
wait_for_rejection(&mut rx, "request-running")
.await
.contains("different payload")
);
handle.send(Method::ListPendingSubmissions).await.unwrap();
let pending = tokio::time::timeout(std::time::Duration::from_secs(1), async {
@@ -1531,7 +1644,7 @@ async fn submit_requires_an_idle_worker() {
})
.await
.expect("pending snapshot");
assert!(pending.submissions.is_empty());
assert_eq!(pending.submissions.len(), 1);
handle
.send(Method::Pause {
@@ -1545,10 +1658,86 @@ async fn submit_requires_an_idle_worker() {
.await
.unwrap();
let paused_rejection = wait_for_rejection(&mut rx, "request-paused").await;
assert!(paused_rejection.contains("requires an idle Worker"));
assert!(paused_rejection.contains("use Notify"));
assert_eq!(
wait_for_acceptance(&mut rx, "request-paused").await,
protocol::SubmissionDisposition::Queued
);
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
handle.send(Method::ListPendingSubmissions).await.unwrap();
let snapshot = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if let Ok(Event::PendingSubmissionsChanged { pending }) = rx.recv().await {
break pending;
}
}
})
.await
.expect("paused pending snapshot");
assert_eq!(snapshot.submissions.len(), 2);
assert_eq!(snapshot.head_id, pending.head_id);
handle
.send(Method::ContinuePending {
expected_revision: snapshot.revision,
expected_head_id: snapshot.head_id.unwrap(),
})
.await
.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(1), |event| {
matches!(event, Event::Error { message, .. } if message.contains("Resume or Cancel"))
})
.await
);
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
// Resume completes the original run, then normal completion drains each
// queued input exactly once and in acceptance order.
handle
.send(Method::Resume {
command: worker_command(&handle),
})
.await
.unwrap();
for _ in 0..3 {
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
matches!(
event,
Event::RunEnd {
result: protocol::RunResult::Finished
}
)
})
.await
);
}
wait_for_status(&handle, WorkerStatus::Idle).await;
let requests = captured.captured_requests();
assert_eq!(requests.len(), 4);
let last_inputs: Vec<_> = requests[1..]
.iter()
.map(|request| {
request
.items
.iter()
.rev()
.find_map(|item| match item {
Item::Message {
role: agen::Role::User,
content,
..
} => Some(
content
.iter()
.map(|part| part.as_text())
.collect::<String>(),
),
_ => None,
})
.unwrap()
})
.collect();
assert_eq!(last_inputs, vec!["first", "second", "third"]);
}
#[tokio::test]
@@ -2879,12 +3068,12 @@ async fn pause_then_resume_preserves_notifications_and_history_consistency() {
assert!(!has_tool_call, "no orphan tool_call in history");
}
/// Paused with an orphan `tool_use` in history must reject a fresh Submit.
/// After explicit Cancel returns the Worker to Idle, a new Submit must produce
/// Paused with an orphan `tool_use` in history must queue a fresh Submit.
/// After explicit Cancel and ContinuePending, the queued Submit must produce
/// a wire-valid next LLM request: the orphan is closed with a synthetic
/// `tool_result`, a system note is inserted, and the new user input is appended.
#[tokio::test]
async fn paused_submit_is_rejected_before_cancel_and_fresh_run() {
async fn paused_submit_waits_for_cancel_and_continue_pending() {
// Response 1: emit a tool_use block (complete with stop) targeting
// our hanging tool. The Engine commits the ToolCall to history,
// then parks inside `execute_tools` waiting on the tool — which is
@@ -2966,20 +3155,20 @@ async fn paused_submit_is_rejected_before_cancel_and_fresh_run() {
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
matches!(
event,
Event::SubmissionRejected {
Event::SubmissionAccepted {
submission_request_id,
message,
disposition: protocol::SubmissionDisposition::Queued,
..
} if submission_request_id == &paused_request_id
&& message.contains("requires an idle Worker")
)
})
.await,
"Paused Submit must be rejected"
"Paused Submit must be queued"
);
assert_eq!(
client_for_assert.captured_requests().len(),
1,
"rejected Paused Submit must not start another LLM request"
"queued Paused Submit must not start another LLM request"
);
handle
@@ -2990,13 +3179,25 @@ async fn paused_submit_is_rejected_before_cancel_and_fresh_run() {
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
// Once explicitly returned to Idle, fresh user input runs interrupt prep,
// which closes the orphan and inserts a system note before the user message.
// Cancel must not start the queued turn. Explicit ContinuePending runs
// interrupt prep, closing the orphan before committing the queued input.
assert_eq!(client_for_assert.captured_requests().len(), 1);
handle.send(Method::ListPendingSubmissions).await.unwrap();
let pending = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if let Ok(Event::PendingSubmissionsChanged { pending }) = rx.recv().await {
break pending;
}
}
})
.await
.expect("pending input retained after Cancel");
assert_eq!(pending.submissions.len(), 1);
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"new request",
))
.send(Method::ContinuePending {
expected_revision: pending.revision,
expected_head_id: pending.head_id.unwrap(),
})
.await
.unwrap();
assert!(
@@ -3007,7 +3208,7 @@ async fn paused_submit_is_rejected_before_cancel_and_fresh_run() {
}
))
.await,
"expected RunEnd::Finished after Cancel→Submit"
"expected RunEnd::Finished after Cancel→ContinuePending"
);
// The second LLM request carries the closure chain. Walk its items