diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index b6b47885..b0088ae1 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -45,7 +45,7 @@ impl FeatureModule for OrchestrationFeature { )) .with_tool(ToolDeclaration::new( TOOL_NAME, - "Spawn and atomically assign a Coder Worker for an inprogress Ticket. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", + "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", )) } @@ -96,9 +96,12 @@ impl Tool for SpawnTicketCoderTool { .ticket_service .workflow_state(&ticket_id) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if workflow_state != ticket::TicketWorkflowState::InProgress { + if !matches!( + workflow_state, + ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress + ) { return Err(ToolError::ExecutionFailed(format!( - "Ticket {ticket_id} must be inprogress before spawning its Coder; current state is {}", + "Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}", workflow_state.as_str() ))); } @@ -206,7 +209,7 @@ mod tests { impl TicketService for RecordingTicketService { fn workflow_state(&self, _ticket_id: &str) -> Result { - Ok(TicketWorkflowState::InProgress) + Ok(TicketWorkflowState::Queued) } } @@ -277,10 +280,10 @@ mod tests { } #[tokio::test] - async fn spawn_ticket_coder_rejects_ticket_before_worker_side_effect() { + async fn spawn_ticket_coder_rejects_ineligible_ticket_before_worker_side_effect() { let worker_service = Arc::new(RecordingService::default()); let tool = SpawnTicketCoderTool { - ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)), worker_service: worker_service.clone(), }; let error = tool @@ -295,7 +298,7 @@ mod tests { ) .await .unwrap_err(); - assert!(error.to_string().contains("must be inprogress")); + assert!(error.to_string().contains("must be queued or inprogress")); assert!(worker_service.requests.lock().unwrap().is_empty()); } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fc50dae1..718312d4 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2902,9 +2902,13 @@ fn validate_ticket_assignment_state( assignment: &WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if ticket.state != TicketWorkflowState::InProgress.as_str() { + if !matches!( + ticket.state.as_str(), + state if state == TicketWorkflowState::Queued.as_str() + || state == TicketWorkflowState::InProgress.as_str() + ) { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} must be inprogress before assigning an implementation Coder; current state is {}", + "Ticket {} must be queued or inprogress before assigning an implementation Coder; current state is {}", ticket.id, ticket.state ))); } @@ -3072,6 +3076,33 @@ fn assign_ticket_worker_from_lifecycle( .current) } +fn accept_queued_ticket_after_worker_spawn( + api: &WorkspaceApi, + assignment: &crate::hosts::WorkerTicketAssignmentRequest, +) -> Result<()> { + let ticket = api.authority.ticket(&assignment.ticket_id)?; + if ticket.state == TicketWorkflowState::InProgress.as_str() { + return Ok(()); + } + if ticket.state != TicketWorkflowState::Queued.as_str() { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} left queued state before Coder spawn acceptance; current state is {}", + ticket.id, ticket.state + ))); + } + let mut change = TicketStateChange::new( + TicketWorkflowState::Queued.as_str(), + TicketWorkflowState::InProgress.as_str(), + "Coder spawn, assignment, and initial input were durably accepted", + "", + ); + change.author = Some("workspace-orchestrator".to_string()); + browser_ticket_backend(api)? + .set_workflow_state(TicketIdOrSlug::Id(ticket.id), change) + .map_err(Error::from)?; + Ok(()) +} + fn existing_lifecycle_assignment_worker( api: &WorkspaceApi, assignment: &crate::hosts::WorkerTicketAssignmentRequest, @@ -9274,6 +9305,20 @@ fn browser_worker_response_from_summary( link_worker_to_workdir(api, &worker_record, workdir_id, None)?; } } + if let Some(assignment) = assignment { + let context = WorkerSpawnCompensationContext { + assignment: Some(assignment), + prepared_workdir_id: selected_working_directory_id, + cleanup_spawned_workdir: false, + }; + finalize_worker_spawn_stage( + api, + &worker, + &context, + WorkerSpawnFinalizeStage::TicketStateAccept, + accept_queued_ticket_after_worker_spawn(api, assignment).map_err(ApiError::from), + )?; + } let runtime_id = worker.worker.runtime_id.clone(); let worker_id = worker.worker.worker_id.clone(); let workspace_id = api.workspace_id().to_string(); @@ -9488,6 +9533,7 @@ enum WorkerSpawnFinalizeStage { WorkerRegistry, TicketAssignmentBind, TicketAssignmentCurrent, + TicketStateAccept, WorkdirRegistry, WorkdirAttachment, } @@ -9498,6 +9544,7 @@ impl WorkerSpawnFinalizeStage { Self::WorkerRegistry => "worker_registry", Self::TicketAssignmentBind => "ticket_assignment_bind", Self::TicketAssignmentCurrent => "ticket_assignment_current", + Self::TicketStateAccept => "ticket_state_accept", Self::WorkdirRegistry => "workdir_registry", Self::WorkdirAttachment => "workdir_attachment", } @@ -12829,7 +12876,7 @@ mod tests { } #[tokio::test] - async fn ticket_assignment_spawn_requires_inprogress_before_runtime_side_effects() { + async fn ticket_assignment_spawn_requires_queued_or_inprogress_before_runtime_side_effects() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -12889,7 +12936,7 @@ mod tests { let api = test_api(workspace.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Assigned Ticket"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketWorkflowState::Queued); let ticket = backend.create(input).unwrap(); let response = create_workspace_worker( State(api.clone()), @@ -12914,6 +12961,10 @@ mod tests { .unwrap() .0; + assert_eq!( + api.authority.ticket(&ticket.id).unwrap().state, + TicketWorkflowState::InProgress.as_str() + ); let current = api .store .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) @@ -12932,6 +12983,50 @@ mod tests { assert_eq!(operation.worker, Some(response.worker_ref)); } + #[tokio::test] + async fn failed_ticket_assignment_spawn_leaves_ticket_queued() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Queued Ticket"); + input.workflow_state = Some(TicketWorkflowState::Queued); + let ticket = backend.create(input).unwrap(); + + let result = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: "missing-runtime".to_string(), + display_name: "Rejected Coder".to_string(), + profile: Some("builtin:coder".to_string()), + ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { + ticket_id: ticket.id.clone(), + operation_id: "failed-queued-assignment".to_string(), + }), + initial_submit: vec![Segment::Flow { + selector: "builtin:coder-review".to_string(), + }], + working_directory: None, + control_operation_id: None, + resolved_control_operation: None, + }), + ) + .await; + + assert!(result.is_err()); + assert_eq!( + api.authority.ticket(&ticket.id).unwrap().state, + TicketWorkflowState::Queued.as_str() + ); + assert!( + api.store + .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn worker_source_auth_rejects_cross_workspace_mutation() { let workspace = tempfile::tempdir().unwrap(); diff --git a/resources/prompts/internal/workspace_orchestrator_queue_attention.md b/resources/prompts/internal/workspace_orchestrator_queue_attention.md index 340298c2..3d96603b 100644 --- a/resources/prompts/internal/workspace_orchestrator_queue_attention.md +++ b/resources/prompts/internal/workspace_orchestrator_queue_attention.md @@ -4,4 +4,4 @@ Workspace: {{workspace_id}} Remaining queued Tickets (bounded): {{ticket_lines}} {{omitted_line}} -Reread the listed Tickets, their relations, orchestration plans, current assignments, Workers, and Workdirs before acting. Continue only work already authorized by the human `ready -> queued` transition. Do not drain the queue automatically and do not create duplicate assignments, Workers, Workdirs, or merges. If no Ticket is currently actionable, record the durable waiting reason on the authoritative Ticket or orchestration plan and stop. Before implementation side effects, record the accepted `queued -> inprogress` transition. +Reread the listed Tickets, their relations, orchestration plans, current assignments, Workers, and Workdirs before acting. Continue only work already authorized by the human `ready -> queued` transition. Do not drain the queue automatically and do not create duplicate assignments, Workers, Workdirs, or merges. If no Ticket is currently actionable, record the durable waiting reason on the authoritative Ticket or orchestration plan and stop. For an actionable queued Ticket, call the guarded `SpawnTicketCoder` operation without first changing Ticket state; it records `queued -> inprogress` only after the Coder, initial input, current assignment, and Workdir finalization are durably accepted. diff --git a/resources/prompts/panel/orchestrator_idle_queue_notice.md b/resources/prompts/panel/orchestrator_idle_queue_notice.md index 3574523a..85e6e05c 100644 --- a/resources/prompts/panel/orchestrator_idle_queue_notice.md +++ b/resources/prompts/panel/orchestrator_idle_queue_notice.md @@ -1,7 +1,7 @@ Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present. -This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Before implementation side effects, verify the Ticket state and record the normal `queued -> inprogress` acceptance through Ticket tools. +This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted. Workspace: {{ workspace }} diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index bdbd7801..95fec5c0 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,7 +2,7 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, current assignment, Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If spawn or finalization fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.