feat: route Orchestrator delivery and Worker starts from Ticket Assign
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user