feat: route Orchestrator delivery and Worker starts from Ticket Assign

This commit is contained in:
2026-10-12 05:13:53 +09:00
parent a6a97bca74
commit 26434bb867
27 changed files with 5226 additions and 1383 deletions
@@ -473,6 +473,9 @@ pub struct WorkerLifecycleWorkdirAttachment {
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
/// Observed delegation required for Orchestrator Ticket starts.
pub orchestrator_assignment_id: Option<String>,
pub runtime_id: String,
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
@@ -494,6 +497,7 @@ fn workspace_worker_create_request(
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => {
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id,
})
@@ -729,6 +733,9 @@ struct WorkerSpawnInput {
/// neither the profile nor an initial Flow segment grants review authority.
#[serde(default)]
ticket_id: Option<String>,
/// Observed current Orchestrator delegation, required for a Ticket start by the Orchestrator.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
#[serde(default)]
display_name: Option<String>,
/// Normal typed initial user submission delivered after spawn. An empty
@@ -913,6 +920,10 @@ impl Tool for WorkspaceWorkerTool {
.transpose()?;
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
.orchestrator_assignment_id
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
@@ -1895,6 +1906,7 @@ mod tests {
#[test]
fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: None,
runtime_id: "runtime-1".to_string(),
workdir_attachments: vec![
WorkerLifecycleWorkdirAttachment {
@@ -1954,6 +1966,7 @@ mod tests {
#[test]
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: Some("delegation-742".into()),
runtime_id: "runtime-1".to_string(),
workdir_attachments: Vec::new(),
profile: "project:ticket-worker".to_string(),
@@ -1971,6 +1984,7 @@ mod tests {
value["ticket_assignment"],
serde_json::json!({
"ticket_id": "T-719",
"orchestrator_assignment_id": "delegation-742",
"operation_id": "spawn-ticket-worker:T-719:call-1",
})
);
@@ -21,7 +21,7 @@ use crate::feature::{
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketWorker";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
@@ -78,6 +78,9 @@ struct SpawnTicketWorkerWorkdirInput {
#[serde(deny_unknown_fields)]
struct SpawnTicketWorkerInput {
ticket_id: String,
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
runtime_id: String,
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
@@ -132,6 +135,10 @@ impl Tool for SpawnTicketWorkerTool {
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
.orchestrator_assignment_id
.map(|id| authority_id(id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id,
workdir_attachments,
profile,
@@ -307,6 +314,7 @@ mod tests {
let service = Arc::new(RecordingService::default());
let mut input = input();
input["flow"] = serde_json::json!("project:investigation");
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
input["workdir_attachments"] = serde_json::json!([
{
"alias": "checkout",
@@ -327,6 +335,10 @@ mod tests {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.runtime_id, "runtime-1");
assert_eq!(
request.orchestrator_assignment_id.as_deref(),
Some("delegation-742")
);
assert_eq!(request.profile, "project:ticket-worker");
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
assert_eq!(
@@ -475,6 +487,16 @@ mod tests {
"initial_request must not be empty",
),
("flow", serde_json::json!(" "), "flow must not be empty"),
(
"orchestrator_assignment_id",
serde_json::json!("http://invalid/id"),
"orchestrator_assignment_id must be an authority id",
),
(
"orchestrator_assignment_id",
serde_json::json!(" "),
"orchestrator_assignment_id must not be empty",
),
(
"runtime_id",
serde_json::json!("runtime/1"),
+52 -4
View File
@@ -156,11 +156,15 @@ impl WorkerPrompt {
];
}
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
/// Model-visible Ticket projection shared by Server and TUI attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
assignment_id: Option<String>,
input_event_id: Option<String>,
waiting_reason: Option<String>,
next_action: Option<String>,
}
impl OrchestratorQueueAttentionTicket {
@@ -175,8 +179,28 @@ impl OrchestratorQueueAttentionTicket {
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
assignment_id: None,
input_event_id: None,
waiting_reason: None,
next_action: None,
})
}
/// Committed delegation/input identity, not a handling-status transition.
/// The receiving Worker must reread current authority before a new action.
pub fn with_assignment_input(
mut self,
assignment_id: &str,
input_event_id: &str,
waiting_reason: Option<&str>,
next_action: Option<&str>,
) -> Self {
self.assignment_id = Some(bounded_queue_attention_text(assignment_id, 1024));
self.input_event_id = Some(bounded_queue_attention_text(input_event_id, 1024));
self.waiting_reason = waiting_reason.map(|text| bounded_queue_attention_text(text, 1024));
self.next_action = next_action.map(|text| bounded_queue_attention_text(text, 1024));
self
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
@@ -200,7 +224,7 @@ impl OrchestratorQueueAttentionContext {
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
/// Prompt-catalog entries sharing a bounded Ticket resource-key projection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
@@ -841,8 +865,10 @@ mod tests {
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.starts_with("Assigned Tickets have unreceived input:"));
// TUI's final operation model is a separate downstream scope; both
// renderers still consume the same bounded resource-key projection.
assert!(tui.starts_with("Queued Tickets require attention:"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
@@ -859,6 +885,28 @@ mod tests {
}
}
#[test]
fn assignment_attention_carries_input_identity_and_bounded_waiting_context() {
let ticket = OrchestratorQueueAttentionTicket::new("T-742", "Partial work")
.unwrap()
.with_assignment_input(
"assignment-a",
"answer-event",
Some("human\nanswer"),
Some(&"x".repeat(2048)),
);
let context = OrchestratorQueueAttentionContext::new(vec![ticket]);
let output = PromptCatalog::builtins_only()
.unwrap()
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(output.contains("Delegation: assignment-a; input: answer-event."));
assert!(output.contains("Waiting reason: human answer"));
assert!(output.contains(&format!("Next action: {}…", "x".repeat(1024))));
assert!(!output.contains(&"x".repeat(1025)));
assert!(output.contains("no ready/Queue step is required"));
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();