feat: persist worker submit activation queue
This commit is contained in:
+124
-48
@@ -11,6 +11,11 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use identity::{WorkerId, WorkerIdParseError};
|
||||
|
||||
/// Allocate an opaque idempotency key for one client Submit request.
|
||||
pub fn new_submission_request_id() -> String {
|
||||
uuid::Uuid::now_v7().to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -31,17 +36,22 @@ fn is_false(value: &bool) -> bool {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||
pub enum Method {
|
||||
Run {
|
||||
/// Durably accept typed input for immediate activation or the session FIFO.
|
||||
///
|
||||
/// `submission_request_id` is generated by the authenticated caller and is
|
||||
/// used only for idempotent retry. Worker allocates the durable
|
||||
/// `submission_id` returned by [`Event::SubmissionAccepted`].
|
||||
Submit {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
},
|
||||
/// Runtime-internal Run carrying an opaque correlation id that is committed
|
||||
/// with the resulting UserInput entry. This variant is not serializable on
|
||||
/// the public Client → Worker protocol.
|
||||
/// Runtime-internal Submit with the same request identity contract. This
|
||||
/// variant is not serializable on the public Client → Worker protocol.
|
||||
#[serde(skip)]
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
RunTracked {
|
||||
SubmitTracked {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
submission_id: String,
|
||||
},
|
||||
/// Human-readable text injected into the target Worker's LLM context
|
||||
/// as a non-blocking system message. `auto_run` controls whether an
|
||||
@@ -50,12 +60,25 @@ pub enum Method {
|
||||
/// No side effects beyond LLM context; use `WorkerEvent` for typed
|
||||
/// lifecycle reports.
|
||||
Notify {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
auto_run: bool,
|
||||
},
|
||||
/// Typed lifecycle report from a child Worker to its direct parent.
|
||||
WorkerEvent(WorkerEvent),
|
||||
/// Return the authoritative FIFO summary without exposing queued payloads.
|
||||
ListPendingSubmissions,
|
||||
/// Remove one queued submission. Running or already activated submissions
|
||||
/// are immutable and therefore cannot be cancelled here.
|
||||
CancelPendingSubmission {
|
||||
submission_id: String,
|
||||
},
|
||||
/// Remove every queued submission while preserving the active run.
|
||||
ClearPendingSubmissions,
|
||||
/// Activate the next queued submission while the Worker is idle. This is an
|
||||
/// explicit recovery operation and never resumes a paused run implicitly.
|
||||
ContinuePending,
|
||||
Resume,
|
||||
Cancel,
|
||||
/// Stop the in-flight turn and transition to `Paused`.
|
||||
@@ -68,7 +91,7 @@ pub enum Method {
|
||||
/// Request an explicit compaction while the Worker is otherwise idle.
|
||||
///
|
||||
/// This is a typed control method: clients must not send `compact` as a
|
||||
/// `Method::Run` user message.
|
||||
/// `Method::Submit` user message.
|
||||
Compact,
|
||||
/// Ask the Worker to list valid rewind targets from its authoritative session log.
|
||||
ListRewindTargets,
|
||||
@@ -181,7 +204,7 @@ impl WorkerEvent {
|
||||
|
||||
/// One typed piece of a user submission.
|
||||
///
|
||||
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||
/// `Method::Submit` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||
/// clients (CLI piping, scripts) only need to produce a single
|
||||
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
||||
/// (paste chips, file refs) and
|
||||
@@ -404,12 +427,13 @@ impl Segment {
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Convenience: a `Run` carrying a single `Segment::Text`.
|
||||
/// Convenience: a `Submit` carrying a single `Segment::Text`.
|
||||
/// Used by dumb clients, inter-Worker tools, and tests that only have
|
||||
/// a string to forward.
|
||||
pub fn run_text(s: impl Into<String>) -> Self {
|
||||
Self::Run {
|
||||
input: vec![Segment::text(s)],
|
||||
pub fn submit_text(submission_request_id: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
Self::Submit {
|
||||
submission_request_id: submission_request_id.into(),
|
||||
input: vec![Segment::text(text)],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,6 +527,37 @@ pub enum ToolResultDisposition {
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
/// Durable acceptance result for one idempotent Submit request.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubmissionDisposition {
|
||||
Started,
|
||||
Queued,
|
||||
}
|
||||
|
||||
/// Bounded public projection of one pending submission. Payload segments and
|
||||
/// provenance remain in the session log and are intentionally not exposed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct PendingSubmissionSummary {
|
||||
pub submission_id: String,
|
||||
pub accepted_at_ms: u64,
|
||||
pub segment_count: u32,
|
||||
pub byte_len: u64,
|
||||
}
|
||||
|
||||
/// Revisioned session-owned FIFO projection used by snapshots and live events.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct PendingSubmissionsSnapshot {
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub notification_count: u32,
|
||||
#[serde(default)]
|
||||
pub submissions: Vec<PendingSubmissionSummary>,
|
||||
}
|
||||
|
||||
/// Canonical, storage-independent projection of committed session history.
|
||||
///
|
||||
/// Worker protocols expose this DTO instead of append-log records. New
|
||||
@@ -511,6 +566,8 @@ pub enum ToolResultDisposition {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SessionSnapshot {
|
||||
#[serde(default)]
|
||||
pub pending_submissions: PendingSubmissionsSnapshot,
|
||||
pub entries: Vec<SessionSnapshotEntry>,
|
||||
}
|
||||
|
||||
@@ -609,16 +666,27 @@ pub struct SessionToolAttachment {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
/// A user input message was accepted, persisted as
|
||||
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
|
||||
/// Broadcast to every subscribed client so TUI / GUI instances show
|
||||
/// the same user line that reconnect snapshots would replay from
|
||||
/// history; clients must not synthesize a separate pending/fake
|
||||
/// message for accepted runs.
|
||||
///
|
||||
/// Fires exactly once per committed user input, after
|
||||
/// `InvokeStart { kind: UserSend }` and before the first
|
||||
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
|
||||
/// Durable Submit acceptance. A `Started` receipt follows the atomic
|
||||
/// UserInput commit; a `Queued` receipt follows the durable FIFO checkpoint.
|
||||
/// Repeating the same request id and exact payload returns the same receipt
|
||||
/// without appending or activating twice.
|
||||
SubmissionAccepted {
|
||||
submission_request_id: String,
|
||||
submission_id: String,
|
||||
disposition: SubmissionDisposition,
|
||||
},
|
||||
/// Correlated rejection before durable acceptance.
|
||||
SubmissionRejected {
|
||||
submission_request_id: String,
|
||||
message: String,
|
||||
},
|
||||
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
|
||||
PendingSubmissionsChanged {
|
||||
pending: PendingSubmissionsSnapshot,
|
||||
},
|
||||
/// A user input message persisted as `LogEntry::AnnotatedUserInput` and
|
||||
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
|
||||
/// instances show the same user line that reconnect snapshots replay.
|
||||
UserMessage {
|
||||
segments: Vec<Segment>,
|
||||
},
|
||||
@@ -641,7 +709,7 @@ pub enum Event {
|
||||
///
|
||||
/// Marker event for the start of an Invoke range; the range extends
|
||||
/// implicitly until the next `InvokeStart`. Fires for every accepted
|
||||
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||
/// `Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
|
||||
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
||||
/// typed system reminder insertion that doesn't break IDLE) do not
|
||||
@@ -1193,7 +1261,7 @@ pub enum TurnResult {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvokeKind {
|
||||
/// `Method::Run` — a user submission.
|
||||
/// `Method::Submit` — a user submission.
|
||||
UserSend,
|
||||
/// `Method::Notify` — free-text notification injected into history.
|
||||
Notify,
|
||||
@@ -1216,7 +1284,7 @@ pub enum RunResult {
|
||||
Finished,
|
||||
Paused,
|
||||
LimitReached,
|
||||
/// The accepted Method::Run produced no assistant/tool output before
|
||||
/// The accepted Method::Submit produced no assistant/tool output before
|
||||
/// user interruption, so the Worker rolled the submit-time turn state back
|
||||
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and
|
||||
/// restore the just-submitted input into the editable composer if desired.
|
||||
@@ -1285,26 +1353,30 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn method_run_json_roundtrip() {
|
||||
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#;
|
||||
fn method_submit_json_roundtrip_and_run_is_rejected() {
|
||||
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"Hello"}]}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
match &method {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 1);
|
||||
match &input[0] {
|
||||
Segment::Text { content } => assert_eq!(content, "Hello"),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
assert!(
|
||||
serde_json::from_str::<Method>(r#"{"method":"run","params":{"input":[]}}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_paste_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
fn method_submit_paste_segment_roundtrip() {
|
||||
let method = Method::Submit {
|
||||
submission_request_id: "request-1".to_string(),
|
||||
input: vec![
|
||||
Segment::text("see "),
|
||||
Segment::Paste {
|
||||
@@ -1318,7 +1390,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 2);
|
||||
match &input[1] {
|
||||
Segment::Paste {
|
||||
@@ -1335,7 +1407,7 @@ mod tests {
|
||||
other => panic!("expected Paste, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,8 +1461,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_flow_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
fn method_submit_flow_segment_roundtrip() {
|
||||
let method = Method::Submit {
|
||||
submission_request_id: "request-1".to_string(),
|
||||
input: vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
@@ -1404,7 +1477,7 @@ mod tests {
|
||||
let decoded = serde_json::from_str::<Method>(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Method::Run { input }
|
||||
Method::Submit { input, .. }
|
||||
if matches!(
|
||||
input.as_slice(),
|
||||
[
|
||||
@@ -1416,15 +1489,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_tracked_run_is_not_public_protocol_json() {
|
||||
let method = Method::RunTracked {
|
||||
fn runtime_tracked_submit_is_not_public_protocol_json() {
|
||||
let method = Method::SubmitTracked {
|
||||
input: vec![Segment::text("private")],
|
||||
submission_id: "submission-1".to_string(),
|
||||
submission_request_id: "request-1".to_string(),
|
||||
};
|
||||
assert!(serde_json::to_string(&method).is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<Method>(
|
||||
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#,
|
||||
r#"{"method":"submit_tracked","input":[],"submission_id":"forged"}"#,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
@@ -1442,16 +1515,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_with_unknown_segment_decodes() {
|
||||
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
|
||||
fn method_submit_with_unknown_segment_decodes() {
|
||||
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
match method {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 2);
|
||||
assert!(matches!(input[0], Segment::Text { .. }));
|
||||
assert!(matches!(input[1], Segment::Unknown));
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,11 +1721,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn method_notify_json_roundtrip_defaults_to_auto_run() {
|
||||
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#;
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: true } if message == "turn done"
|
||||
Method::Notify { ref message, auto_run: true, .. } if message == "turn done"
|
||||
));
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
@@ -1660,11 +1733,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
|
||||
let json = r#"{"method":"notify","params":{"message":"progress","auto_run":false}}"#;
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: false } if message == "progress"
|
||||
Method::Notify { ref message, auto_run: false, .. } if message == "progress"
|
||||
));
|
||||
assert_eq!(serde_json::to_string(&method).unwrap(), json);
|
||||
}
|
||||
@@ -1725,6 +1798,7 @@ mod tests {
|
||||
fn event_snapshot_format() {
|
||||
let event = Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
entry_id: "entry-1".into(),
|
||||
timestamp: 1,
|
||||
@@ -1776,6 +1850,7 @@ mod tests {
|
||||
|
||||
let event = Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Greeting {
|
||||
@@ -1844,6 +1919,7 @@ mod tests {
|
||||
fn event_segment_rotated_roundtrip() {
|
||||
let event = Event::SegmentRotated {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::{
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent,
|
||||
WorkerStatus,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
|
||||
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition,
|
||||
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -75,6 +75,9 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||
|
||||
Reference in New Issue
Block a user