From 761de69e18bb885f3558cd96ade5e4e3822fbd3a Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 8 Oct 2026 21:36:33 +0900 Subject: [PATCH] feat(ticket): generalize Worker launch and durable resource claims --- crates/builtin-source/src/lib.rs | 55 + crates/manifest/src/profile.rs | 72 + crates/server-api/src/lib.rs | 89 +- crates/ticket/src/config.rs | 26 +- crates/ticket/src/lib.rs | 57 +- crates/worker/src/entrypoint.rs | 43 +- .../src/feature/builtin/manage_worker.rs | 41 +- .../src/feature/builtin/orchestration.rs | 492 ++--- .../feature/builtin/resource_projection.rs | 30 +- crates/workspace-server/src/authority.rs | 163 +- crates/workspace-server/src/hosts.rs | 33 +- crates/workspace-server/src/latest_schema.sql | 26 +- crates/workspace-server/src/retention.rs | 55 +- crates/workspace-server/src/server.rs | 1150 +++++++---- .../src/server/tests/ticket_evidence_tests.rs | 8 +- .../server/tests/ticket_worker_claim_tests.rs | 657 ++++++ .../tests/ticket_worker_launch_tests.rs | 1053 ++++++++++ .../src/server/worker_operations.rs | 17 + .../src/server_workspace_config_tests.rs | 20 +- .../src/server_workspace_config_wip_tests.rs | 4 +- crates/workspace-server/src/store.rs | 1755 ++++++++++++++--- openapi/server-api.json | 42 +- resources/profiles/ticket-worker.dcdl | 17 + resources/prompts/catalog.dcdl | 2 + resources/prompts/role/orchestrator.md | 2 +- resources/prompts/role/ticket-worker.md | 7 + .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 4 +- web/workspace/src/lib/generated/ticket-api.ts | 12 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 31 files changed, 4937 insertions(+), 1001 deletions(-) create mode 100644 crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs create mode 100644 crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs create mode 100644 resources/profiles/ticket-worker.dcdl create mode 100644 resources/prompts/role/ticket-worker.md diff --git a/crates/builtin-source/src/lib.rs b/crates/builtin-source/src/lib.rs index e539484ab..f85eaef3d 100644 --- a/crates/builtin-source/src/lib.rs +++ b/crates/builtin-source/src/lib.rs @@ -75,6 +75,16 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ resolved_path: "profiles/job.dcdl", }], }, + BuiltinProfileResource { + selector: Some("builtin:ticket-worker"), + path: "profiles/ticket-worker.dcdl", + source: include_str!("../../../resources/profiles/ticket-worker.dcdl"), + description: "General Ticket work with typed progress and completion tools.", + imports: &[BuiltinProfileImport { + specifier: "./default.dcdl", + resolved_path: DEFAULT_PATH, + }], + }, BuiltinProfileResource { selector: Some("builtin:coder"), path: "profiles/coder.dcdl", @@ -212,3 +222,48 @@ pub fn builtin_profile_entrypoints() -> impl Iterator, pub blockers: Vec, } @@ -7554,7 +7554,7 @@ pub struct TicketQueryItem { pub evidence: TicketEvidenceSummary, pub merge_requests: Vec, pub merge_request: Option, - pub current_coder: Option, + pub current_worker: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7608,7 +7608,7 @@ pub struct TicketDetail { pub linked_objectives: Vec, pub implementation_reports: Vec, pub assignments: Vec, - pub current_coder: Option, + pub current_worker: Option, pub assignment_diagnostics: Vec, pub action_eligibility: TicketActionEligibility, pub merge_requests: Vec, @@ -7705,6 +7705,19 @@ pub struct TicketOrchestrationPlanSearchRequest { pub kind: Option, } +/// Selected existing live Workdir attachment, including its current connection +/// identity. The Workspace authority validates the selection against live +/// attachments; callers must select them explicitly, and use an empty list +/// only when the Worker has no live Workdirs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketWorkerAttachmentBinding { + pub alias: String, + pub working_directory_id: String, + pub connection_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -7712,6 +7725,8 @@ pub struct SetTicketRoleAssignmentRequest { pub operation_id: String, pub principal: TicketAssignmentPrincipal, pub expected_assignment_id: Option, + #[serde(default)] + pub workdir_bindings: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -8350,7 +8365,7 @@ pub enum RuntimeWorkerSpawnIntent { pub enum RuntimeTicketWorkerRole { Intake, Orchestrator, - Coder, + Worker, Reviewer, } @@ -11121,6 +11136,70 @@ mod skill_typescript_tests { mod tests { use super::*; + #[test] + fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection() + { + let base = serde_json::json!({"operation_id":"claim", "principal":{"kind":"worker","runtime_id":"r","worker_id":"w"}, "expected_assignment_id":null}); + let omitted: SetTicketRoleAssignmentRequest = serde_json::from_value(base.clone()).unwrap(); + assert!(omitted.workdir_bindings.is_empty()); + let mut selected = base; + selected["workdir_bindings"] = serde_json::json!([{"alias":"main","working_directory_id":"checkout","connection_id":"connection"}]); + let parsed: SetTicketRoleAssignmentRequest = + serde_json::from_value(selected.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), selected); + for key in ["alias", "working_directory_id", "connection_id"] { + let mut malformed = selected.clone(); + malformed["workdir_bindings"][0] + .as_object_mut() + .unwrap() + .remove(key); + assert!( + serde_json::from_value::(malformed).is_err(), + "{key}" + ); + } + } + + #[test] + fn ticket_worker_roles_and_manual_start_projection_use_worker_wire_names() { + assert_eq!( + serde_json::to_value(TicketAssignmentRole::Worker).unwrap(), + serde_json::json!("worker") + ); + assert_eq!( + serde_json::to_value(RuntimeTicketWorkerRole::Worker).unwrap(), + serde_json::json!("worker") + ); + assert!( + serde_json::from_value::(serde_json::json!("coder")).is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!("coder")).is_err() + ); + let eligibility = serde_json::json!({ + "can_assign_orchestrator": false, "can_unassign_orchestrator": false, + "can_queue": true, "can_start_manual_worker": true, "queue_tickets": [], "blockers": [] + }); + let parsed: TicketActionEligibility = serde_json::from_value(eligibility.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), eligibility); + let mut legacy = eligibility; + legacy + .as_object_mut() + .unwrap() + .remove("can_start_manual_worker"); + legacy["can_start_manual_coder"] = serde_json::json!(true); + assert!(serde_json::from_value::(legacy).is_err()); + for schema in [ + schemars::schema_for!(TicketDetail), + schemars::schema_for!(TicketQueryItem), + ] { + let schema = serde_json::to_value(schema).unwrap(); + let properties = schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("current_worker")); + assert!(!properties.contains_key("current_coder")); + } + } + #[test] fn browser_ticket_targets_are_one_collection_authority() { let edit = BrowserTicketTargetEdit::Set { diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 9c35fc684..1b3a88855 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; pub enum TicketRole { Intake, Orchestrator, - Coder, + Worker, Reviewer, } @@ -21,7 +21,7 @@ impl TicketRole { pub const ALL: [TicketRole; 4] = [ TicketRole::Intake, TicketRole::Orchestrator, - TicketRole::Coder, + TicketRole::Worker, TicketRole::Reviewer, ]; @@ -33,7 +33,7 @@ impl TicketRole { match self { Self::Intake => "intake", Self::Orchestrator => "orchestrator", - Self::Coder => "coder", + Self::Worker => "worker", Self::Reviewer => "reviewer", } } @@ -42,7 +42,7 @@ impl TicketRole { match value { "intake" => Some(Self::Intake), "orchestrator" => Some(Self::Orchestrator), - "coder" => Some(Self::Coder), + "worker" => Some(Self::Worker), "reviewer" => Some(Self::Reviewer), _ => None, } @@ -52,7 +52,7 @@ impl TicketRole { match self { Self::Intake => "builtin:intake", Self::Orchestrator => "builtin:orchestrator", - Self::Coder => "builtin:coder", + Self::Worker => "builtin:default", Self::Reviewer => "builtin:reviewer", } } @@ -96,4 +96,20 @@ mod tests { } assert_eq!(TicketRole::parse("operator"), None); } + + #[test] + fn worker_role_uses_worker_wire_name_and_default_profile() { + assert_eq!(TicketRole::parse("worker"), Some(TicketRole::Worker)); + assert_eq!(TicketRole::parse("coder"), None); + assert_eq!( + serde_json::to_string(&TicketRole::Worker).unwrap(), + "\"worker\"" + ); + assert_eq!( + serde_json::from_str::("\"worker\"").unwrap(), + TicketRole::Worker + ); + assert!(serde_json::from_str::("\"coder\"").is_err()); + assert_eq!(TicketRole::Worker.default_profile(), "builtin:default"); + } } diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index c2d86bff7..4244f0179 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -2360,7 +2360,7 @@ impl SqliteTicketBackend { .event_attributes .get("source_actor_role") .map(String::as_str) - != Some("coder") + != Some("ticket_worker") && !self.event_attributes.contains_key("source_assignment_id") { return Ok(()); @@ -5678,6 +5678,41 @@ mod tests { ); } + #[test] + fn unassigned_worker_actor_is_distinct_from_ticket_worker_requiring_assignment() { + for role in ["worker", "ticket_worker"] { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let (reference, ticket) = backend + .create_with_snapshot(NewTicket::new("Actor boundary")) + .unwrap(); + let source = backend.clone().with_event_attributes(BTreeMap::from([ + ("source_actor_role".into(), role.into()), + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ])); + let result = source.complete(&reference.id, completion_request(&ticket, "complete")); + if role == "worker" { + let completed = result.unwrap(); + assert_eq!( + completed + .events + .last() + .unwrap() + .attributes + .get("source_actor_role") + .map(String::as_str), + Some("worker") + ); + } else { + assert!(result.is_err()); + let reread = backend.show(reference.id.into()).unwrap(); + assert_eq!(reread.meta.workflow_state, ticket.meta.workflow_state); + assert_eq!(reread.events.len(), ticket.events.len()); + } + } + } + #[test] fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() { let tmp = TempDir::new().unwrap(); @@ -5686,7 +5721,7 @@ mod tests { .create_with_snapshot(NewTicket::new("Active source")) .unwrap(); active_assignment_fixture(&backend, &reference.id); - let source = backend.clone().with_event_attributes(assignment_source("coder")) + let source = backend.clone().with_event_attributes(assignment_source("ticket_worker")) .with_mutation_hook(Arc::new(|conn, _| { assert!(!conn.is_autocommit()); let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?; @@ -5735,7 +5770,7 @@ mod tests { "source_worker_id", "source_assignment_id", ] { - let mut attrs = assignment_source("coder"); + let mut attrs = assignment_source("ticket_worker"); attrs.insert(key.to_owned(), "other".to_owned()); let source = backend.clone().with_event_attributes(attrs); assert!( @@ -5763,7 +5798,7 @@ mod tests { .unwrap(); let source = backend .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); assert!( source .complete(&other.id, completion_request(&other_ticket, "complete")) @@ -5777,7 +5812,7 @@ mod tests { .unwrap(); let source = other_workspace .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); assert!( source .complete(&other.id, completion_request(&other_ticket, "complete")) @@ -5798,7 +5833,7 @@ mod tests { "source_worker_id", "source_assignment_id", ] { - let mut attrs = assignment_source("coder"); + let mut attrs = assignment_source("ticket_worker"); attrs.remove(key); let source = backend.clone().with_event_attributes(attrs); assert!( @@ -5809,7 +5844,7 @@ mod tests { } let source = backend .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); assert!( source .complete(&reference.id, completion_request(&ticket, "complete")) @@ -5838,7 +5873,7 @@ mod tests { active_assignment_fixture(&backend, &reference.id); let source = backend .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); let request = completion_request(&ticket, "complete"); let completed = source.complete(&reference.id, request.clone()).unwrap(); release_assignment_fixture(&backend); @@ -5906,7 +5941,7 @@ mod tests { active_assignment_fixture(&backend, &reference.id); let source = backend .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); let request = completion_request(&ticket, "complete"); let completed = source.complete(&reference.id, request.clone()).unwrap(); release_assignment_fixture(&backend); @@ -5916,7 +5951,7 @@ mod tests { "source_assignment_id", "source_actor_role", ] { - let mut attrs = assignment_source("coder"); + let mut attrs = assignment_source("ticket_worker"); attrs.insert(key.to_owned(), "different-source".to_owned()); let other_source = backend.clone().with_event_attributes(attrs); assert!(matches!( @@ -6069,7 +6104,7 @@ mod tests { active_assignment_fixture(&backend, &reference.id); let source = backend .clone() - .with_event_attributes(assignment_source("coder")); + .with_event_attributes(assignment_source("ticket_worker")); let request = completion_request(&ticket, "complete"); let completed = source.complete(&reference.id, request.clone()).unwrap(); release_assignment_fixture(&backend); diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 960a48a19..88745da61 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -368,11 +368,9 @@ fn apply_profile_launch_policy( apply_scope_launch_defaults(&mut manifest.scope, default_scope); manifest.delegation_scope = ScopeConfig::default(); } - Some(TicketRole::Coder) => { - let default_scope = workspace_scope(workspace_root, Permission::Write, &[]); - apply_scope_launch_defaults(&mut manifest.scope, default_scope); - manifest.delegation_scope = ScopeConfig::default(); - } + // Work assignment is not a filesystem or delegation grant. The selected + // Profile and explicit resource bindings remain the authority. + Some(TicketRole::Worker) => {} None => { let worktree_root = workspace_root.join(".worktree"); let default_scope = workspace_scope( @@ -872,6 +870,41 @@ language = "override" assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write); } + #[test] + fn ticket_worker_role_preserves_profile_scope_and_delegation_without_adding_grants() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let explicit_scope = ScopeConfig { + allow: vec![scope_rule(&workspace.join("documents"), Permission::Write)], + deny: vec![scope_rule( + &workspace.join("documents/private"), + Permission::Write, + )], + }; + let explicit_delegation = ScopeConfig { + allow: vec![scope_rule(&workspace.join("reference"), Permission::Read)], + deny: vec![scope_rule( + &workspace.join("reference/private"), + Permission::Read, + )], + }; + for (scope, delegation) in [ + (ScopeConfig::default(), ScopeConfig::default()), + (explicit_scope, explicit_delegation), + ] { + let (mut manifest, _) = load_builtin_default_manifest("ticket-worker").unwrap(); + manifest.scope = scope.clone(); + manifest.delegation_scope = delegation.clone(); + + apply_profile_launch_policy(&mut manifest, &workspace, Some("worker")).unwrap(); + + assert_eq!(manifest.scope.allow, scope.allow); + assert_eq!(manifest.scope.deny, scope.deny); + assert_eq!(manifest.delegation_scope.allow, delegation.allow); + assert_eq!(manifest.delegation_scope.deny, delegation.deny); + } + } + #[test] fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index b88574510..969e89b71 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -725,8 +725,8 @@ struct WorkerSpawnInput { /// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted. #[serde(default)] singleton_key: Option, - /// Optional inprogress Ticket already accepted by the Orchestrator. Set - /// this with one Flow segment to assign the new Coder atomically. + /// Optional Ticket assignment. Backend validates claims and resource requirements; + /// neither the profile nor an initial Flow segment grants review authority. #[serde(default)] ticket_id: Option, #[serde(default)] @@ -861,7 +861,7 @@ impl WorkerOperation { "List only known Runtime Workers and direct SubWorkers granted to the current Worker." } Self::Spawn => { - "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." + "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, with an optional Flow segment. Set `ticket_id` to atomically assign the Worker; Backend validates claims and resource requirements. Selecting a profile or Flow does not grant review authority. The operation id is derived from the durable tool call rather than model input." } Self::SendInput => { "Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle." @@ -1949,6 +1949,41 @@ mod tests { assert!(value.get("initial_text").is_none()); } + #[test] + fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() { + let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest { + runtime_id: "runtime-1".to_string(), + workdir_attachments: Vec::new(), + profile: "project:ticket-worker".to_string(), + singleton_key: None, + ticket_id: Some("T-719".to_string()), + operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()), + display_name: "Worker · T-719".to_string(), + initial_submit: vec![Segment::text("Investigate Ticket T-719.")], + }) + .unwrap(); + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["workdir_attachments"], serde_json::json!([])); + assert_eq!(value["profile"], "project:ticket-worker"); + assert_eq!( + value["ticket_assignment"], + serde_json::json!({ + "ticket_id": "T-719", + "operation_id": "spawn-ticket-worker:T-719:call-1", + }) + ); + assert_eq!( + value["control_operation_id"], + "spawn-ticket-worker:T-719:call-1" + ); + assert_eq!( + value["initial_submit"], + serde_json::json!([ + {"kind": "text", "content": "Investigate Ticket T-719."} + ]) + ); + } + #[tokio::test] async fn worker_message_and_cancel_use_permission_fenced_control_routes() { let client = Arc::new(RecordingWorkspaceClient::default()); diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index 515764bb2..79fab889a 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -20,9 +20,7 @@ use crate::feature::{ }; const FEATURE_ID: &str = "orchestration"; -const TOOL_NAME: &str = "SpawnTicketCoder"; -const CODER_PROFILE: &str = "builtin:coder"; -const CODER_FLOW: &str = "builtin:coder-review"; +const TOOL_NAME: &str = "SpawnTicketWorker"; #[derive(Debug, Default)] pub struct OrchestrationFeature; @@ -37,15 +35,15 @@ impl FeatureModule for OrchestrationFeature { .with_description("Semantic Ticket orchestration operations.") .with_service_requirement(ServiceRequirement::required( ServiceId::builtin(TICKET_SERVICE_ID), - "SpawnTicketCoder requires current typed Ticket authority", + "SpawnTicketWorker requires current typed Ticket authority", )) .with_service_requirement(ServiceRequirement::required( ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID), - "SpawnTicketCoder requires Workspace Worker lifecycle authority", + "SpawnTicketWorker requires Workspace Worker lifecycle authority", )) .with_tool(ToolDeclaration::new( TOOL_NAME, - "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. 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.", + "Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.", )) } @@ -68,7 +66,7 @@ impl FeatureModule for OrchestrationFeature { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct SpawnTicketCoderWorkdirInput { +struct SpawnTicketWorkerWorkdirInput { /// Stable Worker-local routing alias (for example `checkout` or `docs`). alias: String, /// Workspace-authoritative Workdir id. Paths and URLs are not accepted. @@ -80,27 +78,35 @@ struct SpawnTicketCoderWorkdirInput { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct SpawnTicketCoderInput { +struct SpawnTicketWorkerInput { ticket_id: String, runtime_id: String, - /// Every Workdir required by the Ticket target set. Backend authority derives - /// effective read/write capabilities; callers cannot request capabilities. - workdir_attachments: Vec, + /// Registered profile selector. Backend resolves the profile and validates claims. + profile: String, + /// Initial user request delivered through the normal typed submission path. + initial_request: String, + /// Optional Flow selector, delivered as a typed Flow segment before the request. + #[serde(default)] + flow: Option, + /// Alias-keyed Workdir selections; omit or leave empty for a Worker without + /// Workdirs. Backend derives effective capabilities; callers cannot request them. + #[serde(default)] + workdir_attachments: Vec, } -struct SpawnTicketCoderTool { +struct SpawnTicketWorkerTool { ticket_service: Arc, worker_service: Arc, } #[async_trait] -impl Tool for SpawnTicketCoderTool { +impl Tool for SpawnTicketWorkerTool { async fn execute( &self, input_json: &str, ctx: ToolExecutionContext, ) -> Result { - let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| { + let input: SpawnTicketWorkerInput = serde_json::from_str(input_json).map_err(|error| { ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}")) })?; let ticket_ref = authority_id(input.ticket_id, "ticket_id")?; @@ -108,35 +114,28 @@ impl Tool for SpawnTicketCoderTool { .ticket_service .ticket_handoff(&ticket_ref) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if !matches!( - ticket.workflow_state, - ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress - ) { - return Err(ToolError::ExecutionFailed(format!( - "Ticket {} must be queued or inprogress before spawning its Coder; current state is {}", - ticket.resource_key, - ticket.workflow_state.as_str() - ))); - } let call_id = non_empty(ctx.call_id, "tool call_id")?; let runtime_id = authority_id(input.runtime_id, "runtime_id")?; + let profile = non_empty(input.profile, "profile")?; + let initial_request = non_empty(input.initial_request, "initial_request")?; + let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?; let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?; + let mut initial_submit = Vec::new(); + if let Some(selector) = flow { + initial_submit.push(Segment::Flow { selector }); + } + initial_submit.push(Segment::text(initial_request)); let response = self .worker_service .spawn(WorkerLifecycleSpawnRequest { runtime_id, workdir_attachments, - profile: CODER_PROFILE.to_string(), + profile, singleton_key: None, ticket_id: Some(ticket.id.clone()), - operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)), - display_name: format!("Coder · {}", ticket.resource_key), - initial_submit: vec![ - Segment::Flow { - selector: CODER_FLOW.to_string(), - }, - Segment::text(format!("Implement Ticket {}.", ticket.resource_key)), - ], + operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)), + display_name: format!("Worker · {}", ticket.resource_key), + initial_submit, }) .await .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; @@ -147,7 +146,7 @@ impl Tool for SpawnTicketCoderTool { ))); } Ok(ToolOutput { - summary: format!("Spawned Coder for Ticket {}", ticket.resource_key), + summary: format!("Spawned Worker for Ticket {}", ticket.resource_key), content: Some(response.body), attachments: Vec::new(), }) @@ -155,14 +154,8 @@ impl Tool for SpawnTicketCoderTool { } fn validate_workdir_attachments( - attachments: Vec, + attachments: Vec, ) -> Result, ToolError> { - if attachments.is_empty() { - return Err(ToolError::InvalidArgument( - "workdir_attachments must contain at least one Workdir".to_string(), - )); - } - let mut aliases = BTreeSet::new(); let mut workdir_ids = BTreeSet::new(); attachments @@ -200,14 +193,14 @@ fn definition( worker_service: Arc, ) -> ToolDefinition { Arc::new(move || { - let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput)) + let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)) .unwrap_or_else(|_| serde_json::json!({})); let meta = ToolMeta::new(TOOL_NAME) .description( - "Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.", + "Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.", ) .input_schema(schema); - let tool: Arc = Arc::new(SpawnTicketCoderTool { + let tool: Arc = Arc::new(SpawnTicketWorkerTool { ticket_service: ticket_service.clone(), worker_service: worker_service.clone(), }); @@ -254,32 +247,18 @@ mod tests { use ticket::{TicketError, TicketWorkflowState}; - use crate::worker::{WorkspaceClientError, WorkspaceResponse}; - use super::*; use crate::feature::builtin::ticket::TicketHandoff; - - #[derive(Default)] - struct RecordingTicketService; - - impl TicketService for RecordingTicketService { - fn ticket_handoff(&self, ticket_ref: &str) -> Result { - assert_eq!(ticket_ref, "T-482"); - Ok(TicketHandoff { - id: "00001KZXN51C7".to_string(), - resource_key: "T-482".to_string(), - workflow_state: TicketWorkflowState::Queued, - }) - } - } + use crate::worker::{WorkspaceClientError, WorkspaceResponse}; struct FixedTicketService(TicketWorkflowState); impl TicketService for FixedTicketService { - fn ticket_handoff(&self, _ticket_ref: &str) -> Result { + fn ticket_handoff(&self, ticket_ref: &str) -> Result { + assert_eq!(ticket_ref, "T-719"); Ok(TicketHandoff { id: "00001KZXN51C7".to_string(), - resource_key: "T-482".to_string(), + resource_key: "T-719".to_string(), workflow_state: self.0, }) } @@ -288,6 +267,7 @@ mod tests { #[derive(Default)] struct RecordingService { requests: Mutex>, + response: Option, } #[async_trait] @@ -297,193 +277,236 @@ mod tests { request: WorkerLifecycleSpawnRequest, ) -> Result { self.requests.lock().unwrap().push(request); - Ok(WorkspaceResponse { + Ok(self.response.clone().unwrap_or_else(|| WorkspaceResponse { status: 200, body: r#"{"worker_id":"42"}"#.to_string(), - }) + })) + } + } + + fn input() -> serde_json::Value { + serde_json::json!({ + "ticket_id": "T-719", + "runtime_id": "runtime-1", + "profile": "project:ticket-worker", + "initial_request": "Investigate Ticket T-719." + }) + } + + fn tool(service: Arc, state: TicketWorkflowState) -> SpawnTicketWorkerTool { + SpawnTicketWorkerTool { + ticket_service: Arc::new(FixedTicketService(state)), + worker_service: service, } } #[tokio::test] - async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() { + async fn spawn_ticket_worker_forwards_selected_profile_flow_request_and_workdirs() { let service = Arc::new(RecordingService::default()); - let tool = SpawnTicketCoderTool { - ticket_service: Arc::new(RecordingTicketService), - worker_service: service.clone(), - }; - tool.execute( - &serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - { - "alias": "checkout", - "working_directory_id": "workdir-1", - "relative_cwd": "crates/yoi" - }, - { - "alias": "docs", - "working_directory_id": "workdir-2" - } - ] - }) - .to_string(), - ToolExecutionContext::new("call-7", "batch-1", 0), - ) - .await - .unwrap(); + let mut input = input(); + input["flow"] = serde_json::json!("project:investigation"); + input["workdir_attachments"] = serde_json::json!([ + { + "alias": "checkout", + "working_directory_id": "workdir-1", + "relative_cwd": "crates/worker" + }, + {"alias": "docs", "working_directory_id": "workdir-2"} + ]); + let output = tool(service.clone(), TicketWorkflowState::Queued) + .execute( + &input.to_string(), + ToolExecutionContext::new("call-7", "batch-1", 0), + ) + .await + .unwrap(); let requests = service.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); let request = &requests[0]; - assert_eq!(request.profile, CODER_PROFILE); + assert_eq!(request.runtime_id, "runtime-1"); + assert_eq!(request.profile, "project:ticket-worker"); assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7")); assert_eq!( request.operation_id.as_deref(), - Some("spawn-ticket-coder:00001KZXN51C7:call-7") + Some("spawn-ticket-worker:00001KZXN51C7:call-7") ); - assert_eq!(request.display_name, "Coder · T-482"); - assert_eq!(request.workdir_attachments.len(), 2); - assert_eq!(request.workdir_attachments[0].alias, "checkout"); + assert_eq!(request.display_name, "Worker · T-719"); + assert_eq!(request.singleton_key, None); assert_eq!( - request.workdir_attachments[0].working_directory_id, - "workdir-1" + request.workdir_attachments, + vec![ + WorkerLifecycleWorkdirAttachment { + alias: "checkout".into(), + working_directory_id: "workdir-1".into(), + relative_cwd: Some("crates/worker".into()), + }, + WorkerLifecycleWorkdirAttachment { + alias: "docs".into(), + working_directory_id: "workdir-2".into(), + relative_cwd: None, + }, + ] ); - assert_eq!( - request.workdir_attachments[0].relative_cwd.as_deref(), - Some("crates/yoi") - ); - assert_eq!(request.workdir_attachments[1].alias, "docs"); - assert_eq!( - request.workdir_attachments[1].working_directory_id, - "workdir-2" - ); - assert_eq!(request.workdir_attachments[1].relative_cwd, None); assert_eq!( request.initial_submit, vec![ Segment::Flow { - selector: CODER_FLOW.to_string() + selector: "project:investigation".into() }, - Segment::text("Implement Ticket T-482.") + Segment::text("Investigate Ticket T-719."), ] ); - assert!(!request.display_name.contains("00001KZXN51C7")); - assert!(request.initial_submit.iter().all(|segment| { - !Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7") - })); + assert_eq!(output.summary, "Spawned Worker for Ticket T-719"); + assert_eq!(output.content.as_deref(), Some(r#"{"worker_id":"42"}"#)); } #[tokio::test] - 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::Planning)), - worker_service: worker_service.clone(), - }; - let error = tool + async fn spawn_ticket_worker_allows_zero_workdirs_and_omitted_flow() { + let service = Arc::new(RecordingService::default()); + for explicit_empty in [false, true] { + let mut input = input(); + if explicit_empty { + input["workdir_attachments"] = serde_json::json!([]); + input["flow"] = serde_json::Value::Null; + } + tool(service.clone(), TicketWorkflowState::Queued) + .execute( + &input.to_string(), + ToolExecutionContext::new("call-empty", "batch-1", 0), + ) + .await + .unwrap(); + } + let requests = service.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + for request in requests.iter() { + assert!(request.workdir_attachments.is_empty()); + assert_eq!( + request.initial_submit, + vec![Segment::text("Investigate Ticket T-719.")] + ); + } + } + + #[tokio::test] + async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() { + let service = Arc::new(RecordingService { + response: Some(WorkspaceResponse { + status: 409, + body: "Backend rejected Ticket claim".into(), + }), + ..Default::default() + }); + let error = tool(service.clone(), TicketWorkflowState::Planning) .execute( - &serde_json::json!({ - "ticket_id": "00001KZXN51C7", - "runtime_id": "runtime-1", - "workdir_attachments": [{ - "alias": "checkout", - "working_directory_id": "workdir-1" - }] - }) - .to_string(), - ToolExecutionContext::new("call-queued", "batch-1", 0), + &input().to_string(), + ToolExecutionContext::new("call-claim", "batch-1", 0), ) .await .unwrap_err(); - assert!(error.to_string().contains("must be queued or inprogress")); - assert!(worker_service.requests.lock().unwrap().is_empty()); + assert_eq!(service.requests.lock().unwrap().len(), 1); + assert!( + error + .to_string() + .contains("HTTP 409: Backend rejected Ticket claim") + ); } #[tokio::test] - async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() { - let worker_service = Arc::new(RecordingService::default()); - let tool = SpawnTicketCoderTool { - ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)), - worker_service: worker_service.clone(), - }; + async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() { + let service = Arc::new(RecordingService::default()); + let tool = tool(service.clone(), TicketWorkflowState::Queued); let cases = [ ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [] - }), - "must contain at least one Workdir", + "profile", + serde_json::json!(" "), + "profile must not be empty", ), ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - {"alias": "checkout", "working_directory_id": "workdir-1"}, - {"alias": " checkout ", "working_directory_id": "workdir-2"} - ] - }), - "duplicate Workdir attachment alias `checkout`", + "initial_request", + serde_json::json!(" \n "), + "initial_request must not be empty", ), + ("flow", serde_json::json!(" "), "flow must not be empty"), ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - {"alias": "checkout", "working_directory_id": "workdir-1"}, - {"alias": "docs", "working_directory_id": " workdir-1 "} - ] - }), - "duplicate Workdir attachment `workdir-1`", - ), - ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - {"alias": "docs/path", "working_directory_id": "workdir-1"} - ] - }), - "workdir_attachments.alias must be an authority id", - ), - ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - {"alias": "docs", "working_directory_id": "https://example.test/workdir"} - ] - }), - "workdir_attachments.working_directory_id must be an authority id", - ), - ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime/1", - "workdir_attachments": [ - {"alias": "checkout", "working_directory_id": "workdir-1"} - ] - }), + "runtime_id", + serde_json::json!("runtime/1"), "runtime_id must be an authority id", ), ( - serde_json::json!({ - "ticket_id": "T-482", - "runtime_id": "runtime-1", - "workdir_attachments": [ - { - "alias": "checkout", - "working_directory_id": "workdir-1", - "relative_cwd": "../outside" - } - ] - }), + "ticket_id", + serde_json::json!("https://example.test/ticket"), + "ticket_id must be an authority id", + ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": " checkout ", "working_directory_id": "workdir-2"} + ]), + "duplicate Workdir attachment alias `checkout`", + ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": "docs", "working_directory_id": " workdir-1 "} + ]), + "duplicate Workdir attachment `workdir-1`", + ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "docs/path", "working_directory_id": "workdir-1"} + ]), + "workdir_attachments.alias must be an authority id", + ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "docs", "working_directory_id": "https://example.test/workdir"} + ]), + "workdir_attachments.working_directory_id must be an authority id", + ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "checkout", "working_directory_id": "workdir-1", "relative_cwd": "../outside"} + ]), "relative_cwd must be a normalized relative path inside the Workdir", ), + ( + "workdir_attachments", + serde_json::json!([ + {"alias": "checkout", "working_directory_id": "workdir-1", "access": "write"} + ]), + "unknown field `access`", + ), + ( + "review", + serde_json::json!({"ticket_id": "T-719", "merge_request_id": "MR-1"}), + "unknown field `review`", + ), + ( + "operation_id", + serde_json::json!("caller-claim"), + "unknown field `operation_id`", + ), + ( + "display_name", + serde_json::json!("Reviewer"), + "unknown field `display_name`", + ), + ( + "initial_submit", + serde_json::json!([]), + "unknown field `initial_submit`", + ), ]; - - for (index, (input, expected)) in cases.into_iter().enumerate() { + for (index, (field, value, expected)) in cases.into_iter().enumerate() { + let mut input = input(); + input[field] = value; let error = tool .execute( &input.to_string(), @@ -493,10 +516,27 @@ mod tests { .unwrap_err(); assert!( error.to_string().contains(expected), - "unexpected error for case {index}: {error}" + "unexpected error for {field}: {error}" ); } - assert!(worker_service.requests.lock().unwrap().is_empty()); + for field in ["ticket_id", "runtime_id", "profile", "initial_request"] { + let mut input = input(); + input.as_object_mut().unwrap().remove(field); + let error = tool + .execute( + &input.to_string(), + ToolExecutionContext::new("call-missing", "batch-validation", 0), + ) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("missing field `{field}`")), + "{error}" + ); + } + assert!(service.requests.lock().unwrap().is_empty()); } #[test] @@ -517,26 +557,38 @@ mod tests { } #[test] - fn input_surface_does_not_expose_profile_flow_or_assignment_controls() { - let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap(); - for field in [ - "ticket_id", - "runtime_id", - "workdir_attachments", - "alias", - "working_directory_id", - "relative_cwd", - ] { - assert!(schema.contains(field)); + fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() { + let definition = definition( + Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + Arc::new(RecordingService::default()), + ); + let (meta, _) = definition(); + assert_eq!(meta.name, "SpawnTicketWorker"); + let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap(); + assert_eq!(schema["additionalProperties"], false); + let required = schema["required"].as_array().unwrap(); + for field in ["ticket_id", "runtime_id", "profile", "initial_request"] { + assert!( + required.contains(&serde_json::json!(field)), + "missing required field {field}" + ); + } + for field in ["flow", "workdir_attachments"] { + assert!(schema["properties"].get(field).is_some()); + assert!(!required.contains(&serde_json::json!(field))); } for forbidden in [ - "profile", - "selector", + "review", "operation_id", "display_name", "initial_submit", + "claim", + "access", ] { - assert!(!schema.contains(forbidden), "schema leaked {forbidden}"); + assert!( + schema["properties"].get(forbidden).is_none(), + "schema leaked {forbidden}" + ); } } } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index a3367826c..c3733f4af 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -21,7 +21,7 @@ struct ModelTicketQueryItem { workspace_action_priority: Option, matched_fields: Vec, snippet: Option, - current_coder: Option, + current_worker: Option, linked_objectives: Vec, relation_count: usize, blocker_count: usize, @@ -47,7 +47,7 @@ pub(super) struct ModelTicketDetail { relations: ModelTicketRelations, linked_objectives: Vec, assignments: Vec, - current_coder: Option, + current_worker: Option, implementation_reports: Vec, merge_requests: Vec, merge_request: Option, @@ -195,7 +195,7 @@ struct ModelTicketActions { can_assign_orchestrator: bool, can_unassign_orchestrator: bool, can_queue: bool, - can_start_manual_coder: bool, + can_start_manual_worker: bool, } #[derive(Debug, Serialize)] @@ -244,8 +244,8 @@ fn project_ticket_query_item(value: &Value) -> Result Result Result { let root = object(&value, "Ticket detail response")?; - let current_coder = root - .get("current_coder") + let current_worker = root + .get("current_worker") .filter(|value| !value.is_null()) .map(project_worker) .transpose()?; let assignments = array_field(root, "assignments")? .iter() - .map(|assignment| project_assignment(assignment, current_coder.as_ref())) + .map(|assignment| project_assignment(assignment, current_worker.as_ref())) .collect::, _>>()?; Ok(ModelTicketDetail { @@ -302,7 +302,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result, _>>()?, assignments, - current_coder, + current_worker, implementation_reports: array_field(root, "implementation_reports")? .iter() .map(project_evidence_event) @@ -483,13 +483,13 @@ fn project_ticket_summary(value: &Value) -> Result { fn project_assignment( value: &Value, - current_coder: Option<&ModelWorkerSummary>, + current_worker: Option<&ModelWorkerSummary>, ) -> Result { let assignment = object(value, "Ticket assignment")?; let principal = object_field(assignment, "principal")?; let kind = string_field(principal, "kind")?; let principal = match kind.as_str() { - "worker" => current_coder + "worker" => current_worker .map(|coder| coder.worker.clone()) .ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?, "workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?), @@ -572,7 +572,7 @@ fn project_actions(value: &Value) -> Result { can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?, can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?, can_queue: bool_field(actions, "can_queue")?, - can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?, + can_start_manual_worker: bool_field(actions, "can_start_manual_worker")?, }) } @@ -783,7 +783,7 @@ mod tests { "workspace_action_priority": "active_work", "matched_fields": ["title"], "snippet": "Ticket", - "current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"}, + "current_worker": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"}, "linked_objective_ids": ["00001OBJECTIVEINTERNAL"], "linked_objective_keys": ["O-6"], "relation_count": 0, @@ -870,7 +870,7 @@ mod tests { "linked_objectives": [], "implementation_reports": [], "assignments": [], - "current_coder": null, + "current_worker": null, "merge_requests": [], "merge_request": null, "evidence": { @@ -889,7 +889,7 @@ mod tests { "can_assign_orchestrator": true, "can_unassign_orchestrator": false, "can_queue": false, - "can_start_manual_coder": false + "can_start_manual_worker": false }, "event_page": {"next_cursor": null, "has_more": false} })) diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 818d71a21..89873cee2 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -750,9 +750,9 @@ impl SqliteWorkspaceAuthority { assigned_at: assignment.assigned_at, }) .collect::>(); - let current_coder = role_assignments + let current_worker = role_assignments .iter() - .find(|assignment| assignment.role == TicketAssignmentRole::Coder) + .find(|assignment| assignment.role == TicketAssignmentRole::Worker) .and_then(|assignment| { assignment .principal @@ -779,9 +779,9 @@ impl SqliteWorkspaceAuthority { let has_orchestrator = active_role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator); - let has_coder = active_role_assignments + let has_worker = active_role_assignments .iter() - .any(|assignment| assignment.role == TicketAssignmentRole::Coder); + .any(|assignment| assignment.role == TicketAssignmentRole::Worker); let targets = ticket .meta .targets @@ -799,7 +799,6 @@ impl SqliteWorkspaceAuthority { }) .collect::>>()?; let write_repository_keys = ticket_write_target_repository_keys(&targets); - let has_target = !write_repository_keys.is_empty(); let has_blockers = !ticket.relations.blockers.is_empty(); let mut assignment_diagnostics = Vec::new(); if let Some(legacy_assignee) = ticket @@ -818,17 +817,20 @@ impl SqliteWorkspaceAuthority { ticket.meta.workflow_state, TicketWorkflowState::Planning | TicketWorkflowState::Ready ) && !has_orchestrator - && !has_coder, + && !has_worker, can_unassign_orchestrator: has_orchestrator && matches!( ticket.meta.workflow_state, TicketWorkflowState::Planning | TicketWorkflowState::Ready ), can_queue: true, - can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready - && !has_orchestrator - && !has_coder - && has_target + can_start_manual_worker: matches!( + ticket.meta.workflow_state, + TicketWorkflowState::Planning + | TicketWorkflowState::Ready + | TicketWorkflowState::Queued + | TicketWorkflowState::InProgress + ) && !has_worker && !has_blockers, queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, @@ -960,7 +962,7 @@ impl SqliteWorkspaceAuthority { linked_objectives, implementation_reports, assignments, - current_coder, + current_worker, assignment_diagnostics, action_eligibility, merge_requests, @@ -2353,7 +2355,7 @@ fn ticket_query_item( evidence: detail.evidence.clone(), merge_requests: detail.merge_requests.clone(), merge_request: detail.merge_request.clone(), - current_coder: detail.current_coder.clone(), + current_worker: detail.current_worker.clone(), } } @@ -3193,6 +3195,143 @@ mod tests { ); } + #[tokio::test] + async fn manual_worker_eligibility_accepts_explicit_states_without_reviving_released_work() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "w".into(), + owner_account_id: "owner-account".into(), + display_name: "W".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + store + .upsert_worker_registry(&crate::store::WorkerRegistryRecord { + workspace_id: "w".into(), + worker: worker_runtime::identity::RuntimeWorkerRef::new("runtime", "worker"), + display_name: "Worker".into(), + profile: Some("builtin:default".into()), + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let ticket = backend + .create(ticket::NewTicket::new("Explicit work")) + .unwrap(); + let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap(); + for (state, allowed) in [ + ("planning", true), + ("ready", true), + ("queued", true), + ("inprogress", true), + ("done", false), + ("closed", false), + ] { + conn.execute( + "UPDATE typed_tickets SET workflow_state=?1 WHERE ticket_id=?2", + rusqlite::params![state, ticket.id], + ) + .unwrap(); + assert_eq!( + authority + .ticket(&ticket.id) + .unwrap() + .action_eligibility + .can_start_manual_worker, + allowed, + "{state}" + ); + } + conn.execute( + "UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1", + [&ticket.id], + ) + .unwrap(); + let record = crate::store::TicketRoleAssignmentRecord { + workspace_id: "w".into(), + ticket_id: ticket.id.clone(), + assignment_id: "assignment".into(), + role: TicketAssignmentRole::Worker, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: "runtime".into(), + worker_id: "worker".into(), + }, + assigned_by: "owner-account".into(), + assigned_at: "2".into(), + }; + let orchestrator = crate::store::TicketRoleAssignmentRecord { + role: TicketAssignmentRole::Orchestrator, + assignment_id: "orchestrator".into(), + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..record.clone() + }; + store + .set_current_ticket_role_assignment( + &orchestrator, + None, + "orchestrator-event", + "orchestrator-op", + false, + ) + .unwrap(); + assert!( + authority + .ticket(&ticket.id) + .unwrap() + .action_eligibility + .can_start_manual_worker + ); + store + .start_ticket_with_worker_assignment(&record, "event", "operation", None) + .unwrap(); + assert!( + !authority + .ticket(&ticket.id) + .unwrap() + .action_eligibility + .can_start_manual_worker + ); + conn.execute( + "UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id=?1", + [&ticket.id], + ) + .unwrap(); + conn.execute( + "UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1", + [&ticket.id], + ) + .unwrap(); + let detail = authority.ticket(&ticket.id).unwrap(); + assert!(detail.action_eligibility.can_start_manual_worker); + assert_eq!(detail.assignments.len(), 2); + let worker = detail + .assignments + .iter() + .find(|a| a.role == "worker") + .unwrap(); + assert_eq!(worker.assignment_id, record.assignment_id); + assert_eq!( + detail.current_worker.unwrap().assignment_id, + record.assignment_id + ); + } + #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index a91b2b160..c6f178b17 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -484,7 +484,7 @@ pub(crate) fn worker_spawn_idempotency( let Some(assignment) = request.ticket_assignment.as_ref() else { return Ok(None); }; - let encoded = serde_json::to_vec(request) + let encoded = serde_json::to_vec(&(request, request.resolved_workdir_attachments.as_slice())) .map_err(|error| format!("serialize Worker spawn idempotency input: {error}"))?; Ok(Some(( assignment.operation_id.clone(), @@ -578,7 +578,7 @@ pub enum WorkerSpawnIntent { pub enum TicketWorkerRole { Intake, Orchestrator, - Coder, + Worker, Reviewer, } @@ -6456,7 +6456,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str { WorkerSpawnIntent::TicketRole { role, .. } => match role { TicketWorkerRole::Intake => "ticket_intake", TicketWorkerRole::Orchestrator => "ticket_orchestrator", - TicketWorkerRole::Coder => "ticket_coder", + TicketWorkerRole::Worker => "ticket_worker", TicketWorkerRole::Reviewer => "ticket_reviewer", }, } @@ -6521,6 +6521,27 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[test] + fn ticket_worker_spawn_intent_uses_worker_wire_role() { + let intent = WorkerSpawnIntent::TicketRole { + ticket_id: "ticket".into(), + role: TicketWorkerRole::Worker, + }; + let encoded = json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "worker"}); + assert_eq!(serde_json::to_value(&intent).unwrap(), encoded); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + intent + ); + assert!( + serde_json::from_value::( + json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "coder"}) + ) + .is_err() + ); + assert_eq!(worker_spawn_intent_label(&intent), "ticket_worker"); + } + #[test] fn remote_restore_transport_failure_requires_reconciliation() { let provider = RemoteWorkerRuntime::new( @@ -7538,7 +7559,7 @@ mod tests { WorkerSpawnRequest { intent: WorkerSpawnIntent::TicketRole { ticket_id: "00001KVZSGT0Q".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, requested_worker_name: None, singleton_key: None, @@ -7827,7 +7848,7 @@ mod tests { WorkerSpawnRequest { intent: WorkerSpawnIntent::TicketRole { ticket_id: "00001KVZSGT0Q".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, requested_worker_name: Some("friendly-name-is-not-authority".to_string()), singleton_key: None, @@ -7939,7 +7960,7 @@ mod tests { WorkerSpawnRequest { intent: WorkerSpawnIntent::TicketRole { ticket_id: "00001KVZSGT0Q".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, requested_worker_name: None, singleton_key: None, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 3e9db8685..87fbac7cc 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -335,8 +335,8 @@ CREATE TABLE ticket_assignment_operations ( operation_id TEXT NOT NULL, action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), principal_kind TEXT NOT NULL DEFAULT 'worker' CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), principal_id TEXT, @@ -346,8 +346,14 @@ CREATE TABLE ticket_assignment_operations ( expected_assignment_id TEXT, created_at TEXT NOT NULL, request_fingerprint TEXT, + claim_state TEXT NOT NULL DEFAULT 'committed' + CHECK(claim_state IN ('pending', 'failed', 'committed')), + failure_reason TEXT, + binding_recovery_json TEXT, PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK(claim_state = 'committed' OR assignment_id IS NULL), + CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) ); CREATE TABLE ticket_assignment_ticket_tombstones ( workspace_id TEXT NOT NULL, @@ -367,8 +373,8 @@ CREATE TABLE ticket_assignment_worker_tombstones ( CREATE TABLE ticket_current_worker_assignments ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), assignment_id TEXT NOT NULL, principal_kind TEXT NOT NULL DEFAULT 'worker' CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), @@ -393,8 +399,8 @@ CREATE TABLE ticket_current_worker_assignments ( CREATE TABLE ticket_worker_assignment_events ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), event_id TEXT NOT NULL, action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), assignment_id TEXT, @@ -410,8 +416,8 @@ CREATE TABLE ticket_worker_assignments ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, assignment_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), principal_kind TEXT NOT NULL DEFAULT 'worker' CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), principal_id TEXT, @@ -1215,7 +1221,7 @@ CREATE INDEX ticket_current_principal_idx ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); CREATE UNIQUE INDEX ticket_current_singleton_role_idx ON ticket_current_worker_assignments(workspace_id, ticket_id, role) - WHERE role IN ('orchestrator', 'coder'); + WHERE role IN ('orchestrator', 'worker'); CREATE INDEX typed_ticket_events_workspace_kind_ticket ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 34cf2c671..2dd96996a 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -69,6 +69,10 @@ pub struct WorkerRemovalPlanRequest { #[serde(tag = "kind", rename_all = "snake_case")] pub enum WorkerRemovalBlocker { Hold, + PendingTicketClaim { + operation_id: String, + ticket_id: String, + }, UnfinishedWork { assignment_id: String, ticket_id: String, @@ -232,6 +236,9 @@ impl SqliteWorkspaceStore { if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? { blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id}); } + if let Some((operation_id,ticket_id))=tx.query_row("SELECT operation_id,ticket_id FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign')",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? { + blockers.push(WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id}); + } let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?; let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp); let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp)); @@ -276,7 +283,7 @@ impl SqliteWorkspaceStore { mark_stale(&tx,&plan,"hold added")?;tx.commit()?; return Err(stale_error(&plan,"hold added")); } - let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?; + let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?; if assigned{ mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?; return Err(stale_error(&plan,"unfinished work added")); @@ -462,7 +469,7 @@ impl SqliteWorkspaceStore { let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?; if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));} if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); } - let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?; + let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?; if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); } let now=Utc::now().to_rfc3339(); if let Some(a)=&result.archive{ @@ -1092,7 +1099,7 @@ fn parse_state(v: &str) -> rusqlite::Result { mod tests { use super::*; use crate::store::{ - ControlPlaneStore, TicketCoderAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord, + ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord, }; use worker_runtime::identity::WorkerId; fn worker_id() -> WorkerId { @@ -1431,7 +1438,7 @@ mod tests { s: &SqliteWorkspaceStore, assignment_id: &str, ) -> crate::Result<()> { - let assignment = TicketCoderAssignmentRecord { + let assignment = TicketWorkerAssignmentRecord { workspace_id: "w".into(), ticket_id: "ticket".into(), assignment_id: assignment_id.into(), @@ -1439,7 +1446,7 @@ mod tests { assigned_by: "test".into(), assigned_at: "t".into(), }; - s.set_current_ticket_coder_assignment( + s.set_current_ticket_worker_assignment( &assignment, None, &format!("event-{assignment_id}"), @@ -1474,6 +1481,38 @@ mod tests { store } + #[test] + fn pending_ticket_claim_blocks_removal_and_is_rechecked_after_planning() { + let s = setup(); + let old = s.plan_worker_removal(&req(), &inv()).unwrap(); + s.reserve_ticket_assignment_operation( + "w", + "claim", + "ticket", + "r", + Some(&worker_id().to_string()), + "intent", + "t", + ) + .unwrap(); + let blocked = s.plan_worker_removal(&req(), &inv()).unwrap(); + assert!( + matches!(&blocked.blockers[..], [WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id}] if operation_id=="claim" && ticket_id=="ticket") + ); + assert!( + s.begin_worker_removal("w", &old.plan_id, &old.input_fingerprint) + .is_err() + ); + s.fail_ticket_assignment_operation("w", "claim", "No Runtime effects dispatched") + .unwrap(); + assert!( + s.plan_worker_removal(&req(), &inv()) + .unwrap() + .blockers + .is_empty() + ); + } + #[test] fn assignment_and_orphan_are_authoritative() { let s = setup(); @@ -1758,7 +1797,7 @@ mod tests { let replaced = tx.execute( "UPDATE ticket_current_worker_assignments SET assignment_id='new-assignment', updated_at='new' - WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder' + WHERE workspace_id='w' AND ticket_id='ticket' AND role='worker' AND assignment_id='assignment' AND EXISTS ( SELECT 1 FROM ticket_assignment_work_releases @@ -2088,7 +2127,7 @@ mod tests { ).map_err(StoreError::from)).unwrap(); assert_eq!(revision, "rev1"); - let assignment = TicketCoderAssignmentRecord { + let assignment = TicketWorkerAssignmentRecord { workspace_id: "w".into(), ticket_id: "new-ticket".into(), assignment_id: "new-assignment".into(), @@ -2101,7 +2140,7 @@ mod tests { }; assert!( store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &assignment, None, "event", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index eb92e0795..0eadbc906 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -209,7 +209,7 @@ use crate::store::{ DeviceLoginFlowRecord, ExternalWorkdirGrantRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome, RepositoryRecord, RuntimeRemovalOperation, RuntimeRemovalOperationState, TicketAssignmentPrincipal, TicketAssignmentRole, - TicketCoderAssignmentRecord, TicketRoleAssignmentRecord, UserRecord, + TicketRoleAssignmentRecord, TicketWorkerAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord, WorkdirRegistrySource, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, @@ -4714,9 +4714,39 @@ impl WorkspaceApi { } fn spawn_workspace_worker( + &self, + runtime_id: &str, + request: WorkerSpawnRequest, + ) -> ApiResult { + let assignment = request.ticket_assignment.clone(); + let mut dispatched = false; + let mut result = self.spawn_workspace_worker_inner(runtime_id, request, &mut dispatched); + // A definite pre-dispatch rejection must not strand a Ticket claim. Once + // dispatch was attempted, only confirmed compensation may settle it. + if !dispatched && result.is_err() { + if let Some(assignment) = assignment.as_ref() { + if let Err(error) = self.store.fail_ticket_assignment_operation( + &self.config.workspace_id, + &assignment.operation_id, + "Worker creation rejected before Runtime dispatch", + ) { + if let Err(source) = &mut result { + source.diagnostics.push(spawn_compensation_diagnostic( + "ticket_claim_failure_record_failed", + error.to_string(), + )); + } + } + } + } + result + } + + fn spawn_workspace_worker_inner( &self, runtime_id: &str, mut request: WorkerSpawnRequest, + dispatched: &mut bool, ) -> ApiResult { if let Some(singleton_key) = request.singleton_key.as_deref() { crate::store::validate_worker_singleton_key(singleton_key)?; @@ -4798,14 +4828,22 @@ impl WorkspaceApi { } }; let worker_id = reservation.worker_id; + if let Some(assignment) = request.ticket_assignment.as_ref() { + self.store.bind_ticket_assignment_operation_worker( + &self.config.workspace_id, + &assignment.operation_id, + &worker_id.to_string(), + )?; + } request.resolved_memory_settings = Some(reservation.memory_settings); let reservation_fingerprint = reservation.create_fingerprint.clone(); let create_binding = WorkerCreateBinding { worker_id, create_fingerprint: reservation.create_fingerprint, }; + let lifecycle_assignment = request.ticket_assignment.clone(); let compensation_context = WorkerSpawnCompensationContext { - assignment: None, + assignment: lifecycle_assignment.as_ref(), spawned_workdir_ids: &spawned_workdir_ids, }; let mut reserved_attachments: Vec<( @@ -4853,6 +4891,7 @@ impl WorkspaceApi { *capabilities, )); } + *dispatched = true; let mut result = match self .runtime .spawn_worker(runtime_id, create_binding, request) @@ -4860,6 +4899,7 @@ impl WorkspaceApi { Ok(result) => result, Err(error) => { if matches!(&error, RuntimeRegistryError::UnknownRuntime(_)) { + *dispatched = false; // Registry lookup failed before dispatch, so no Runtime could have created this // Worker. Terminalize the reservation immediately instead of retaining an // uncertain singleton generation forever. @@ -5407,7 +5447,7 @@ impl WorkspaceApi { .into()); } - let targets = validated_ticket_implementation_targets(self, ticket_id)?; + let targets = validated_ticket_resource_targets(self, ticket_id)?; let mut aliases = HashSet::new(); let mut workdir_ids = HashSet::new(); let mut covered_repositories = HashSet::new(); @@ -5440,8 +5480,11 @@ impl WorkspaceApi { runtime_id, repository_id, } => (runtime_id, repository_id), - WorkdirRegistrySource::ExternalGrant { .. } - | WorkdirRegistrySource::WorkspaceConfig { .. } => { + WorkdirRegistrySource::ExternalGrant { .. } => { + claim.capabilities = workdir_source_capabilities(self, &workdir)?; + continue; + } + WorkdirRegistrySource::WorkspaceConfig { .. } => { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", claim.alias @@ -5465,10 +5508,14 @@ impl WorkspaceApi { claim.alias ))) })?; - if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + if target + .ref_selector + .as_deref() + .is_some_and(|selector| workdir.creation_selector.as_deref() != Some(selector)) + { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` target selector `{}` does not match attachment `{}` selector `{}`", - target.ref_selector, + target.ref_selector.as_deref().unwrap_or("unspecified"), claim.alias, workdir.creation_selector.as_deref().unwrap_or("none") )) @@ -5486,35 +5533,15 @@ impl WorkspaceApi { .intersection(workdir_source_capabilities(self, &workdir)?); } - let missing = targets - .iter() - .filter(|target| !covered_repositories.contains(&target.repository_id)) - .map(|target| target.repository_key.as_str()) - .collect::>(); - if !missing.is_empty() { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` is missing one-to-one Workdir attachment coverage for target(s): {}", - missing.join(", ") - )) - .into()); - } - if request.resolved_workdir_attachments.len() != targets.len() { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` requires exactly {} Workdir attachment(s), found {}", - targets.len(), - request.resolved_workdir_attachments.len() - )) - .into()); - } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] -struct ValidatedTicketImplementationTarget { +struct ValidatedTicketResourceTarget { repository_id: String, repository_key: String, - ref_selector: String, + ref_selector: Option, access: TicketTargetAccess, capabilities: workdir::WorkdirSessionCapabilities, } @@ -5595,32 +5622,13 @@ fn workdir_source_capabilities( } } -fn validated_ticket_implementation_targets( +fn validated_ticket_resource_targets( api: &WorkspaceApi, ticket_id: &str, -) -> ApiResult> { +) -> ApiResult> { let ticket = browser_ticket_backend(api)? .show(TicketIdOrSlug::Id(ticket_id.to_string())) .map_err(Error::from)?; - if ticket.meta.targets.is_empty() { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` has no persisted implementation targets" - )) - .into()); - } - let read_write_count = ticket - .meta - .targets - .iter() - .filter(|target| target.access == TicketTargetAccess::ReadWrite) - .count(); - if read_write_count == 0 { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` requires at least one read_write target" - )) - .into()); - } - let mut repository_ids = HashSet::new(); ticket .meta @@ -5639,22 +5647,14 @@ fn validated_ticket_implementation_targets( )) .into()); } - let ref_selector = target - .ref_selector - .filter(|selector| !selector.trim().is_empty()) - .ok_or_else(|| { - ApiError::from(Error::InvalidInput(format!( - "Ticket `{ticket_id}` target Repository `{}` has no persisted selector", - repository.repository_key - ))) - })?; + let ref_selector = target.ref_selector.filter(|selector| !selector.trim().is_empty()); let capabilities = match target.access { TicketTargetAccess::ReadOnly => { workdir::WorkdirSessionCapabilities::READ_ONLY } TicketTargetAccess::ReadWrite => workdir::WorkdirSessionCapabilities::ALL, }; - Ok(ValidatedTicketImplementationTarget { + Ok(ValidatedTicketResourceTarget { repository_id, repository_key: repository.repository_key, ref_selector, @@ -5670,13 +5670,13 @@ fn require_ticket_read_write_target( ticket_id: &str, repository_id: &str, selector: &str, -) -> ApiResult { - let write_target = validated_ticket_implementation_targets(api, ticket_id)? +) -> ApiResult { + let write_target = validated_ticket_resource_targets(api, ticket_id)? .into_iter() .find(|target| { target.access == TicketTargetAccess::ReadWrite && target.repository_id == repository_id - && target.ref_selector == selector + && target.ref_selector.as_deref() == Some(selector) }) .ok_or_else(|| { ApiError::from(Error::InvalidInput( @@ -5703,17 +5703,14 @@ fn ticket_target_capabilities_for_workdir( let Some(assignment) = api .store .get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? - .filter(|assignment| assignment.role == TicketAssignmentRole::Coder) + .filter(|assignment| assignment.role == TicketAssignmentRole::Worker) else { return Ok(source_capabilities); }; let WorkdirRegistrySource::Repository { repository_id, .. } = &workdir.source else { - return Err(Error::InvalidInput(format!( - "assigned Ticket Coder Workdir `{}` is not a Repository target", - workdir.workdir_id - ))); + return Ok(source_capabilities); }; - let targets = validated_ticket_implementation_targets(api, &assignment.ticket_id) + let targets = validated_ticket_resource_targets(api, &assignment.ticket_id) .map_err(|error| error.error)?; let target = targets .into_iter() @@ -5724,7 +5721,11 @@ fn ticket_target_capabilities_for_workdir( workdir.workdir_id, assignment.ticket_id )) })?; - if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + if target + .ref_selector + .as_deref() + .is_some_and(|selector| workdir.creation_selector.as_deref() != Some(selector)) + { return Err(Error::InvalidInput(format!( "Workdir `{}` selector does not match assigned Ticket `{}` target selector", workdir.workdir_id, assignment.ticket_id @@ -5747,24 +5748,45 @@ fn effective_worker_workdir_capabilities( } #[derive(Clone, Debug)] -struct ManualTicketCoderWorkdirBinding { +struct ManualTicketWorkerWorkdirBinding { original_links: Vec, effective_links: Vec, runtime_attachments: Vec, } -fn validate_manual_ticket_coder_workdir_binding( +fn validate_manual_ticket_worker_workdir_binding( api: &WorkspaceApi, worker: &RuntimeWorkerRef, ticket_id: &str, -) -> ApiResult { - let targets = validated_ticket_implementation_targets(api, ticket_id)?; + selected: &[server_api::TicketWorkerAttachmentBinding], +) -> ApiResult { + let targets = validated_ticket_resource_targets(api, ticket_id)?; let original_links = api .store .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) .collect::>(); + // An existing Worker must explicitly bind every retained connection. Assignment + // alone cannot silently inherit unrelated Repository or ExternalGrant authority. + if selected.len() != original_links.len() + || original_links.iter().any(|link| { + selected + .iter() + .filter(|selection| { + selection.alias == link.alias + && selection.working_directory_id == link.workdir_id + && selection.connection_id == link.connection_id + }) + .count() + != 1 + }) + { + return Err(Error::TicketAssignmentConflict( + "explicit Worker claim must name the exact live Workdir connections; detach unwanted resources before claiming" + .to_string(), + ).into()); + } let mut aliases = HashSet::new(); let mut workdir_ids = HashSet::new(); let mut covered_repositories = HashSet::new(); @@ -5817,8 +5839,23 @@ fn validate_manual_ticket_coder_workdir_binding( runtime_id, repository_id, } => (runtime_id, repository_id), - WorkdirRegistrySource::ExternalGrant { .. } - | WorkdirRegistrySource::WorkspaceConfig { .. } => { + WorkdirRegistrySource::ExternalGrant { .. } => { + let capabilities = link + .capabilities + .intersection(workdir_source_capabilities(api, &workdir)?); + effective_links.push(WorkerWorkdirLinkRecord { + capabilities, + ..link.clone() + }); + runtime_attachments.push(LogicalWorkdirAttachment { + alias: workdir::WorkdirAttachmentAlias::new(link.alias.clone()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + working_directory_id: link.workdir_id.clone(), + capabilities, + }); + continue; + } + WorkdirRegistrySource::WorkspaceConfig { .. } => { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", link.alias @@ -5842,10 +5879,14 @@ fn validate_manual_ticket_coder_workdir_binding( link.alias ))) })?; - if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + if target + .ref_selector + .as_deref() + .is_some_and(|selector| workdir.creation_selector.as_deref() != Some(selector)) + { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` target selector `{}` does not match attachment `{}` selector `{}`", - target.ref_selector, + target.ref_selector.as_deref().unwrap_or("unspecified"), link.alias, workdir.creation_selector.as_deref().unwrap_or("none") )) @@ -5874,28 +5915,7 @@ fn validate_manual_ticket_coder_workdir_binding( }); } - let missing = targets - .iter() - .filter(|target| !covered_repositories.contains(&target.repository_id)) - .map(|target| target.repository_key.as_str()) - .collect::>(); - if !missing.is_empty() { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` is missing one-to-one Workdir attachment coverage for target(s): {}", - missing.join(", ") - )) - .into()); - } - if original_links.len() != targets.len() { - return Err(Error::InvalidInput(format!( - "Ticket `{ticket_id}` requires exactly {} Workdir attachment(s), found {}", - targets.len(), - original_links.len() - )) - .into()); - } - - Ok(ManualTicketCoderWorkdirBinding { + Ok(ManualTicketWorkerWorkdirBinding { original_links, effective_links, runtime_attachments, @@ -6757,7 +6777,7 @@ fn worker_spawn_request_from_api( server_api::RuntimeTicketWorkerRole::Orchestrator => { TicketWorkerRole::Orchestrator } - server_api::RuntimeTicketWorkerRole::Coder => TicketWorkerRole::Coder, + server_api::RuntimeTicketWorkerRole::Worker => TicketWorkerRole::Worker, server_api::RuntimeTicketWorkerRole::Reviewer => TicketWorkerRole::Reviewer, }, } @@ -12819,7 +12839,7 @@ async fn scoped_show_ticket( fn parse_ticket_assignment_role(role: &str) -> ApiResult { match role { "orchestrator" => Ok(TicketAssignmentRole::Orchestrator), - "coder" => Ok(TicketAssignmentRole::Coder), + "worker" => Ok(TicketAssignmentRole::Worker), "owner" => Ok(TicketAssignmentRole::Owner), "contributor" => Ok(TicketAssignmentRole::Contributor), _ => Err(Error::InvalidInput(format!("unknown Ticket assignment role `{role}`")).into()), @@ -12831,7 +12851,7 @@ fn project_ticket_role_assignment_record( ) -> server_api::TicketRoleAssignmentRecord { let role = match record.role { TicketAssignmentRole::Orchestrator => server_api::TicketAssignmentRole::Orchestrator, - TicketAssignmentRole::Coder => server_api::TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker => server_api::TicketAssignmentRole::Worker, TicketAssignmentRole::Owner => server_api::TicketAssignmentRole::Owner, TicketAssignmentRole::Contributor => server_api::TicketAssignmentRole::Contributor, }; @@ -12904,10 +12924,10 @@ async fn scoped_list_ticket_assignments( })) } -async fn compensate_manual_ticket_coder_binding( +async fn compensate_manual_ticket_worker_binding( api: &WorkspaceApi, worker: &RuntimeWorkerRef, - binding: &ManualTicketCoderWorkdirBinding, + binding: &ManualTicketWorkerWorkdirBinding, operation_id: &str, worker_may_be_live: bool, ) -> Vec { @@ -12917,7 +12937,7 @@ async fn compensate_manual_ticket_coder_binding( worker, WorkerLifecycleRequest { reason: Some( - "roll back failed manual Coder assignment capability binding".to_string(), + "roll back failed manual Worker assignment capability binding".to_string(), ), ticket_assignment: None, }, @@ -12925,14 +12945,14 @@ async fn compensate_manual_ticket_coder_binding( Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} Ok(result) => { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_worker_stop_rollback_failed", + "manual_worker_worker_stop_rollback_failed", runtime_diagnostics_message(&result.diagnostics), )); return diagnostics; } Err(error) => { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_worker_stop_rollback_failed", + "manual_worker_worker_stop_rollback_failed", sanitize_backend_error(&error.message()), )); return diagnostics; @@ -12940,19 +12960,43 @@ async fn compensate_manual_ticket_coder_binding( } if let Err(error) = close_current_worker_session_locked(api, worker).await { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_session_close_rollback_failed", + "manual_worker_session_close_rollback_failed", sanitize_backend_error(&error.to_string()), )); return diagnostics; } } + let mut original_links = binding.original_links.clone(); + for link in &mut original_links { + let ceiling = api + .store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .map_err(ApiError::from) + .and_then(|record| { + record.ok_or_else(|| { + Error::InvalidInput(format!("Workdir {} is unavailable", link.workdir_id)) + .into() + }) + }) + .and_then(|record| workdir_source_capabilities(api, &record).map_err(ApiError::from)); + match ceiling { + Ok(capabilities) => link.capabilities = link.capabilities.intersection(capabilities), + Err(error) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_worker_capability_rollback_unavailable", + error.error.to_string(), + )); + return diagnostics; + } + } + } if let Err(error) = api.store.replace_worker_workdir_link_capabilities( &api.config.workspace_id, worker, - &binding.original_links, + &original_links, ) { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_workdir_capability_rollback_failed", + "manual_worker_workdir_capability_rollback_failed", format!( "Failed to restore Workdir capabilities for Worker {}:{}: {}", worker.runtime_id, @@ -12962,11 +13006,11 @@ async fn compensate_manual_ticket_coder_binding( )); return diagnostics; } - let original_attachments = match logical_attachments_from_links(&binding.original_links) { + let original_attachments = match logical_attachments_from_links(&original_links) { Ok(attachments) => attachments, Err(error) => { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_runtime_attachment_rollback_failed", + "manual_worker_runtime_attachment_rollback_failed", sanitize_backend_error(&error.to_string()), )); return diagnostics; @@ -12979,14 +13023,14 @@ async fn compensate_manual_ticket_coder_binding( Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} Ok(result) => { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_runtime_attachment_rollback_failed", + "manual_worker_runtime_attachment_rollback_failed", runtime_diagnostics_message(&result.diagnostics), )); return diagnostics; } Err(error) => { diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_runtime_attachment_rollback_failed", + "manual_worker_runtime_attachment_rollback_failed", sanitize_backend_error(&error.message()), )); return diagnostics; @@ -12999,21 +13043,22 @@ async fn compensate_manual_ticket_coder_binding( ) { Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => {} Ok(result) => diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_worker_restore_failed", + "manual_worker_worker_restore_failed", runtime_diagnostics_message(&result.diagnostics), )), Err(error) => diagnostics.push(spawn_compensation_diagnostic( - "manual_coder_worker_restore_failed", + "manual_worker_worker_restore_failed", sanitize_backend_error(&error.error.to_string()), )), } diagnostics } -async fn start_manual_ticket_coder_assignment( +async fn start_manual_ticket_worker_assignment( api: &WorkspaceApi, record: &TicketRoleAssignmentRecord, operation_id: &str, + selected: &[server_api::TicketWorkerAttachmentBinding], ) -> ApiResult { let TicketAssignmentPrincipal::Worker { runtime_id, @@ -13021,20 +13066,110 @@ async fn start_manual_ticket_coder_assignment( } = &record.principal else { return Err(Error::TicketAssignmentConflict( - "Workspace agent principal cannot occupy the Coder role".to_string(), + "Workspace agent principal cannot occupy the Worker role".to_string(), ) .into()); }; let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); + let session_lock = current_worker_session_lock(api, &worker); + let _session_guard = session_lock.lock().await; - if api + let mut selected = selected.to_vec(); + selected.sort_by(|left, right| left.alias.cmp(&right.alias)); + let recovery = api + .config_store + .get_ticket_claim_recovery(&api.config.workspace_id, operation_id)?; + let item_revision = match &recovery { + Some(saved) => saved.item_revision.clone(), + None => api.authority.ticket(&record.ticket_id)?.item_revision, + }; + let binding_fingerprint = serde_json::to_string(&(&selected, &item_revision)) + .map_err(|error| Error::Store(error.to_string()))?; + let fingerprint = + crate::store::manual_ticket_assignment_fingerprint(record, Some(&binding_fingerprint))?; + if let Some(operation) = api .store .get_ticket_assignment_operation(&api.config.workspace_id, operation_id)? - .is_some() { + if operation.request_fingerprint.as_deref() != Some(fingerprint.as_str()) { + return Err(Error::TicketAssignmentConflict( + "manual Worker claim operation was reused with different input".into(), + ) + .into()); + } + if operation.claim_state == "failed" { + return Err(Error::TicketAssignmentConflict(format!( + "manual claim failed: {}", + operation + .failure_reason + .as_deref() + .unwrap_or("unknown failure") + )) + .into()); + } + if operation.assignment_id.is_none() { + let saved = recovery.as_ref().ok_or_else(|| Error::TicketAssignmentConflict("pending manual claim has no admitted recovery context; reconciliation required".into()))?; + let request_id = format!("manual-worker-restore:{operation_id}"); + let request=api.config_store.get_internal_worker_restore_intent(&api.config.workspace_id,&worker,&request_id)?.ok_or_else(|| Error::TicketAssignmentConflict("pending manual claim has no pinned restore intent; reconciliation required before any Runtime effects".into()))?; + let binding = ManualTicketWorkerWorkdirBinding { + original_links: saved.original_links.clone(), + effective_links: saved.effective_links.clone(), + runtime_attachments: logical_attachments_from_links(&saved.effective_links)?, + }; + let restored = resume_internal_worker_restore_intent(api, &worker, request)?; + match restored.state { + server_api::WorkerRestoreState::Accepted => { + return finalize_manual_ticket_worker_claim( + api, + record, + operation_id, + &binding_fingerprint, + &item_revision, + &worker, + &binding, + ) + .await; + } + server_api::WorkerRestoreState::ReconciliationRequired => { + return Err(ApiError::with_diagnostics( + Error::TicketAssignmentConflict( + "manual Worker restore remains pending; reconcile this exact claim" + .into(), + ), + restored.diagnostics, + )); + } + _ => { + let diagnostics = compensate_manual_ticket_worker_binding( + api, + &worker, + &binding, + operation_id, + false, + ) + .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "manual restore rejected and compensated", + )?; + } + return Err(ApiError::with_diagnostics( + Error::TicketAssignmentConflict("manual restore rejected".into()), + diagnostics, + )); + } + } + } return api .store - .start_ready_ticket_with_coder_assignment(record, &new_id("tasev"), operation_id) + .start_ticket_with_worker_assignment( + record, + &new_id("tasev"), + operation_id, + Some(&binding_fingerprint), + ) .map_err(ApiError::from); } @@ -13044,21 +13179,51 @@ async fn start_manual_ticket_coder_assignment( .map_err(|error| error.into_error())?; if observed.state != "idle" { return Err(Error::TicketAssignmentConflict(format!( - "manual Coder assignment requires an idle Worker; Worker {}:{} is {}", + "manual Worker assignment requires an idle Worker; Worker {}:{} is {}", worker.runtime_id, worker.worker_id, observed.state )) .into()); } - let session_lock = current_worker_session_lock(api, &worker); - let _session_guard = session_lock.lock().await; - let binding = validate_manual_ticket_coder_workdir_binding(api, &worker, &record.ticket_id)?; + let binding = + validate_manual_ticket_worker_workdir_binding(api, &worker, &record.ticket_id, &selected)?; + let admitted = api.store.reserve_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + &record.ticket_id, + &worker.runtime_id, + Some(&worker.worker_id), + &fingerprint, + &record.assigned_at, + )?; + if !admitted { + return Err(Error::TicketAssignmentConflict("manual Worker claim is already admitted; reread its durable result instead of redispatching".into()).into()); + } + let saved = crate::store::TicketWorkerClaimRecovery { + item_revision: item_revision.clone(), + selected: selected.clone(), + original_links: binding.original_links.clone(), + effective_links: binding.effective_links.clone(), + }; + if let Err(error) = api.config_store.retain_ticket_claim_recovery( + &api.config.workspace_id, + operation_id, + &fingerprint, + &saved, + ) { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "manual claim recovery could not be persisted before dispatch", + )?; + return Err(error.into()); + } let stopped = api .runtime .stop_worker( &worker, WorkerLifecycleRequest { reason: Some(format!( - "bind authoritative Workdir capabilities for Ticket {} manual Coder assignment", + "bind authoritative Workdir capabilities for Ticket {} manual Worker assignment", record.ticket_id )), ticket_assignment: None, @@ -13066,9 +13231,16 @@ async fn start_manual_ticket_coder_assignment( ) .map_err(|error| error.into_error())?; if stopped.state != InternalWorkerOperationState::Accepted { + if stopped.state == InternalWorkerOperationState::Rejected { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "Runtime rejected manual Worker stop before resource binding", + )?; + } return Err(Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), - code: "manual_coder_worker_stop_rejected".to_string(), + code: "manual_worker_worker_stop_rejected".to_string(), message: runtime_diagnostics_message(&stopped.diagnostics), } .into()); @@ -13077,18 +13249,25 @@ async fn start_manual_ticket_coder_assignment( let diagnostics = match restore_internal_worker_intent( &api, &worker, - format!("manual-coder-recover:{operation_id}"), + format!("manual-worker-recover:{operation_id}"), ) { Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => Vec::new(), Ok(result) => vec![spawn_compensation_diagnostic( - "manual_coder_worker_restore_failed", + "manual_worker_worker_restore_failed", runtime_diagnostics_message(&result.diagnostics), )], Err(restore_error) => vec![spawn_compensation_diagnostic( - "manual_coder_worker_restore_failed", + "manual_worker_worker_restore_failed", sanitize_backend_error(&restore_error.error.to_string()), )], }; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + &error.to_string(), + )?; + } return Err(ApiError::with_diagnostics(error, diagnostics)); } @@ -13099,22 +13278,46 @@ async fn start_manual_ticket_coder_assignment( match replacement { Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} Ok(result) => { - let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, false) - .await; + let diagnostics = compensate_manual_ticket_worker_binding( + api, + &worker, + &binding, + operation_id, + false, + ) + .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "manual Worker rebind rejected and compensated", + )?; + } return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), - code: "manual_coder_workdir_binding_rejected".to_string(), + code: "manual_worker_workdir_binding_rejected".to_string(), message: runtime_diagnostics_message(&result.diagnostics), }, diagnostics, )); } Err(error) => { - let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, false) - .await; + let diagnostics = compensate_manual_ticket_worker_binding( + api, + &worker, + &binding, + operation_id, + false, + ) + .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + &error.to_string(), + )?; + } return Err(ApiError::with_diagnostics(error, diagnostics)); } } @@ -13125,47 +13328,114 @@ async fn start_manual_ticket_coder_assignment( &binding.effective_links, ) { let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, false) + compensate_manual_ticket_worker_binding(api, &worker, &binding, operation_id, false) .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + &error.to_string(), + )?; + } return Err(ApiError::with_diagnostics(error, diagnostics)); } match restore_internal_worker_intent( &api, &worker, - format!("manual-coder-restore:{operation_id}"), + format!("manual-worker-restore:{operation_id}"), ) { Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => {} + Ok(result) if result.state == server_api::WorkerRestoreState::ReconciliationRequired => { + return Err(ApiError::with_diagnostics(Error::TicketAssignmentConflict( + "manual Worker restore outcome is unknown; retain the pending claim and reconcile the same restore intent".into() + ), result.diagnostics)); + } Ok(result) => { - let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, false) - .await; + let diagnostics = compensate_manual_ticket_worker_binding( + api, + &worker, + &binding, + operation_id, + false, + ) + .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "manual Worker rebind rejected and compensated", + )?; + } return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), - code: "manual_coder_worker_restore_rejected".to_string(), + code: "manual_worker_worker_restore_rejected".to_string(), message: runtime_diagnostics_message(&result.diagnostics), }, diagnostics, )); } - Err(error) => { - let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, false) - .await; - return Err(ApiError::with_diagnostics(error.error, diagnostics)); - } + // A lost restore response is not proof of rejection. Keep the narrowed + // connections and pending claim; never launch a compensation restore here. + Err(error) => return Err(error), } - match api - .store - .start_ready_ticket_with_coder_assignment(record, &new_id("tasev"), operation_id) - { + finalize_manual_ticket_worker_claim( + api, + record, + operation_id, + &binding_fingerprint, + &item_revision, + &worker, + &binding, + ) + .await +} + +async fn finalize_manual_ticket_worker_claim( + api: &WorkspaceApi, + record: &TicketRoleAssignmentRecord, + operation_id: &str, + binding_fingerprint: &str, + item_revision: &str, + worker: &RuntimeWorkerRef, + binding: &ManualTicketWorkerWorkdirBinding, +) -> ApiResult { + if api.authority.ticket(&record.ticket_id)?.item_revision != item_revision { + let diagnostics = + compensate_manual_ticket_worker_binding(api, &worker, &binding, operation_id, true) + .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + "Ticket changed during explicit Worker binding", + )?; + } + return Err(ApiError::with_diagnostics( + Error::TicketAssignmentConflict("Ticket changed during explicit Worker binding".into()), + diagnostics, + )); + } + match api.store.start_ticket_with_worker_assignment( + record, + &new_id("tasev"), + operation_id, + Some(binding_fingerprint), + ) { Ok(assignment) => Ok(assignment), Err(error) => { let diagnostics = - compensate_manual_ticket_coder_binding(api, &worker, &binding, operation_id, true) + compensate_manual_ticket_worker_binding(api, &worker, &binding, operation_id, true) .await; + if diagnostics.is_empty() { + api.store.fail_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + &error.to_string(), + )?; + } Err(ApiError::with_diagnostics(error, diagnostics)) } } @@ -13213,12 +13483,13 @@ async fn scoped_set_ticket_assignment( .get_active_ticket_role_assignment( &workspace_id, &ticket.id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, )? .is_some() { return Err(Error::TicketAssignmentConflict( - "Orchestrator assignment conflicts with an active Coder assignment".to_string(), + "Orchestrator assignment conflicts with an active Worker assignment" + .to_string(), ) .into()); } @@ -13230,15 +13501,21 @@ async fn scoped_set_ticket_assignment( expected_assignment_id.is_some(), )? } - TicketAssignmentRole::Coder => { + TicketAssignmentRole::Worker => { if expected_assignment_id.is_some() { return Err(Error::TicketAssignmentConflict( - "manual Coder start does not support reassign; clear through a guarded lifecycle operation first" + "manual Worker start does not support reassign; clear through a guarded lifecycle operation first" .to_string(), ) .into()); } - start_manual_ticket_coder_assignment(&api, &record, &operation_id).await? + start_manual_ticket_worker_assignment( + &api, + &record, + &operation_id, + &request.workdir_bindings, + ) + .await? } TicketAssignmentRole::Owner | TicketAssignmentRole::Contributor => { return Err(Error::TicketAssignmentConflict( @@ -13346,7 +13623,7 @@ async fn scoped_cancel_ticket_implementation( let current = api.store.get_active_ticket_role_assignment( &path.workspace_id, &ticket.id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, )?; if let Some(assignment) = current.filter(|value| value.assignment_id == assignment_id) && let TicketAssignmentPrincipal::Worker { @@ -13365,10 +13642,10 @@ async fn scoped_cancel_ticket_implementation( .into()); } let worker = RuntimeWorkerRef::new(runtime_id, worker_id); - cancel_ticket_coder_worker(&api, &worker, &reason).await?; + cancel_ticket_worker(&api, &worker, &reason).await?; } - let cancelled = api.store.cancel_current_ticket_coder_assignment( + let cancelled = api.store.cancel_current_ticket_worker_assignment( &path.workspace_id, &ticket.id, &assignment_id, @@ -13388,7 +13665,7 @@ async fn scoped_cancel_ticket_implementation( browser_ticket_detail(&api, &ticket.id) } -async fn cancel_ticket_coder_worker( +async fn cancel_ticket_worker( api: &WorkspaceApi, worker: &RuntimeWorkerRef, reason: &str, @@ -13408,7 +13685,7 @@ async fn cancel_ticket_coder_worker( Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), code: "workspace_ticket_implementation_cancel_rejected".to_string(), - message: "Runtime did not cancel the assigned Coder Worker".to_string(), + message: "Runtime did not cancel the assigned Worker Worker".to_string(), }, result.diagnostics, )); @@ -13425,37 +13702,18 @@ fn validate_ticket_assignment_state( assignment: &WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if !matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::Queued.as_str() - || state == TicketWorkflowState::InProgress.as_str() - ) { + if matches!(ticket.state.as_str(), "done" | "closed") { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} must be queued or inprogress before assigning an implementation Coder; current state is {}", + "Ticket {} is {}; reopen through an explicit lifecycle decision before starting work", ticket.id, ticket.state ))); } - let Some(orchestrator_assignment) = - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, &ticket.state)? - else { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} cannot be assigned an orchestration Coder without an active Orchestrator role assignment", - ticket.id - ))); - }; - let queued = browser_ticket_backend(api)?.show(TicketIdOrSlug::Id(ticket.id.clone()))?; - let queued_assignment_id = queued - .events - .iter() - .rev() - .find_map(|event| event.attributes.get("orchestrator_assignment_id")); - if queued_assignment_id.map(String::as_str) - != Some(orchestrator_assignment.assignment_id.as_str()) - { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} Queue fence does not match active Orchestrator assignment {}", - ticket.id, orchestrator_assignment.assignment_id - ))); + let dependencies = + browser_ticket_backend(api)?.dependency_check(TicketIdOrSlug::Id(ticket.id))?; + if !dependencies.blockers.is_empty() { + return Err(Error::TicketAssignmentConflict( + "Ticket work is blocked by unresolved relations".into(), + )); } Ok(()) } @@ -13472,11 +13730,11 @@ fn validate_ticket_assignment_spawn( match &request.intent { WorkerSpawnIntent::TicketRole { ticket_id, - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, } if ticket_id == &assignment.ticket_id => {} WorkerSpawnIntent::TicketRole { ticket_id, - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, } => { return Err(Error::TicketAssignmentConflict(format!( "spawn intent Ticket {ticket_id} does not match assignment Ticket {}", @@ -13485,28 +13743,20 @@ fn validate_ticket_assignment_spawn( } _ => { return Err(Error::TicketAssignmentConflict( - "ticket_assignment is accepted only for a Ticket-role Coder spawn".to_string(), + "ticket_assignment is accepted only for a Ticket-role Ticket Worker spawn" + .to_string(), )); } } - if !request - .initial_submit - .iter() - .any(|segment| matches!(segment, Segment::Flow { .. })) - { - return Err(Error::TicketAssignmentConflict( - "Ticket-assigned Coder spawn requires one Flow segment in initial_submit".to_string(), - )); - } if !request.workdir_attachment_requests.is_empty() || !request.resolved_workdir_attachment_requests.is_empty() { return Err(Error::TicketAssignmentConflict( - "Ticket-assigned Coder spawn requires existing Workspace Workdir attachments; new materialization requests cannot carry persisted Ticket target capabilities" + "Ticket-assigned Ticket Worker spawn requires existing Workspace Workdir attachments; new materialization requests cannot carry persisted Ticket target capabilities" .to_string(), )); } - validated_ticket_implementation_targets(api, &assignment.ticket_id) + validated_ticket_resource_targets(api, &assignment.ticket_id) .map_err(|error| Error::TicketAssignmentConflict(error.error.to_string()))?; validate_ticket_assignment_state(api, assignment)?; @@ -13585,7 +13835,7 @@ fn assign_ticket_worker_from_lifecycle( assignment: &crate::hosts::WorkerTicketAssignmentRequest, runtime_id: &str, worker_id: &str, -) -> Result { +) -> Result { let ticket = api.authority.ticket(&assignment.ticket_id)?; let worker = RuntimeWorkerRef::new(runtime_id, worker_id); if let Some(operation) = api @@ -13611,7 +13861,7 @@ fn assign_ticket_worker_from_lifecycle( ))); } let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let record = TicketCoderAssignmentRecord { + let record = TicketWorkerAssignmentRecord { workspace_id: api.config.workspace_id.clone(), ticket_id: ticket.id, assignment_id: new_id("tasg"), @@ -13621,7 +13871,7 @@ fn assign_ticket_worker_from_lifecycle( }; Ok(api .store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &record, None, &new_id("tasev"), @@ -13639,16 +13889,16 @@ fn accept_queued_ticket_after_worker_spawn( if ticket.state == TicketWorkflowState::InProgress.as_str() { return Ok(()); } - if ticket.state != TicketWorkflowState::Queued.as_str() { + if !matches!(ticket.state.as_str(), "planning" | "ready" | "queued") { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} left queued state before Coder spawn acceptance; current state is {}", + "Ticket {} left queued state before Ticket Worker spawn acceptance; current state is {}", ticket.id, ticket.state ))); } let mut change = TicketStateChange::new( - TicketWorkflowState::Queued.as_str(), + ticket.state.as_str(), TicketWorkflowState::InProgress.as_str(), - "Coder spawn, assignment, and initial input were durably accepted", + "Ticket Worker spawn, assignment, and initial input were durably accepted", "", ); change.author = Some("workspace-orchestrator".to_string()); @@ -13678,6 +13928,9 @@ fn existing_lifecycle_assignment_worker( assignment.operation_id ))); } + if operation.assignment_id.is_none() { + return Ok(None); + } let Some(worker_ref) = operation.worker else { return Ok(None); }; @@ -14132,7 +14385,7 @@ async fn execute_ticket_rest_operation( let context = worker_ticket_source_context(api, workspace_id, source, before.as_ref()); let is_orchestrator = find_workspace_orchestrator(api).is_some_and(|worker| worker.worker == *source); - if context.actor_role != "coder" && !is_orchestrator { + if context.actor_role != "ticket_worker" && !is_orchestrator { return Err(Error::WorkspacePermissionDenied( "Ticket state decisions require the assigned Worker or registered Workspace Orchestrator".into(), ).into()); @@ -14861,7 +15114,7 @@ fn observe_published_source_ref( fn require_assigned_workdir_source( api: &WorkspaceApi, - assignment: &crate::store::TicketCoderAssignmentRecord, + assignment: &crate::store::TicketWorkerAssignmentRecord, repository_id: &str, selector: &str, revision_ref: &str, @@ -16213,7 +16466,7 @@ async fn scoped_ticket_state_update( let context = worker_ticket_source_context(&api, &workspace_id, &source, Some(&before)); let is_orchestrator = find_workspace_orchestrator(&api).is_some_and(|worker| worker.worker == source); - if !replay && context.actor_role != "coder" && !is_orchestrator { + if !replay && context.actor_role != "ticket_worker" && !is_orchestrator { return Err(Error::WorkspacePermissionDenied( "Ticket state decisions require the assigned Worker or registered Workspace Orchestrator".into(), ).into()); @@ -16552,7 +16805,7 @@ impl WorkerTicketSourceContext { fn worker_source_actor_role(is_current_assignment: bool, is_orchestrator: bool) -> &'static str { if is_current_assignment { - "coder" + "ticket_worker" } else if is_orchestrator { "orchestrator" } else { @@ -24462,7 +24715,18 @@ fn build_runtime_cleanup_plan( )?; let is_running = live_running_worker_ids.contains(&record.worker); let pinned = record.retention_state == "pinned"; - let blocking_reason = if let Some(assignment) = active_assignment { + let pending_claim = api + .store + .get_pending_ticket_assignment_operation_for_worker( + &api.config.workspace_id, + &record.worker, + )?; + let blocking_reason = if let Some(claim) = pending_claim { + Some(format!( + "worker has pending claim {} for Ticket {}", + claim.operation_id, claim.ticket_id + )) + } else if let Some(assignment) = active_assignment { Some(format!( "worker has unfinished work for Ticket `{}` (`{}`)", assignment.ticket_id, @@ -24678,6 +24942,16 @@ async fn execute_runtime_cleanup_with_context( candidate.runtime_id.clone(), candidate.runtime_worker_id.clone(), ); + if let Some(claim) = api + .store + .get_pending_ticket_assignment_operation_for_worker(&api.config.workspace_id, &worker)? + { + return Err(cleanup_api_error( + runtime_id, + "workspace_cleanup_worker_claim_pending", + &format!("Worker has pending Ticket claim {}", claim.operation_id), + )); + } if let Some(assignment) = api .store .get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)? @@ -25067,7 +25341,6 @@ async fn scoped_restore_runtime_worker_with_context( validate_workspace_scope(&api, &path.workspace_id)?; let resolved = WorkspaceWorker::resolve(&api, &path.worker.runtime_id, &path.worker.worker_id)?; let _authorization = resolved.authorize_operation(&context, "restore").await?; - let workspace_id = path.workspace_id.clone(); let runtime_id = resolved.identity().runtime_id.clone(); let worker_id = resolved.identity().worker_id.clone(); if matches!(context, WorkerOperationContext::WorkerControl { .. }) @@ -25078,45 +25351,10 @@ async fn scoped_restore_runtime_worker_with_context( ) .into()); } - let assignment_request = match ( - query.ticket_id.clone(), - query.assignment_operation_id.clone(), - ) { - (Some(ticket_id), Some(operation_id)) => { - Some(crate::hosts::WorkerTicketAssignmentRequest { - ticket_id, - operation_id, - }) - } - (None, None) => None, - _ => { - return Err(Error::TicketAssignmentConflict( - "restore assignment requires both ticket_id and assignment_operation_id" - .to_string(), - ) - .into()); - } - }; - if let Some(assignment) = assignment_request.as_ref() { - let fingerprint = format!( - "sha256:{}", - Sha256::digest(format!( - "restore\0{}\0{}\0{}", - assignment.ticket_id, runtime_id, worker_id - )) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - ); - api.store.reserve_ticket_assignment_operation( - &workspace_id, - &assignment.operation_id, - &assignment.ticket_id, - &runtime_id, - Some(&worker_id), - &fingerprint, - &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - )?; + if query.ticket_id.is_some() || query.assignment_operation_id.is_some() { + return Err(Error::TicketAssignmentConflict( + "Restore does not assign Ticket work; use explicit Worker assignment with resource bindings".into() + ).into()); } drop(_authorization); let response = restore_runtime_worker_with_context( @@ -25127,10 +25365,6 @@ async fn scoped_restore_runtime_worker_with_context( request, ) .await?; - if let Some(assignment) = assignment_request.as_ref() { - assign_ticket_worker_from_lifecycle(&api, assignment, &runtime_id, &worker_id)?; - accept_queued_ticket_after_worker_spawn(&api, assignment)?; - } Ok(response) } @@ -27135,15 +27369,6 @@ fn browser_worker_spawn_policy( let expected_segments = initial_submit.len(); match ticket_assignment { Some(assignment) => { - if !initial_submit - .iter() - .any(|segment| matches!(segment, Segment::Flow { .. })) - { - return Err(Error::InvalidInput( - "Ticket-assigned Coder spawn requires one Flow segment in initial_submit" - .to_string(), - )); - } let ticket_id = assignment.ticket_id.trim().to_string(); if ticket_id.is_empty() { return Err(Error::InvalidInput( @@ -27159,7 +27384,7 @@ fn browser_worker_spawn_policy( Ok(( WorkerSpawnIntent::TicketRole { ticket_id: ticket_id.clone(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }, Some(WorkerTicketAssignmentRequest { @@ -27174,7 +27399,7 @@ fn browser_worker_spawn_policy( .any(|segment| matches!(segment, Segment::Flow { .. })) { return Err(Error::InvalidInput( - "Workspace Worker Flow spawn requires ticket_assignment; use a typed Ticket-assigned Coder spawn" + "Workspace Worker Flow spawn requires ticket_assignment; use a typed Ticket-assigned Ticket Worker spawn" .to_string(), )); } @@ -27224,6 +27449,14 @@ async fn create_workspace_worker_inner( request: CreateWorkspaceWorkerRequest, resolved_control_operation: Option, ) -> ApiResult> { + let claim_lock = request + .ticket_assignment + .as_ref() + .map(|a| worker_control_lock(&api, &format!("ticket-claim:{}", a.operation_id))); + let _claim_guard = match claim_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; let CreateWorkspaceWorkerRequest { runtime_id, display_name, @@ -27386,12 +27619,12 @@ async fn create_workspace_worker_inner( )) })?; if let Some(worker) = existing_lifecycle_assignment_worker(&api, assignment, &runtime_id)? { - return Ok(Json(browser_worker_response_from_summary( + accept_queued_ticket_after_worker_spawn(&api, assignment)?; + return Ok(Json(browser_worker_response_for_committed_summary( &api, worker, display_name, Vec::new(), - Some(assignment), )?)); } if operation.assignment_id.is_some() { @@ -27547,6 +27780,15 @@ fn browser_worker_response_from_summary( accept_queued_ticket_after_worker_spawn(api, assignment).map_err(ApiError::from), )?; } + browser_worker_response_for_committed_summary(api, worker, display_name, diagnostics) +} + +fn browser_worker_response_for_committed_summary( + api: &WorkspaceApi, + worker: InternalWorkerSummary, + _display_name: String, + diagnostics: Vec, +) -> ApiResult { let runtime_id = worker.worker.runtime_id.clone(); let worker_id = worker.worker.worker_id.clone(); let workspace_id = api.workspace_id().to_string(); @@ -27917,13 +28159,17 @@ fn restore_internal_observed_worker( token, explicit.then_some(request_id.as_str()), )?; + resume_internal_worker_restore_intent(api, &observed.worker, request) +} + +fn resume_internal_worker_restore_intent( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: server_api::WorkerRestoreRequest, +) -> ApiResult { let request_id = request.request_id.clone(); - let result = match WorkspaceWorker::resolve( - api, - &observed.worker.runtime_id, - &observed.worker.worker_id, - )? - .restore_internal(request) + let result = match WorkspaceWorker::resolve(api, &worker.runtime_id, &worker.worker_id)? + .restore_internal(request) { Ok(result) => result, Err(error) => { @@ -27933,7 +28179,7 @@ fn restore_internal_observed_worker( if matches!(error.error, Error::RestoreObservationConflict) { api.config_store.settle_internal_worker_restore_intent( api.workspace_id(), - &observed.worker, + worker, &request_id, )?; } @@ -27943,7 +28189,7 @@ fn restore_internal_observed_worker( if result.state != server_api::WorkerRestoreState::ReconciliationRequired { api.config_store.settle_internal_worker_restore_intent( api.workspace_id(), - &observed.worker, + worker, &request_id, )?; } @@ -28476,26 +28722,16 @@ async fn create_runtime_worker( Json(request): Json, ) -> ApiResult> { let mut request = worker_spawn_request_from_api(request)?; + let claim_lock = request + .ticket_assignment + .as_ref() + .map(|a| worker_control_lock(&api, &format!("ticket-claim:{}", a.operation_id))); + let _claim_guard = match claim_lock { + Some(lock) => Some(lock.lock_owned().await), + None => None, + }; validate_caller_worker_singleton_key(request.singleton_key.as_deref())?; validate_worker_initial_submit(&request.initial_submit)?; - if let Some(assignment) = request.ticket_assignment.as_ref() - && let Some(worker) = existing_lifecycle_assignment_worker(&api, assignment, &runtime_id)? - { - validate_ticket_assignment_spawn(&api, &runtime_id, &request)?; - assign_ticket_worker_from_lifecycle( - &api, - assignment, - &runtime_id, - &worker.worker.worker_id, - )?; - accept_queued_ticket_after_worker_spawn(&api, assignment)?; - return Ok(Json(worker_spawn_result_to_api(WorkerSpawnResult { - state: InternalWorkerOperationState::Accepted, - worker: Some(worker), - acceptance_evidence: Vec::new(), - diagnostics: Vec::new(), - })?)); - } let lifecycle_assignment = request.ticket_assignment.clone(); validate_ticket_assignment_spawn(&api, &runtime_id, &request)?; reject_workdir_for_embedded_runtime( @@ -28564,6 +28800,17 @@ async fn create_runtime_worker( return Err(ApiError::with_diagnostics(error, diagnostics)); } } + if let Some(assignment) = lifecycle_assignment.as_ref() + && let Some(worker) = existing_lifecycle_assignment_worker(&api, assignment, &runtime_id)? + { + accept_queued_ticket_after_worker_spawn(&api, assignment)?; + return Ok(Json(worker_spawn_result_to_api(WorkerSpawnResult { + state: InternalWorkerOperationState::Accepted, + worker: Some(worker), + acceptance_evidence: Vec::new(), + diagnostics: Vec::new(), + })?)); + } let result = api.spawn_workspace_worker(&runtime_id, request)?; if let Some(worker) = result.worker.as_ref() { let compensation = WorkerSpawnCompensationContext { @@ -32223,6 +32470,8 @@ mod tests { mod subject_spawn_tests; mod subjektiv_jobs_tests; mod ticket_evidence_tests; + mod ticket_worker_claim_tests; + mod ticket_worker_launch_tests; mod value_profiles_tests; mod worker_operations_tests; include!("server_workspace_config_tests.rs"); @@ -34487,6 +34736,8 @@ mod tests { repository_access_requests: Arc>>, reject_repository_access: Arc>, + next_restore_outcome: Arc>>, + restore_attempts: Arc>, } impl WorkdirlessFixtureRuntime { @@ -34846,9 +35097,12 @@ mod tests { worker_id: &str, _request: runtime_api::WorkerRestoreRequest, ) -> InternalWorkerRestoreResult { + *self.restore_attempts.lock().unwrap() += 1; let worker = self.worker(worker_id).worker; InternalWorkerRestoreResult { - state: if worker.is_some() { + state: if let Some(outcome) = self.next_restore_outcome.lock().unwrap().take() { + outcome + } else if worker.is_some() { server_api::WorkerRestoreState::Accepted } else { server_api::WorkerRestoreState::Rejected @@ -36709,7 +36963,7 @@ mod tests { } #[test] - fn worker_ticket_assignment_projects_coder_intent_and_run_acceptance() { + fn worker_ticket_assignment_projects_optional_flow_and_run_acceptance() { let initial_submit = vec![ Segment::Flow { selector: "builtin:coder-review".to_string(), @@ -36727,7 +36981,7 @@ mod tests { intent, WorkerSpawnIntent::TicketRole { ticket_id: "00001KZ9E0DBS".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, } ); assert_eq!( @@ -36756,9 +37010,9 @@ mod tests { assert!( browser_worker_spawn_policy( Some(assignment_request()), - &[Segment::text("Ticket text without Flow")], + &[Segment::text("Research without Flow")], ) - .is_err() + .is_ok() ); assert!( browser_worker_spawn_policy( @@ -36792,7 +37046,7 @@ mod tests { } #[tokio::test] - async fn ticket_assignment_spawn_requires_queued_or_inprogress_before_runtime_side_effects() { + async fn ticket_assignment_spawn_accepts_planning_without_runtime_side_effects() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -36804,7 +37058,7 @@ mod tests { requested_worker_name: Some("Rejected Coder".to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: ticket.id.clone(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -36831,7 +37085,7 @@ mod tests { }; assert!( - validate_ticket_assignment_spawn(&api, EMBEDDED_WORKER_RUNTIME_ID, &request).is_err() + validate_ticket_assignment_spawn(&api, EMBEDDED_WORKER_RUNTIME_ID, &request).is_ok() ); assert!( api.store @@ -36876,7 +37130,7 @@ mod tests { let current = fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .unwrap(); let response_ref = RuntimeWorkerRef::new(&response.runtime_id, &response.worker_id); @@ -36931,7 +37185,7 @@ mod tests { ); assert!( api.store - .get_current_ticket_coder_assignment(&api.config.workspace_id, &ticket.id) + .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) .unwrap() .is_none() ); @@ -37358,7 +37612,7 @@ mod tests { .unwrap() .id; assert_eq!( - validated_ticket_implementation_targets(&api, &ticket_id) + validated_ticket_resource_targets(&api, &ticket_id) .unwrap() .len(), 2 @@ -37622,7 +37876,7 @@ mod tests { fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .is_none() ); @@ -37697,7 +37951,7 @@ mod tests { let worker = fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .unwrap() .worker; @@ -37718,13 +37972,17 @@ mod tests { } #[tokio::test] - async fn guarded_spawn_rejects_missing_target_before_runtime_side_effects() { + async fn guarded_spawn_rejects_mismatched_selector_before_runtime_side_effects() { let fixture = guarded_spawn_fixture().await; - let mut payload = guarded_spawn_payload(&fixture, "guarded-missing-target"); - payload["workdir_attachments"] - .as_array_mut() + let payload = guarded_spawn_payload(&fixture, "guarded-selector-mismatch"); + let mut docs = fixture + .api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.read_only_workdir_id) .unwrap() - .retain(|attachment| attachment["alias"] != "docs"); + .unwrap(); + docs.creation_selector = Some("other".into()); + fixture.api.store.upsert_workdir_registry(&docs).unwrap(); let response = post_guarded_spawn(&fixture, payload).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); @@ -37924,7 +38182,7 @@ mod tests { drop(requests); let worker = api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) .unwrap() .unwrap() .worker; @@ -37968,7 +38226,7 @@ mod tests { r#" CREATE TRIGGER fail_guarded_coder_assignment BEFORE INSERT ON ticket_current_worker_assignments - WHEN NEW.role = 'coder' + WHEN NEW.role = 'worker' BEGIN SELECT RAISE(ABORT, 'injected guarded Coder assignment failure'); END; @@ -37997,7 +38255,7 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: fixture.ticket_id.clone(), assignment_id: "existing-coder-assignment".to_string(), - role: TicketAssignmentRole::Coder, + role: TicketAssignmentRole::Worker, principal: TicketAssignmentPrincipal::Worker { runtime_id: fixture.controller.runtime_id.clone(), worker_id: fixture.controller.worker_id.clone(), @@ -38034,7 +38292,7 @@ mod tests { let current = fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .unwrap(); assert_eq!(current.assignment_id, "existing-coder-assignment"); @@ -47019,10 +47277,13 @@ mod tests { )] ); let implementation_targets = - validated_ticket_implementation_targets(&api, &ticket_ref.id).unwrap(); + validated_ticket_resource_targets(&api, &ticket_ref.id).unwrap(); assert_eq!(implementation_targets.len(), 1); assert_eq!(implementation_targets[0].repository_key, repository_key); - assert_eq!(implementation_targets[0].ref_selector, "develop"); + assert_eq!( + implementation_targets[0].ref_selector.as_deref(), + Some("develop") + ); assert_eq!( implementation_targets[0].access, TicketTargetAccess::ReadWrite @@ -47108,8 +47369,10 @@ mod tests { assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!(ready.meta.targets[0].ref_selector, None); assert!( - validated_ticket_implementation_targets(&api, &reference.id).is_err(), - "asset operations still require their own grants and selectors" + validated_ticket_resource_targets(&api, &reference.id).unwrap()[0] + .ref_selector + .is_none(), + "generic work can select resources independently; MR operations still require an explicit write selector" ); } @@ -47138,10 +47401,10 @@ mod tests { #[test] fn worker_source_actor_roles_use_canonical_vocabulary() { - assert_eq!(worker_source_actor_role(true, false), "coder"); + assert_eq!(worker_source_actor_role(true, false), "ticket_worker"); assert_eq!(worker_source_actor_role(false, true), "orchestrator"); assert_eq!(worker_source_actor_role(false, false), "worker"); - assert_eq!(worker_source_actor_role(true, true), "coder"); + assert_eq!(worker_source_actor_role(true, true), "ticket_worker"); } #[test] @@ -47201,7 +47464,7 @@ mod tests { requested_worker_name: Some("notification-source".to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: "notification-source".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -47294,8 +47557,8 @@ mod tests { .unwrap(); } api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket.id.clone(), assignment_id: "notification-source-assignment".to_string(), @@ -47389,8 +47652,8 @@ mod tests { .unwrap(); let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_id.to_string(), assignment_id: assignment_id.to_string(), @@ -47525,7 +47788,7 @@ mod tests { ); assert!( api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &reference.id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) .unwrap() .is_some() ); @@ -47596,7 +47859,7 @@ mod tests { }; let Json(detail) = scoped_get_ticket(State(api.clone()), path()).await.unwrap(); assert_eq!(detail.state, state); - assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder"); + assert_eq!(detail.current_worker.unwrap().assignment_id, "old-coder"); assert!( detail .assignments @@ -47710,6 +47973,7 @@ mod tests { "orchestrator".to_string(), )), Json(server_api::SetTicketRoleAssignmentRequest { + workdir_bindings: Vec::new(), operation_id: "fresh-orchestrator-cycle".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key: "workspace-orchestrator".to_string(), @@ -47731,7 +47995,7 @@ mod tests { .await .unwrap(); assert!(detail.action_eligibility.can_queue); - assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder"); + assert_eq!(detail.current_worker.unwrap().assignment_id, "old-coder"); let _ = scoped_queue_ticket_record( State(api.clone()), AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), @@ -47777,7 +48041,7 @@ mod tests { docs_workdir_id: String, } - async fn manual_coder_assignment_fixture() -> ManualCoderAssignmentFixture { + async fn manual_worker_assignment_fixture() -> ManualCoderAssignmentFixture { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api_with_docs_repository(workspace.path()).await; @@ -47875,11 +48139,24 @@ mod tests { } } - fn manual_coder_assignment_request( + fn manual_worker_assignment_request( fixture: &ManualCoderAssignmentFixture, operation_id: &str, ) -> server_api::SetTicketRoleAssignmentRequest { server_api::SetTicketRoleAssignmentRequest { + workdir_bindings: fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .filter(|link| link.unlinked_at.is_none()) + .map(|link| server_api::TicketWorkerAttachmentBinding { + alias: link.alias, + working_directory_id: link.workdir_id, + connection_id: link.connection_id, + }) + .collect(), operation_id: operation_id.to_string(), principal: server_api::TicketAssignmentPrincipal::Worker { runtime_id: fixture.worker.runtime_id.clone(), @@ -47890,16 +48167,16 @@ mod tests { } #[tokio::test] - async fn manual_coder_assignment_validates_and_rebinds_all_ticket_targets() { - let fixture = manual_coder_assignment_fixture().await; + async fn manual_worker_assignment_validates_and_rebinds_all_ticket_targets() { + let fixture = manual_worker_assignment_fixture().await; let Json(response) = scoped_set_ticket_assignment( State(fixture.api.clone()), AxumPath(( TEST_WORKSPACE_ID.to_string(), fixture.ticket_id.clone(), - "coder".to_string(), + "worker".to_string(), )), - Json(manual_coder_assignment_request( + Json(manual_worker_assignment_request( &fixture, "manual-multi-target-success", )), @@ -47967,15 +48244,15 @@ mod tests { #[tokio::test] async fn active_target_edits_do_not_expand_bound_grants_and_mismatch_fails_closed() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let _ = scoped_set_ticket_assignment( State(fixture.api.clone()), AxumPath(( TEST_WORKSPACE_ID.into(), fixture.ticket_id.clone(), - "coder".into(), + "worker".into(), )), - Json(manual_coder_assignment_request( + Json(manual_worker_assignment_request( &fixture, "bind-original-grants", )), @@ -48057,12 +48334,13 @@ mod tests { } #[tokio::test] - async fn manual_coder_assignment_rejects_incomplete_or_mismatched_workdirs_before_state_change() - { - for case in ["missing", "undeclared", "selector"] { - let fixture = manual_coder_assignment_fixture().await; + async fn manual_worker_assignment_rejects_stale_or_mismatched_workdirs_before_state_change() { + for case in ["stale", "undeclared", "selector"] { + let fixture = manual_worker_assignment_fixture().await; + let request = + manual_worker_assignment_request(&fixture, &format!("manual-invalid-{case}")); match case { - "missing" => { + "stale" => { fixture .api .store @@ -48132,17 +48410,22 @@ mod tests { AxumPath(( TEST_WORKSPACE_ID.to_string(), fixture.ticket_id.clone(), - "coder".to_string(), - )), - Json(manual_coder_assignment_request( - &fixture, - &format!("manual-invalid-{case}"), + "worker".to_string(), )), + Json(request), ) .await .unwrap_err() .into_response(); - assert_eq!(error.status(), StatusCode::BAD_REQUEST, "case={case}"); + assert_eq!( + error.status(), + if case == "stale" { + StatusCode::CONFLICT + } else { + StatusCode::BAD_REQUEST + }, + "case={case}" + ); assert_eq!( fixture .api @@ -48160,7 +48443,7 @@ mod tests { .get_current_ticket_role_assignment( TEST_WORKSPACE_ID, &fixture.ticket_id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, ) .unwrap() .is_none(), @@ -48182,17 +48465,17 @@ mod tests { } #[tokio::test] - async fn manual_coder_assignment_failure_restores_worker_and_attachment_capabilities() { - let fixture = manual_coder_assignment_fixture().await; + async fn manual_worker_assignment_failure_restores_worker_and_attachment_capabilities() { + let fixture = manual_worker_assignment_fixture().await; rusqlite::Connection::open(&fixture.api.config.database_path) .unwrap() .execute_batch( r#" - CREATE TRIGGER fail_manual_coder_assignment + CREATE TRIGGER fail_manual_worker_assignment BEFORE INSERT ON ticket_current_worker_assignments - WHEN NEW.role = 'coder' + WHEN NEW.role = 'worker' BEGIN - SELECT RAISE(ABORT, 'injected manual Coder assignment failure'); + SELECT RAISE(ABORT, 'injected manual Worker assignment failure'); END; "#, ) @@ -48204,9 +48487,9 @@ mod tests { AxumPath(( TEST_WORKSPACE_ID.to_string(), fixture.ticket_id.clone(), - "coder".to_string(), + "worker".to_string(), )), - Json(manual_coder_assignment_request( + Json(manual_worker_assignment_request( &fixture, "manual-assignment-failure", )), @@ -48230,7 +48513,7 @@ mod tests { .get_current_ticket_role_assignment( TEST_WORKSPACE_ID, &fixture.ticket_id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, ) .unwrap() .is_none() @@ -48267,6 +48550,7 @@ mod tests { .create(ticket::NewTicket::new("Idempotent assignment")) .unwrap(); let request = || server_api::SetTicketRoleAssignmentRequest { + workdir_bindings: Vec::new(), operation_id: "same-role-operation".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key: "workspace-orchestrator".to_string(), @@ -48319,9 +48603,10 @@ mod tests { AxumPath(( TEST_WORKSPACE_ID.to_string(), ticket_id.clone(), - "coder".to_string(), + "worker".to_string(), )), Json(server_api::SetTicketRoleAssignmentRequest { + workdir_bindings: Vec::new(), operation_id: "invalid-workspace-agent-coder".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key: "workspace-orchestrator".to_string(), @@ -48334,7 +48619,7 @@ mod tests { .into_response(); assert_eq!(invalid_coder.status(), StatusCode::CONFLICT); - let assignment = TicketCoderAssignmentRecord { + let assignment = TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_id.clone(), assignment_id: "assignment-api-1".to_string(), @@ -48343,7 +48628,7 @@ mod tests { assigned_at: TEST_CREATED_AT.to_string(), }; api.store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &assignment, None, "event-api-1", @@ -48364,7 +48649,7 @@ mod tests { let coder = read .assignments .iter() - .find(|assignment| assignment.role == server_api::TicketAssignmentRole::Coder) + .find(|assignment| assignment.role == server_api::TicketAssignmentRole::Worker) .unwrap(); assert_eq!(coder.assignment_id, assignment.assignment_id); let Json(detail) = browser_ticket_detail(&api, &ticket_id).unwrap(); @@ -48373,17 +48658,17 @@ mod tests { .action_eligibility .blockers .iter() - .any(|blocker| blocker.contains("Orchestrator and manual Coder")) + .any(|blocker| blocker.contains("Orchestrator and manual Worker")) ); assert!(!detail.action_eligibility.can_assign_orchestrator); - assert!(!detail.action_eligibility.can_start_manual_coder); + assert!(!detail.action_eligibility.can_start_manual_worker); let stale = scoped_clear_ticket_assignment( State(api.clone()), AxumPath(( TEST_WORKSPACE_ID.to_string(), ticket_id.clone(), - "coder".to_string(), + "worker".to_string(), )), Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-stale".to_string()), @@ -48400,7 +48685,7 @@ mod tests { AxumPath(( TEST_WORKSPACE_ID.to_string(), ticket_id.clone(), - "coder".to_string(), + "worker".to_string(), )), Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-current".to_string()), @@ -48415,7 +48700,7 @@ mod tests { AxumPath(( TEST_WORKSPACE_ID.to_string(), ticket_id, - "coder".to_string(), + "worker".to_string(), )), Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-current".to_string()), @@ -48440,7 +48725,7 @@ mod tests { requested_worker_name: Some("cancelled-coder".to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: "implementation-cancellation".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -48488,8 +48773,8 @@ mod tests { input.workflow_state = Some(TicketWorkflowState::InProgress); let ticket = backend.create(input).unwrap(); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket.id.clone(), assignment_id: "cancelled-assignment".to_string(), @@ -48522,12 +48807,12 @@ mod tests { .await .unwrap(); assert_eq!(cancelled.state, TicketWorkflowState::Ready.as_str()); - assert!(cancelled.current_coder.is_none()); + assert!(cancelled.current_worker.is_none()); assert!( !cancelled .assignments .iter() - .any(|assignment| assignment.role == "coder") + .any(|assignment| assignment.role == "worker") ); assert_eq!(api.runtime.worker(&worker).unwrap().state, "idle"); @@ -48545,7 +48830,7 @@ mod tests { requested_worker_name: Some(name.to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: name.to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -48626,8 +48911,8 @@ mod tests { .create(ticket::NewTicket::new("Notify assigned Worker")) .unwrap(); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_ref.id.clone(), assignment_id: "notify-assignment".to_string(), @@ -48729,8 +49014,8 @@ mod tests { ); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_ref.id.clone(), assignment_id: "source-assignment".to_string(), @@ -48777,7 +49062,7 @@ mod tests { .attributes .get("source_actor_role") .map(String::as_str), - Some("coder") + Some("ticket_worker") ); let human_mutation = execute_worker_ticket_test_operation( @@ -48888,7 +49173,7 @@ mod tests { assert_eq!(after.state, browser_ticket_workflow_state(state).as_str()); assert!( api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &reference.id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) .unwrap() .is_none() ); @@ -48945,7 +49230,7 @@ mod tests { ); assert!( api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) .unwrap() .is_none() ); @@ -49095,7 +49380,7 @@ mod tests { requested_worker_name: Some("orchestrator-source".to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: "source-ticket".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -49163,7 +49448,7 @@ mod tests { requested_worker_name: Some("orchestrator-source".to_string()), intent: WorkerSpawnIntent::TicketRole { ticket_id: "source-ticket".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, singleton_key: None, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -49225,8 +49510,8 @@ mod tests { }) .unwrap(); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_ref.id.clone(), assignment_id: "missing-recipient-assignment".to_string(), @@ -50382,12 +50667,12 @@ mod tests { } #[tokio::test] - async fn runtime_worker_ticket_assignment_rejects_missing_target_attachments() { + async fn runtime_worker_ticket_assignment_can_omit_targets_and_flow_and_replay_exact_start() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Unsafe direct Runtime assignment"); + let mut input = ticket::NewTicket::new("Investigation without a checkout"); input.workflow_state = Some(TicketWorkflowState::Queued); set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = backend.create(input).unwrap(); @@ -50396,46 +50681,69 @@ mod tests { requested_worker_name: Some("assigned-spawn".to_string()), intent: server_api::RuntimeWorkerSpawnIntent::TicketRole { ticket_id: ticket.id.clone(), - role: server_api::RuntimeTicketWorkerRole::Coder, + role: server_api::RuntimeTicketWorkerRole::Worker, }, singleton_key: None, acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments: 1, }, - profile: server_api::RuntimeProfileSelector::Builtin("builtin:coder".to_string()), + profile: server_api::RuntimeProfileSelector::Builtin("builtin:intake".to_string()), ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { ticket_id: ticket.id.clone(), - operation_id: "unsafe-runtime-assignment".to_string(), + operation_id: "runtime-explicit-work".to_string(), }), initial_submit: vec![ - serde_json::to_value(Segment::Flow { - selector: "builtin:coder-review".to_string(), - }) - .unwrap(), + serde_json::to_value(Segment::text("Investigate this Ticket")).unwrap(), ], workdir_attachment_requests: Vec::new(), }; - let error = scoped_create_runtime_worker( + let Json(result) = scoped_create_runtime_worker( State(api.clone()), AxumPath(ScopedRuntimePath { workspace_id: TEST_WORKSPACE_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), }), + Json(request.clone()), + ) + .await + .unwrap(); + assert_eq!(result.state, server_api::WorkerOperationState::Accepted); + assert!( + result + .worker + .as_ref() + .unwrap() + .workdir_attachments + .is_empty() + ); + assert!( + api.store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_some() + ); + assert_eq!( + backend + .show(ticket.id.clone().into()) + .unwrap() + .meta + .workflow_state, + TicketWorkflowState::InProgress + ); + let Json(replay) = scoped_create_runtime_worker( + State(api), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(), + }), Json(request), ) .await - .unwrap_err(); - assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); - assert!( - api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id) - .unwrap() - .is_none() - ); + .unwrap(); assert_eq!( - backend.show(ticket.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + result.worker.unwrap().worker_id, + replay.worker.unwrap().worker_id ); } @@ -52099,7 +52407,7 @@ mod tests { workspace_id: api.config.workspace_id.clone(), ticket_id: ticket_id.to_string(), assignment_id: format!("assignment-{ticket_id}"), - role: TicketAssignmentRole::Coder, + role: TicketAssignmentRole::Worker, principal: TicketAssignmentPrincipal::Worker { runtime_id: "runtime-test".to_string(), worker_id: runtime_worker_id.to_string(), @@ -52694,7 +53002,7 @@ mod tests { #[tokio::test] async fn current_worker_workdir_catalog_enumerates_complete_stable_inventory() { - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) .unwrap(); @@ -52971,7 +53279,7 @@ mod tests { #[tokio::test] async fn current_worker_attachment_list_and_stale_detach_use_signed_identity() { - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) .unwrap(); @@ -53195,7 +53503,7 @@ mod tests { #[tokio::test] async fn current_worker_attachment_connection_lookup_precedes_paging_and_is_scoped() { - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) .unwrap(); @@ -54565,7 +54873,7 @@ mod tests { .unwrap(); assert_eq!( candidate.blocking_reason.as_deref(), - Some("worker has unfinished work for Ticket `ticket-assigned` (`coder`)") + Some("worker has unfinished work for Ticket `ticket-assigned` (`worker`)") ); let request = ExecuteRuntimeCleanupRequest { expected_plan_revision: plan.revision.clone(), @@ -54667,7 +54975,7 @@ mod tests { .unwrap(); assert!( api.store - .get_current_ticket_coder_assignment(&api.config.workspace_id, ticket_id) + .get_current_ticket_worker_assignment(&api.config.workspace_id, ticket_id) .unwrap() .is_some(), "terminal responsibility must remain visible: {state}" @@ -56789,7 +57097,7 @@ mod tests { "intent": { "kind": "ticket_role", "ticket_id": "00001KVZSGT0Q", - "role": "coder" + "role": "worker" }, "acceptance": { "kind": "run_accepted", @@ -56828,7 +57136,7 @@ mod tests { "intent": { "kind": "ticket_role", "ticket_id": "00001KVZSGT0Q", - "role": "coder" + "role": "worker" }, "acceptance": { "kind": "run_accepted", @@ -57534,7 +57842,7 @@ mod tests { WorkerSpawnRequest { intent: WorkerSpawnIntent::TicketRole { ticket_id: "00001KVZSGT0Q".to_string(), - role: TicketWorkerRole::Coder, + role: TicketWorkerRole::Worker, }, requested_worker_name: None, singleton_key: None, diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs index d63f64c1a..cc34cb599 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -74,8 +74,8 @@ impl EvidenceApiFixture { .unwrap(); let assignment_id = "evidence-assignment".to_string(); api.store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.into(), ticket_id: ticket_id.clone(), assignment_id: assignment_id.clone(), @@ -319,7 +319,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .is_some() ); @@ -512,7 +512,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop fixture .api .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .is_some() ); diff --git a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs new file mode 100644 index 000000000..fdd75f486 --- /dev/null +++ b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs @@ -0,0 +1,657 @@ +use super::*; + +async fn claim( + fixture: &ManualCoderAssignmentFixture, + request: server_api::SetTicketRoleAssignmentRequest, +) -> ApiResult> { + scoped_set_ticket_assignment( + State(fixture.api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.into(), + fixture.ticket_id.clone(), + "worker".into(), + )), + Json(request), + ) + .await +} + +#[tokio::test] +async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assignment_on_retry() { + let fixture = manual_worker_assignment_fixture().await; + for id in [&fixture.main_workdir_id, &fixture.docs_workdir_id] { + fixture + .api + .store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &fixture.worker, + Some(id), + TEST_CREATED_AT, + ) + .unwrap(); + } + sync_runtime_worker_workdir_attachments(&fixture.api, &fixture.worker).unwrap(); + let backend = browser_ticket_backend(&fixture.api).unwrap(); + backend + .edit_item( + fixture.ticket_id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + backend + .set_workflow_state( + fixture.ticket_id.clone().into(), + TicketStateChange::new( + "ready", + "planning", + "Explicit investigation", + "No repository resources required", + ), + ) + .unwrap(); + let request = manual_worker_assignment_request(&fixture, "claim-without-resources"); + assert!(request.workdir_bindings.is_empty()); + let Json(first) = claim(&fixture, request.clone()).await.unwrap(); + let Json(replay) = claim(&fixture, request).await.unwrap(); + assert_eq!( + first.assignment.unwrap().assignment_id, + replay.assignment.unwrap().assignment_id + ); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + "inprogress" + ); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1); + assert!( + fixture + .runtime + .logical_attachments(&fixture.worker.worker_id) + .is_empty() + ); +} + +#[tokio::test] +async fn explicit_existing_worker_claim_narrows_read_only_targets_without_orchestrator_conflict() { + let fixture = manual_worker_assignment_fixture().await; + let backend = browser_ticket_backend(&fixture.api).unwrap(); + backend + .edit_item( + fixture.ticket_id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { + targets: vec![ + test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadOnly, + ), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ], + }), + ..Default::default() + }, + ) + .unwrap(); + assign_test_orchestrator(&fixture.api, &fixture.ticket_id); + let request = manual_worker_assignment_request(&fixture, "claim-read-only"); + let _ = claim(&fixture, request).await.unwrap(); + for link in fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .filter(|link| link.unlinked_at.is_none()) + { + assert_eq!( + link.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let workdir = fixture + .api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &link.workdir_id) + .unwrap() + .unwrap(); + assert_eq!( + effective_worker_workdir_capabilities( + &fixture.api, + &fixture.worker, + &workdir, + link.capabilities + ) + .unwrap(), + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + assert!( + fixture + .runtime + .logical_attachments(&fixture.worker.worker_id) + .iter() + .all(|a| a.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY) + ); + assert!( + require_ticket_read_write_target( + &fixture.api, + &fixture.ticket_id, + &test_repository_id(&fixture.api), + "develop" + ) + .is_err() + ); +} + +#[tokio::test] +async fn explicit_existing_worker_claim_requires_connections_instead_of_ambient_attachments() { + let fixture = manual_worker_assignment_fixture().await; + let mut request = manual_worker_assignment_request(&fixture, "claim-ambient-denied"); + request.workdir_bindings.clear(); + let response = claim(&fixture, request).await.unwrap_err().into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!(String::from_utf8_lossy(&bytes).contains("exact live Workdir connections")); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 0); + assert!( + fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-ambient-denied") + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_recovery() { + let fixture = manual_worker_assignment_fixture().await; + *fixture.runtime.next_restore_outcome.lock().unwrap() = + Some(server_api::WorkerRestoreState::ReconciliationRequired); + let request = manual_worker_assignment_request(&fixture, "claim-unknown"); + assert!(claim(&fixture, request.clone()).await.is_err()); + let operation = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown") + .unwrap() + .unwrap(); + assert_eq!(operation.claim_state, "pending"); + assert!(operation.assignment_id.is_none()); + assert_eq!(operation.worker, Some(fixture.worker.clone())); + assert!(operation.request_fingerprint.is_some()); + let recovery = fixture + .api + .config_store + .get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown") + .unwrap() + .unwrap(); + assert_eq!( + recovery.item_revision, + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .item_revision + ); + assert_eq!(recovery.selected.len(), request.workdir_bindings.len()); + for selected in &request.workdir_bindings { + assert!(recovery.selected.contains(selected)); + } + assert!( + recovery + .original_links + .iter() + .all(|link| link.capabilities == workdir::WorkdirSessionCapabilities::ALL) + ); + assert_eq!( + recovery + .effective_links + .iter() + .find(|link| link.alias == "docs") + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + // This fake proves the domain transition, not execution-level restore deduplication. + *fixture.runtime.next_restore_outcome.lock().unwrap() = + Some(server_api::WorkerRestoreState::ReconciliationRequired); + assert!(claim(&fixture, request.clone()).await.is_err()); + let pending = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert!(pending.assignment_id.is_none()); + assert_eq!(pending.request_fingerprint, operation.request_fingerprint); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 2); + assert!( + fixture + .api + .store + .reserve_ticket_assignment_operation( + TEST_WORKSPACE_ID, + "competing-launch", + &fixture.ticket_id, + &fixture.worker.runtime_id, + None, + "other-intent", + TEST_CREATED_AT + ) + .is_err() + ); + let worker = WorkspaceWorker::resolve( + &fixture.api, + &fixture.worker.runtime_id, + &fixture.worker.worker_id, + ) + .unwrap(); + let denied = worker + .input( + &WorkerOperationContext::Backend, + WorkerInputRequest { + kind: WorkerInputKind::User, + content: "Do not run while claim is unresolved".into(), + segments: None, + submission_request_id: None, + }, + ) + .await + .unwrap_err(); + assert!(denied.error.to_string().contains("pending Ticket claim")); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + "ready" + ); + assert!( + fixture + .api + .store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_none() + ); + + let Json(accepted) = claim(&fixture, request.clone()).await.unwrap(); + let assignment = accepted.assignment.unwrap(); + let committed = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown") + .unwrap() + .unwrap(); + assert_eq!(committed.claim_state, "committed"); + assert_eq!( + committed.assignment_id, + Some(assignment.assignment_id.clone()) + ); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + "inprogress" + ); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3); + assert_eq!( + fixture + .api + .config_store + .get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown") + .unwrap() + .unwrap(), + recovery + ); + let Json(replay) = claim(&fixture, request).await.unwrap(); + assert_eq!( + replay.assignment.unwrap().assignment_id, + assignment.assignment_id + ); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3); +} + +#[tokio::test] +async fn pending_manual_worker_claim_rejects_changed_bindings_without_restore() { + let fixture = manual_worker_assignment_fixture().await; + *fixture.runtime.next_restore_outcome.lock().unwrap() = + Some(server_api::WorkerRestoreState::ReconciliationRequired); + let mut request = manual_worker_assignment_request(&fixture, "claim-changed-bindings"); + assert!(claim(&fixture, request.clone()).await.is_err()); + let admitted = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + request.workdir_bindings[0].connection_id = "changed-connection".into(); + let response = claim(&fixture, request.clone()) + .await + .unwrap_err() + .into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!(String::from_utf8_lossy(&bytes).contains("different input")); + let pending = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert!(pending.assignment_id.is_none()); + assert_eq!(pending.request_fingerprint, admitted.request_fingerprint); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1); +} + +#[tokio::test] +async fn pending_manual_worker_claim_without_pinned_intent_rejects_replay_without_restore() { + let fixture = manual_worker_assignment_fixture().await; + *fixture.runtime.next_restore_outcome.lock().unwrap() = + Some(server_api::WorkerRestoreState::ReconciliationRequired); + let request = manual_worker_assignment_request(&fixture, "claim-missing-intent"); + assert!(claim(&fixture, request.clone()).await.is_err()); + // Simulate an interrupted/legacy pending receipt with no exact restore intent. + let request_id = format!("manual-worker-restore:{}", request.operation_id); + fixture.api.config_store.with_conn(|conn| { + let deleted = conn.execute( + "DELETE FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4", + rusqlite::params![TEST_WORKSPACE_ID, fixture.worker.runtime_id, fixture.worker.worker_id, request_id], + )?; + assert_eq!(deleted, 1); + Ok(()) + }).unwrap(); + let response = claim(&fixture, request.clone()) + .await + .unwrap_err() + .into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!(String::from_utf8_lossy(&bytes).contains("no pinned restore intent")); + let pending = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert!(pending.assignment_id.is_none()); + assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1); + assert!( + fixture + .api + .config_store + .get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &fixture.worker, &request_id) + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits_without_input() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + execution.accept_restores(); + let worker = spawn_ticket_check_source(&api, "manual-claim-reconciliation"); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .is_empty() + ); + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Manual investigation without resources"); + input.workflow_state = Some(TicketWorkflowState::Ready); + let ticket_id = backend.create(input).unwrap().id; + backend + .edit_item( + ticket_id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + backend + .set_workflow_state( + ticket_id.clone().into(), + TicketStateChange::new( + "ready", + "planning", + "Explicit investigation", + "No repository resources required", + ), + ) + .unwrap(); + let admitted_revision = api.authority.ticket(&ticket_id).unwrap().item_revision; + let request = server_api::SetTicketRoleAssignmentRequest { + operation_id: "claim-runtime-reconciliation".into(), + principal: server_api::TicketAssignmentPrincipal::Worker { + runtime_id: worker.runtime_id.clone(), + worker_id: worker.worker_id.clone(), + }, + expected_assignment_id: None, + workdir_bindings: Vec::new(), + }; + let submit_claim = || { + scoped_set_ticket_assignment( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.into(), ticket_id.clone(), "worker".into())), + Json(request.clone()), + ) + }; + let uncertain_restore = || { + worker_runtime::execution::WorkerExecutionSpawnResult::Errored( + worker_runtime::execution::WorkerExecutionResult::rejected( + worker_runtime::execution::WorkerExecutionOperation::Restore, + "uncertain manual claim restore", + ), + ) + }; + *execution.restore_failure.lock().unwrap() = Some(uncertain_restore()); + let response = submit_claim().await.unwrap_err().into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!(String::from_utf8_lossy(&bytes).contains("unknown")); + let pending = api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert!(pending.assignment_id.is_none()); + assert_eq!(pending.worker, Some(worker.clone())); + assert!(pending.request_fingerprint.is_some()); + assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning"); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .is_none() + ); + let recovery = api + .config_store + .get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(recovery.item_revision, admitted_revision); + assert!(recovery.selected.is_empty()); + assert!(recovery.original_links.is_empty()); + assert!(recovery.effective_links.is_empty()); + let request_id = format!("manual-worker-restore:{}", request.operation_id); + let pinned = api + .config_store + .get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id) + .unwrap() + .unwrap(); + assert_eq!(pinned.request_id, request_id); + let original = execution.restore_operations.lock().unwrap()[0]; + assert_eq!( + execution.restore_operations.lock().unwrap().as_slice(), + &[original] + ); + assert!(execution.reconcile_operations.lock().unwrap().is_empty()); + + let handle = WorkspaceWorker::resolve(&api, &worker.runtime_id, &worker.worker_id).unwrap(); + let denied = handle + .input( + &WorkerOperationContext::Backend, + WorkerInputRequest { + kind: WorkerInputKind::User, + content: "Do not submit while the manual claim is pending".into(), + segments: None, + submission_request_id: None, + }, + ) + .await + .unwrap_err(); + assert!(denied.error.to_string().contains("pending Ticket claim")); + + *execution.restore_failure.lock().unwrap() = Some(uncertain_restore()); + let response = submit_claim().await.unwrap_err().into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!(String::from_utf8_lossy(&bytes).contains("pending")); + let still_pending = api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(still_pending.claim_state, "pending"); + assert!(still_pending.assignment_id.is_none()); + assert_eq!( + still_pending.request_fingerprint, + pending.request_fingerprint + ); + assert_eq!( + api.authority.ticket(&ticket_id).unwrap().item_revision, + admitted_revision + ); + assert_eq!( + api.config_store + .get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id) + .unwrap() + .unwrap(), + pinned + ); + assert_eq!( + execution.reconcile_operations.lock().unwrap().as_slice(), + &[original] + ); + + let Json(accepted) = submit_claim().await.unwrap(); + let assignment = accepted.assignment.unwrap(); + assert_eq!(assignment.principal, request.principal); + let committed = api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(committed.claim_state, "committed"); + assert_eq!( + committed.assignment_id, + Some(assignment.assignment_id.clone()) + ); + assert_eq!(committed.request_fingerprint, pending.request_fingerprint); + assert_eq!( + api.authority.ticket(&ticket_id).unwrap().state, + "inprogress" + ); + assert_eq!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .unwrap() + .assignment_id, + assignment.assignment_id + ); + assert_eq!( + api.config_store + .get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id) + .unwrap() + .unwrap(), + recovery + ); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .is_empty() + ); + // The executor logs reconcile_restore in restore_operations as well. There is + // exactly one fresh dispatch; both subsequent entries reconcile its same id. + assert_eq!( + execution.restore_operations.lock().unwrap().as_slice(), + &[original, original, original] + ); + assert_eq!( + execution.reconcile_operations.lock().unwrap().as_slice(), + &[original, original] + ); + let Json(replay) = submit_claim().await.unwrap(); + assert_eq!( + replay.assignment.unwrap().assignment_id, + assignment.assignment_id + ); + assert_eq!(execution.restore_operations.lock().unwrap().len(), 3); + assert_eq!(execution.reconcile_operations.lock().unwrap().len(), 2); + assert!( + !execution + .protocol_methods() + .iter() + .any(|(_, method)| matches!( + method, + protocol::Method::Submit { .. } + | protocol::Method::SubmitTracked { .. } + | protocol::Method::SubmitIfIdle { .. } + )), + "manual claims and denied input must not generate a Submit" + ); +} diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs new file mode 100644 index 000000000..049d4d4ba --- /dev/null +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -0,0 +1,1053 @@ +// T-719 launch regressions. Parent wiring: mod ticket_worker_launch_tests; +// under server::tests. Runtime execution is in-process; no Worker binary is spawned. +use super::*; +use worker_runtime::execution::{ + WorkerExecutionBackend, WorkerExecutionResult, WorkerExecutionSpawnRequest, + WorkerExecutionSpawnResult, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult, +}; + +// The existing deterministic executor deliberately does not fetch Workspace +// Config. Supply only that narrow boundary here, using the Backend's real +// published Profile projection; leave Worker creation/control to the real Runtime. +#[derive(Default)] +struct LaunchExecutionBackend { + execution: DeterministicExecutionBackend, + bundles: Mutex>, + creates: Mutex>, + submits: Mutex< + Vec<( + worker_runtime::identity::WorkerRef, + worker_runtime::interaction::WorkerInput, + )>, + >, +} + +impl LaunchExecutionBackend { + fn creates_for(&self, worker_id: &str) -> Vec { + self.creates + .lock() + .unwrap() + .iter() + .filter(|request| request.worker_id.to_string() == worker_id) + .cloned() + .collect() + } + + fn submits_for(&self, worker_id: &str) -> Vec { + self.submits + .lock() + .unwrap() + .iter() + .filter(|(worker, _)| worker.worker_id.to_string() == worker_id) + .map(|(_, input)| input.clone()) + .collect() + } + + fn publish_profile(&self, api: &WorkspaceApi, profile: &str) { + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let profiles = crate::profile_settings::project_profiles_from_workspace_config( + TEST_WORKSPACE_ID, + &state, + ) + .unwrap(); + let prompts = api + .prompt_projection_cache + .resolve(TEST_WORKSPACE_ID, &state) + .unwrap(); + let bundle = + crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( + &profiles, + &state, + TEST_WORKSPACE_ID, + &api.config.workspace_created_at, + profile, + prompts.as_ref(), + ) + .unwrap() + .unwrap(); + self.bundles + .lock() + .unwrap() + .insert(profile.to_string(), bundle); + } +} + +impl WorkerExecutionBackend for LaunchExecutionBackend { + fn backend_id(&self) -> &str { + "ticket-worker-launch-test" + } + + fn fetch_workspace_config( + &self, + request: WorkspaceConfigFetchRequest, + ) -> std::result::Result { + assert_eq!(request.workspace_api.workspace_id, TEST_WORKSPACE_ID); + let key = match request.profile { + ProfileSelector::Builtin(key) | ProfileSelector::Named(key) => key, + }; + let bundle = self + .bundles + .lock() + .unwrap() + .get(&key) + .cloned() + .ok_or_else(|| format!("unpublished test Profile {key}"))?; + assert_eq!(bundle.metadata.id, request.expected.id); + assert_eq!(bundle.metadata.digest, request.expected.digest); + Ok(WorkspaceConfigFetchResult::Modified(bundle)) + } + + fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { + self.creates.lock().unwrap().push(request.request.clone()); + self.execution.spawn_worker(request) + } + + fn dispatch_input( + &self, + worker: &worker_runtime::identity::WorkerRef, + input: worker_runtime::interaction::WorkerInput, + ) -> WorkerExecutionResult { + self.submits + .lock() + .unwrap() + .push((worker.clone(), input.clone())); + self.execution.dispatch_input(worker, input) + } + + fn stop_worker(&self, worker: &worker_runtime::identity::WorkerRef) -> WorkerExecutionResult { + self.execution.stop_worker(worker) + } +} + +struct LaunchFixture { + _temp: tempfile::TempDir, + api: WorkspaceApi, + backend: Arc, + runtime_id: String, + runtime_server: Option>, +} + +impl Drop for LaunchFixture { + fn drop(&mut self) { + if let Some(server) = &self.runtime_server { + server.abort(); + } + } +} + +impl LaunchFixture { + async fn new(remote: bool) -> Self { + let temp = tempfile::tempdir().unwrap(); + let config = test_server_config(temp.path()); + let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let backend = Arc::new(LaunchExecutionBackend::default()); + let api = + WorkspaceApi::new_with_execution_backend(config, Arc::new(store), backend.clone()) + .await + .unwrap(); + let mut fixture = Self { + _temp: temp, + api, + backend, + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + runtime_server: None, + }; + if remote { + fixture.runtime_id = "ticket-worker-real-http".to_string(); + use worker_runtime::workspace_issuer::{ + InMemoryWorkspaceClaimReplayProtection, + InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner, + WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, + }; + let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap(); + let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(); + let workspace_identity = fixture + .api + .signing_identities + .provision_existing(TEST_WORKSPACE_ID, &test_owner_actor().account_id) + .unwrap(); + let backend_url = fixture.api.config.backend_base_url.clone().unwrap(); + let verifier = WorkspaceCapabilityVerifier::new( + vec![WorkspaceIssuerTrustRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + backend_url: backend_url.clone(), + key_id: workspace_identity.key_id.clone(), + algorithm: workspace_identity.algorithm.clone(), + public_key: workspace_identity.public_key.clone().unwrap(), + public_key_fingerprint: workspace_identity + .public_key_fingerprint + .clone() + .unwrap(), + identity_revision: workspace_identity.revision, + trust_generation: workspace_identity.revision, + state: WorkspaceIssuerTrustState::Active, + registered_at_unix: 1, + updated_at_unix: 1, + }], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let runtime = worker_runtime::Runtime::with_execution_backend( + worker_runtime::RuntimeOptions::default(), + fixture.backend.clone(), + ) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let binding = WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: fixture.runtime_id.clone(), + display_name: "Ticket Worker real HTTP Runtime".into(), + base_url: endpoint.clone(), + public_key: runtime_identity.public_key.clone(), + public_key_fingerprint: signer.public_key_fingerprint().to_string(), + binding_revision: 1, + state: StoredRuntimeBindingState::Configured, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(workspace_identity.key_id), + workspace_key_generation: Some(workspace_identity.revision), + created_at: TEST_CREATED_AT.into(), + updated_at: TEST_CREATED_AT.into(), + revoked_at: None, + }; + fixture + .api + .store + .upsert_workspace_runtime_binding_record(binding.clone(), false) + .await + .unwrap(); + fixture.runtime_server = Some(tokio::spawn(async move { + worker_runtime::http_server::serve_runtime_http_with_workspace_auth( + runtime, + listener, + None, + worker_runtime::http_server::WorkspaceRuntimeHttpAuth { + verifier, + signer, + verifications: Arc::new( + InMemoryWorkspaceRuntimeVerificationAuthority::default(), + ), + }, + ) + .await + .unwrap(); + })); + let mut remote_config = RemoteRuntimeConfig { + runtime_id: fixture.runtime_id.clone(), + workspace_id: Some(TEST_WORKSPACE_ID.into()), + display_name: binding.display_name.clone(), + base_url: endpoint, + bearer_token: None, + workspace_authorization: None, + strict_public_egress: false, + cached_worker_creation_available: true, + cached_os: "linux".into(), + cached_arch: "x86_64".into(), + cached_status: "active".into(), + timeout: std::time::Duration::from_secs(5), + }; + fixture.api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + remote_config.clone(), + TEST_WORKSPACE_ID.to_string(), + backend_url.clone(), + ) + .unwrap(), + ); + let verified = perform_workspace_runtime_verification( + &fixture.api, + fixture.api.runtime.clone(), + &binding, + ) + .await + .unwrap(); + remote_config.workspace_authorization = + Some(crate::hosts::WorkspaceRuntimeAuthorization::new( + fixture.api.store.clone(), + fixture.api.signing_identities.clone(), + backend_url.clone(), + Some(verified), + )); + fixture.api.runtime.register_or_replace( + RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url) + .unwrap(), + ); + } + fixture + } + + fn ticket(&self, state: TicketWorkflowState) -> String { + let mut input = + ticket::NewTicket::new(format!("Explicit Worker launch from {}", state.as_str())); + input.workflow_state = Some(state); + browser_ticket_backend(&self.api) + .unwrap() + .create(input) + .unwrap() + .id + } + + fn payload(&self, ticket_id: &str, operation_id: &str, profile: &str, flow: bool) -> Value { + self.backend.publish_profile(&self.api, profile); + let mut segments = vec![ + json!({"kind": "text", "content": "Research this Ticket without changing repositories."}), + ]; + if flow { + segments.insert( + 0, + json!({"kind": "flow", "selector": "builtin:coder-review"}), + ); + } + json!({ + "runtime_id": self.runtime_id, "display_name": "Explicit Ticket Worker", "profile": profile, + "ticket_assignment": {"ticket_id": ticket_id, "operation_id": operation_id}, + "initial_submit": segments, "workdir_attachments": [], "feature_connections": {}, + }) + } + + async fn post(&self, workspace_id: &str, payload: Value) -> (StatusCode, Value) { + let response = build_inner_router(self.api.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/w/{workspace_id}/workers")) + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let body = serde_json::from_slice(&bytes).unwrap_or_else(|error| { + panic!("{status}: {error}: {}", String::from_utf8_lossy(&bytes)) + }); + (status, body) + } + + fn assert_assigned( + &self, + ticket_id: &str, + operation_id: &str, + body: &Value, + ) -> TicketWorkerAssignmentRecord { + assert_eq!(body["workspace_id"], TEST_WORKSPACE_ID, "{body}"); + assert_eq!(body["runtime_id"], self.runtime_id, "{body}"); + assert!( + body["console_href"] + .as_str() + .unwrap() + .starts_with(&format!("/w/{TEST_WORKSPACE_ID}/workers/")), + "{body}" + ); + let current = self + .api + .store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, ticket_id) + .unwrap() + .unwrap(); + assert_eq!( + current.worker, + RuntimeWorkerRef::new(&self.runtime_id, body["worker_id"].as_str().unwrap()) + ); + assert!( + self.api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, ¤t.worker) + .unwrap() + .is_empty(), + "workdirless Ticket Worker must have no authoritative attachments: {body}" + ); + let operation = self + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, operation_id) + .unwrap() + .unwrap(); + assert_eq!(operation.worker, Some(current.worker.clone())); + assert_eq!( + operation.assignment_id.as_deref(), + Some(current.assignment_id.as_str()) + ); + assert_eq!( + self.api.authority.ticket(ticket_id).unwrap().state, + "inprogress" + ); + current + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_or_orchestrator() { + let fixture = LaunchFixture::new(false).await; + for state in [ + TicketWorkflowState::Planning, + TicketWorkflowState::Ready, + TicketWorkflowState::Queued, + TicketWorkflowState::InProgress, + ] { + let ticket_id = fixture.ticket(state); + let operation_id = format!("explicit-{}", state.as_str()); + assert!( + fixture + .api + .store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket_id, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{}: {body}", state.as_str()); + let assignment = fixture.assert_assigned(&ticket_id, &operation_id, &body); + assert_eq!(body["worker"]["profile"], "builtin:companion", "{body}"); + let creates = fixture.backend.creates_for(&assignment.worker.worker_id); + assert_eq!(creates.len(), 1); + assert!(creates[0].workdir_attachments.is_empty()); + let submits = fixture.backend.submits_for(&assignment.worker.worker_id); + assert_eq!(submits.len(), 1); + assert_eq!( + serde_json::to_value(&submits[0].segments).unwrap(), + json!([{"kind": "text", "content": "Research this Ticket without changing repositories."}]) + ); + assert!(submits[0].submission_request_id.is_some()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn real_runtime_http_worker_launch_preserves_optional_flow_profile_submit_and_assignment() { + let fixture = LaunchFixture::new(true).await; + for (profile, flow) in [("builtin:intake", false), ("builtin:coder", true)] { + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let operation_id = format!("real-http-{flow}"); + let payload = fixture.payload(&ticket_id, &operation_id, profile, flow); + let expected_submit = payload["initial_submit"].clone(); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{profile}: {body}"); + fixture.assert_assigned(&ticket_id, &operation_id, &body); + assert_eq!(body["worker"]["profile"], profile, "{body}"); + let creates = fixture + .backend + .creates_for(body["worker_id"].as_str().unwrap()); + assert_eq!(creates.len(), 1); + let create = &creates[0]; + assert_eq!( + serde_json::to_value(&create.initial_input.as_ref().unwrap().segments).unwrap(), + expected_submit + ); + assert!(create.workdir_attachments.is_empty()); + assert_eq!( + create.workspace_api.as_ref().unwrap().workspace_id, + TEST_WORKSPACE_ID + ); + let submits = fixture + .backend + .submits_for(body["worker_id"].as_str().unwrap()); + assert_eq!(submits.len(), 1); + assert_eq!( + serde_json::to_value(&submits[0].segments).unwrap(), + expected_submit + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn worker_launch_uses_published_project_profile_and_workspace_default_on_real_runtime_http() { + let fixture = LaunchFixture::new(true).await; + let state = fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main = config_source::VirtualPath::parse("main.dcdl").unwrap(); + let request = crate::config_source::ConfigCommitRequest { + base_revision: state.snapshot.revision, + base_digest: state.snapshot.digest.clone(), + entrypoints: state.contract.entrypoints.clone(), + changes: vec![config_source::ConfigTreeChange::Update { + path: main.clone(), + expected_digest: state.snapshot.entries[&main].content_digest.clone(), + content: r#"{ + profile = { + default_profile = "project:research"; + entries = [{ selector = "project:research"; label = "Research"; + description = "Ticket research recipe"; + profile = import "$builtin/profiles/intake.dcdl"; + }]; + }; + }"# + .into(), + }], + }; + let candidate = fixture + .api + .config_store + .evaluate_workspace_config_candidate_with_schema( + TEST_WORKSPACE_ID, + &request, + fixture.api.config_schema_registry.compose().unwrap(), + ) + .unwrap(); + fixture + .api + .config_store + .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) + .unwrap(); + for explicit in [true, false] { + let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let operation_id = format!("project-profile-{explicit}"); + let mut payload = fixture.payload(&ticket_id, &operation_id, "project:research", false); + if !explicit { + payload.as_object_mut().unwrap().remove("profile"); + } + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{body}"); + fixture.assert_assigned(&ticket_id, &operation_id, &body); + assert_eq!(body["worker"]["profile"], "project:research", "{body}"); + let creates = fixture + .backend + .creates_for(body["worker_id"].as_str().unwrap()); + assert_eq!(creates.len(), 1); + assert_eq!( + creates[0].profile, + ProfileSelector::Named("project:research".into()) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() { + let fixture = LaunchFixture::new(false).await; + for state in [TicketWorkflowState::Done, TicketWorkflowState::Closed] { + let ticket_id = fixture.ticket(state); + let operation_id = format!("terminal-{}", state.as_str()); + let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert!( + body["message"].as_str().unwrap().contains(state.as_str()), + "{body}" + ); + assert_eq!( + fixture.api.authority.ticket(&ticket_id).unwrap().state, + state.as_str() + ); + assert!( + fixture + .api + .store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .is_none() + ); + assert!( + fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &operation_id) + .unwrap() + .is_none() + ); + } + assert!(fixture.backend.creates.lock().unwrap().is_empty()); + assert!(fixture.backend.submits.lock().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignment() { + let fixture = Arc::new(LaunchFixture::new(false).await); + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let payload = fixture.payload(&ticket_id, "concurrent-launch", "builtin:companion", false); + let barrier = Arc::new(tokio::sync::Barrier::new(5)); + let mut tasks = Vec::new(); + for _ in 0..4 { + let fixture = fixture.clone(); + let payload = payload.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + fixture.post(TEST_WORKSPACE_ID, payload).await + })); + } + barrier.wait().await; + let mut assigned = None; + for task in tasks { + let (status, body) = task.await.unwrap(); + assert_eq!(status, StatusCode::OK, "{body}"); + let current = fixture.assert_assigned(&ticket_id, "concurrent-launch", &body); + if let Some(expected) = &assigned { + assert_eq!(¤t, expected); + } + assigned = Some(current); + } + let assigned = assigned.unwrap(); + assert_eq!( + fixture + .backend + .creates_for(&assigned.worker.worker_id) + .len(), + 1 + ); + assert_eq!( + fixture + .backend + .submits_for(&assigned.worker.worker_id) + .len(), + 1 + ); + assert!(fixture.backend.execution.stops.lock().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operation() { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let mut rejected = fixture.payload(&ticket_id, "invalid-resources", "builtin:companion", false); + rejected["workdir_attachments"] = + json!([{"alias":"checkout","working_directory_id":"missing-workdir"}]); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, rejected.clone()).await; + assert!(!status.is_success(), "{body}"); + let receipt = fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "invalid-resources") + .unwrap() + .unwrap(); + assert_eq!(receipt.claim_state, "failed"); + assert!(fixture.backend.creates.lock().unwrap().is_empty()); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, rejected).await; + assert!( + !status.is_success(), + "failed receipt cannot restart: {body}" + ); + let corrected = fixture.payload( + &ticket_id, + "corrected-resources", + "builtin:companion", + false, + ); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, corrected).await; + assert_eq!(status, StatusCode::OK, "{body}"); + fixture.assert_assigned(&ticket_id, "corrected-resources", &body); +} + +#[tokio::test(flavor = "multi_thread")] +async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submit() { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let payload = fixture.payload(&ticket_id, "worker-retry", "builtin:companion", false); + let (status, first) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await; + assert_eq!(status, StatusCode::OK, "{first}"); + let assignment = fixture.assert_assigned(&ticket_id, "worker-retry", &first); + let (status, replay) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["worker_id"], first["worker_id"]); + assert_eq!( + fixture + .assert_assigned(&ticket_id, "worker-retry", &replay) + .assignment_id, + assignment.assignment_id + ); + let mut attachment_conflict = payload.clone(); + attachment_conflict["workdir_attachments"] = json!([{"alias":"changed", "working_directory_id":"different-workdir", "relative_cwd":"docs"}]); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, attachment_conflict).await; + assert_eq!( + status, + StatusCode::CONFLICT, + "changed attachment intent: {body}" + ); + let mut conflict = payload; + conflict["initial_submit"][0]["content"] = + json!("A different operation payload must not reuse the reservation."); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, conflict).await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert!( + body["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "{body}" + ); + assert_eq!( + fixture + .backend + .creates_for(&assignment.worker.worker_id) + .len(), + 1 + ); + assert_eq!( + fixture + .backend + .submits_for(&assignment.worker.worker_id) + .len(), + 1 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_launch_retry_identity() + { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let payload = fixture.payload( + &ticket_id, + "worker-unknown-input", + "builtin:companion", + false, + ); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let assignment = fixture.assert_assigned(&ticket_id, "worker-unknown-input", &body); + fixture + .backend + .execution + .reject_inputs("acceptance response unavailable"); + let worker = WorkspaceWorker::resolve( + &fixture.api, + &assignment.worker.runtime_id, + &assignment.worker.worker_id, + ) + .unwrap(); + let result = worker + .input( + &WorkerOperationContext::Backend, + WorkerInputRequest { + kind: WorkerInputKind::User, + content: "Continue Ticket research".into(), + submission_request_id: Some("unknown-worker-submit".into()), + segments: None, + }, + ) + .await + .unwrap(); + assert_eq!( + result.disposition, + WorkerInputDisposition::Unknown, + "{result:?}" + ); + assert!(fixture.backend.execution.stops.lock().unwrap().is_empty()); + let (status, replay) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + let current = fixture.assert_assigned(&ticket_id, "worker-unknown-input", &replay); + assert_eq!(current.assignment_id, assignment.assignment_id); + assert_eq!(current.worker, assignment.worker); + assert_eq!( + fixture + .backend + .creates_for(&assignment.worker.worker_id) + .len(), + 1 + ); + assert_eq!( + fixture + .backend + .submits_for(&assignment.worker.worker_id) + .len(), + 2, + "initial Submit and one outcome-unknown input; replay must not re-submit" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn scoped_worker_launch_rejects_foreign_workspace_and_unpublished_profile_with_body() { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let payload = fixture.payload(&ticket_id, "scope-rejected", "builtin:companion", false); + let (status, body) = fixture + .post("019d0000-0000-7000-8000-0000000000bb", payload.clone()) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert!( + body["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "{body}" + ); + let mut unknown = payload; + unknown["profile"] = json!("project:not-published"); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, unknown).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["message"] + .as_str() + .unwrap() + .contains("Backend-published"), + "{body}" + ); + assert!(fixture.backend.creates.lock().unwrap().is_empty()); + assert!( + fixture + .api + .store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "scope-rejected") + .unwrap() + .is_none() + ); +} + +// This is the capability policy boundary. The API/runtime tests above separately +// prove transport, creation, Submit acknowledgement, and durable assignment. +#[tokio::test] +async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_capabilities() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let mut input = + ticket::NewTicket::new("Read-only Ticket with explicit external research grant"); + input.targets = vec![test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadOnly, + )]; + let ticket_id = browser_ticket_backend(&api) + .unwrap() + .create(input) + .unwrap() + .id; + let workdir = WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + workdir_id: "read-only-target".into(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(), + repository_id: test_repository_id(&api), + }, + creation_selector: Some("develop".into()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".into()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".into(), + cleanliness: "clean".into(), + created_at: TEST_CREATED_AT.into(), + updated_at: TEST_CREATED_AT.into(), + }; + api.store.upsert_workdir_registry(&workdir).unwrap(); + let external = WorkdirRegistryRecord { + workdir_id: "external-research".into(), + source: WorkdirRegistrySource::ExternalGrant { + grant_id: "research-grant".into(), + }, + creation_selector: None, + current_selector: None, + ..workdir.clone() + }; + api.store + .create_external_workdir_grant( + &ExternalWorkdirGrantRecord { + grant_id: "research-grant".into(), + workspace_id: TEST_WORKSPACE_ID.into(), + workdir_id: external.workdir_id.clone(), + provider_instance_id: "research-provider".into(), + display_name: "Research".into(), + permissions: "read_only".into(), + created_by: "fixture".into(), + created_at: TEST_CREATED_AT.into(), + expires_at: None, + generation: 1, + status: "pending".into(), + updated_at: TEST_CREATED_AT.into(), + }, + &external, + ) + .unwrap(); + let mut request = WorkerSpawnRequest { + requested_worker_name: Some("Read-only research Worker".into()), + intent: WorkerSpawnIntent::TicketRole { + ticket_id, + role: TicketWorkerRole::Worker, + }, + singleton_key: None, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 1, + }, + profile: ProfileSelector::Builtin("builtin:intake".into()), + ticket_assignment: None, + initial_submit: vec![Segment::text("Research without writes")], + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: vec![ + WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("target").unwrap(), + working_directory_id: workdir.workdir_id.clone(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + }, + WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("research").unwrap(), + working_directory_id: external.workdir_id.clone(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + }, + ], + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_control_operation: None, + }; + api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request) + .unwrap(); + assert!( + request + .resolved_workdir_attachments + .iter() + .all(|claim| claim.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY) + ); + // Repository targets bound allowable scope, not mandatory resources. + request + .resolved_workdir_attachments + .retain(|claim| claim.alias.as_str() == "research"); + api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request) + .unwrap(); + assert_eq!( + request.resolved_workdir_attachments[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_planning() + { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + rusqlite::Connection::open(&fixture.api.config.database_path) + .unwrap() + .execute_batch( + r#" + CREATE TRIGGER fail_generic_worker_assignment + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.role = 'worker' + BEGIN SELECT RAISE(ABORT, 'injected Worker assignment failure'); END; + "#, + ) + .unwrap(); + let payload = fixture.payload(&ticket_id, "compensated-worker", "builtin:companion", false); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}"); + assert!( + body["message"].as_str().unwrap().contains("compensat"), + "{body}" + ); + let creates = fixture + .backend + .creates + .lock() + .unwrap() + .iter() + .filter(|request| request.display_name.as_deref() == Some("Explicit Ticket Worker")) + .cloned() + .collect::>(); + assert_eq!(creates.len(), 1); + let worker_id = creates[0].worker_id.to_string(); + assert!( + fixture + .backend + .execution + .stops + .lock() + .unwrap() + .iter() + .any(|worker| worker.worker_id.to_string() == worker_id) + ); + assert!( + fixture + .backend + .execution + .contexts + .lock() + .unwrap() + .keys() + .all(|worker| worker.worker_id.to_string() != worker_id) + ); + assert!( + fixture + .api + .store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .is_none() + ); + assert_eq!( + fixture.api.authority.ticket(&ticket_id).unwrap().state, + "planning" + ); + let counts: (i64, i64) = fixture.api.config_store.with_conn(|conn| Ok(( + conn.query_row("SELECT COUNT(*) FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", rusqlite::params![TEST_WORKSPACE_ID, fixture.runtime_id, worker_id], |row| row.get(0))?, + conn.query_row("SELECT COUNT(*) FROM worker_create_reservations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND state = 'reserved'", rusqlite::params![TEST_WORKSPACE_ID, fixture.runtime_id, worker_id], |row| row.get(0))?, + ))).unwrap(); + assert_eq!(counts, (0, 0)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_mr_review() { + let fixture = LaunchFixture::new(false).await; + let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let payload = fixture.payload(&ticket_id, "ordinary-reviewer", "builtin:reviewer", false); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let current = fixture.assert_assigned(&ticket_id, "ordinary-reviewer", &body); + assert_eq!(body["worker"]["profile"], "builtin:reviewer", "{body}"); + let store = merge_request_store(&fixture.api, TEST_WORKSPACE_ID).unwrap(); + let auth = merge_request::MergeRequestAuth { + workspace_id: TEST_WORKSPACE_ID.into(), + repository_id: test_repository_id(&fixture.api), + runtime_id: current.worker.runtime_id.clone(), + worker_id: current.worker.worker_id.clone(), + assignment_id: current.assignment_id, + }; + let mr_id = "ordinary-profile-is-not-review-authority"; + store + .open_merge_request(merge_request::OpenMergeRequest { + merge_request_id: mr_id.into(), + ticket_id: ticket_id.clone(), + repository_id: auth.repository_id.clone(), + selector_from: "work/research".into(), + selector_to: "develop".into(), + summary: String::new(), + auth: auth.clone(), + now: Utc::now(), + }) + .unwrap(); + let error = store + .request_review(merge_request::RequestMergeRequestReview { + merge_request_id: mr_id.into(), + ticket_id: ticket_id.clone(), + ticket_item_revision: ticket_item_checker::item_revision( + &browser_ticket_backend(&fixture.api) + .unwrap() + .show(ticket_id.clone().into()) + .unwrap(), + ), + ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject { + merge_request_id: mr_id.into(), + subject_ref: "immutable-source".into(), + }], + subject_ref: "immutable-source".into(), + child_session_id: current.worker.worker_id, + capability_token: "profile-alone-is-not-a-capability".into(), + auth, + now: Utc::now(), + }) + .unwrap_err(); + assert!( + matches!(error, merge_request::MergeRequestError::Unauthorized(ref message) if message == "reviewer child attestation missing"), + "{error}" + ); + assert!( + store + .authorize_review_submission(mr_id, "profile-alone-is-not-a-capability") + .is_err() + ); +} diff --git a/crates/workspace-server/src/server/worker_operations.rs b/crates/workspace-server/src/server/worker_operations.rs index 754787c3c..3f8419430 100644 --- a/crates/workspace-server/src/server/worker_operations.rs +++ b/crates/workspace-server/src/server/worker_operations.rs @@ -359,6 +359,8 @@ impl WorkspaceWorker { "send_input" }; let _guard = self.authorize_operation(context, permission).await?; + let session_lock = current_worker_session_lock(&self.api, &self.identity); + let _session_guard = session_lock.lock().await; self.ensure_interactive()?; if matches!(context, WorkerOperationContext::WorkerControl { .. }) && request.kind == WorkerInputKind::User @@ -373,6 +375,19 @@ impl WorkspaceWorker { } fn ensure_interactive(&self) -> ApiResult<()> { + if let Some(claim) = self + .api + .store + .get_pending_ticket_assignment_operation_for_worker( + self.api.workspace_id(), + &self.identity, + )? + { + return Err(Error::TicketAssignmentConflict(format!( + "Worker has pending Ticket claim {}; reconcile that exact operation before admitting input", + claim.operation_id, + )).into()); + } if self .api .store @@ -578,6 +593,8 @@ impl WorkspaceWorkerMethodSender { .worker .authorize_operation(context, "send_input") .await?; + let session_lock = current_worker_session_lock(&self.worker.api, &self.worker.identity); + let _session_guard = session_lock.lock().await; self.worker.ensure_interactive()?; let WorkerOperationContext::Browser { source, .. } = context else { return Err(Error::WorkspacePermissionDenied( diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index e65ae4e62..5a1775278 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -129,7 +129,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() { - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); let api = &fixture.api; @@ -222,7 +222,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; @@ -299,7 +299,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_grants_owner_scope_and_signed_routes() { - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); let api = &fixture.api; @@ -615,7 +615,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_readonly_revoke_and_conditional_lifetime_fences() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; let read_grant = grant(api, &fixture.worker, Access::ReadOnly).await; assert!( @@ -827,7 +827,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_two_workers_ui_cas_and_identical_virtual_replacement() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; let second = second_worker(&fixture).await; grant(api, &fixture.worker, Access::ReadWrite).await; @@ -982,7 +982,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_invalid_bounds_and_failed_save_do_not_corrupt_tree() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; @@ -1114,7 +1114,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_operation_and_revoke_share_the_detach_session_lock() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; let granted = grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; @@ -1168,7 +1168,7 @@ mod workspace_config_integration { } #[tokio::test] async fn workspace_config_grant_restart_source_immutability_and_owner_acceptance_guard() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; let granted = grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; @@ -1255,7 +1255,7 @@ mod workspace_config_integration { #[tokio::test] async fn workspace_config_auxiliary_secret_effects_are_unknown_not_false_rollback() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; @@ -1324,7 +1324,7 @@ mod workspace_config_integration { } #[tokio::test] async fn workspace_config_observe_node_limit_is_complete_or_error_not_silent_truncation() { - let fixture = manual_coder_assignment_fixture().await; + let fixture = manual_worker_assignment_fixture().await; let api = &fixture.api; grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 28a7c496e..c0df1d1a5 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -259,7 +259,7 @@ async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Att async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() { use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip; use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature}; - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await; @@ -706,7 +706,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w decode_runtime_request_source_claims, }; // Remote Runtime-owned HTTP adapter, including its real request signer. - let mut fixture = manual_coder_assignment_fixture().await; + let mut fixture = manual_worker_assignment_fixture().await; let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); let claims = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c3e242f29..5aa116eee 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 82; +const LATEST_SCHEMA_VERSION: i64 = 83; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; @@ -288,6 +288,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Ticket responsibility and unfinished work separation", apply: migrate_assignment_work_v81_to_v82, }, + Migration { + version: 83, + name: "Generic Ticket Worker roles and durable claims", + apply: migrate_ticket_worker_v82_to_v83, + }, ]; #[derive(Clone, Copy)] @@ -750,7 +755,7 @@ pub struct WorkerControlGrantRecord { #[serde(rename_all = "snake_case")] pub enum TicketAssignmentRole { Orchestrator, - Coder, + Worker, Owner, Contributor, } @@ -759,7 +764,7 @@ impl TicketAssignmentRole { pub fn as_str(self) -> &'static str { match self { Self::Orchestrator => "orchestrator", - Self::Coder => "coder", + Self::Worker => "worker", Self::Owner => "owner", Self::Contributor => "contributor", } @@ -768,7 +773,7 @@ impl TicketAssignmentRole { fn from_db(value: &str) -> rusqlite::Result { match value { "orchestrator" => Ok(Self::Orchestrator), - "coder" => Ok(Self::Coder), + "worker" => Ok(Self::Worker), "owner" => Ok(Self::Owner), "contributor" => Ok(Self::Contributor), _ => Err(rusqlite::Error::FromSqlConversionFailure( @@ -780,7 +785,7 @@ impl TicketAssignmentRole { } pub fn is_singleton(self) -> bool { - matches!(self, Self::Orchestrator | Self::Coder) + matches!(self, Self::Orchestrator | Self::Worker) } } @@ -831,7 +836,7 @@ pub struct TicketRoleAssignmentRecord { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TicketCoderAssignmentRecord { +pub struct TicketWorkerAssignmentRecord { pub workspace_id: String, pub ticket_id: String, pub assignment_id: String, @@ -841,7 +846,7 @@ pub struct TicketCoderAssignmentRecord { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TicketCoderAssignmentEventRecord { +pub struct TicketWorkerAssignmentEventRecord { pub workspace_id: String, pub ticket_id: String, pub event_id: String, @@ -854,8 +859,8 @@ pub struct TicketCoderAssignmentEventRecord { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketWorkerAssignmentUpdate { - pub current: TicketCoderAssignmentRecord, - pub previous: Option, + pub current: TicketWorkerAssignmentRecord, + pub previous: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -1837,6 +1842,19 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, operation_id: &str, ) -> Result>; + fn get_pending_ticket_assignment_operation_for_worker( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result>; + fn fail_ticket_assignment_operation( + &self, + workspace_id: &str, + operation_id: &str, + reason: &str, + ) -> Result<()>; + /// True only for the transaction that installs the reservation. A matching + /// replay returns false; callers must not redispatch its Runtime effects. fn reserve_ticket_assignment_operation( &self, workspace_id: &str, @@ -1846,7 +1864,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { worker_id: Option<&str>, request_fingerprint: &str, created_at: &str, - ) -> Result<()>; + ) -> Result; fn bind_ticket_assignment_operation_worker( &self, workspace_id: &str, @@ -1902,11 +1920,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { operation_id: &str, allow_reassign: bool, ) -> Result; - fn start_ready_ticket_with_coder_assignment( + fn start_ticket_with_worker_assignment( &self, record: &TicketRoleAssignmentRecord, event_id: &str, operation_id: &str, + binding_fingerprint: Option<&str>, ) -> Result; fn clear_current_ticket_role_assignment( &self, @@ -1920,7 +1939,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { occurred_at: &str, reason: Option<&str>, ) -> Result; - fn cancel_current_ticket_coder_assignment( + fn cancel_current_ticket_worker_assignment( &self, workspace_id: &str, ticket_id: &str, @@ -1936,15 +1955,15 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, ticket_id: &str, - ) -> Result>; - fn get_current_ticket_coder_assignment( + ) -> Result>; + fn get_current_ticket_worker_assignment( &self, workspace_id: &str, ticket_id: &str, - ) -> Result>; - fn set_current_ticket_coder_assignment( + ) -> Result>; + fn set_current_ticket_worker_assignment( &self, - record: &TicketCoderAssignmentRecord, + record: &TicketWorkerAssignmentRecord, expected_assignment_id: Option<&str>, event_id: &str, operation_id: &str, @@ -1959,13 +1978,13 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { event_id: &str, actor: &str, created_at: &str, - ) -> Result>; - fn list_ticket_coder_assignment_events( + ) -> Result>; + fn list_ticket_worker_assignment_events( &self, workspace_id: &str, ticket_id: &str, limit: usize, - ) -> Result>; + ) -> Result>; fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>; fn get_workdir_registry( @@ -2223,6 +2242,50 @@ impl SqliteWorkspaceStore { }) } + pub(crate) fn get_internal_worker_restore_intent( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + request_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row("SELECT request_id, expected_token FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4", + params![workspace_id,worker.runtime_id,worker.worker_id,request_id], |row| Ok(server_api::WorkerRestoreRequest { request_id:row.get(0)?, expected_observation_token:row.get(1)? })) + .optional().map_err(Error::from) + }) + } + + pub(crate) fn get_ticket_claim_recovery( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result> { + self.with_conn(|conn| read_ticket_claim_recovery(conn, workspace_id, operation_id)) + } + + pub(crate) fn retain_ticket_claim_recovery( + &self, + workspace_id: &str, + operation_id: &str, + fingerprint: &str, + recovery: &TicketWorkerClaimRecovery, + ) -> Result<()> { + let encoded = serde_json::to_string(recovery).map_err(|e| Error::Store(e.to_string()))?; + self.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let receipt=read_assignment_operation(&tx,workspace_id,operation_id)?.ok_or_else(|| Error::TicketAssignmentConflict("manual claim admission is missing".into()))?; + if receipt.claim_state!="pending" || receipt.request_fingerprint.as_deref()!=Some(fingerprint) { + return Err(Error::TicketAssignmentConflict("manual claim recovery does not match pending admission".into())); + } + if let Some(existing)=read_ticket_claim_recovery(&tx,workspace_id,operation_id)? { + if &existing!=recovery { return Err(Error::TicketAssignmentConflict("manual claim recovery is immutable".into())); } + } else { + tx.execute("UPDATE ticket_assignment_operations SET binding_recovery_json=?3 WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id,encoded])?; + } + tx.commit()?; Ok(()) + }) + } + /// Client intent journal, not a lifecycle state machine: pin the public tuple /// before dispatch and keep it unchanged until a terminal receipt is obtained. pub(crate) fn pin_internal_worker_restore_intent( @@ -7948,6 +8011,54 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| read_assignment_operation(conn, workspace_id, operation_id)) } + fn get_pending_ticket_assignment_operation_for_worker( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result> { + self.with_conn(|conn| { + let operation_id: Option = conn + .query_row( + "SELECT operation_id FROM ticket_assignment_operations + WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 + AND claim_state='pending' AND action IN ('assign','reassign') + AND assignment_id IS NULL ORDER BY created_at, operation_id LIMIT 1", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| row.get(0), + ) + .optional()?; + operation_id + .map(|id| read_assignment_operation(conn, workspace_id, &id)) + .transpose() + .map(Option::flatten) + }) + } + + fn fail_ticket_assignment_operation( + &self, + workspace_id: &str, + operation_id: &str, + reason: &str, + ) -> Result<()> { + validate_non_empty("reason", reason)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let receipt = read_assignment_operation(&tx, workspace_id, operation_id)? + .ok_or_else(|| Error::TicketAssignmentConflict(format!("missing Ticket claim `{operation_id}`")))?; + if receipt.claim_state == "failed" { + return Ok(()); // Keep the original reason and fingerprint immutable. + } + if receipt.claim_state != "pending" || receipt.assignment_id.is_some() { + return Err(Error::TicketAssignmentConflict(format!("Ticket claim `{operation_id}` is already committed"))); + } + tx.execute("UPDATE ticket_assignment_operations SET claim_state='failed', failure_reason=?3 + WHERE workspace_id=?1 AND operation_id=?2 AND claim_state='pending' AND assignment_id IS NULL", + params![workspace_id, operation_id, reason])?; + tx.commit()?; + Ok(()) + }) + } + fn reserve_ticket_assignment_operation( &self, workspace_id: &str, @@ -7957,13 +8068,82 @@ impl ControlPlaneStore for SqliteWorkspaceStore { worker_id: Option<&str>, request_fingerprint: &str, created_at: &str, - ) -> Result<()> { - self.with_conn(|conn| { - let inserted = conn.execute( - r#"INSERT OR IGNORE INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, runtime_id, worker_id, - assignment_id, expected_assignment_id, created_at, request_fingerprint - ) VALUES (?1, ?2, 'assign', ?3, ?4, ?5, NULL, NULL, ?6, ?7)"#, + ) -> Result { + validate_non_empty("operation_id", operation_id)?; + validate_non_empty("request_fingerprint", request_fingerprint)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(existing) = read_assignment_operation(&tx, workspace_id, operation_id)? { + if existing.action == "assign" + && existing.ticket_id == ticket_id + && existing.runtime_id.as_deref() == Some(runtime_id) + && (worker_id.is_none() + || existing + .worker + .as_ref() + .map(|worker| worker.worker_id.as_str()) + == worker_id) + && existing.expected_assignment_id.is_none() + && existing.request_fingerprint.as_deref() == Some(request_fingerprint) + { + tx.commit()?; + return Ok(false); + } + return Err(Error::TicketAssignmentConflict(format!( + "assignment operation {operation_id} was already used with different input" + ))); + } + let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id)); + ensure_no_competing_ticket_claim( + &tx, + workspace_id, + ticket_id, + worker.as_ref(), + operation_id, + )?; + if let Some(worker) = &worker { + ensure_worker_assignment_available(&tx, workspace_id, worker)?; + let occupied: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id=?1 AND role='worker' AND runtime_id=?2 AND worker_id=?3)", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| row.get(0), + )?; + if occupied { + return Err(Error::TicketAssignmentConflict( + "Worker has unfinished Ticket work".into(), + )); + } + } + let state: String = tx.query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id], + |row| row.get(0), + )?; + if !matches!( + state.as_str(), + "planning" | "ready" | "queued" | "inprogress" + ) { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{ticket_id}` cannot claim work in state `{state}`" + ))); + } + let occupied: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker')", + params![workspace_id, ticket_id], + |row| row.get(0), + )?; + if occupied { + return Err(Error::TicketAssignmentConflict( + "Ticket has unfinished Worker work".into(), + )); + } + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id,operation_id,action,ticket_id,runtime_id,worker_id,assignment_id, + expected_assignment_id,created_at,request_fingerprint,claim_state) + VALUES(?1,?2,'assign',?3,?4,?5,NULL,NULL,?6,?7,'pending')", params![ workspace_id, operation_id, @@ -7971,36 +8151,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id, worker_id, created_at, - request_fingerprint, + request_fingerprint ], )?; - if inserted > 0 { - return Ok(()); - } - let existing = read_assignment_operation(conn, workspace_id, operation_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict(format!( - "assignment operation {operation_id} could not be reserved" - )) - })?; - if existing.action == "assign" - && existing.ticket_id == ticket_id - && existing.runtime_id.as_deref() == Some(runtime_id) - && (worker_id.is_none() - || existing - .worker - .as_ref() - .map(|worker| worker.worker_id.as_str()) - == worker_id) - && existing.expected_assignment_id.is_none() - && existing.request_fingerprint.as_deref() == Some(request_fingerprint) - { - Ok(()) - } else { - Err(Error::TicketAssignmentConflict(format!( - "assignment operation {operation_id} was already used with different input" - ))) - } + tx.commit()?; + Ok(true) }) } @@ -8010,20 +8165,32 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, worker_id: &str, ) -> Result<()> { - self.with_conn(|conn| { - let updated = conn.execute( - r#"UPDATE ticket_assignment_operations - SET worker_id = ?3 - WHERE workspace_id = ?1 AND operation_id = ?2 - AND assignment_id IS NULL AND (worker_id IS NULL OR worker_id = ?3)"#, - params![workspace_id, operation_id, worker_id], - )?; - if updated == 1 { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let receipt = read_assignment_operation(&tx, workspace_id, operation_id)? + .ok_or_else(|| Error::TicketAssignmentConflict(format!("missing Ticket claim `{operation_id}`")))?; + // Exact binding replay is safe even after assignment finalization. + if receipt.claim_state == "committed" && receipt.worker.as_ref().is_some_and(|w| w.worker_id == worker_id) { + tx.commit()?; return Ok(()); } - Err(Error::TicketAssignmentConflict(format!( - "assignment operation {operation_id} cannot bind Worker {worker_id}" - ))) + if receipt.claim_state != "pending" || receipt.assignment_id.is_some() + || receipt.worker.as_ref().is_some_and(|w| w.worker_id != worker_id) { + return Err(Error::TicketAssignmentConflict(format!("assignment operation {operation_id} cannot bind Worker {worker_id}"))); + } + let runtime_id = receipt.runtime_id.as_deref().ok_or_else(|| Error::TicketAssignmentConflict("pending Worker claim has no Runtime".into()))?; + let worker = RuntimeWorkerRef::new(runtime_id, worker_id); + ensure_no_competing_ticket_claim(&tx, workspace_id, &receipt.ticket_id, Some(&worker), operation_id)?; + ensure_worker_assignment_available(&tx, workspace_id, &worker)?; + let occupied: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id=?1 AND role='worker' AND runtime_id=?2 AND worker_id=?3)", + params![workspace_id,runtime_id,worker_id], |row| row.get(0))?; + if occupied { return Err(Error::TicketAssignmentConflict("Worker has unfinished Ticket work".into())); } + tx.execute("UPDATE ticket_assignment_operations SET worker_id=?3 + WHERE workspace_id=?1 AND operation_id=?2 AND claim_state='pending' AND assignment_id IS NULL", + params![workspace_id,operation_id,worker_id])?; + tx.commit()?; + Ok(()) }) } @@ -8032,8 +8199,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, operation_id: &str, ) -> Result<()> { - self.with_conn(|conn| { - let transaction = conn.unchecked_transaction()?; + self.with_conn_mut(|conn| { + let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let operation = read_assignment_operation(&transaction, workspace_id, operation_id)?; let Some(operation) = operation else { transaction.commit()?; @@ -8046,6 +8213,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ))); } + if operation.claim_state != "committed" { + if operation.claim_state == "pending" { + transaction.execute("UPDATE ticket_assignment_operations SET claim_state='failed', failure_reason='Ticket assignment rolled back' + WHERE workspace_id=?1 AND operation_id=?2", params![workspace_id,operation_id])?; + } + transaction.commit()?; + return Ok(()); // Pending/failed fingerprints are durable receipts. + } + ensure_no_competing_ticket_claim(&transaction, workspace_id, &operation.ticket_id, operation.worker.as_ref(), operation_id)?; transaction.execute( "DELETE FROM ticket_assignment_operations WHERE workspace_id = ?1 AND operation_id = ?2", params![workspace_id, operation_id], @@ -8270,6 +8446,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { return Ok(persisted); } + ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, + record.principal.worker().as_ref(), operation_id)?; let current_sql = ticket_role_assignment_select_sql( "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ ORDER BY a.assigned_at, a.assignment_id", @@ -8410,20 +8588,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn start_ready_ticket_with_coder_assignment( + fn start_ticket_with_worker_assignment( &self, record: &TicketRoleAssignmentRecord, event_id: &str, operation_id: &str, + binding_fingerprint: Option<&str>, ) -> Result { - if record.role != TicketAssignmentRole::Coder + if record.role != TicketAssignmentRole::Worker || !matches!( record.principal, TicketAssignmentPrincipal::Worker { .. } | TicketAssignmentPrincipal::User { .. } ) { return Err(Error::TicketAssignmentConflict( - "manual Ticket start requires a Coder user or Worker principal".to_string(), + "manual Ticket start requires a Worker user or Worker principal".to_string(), )); } let (principal_id, runtime_id, worker_id) = match &record.principal { @@ -8436,59 +8615,56 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())), TicketAssignmentPrincipal::WorkspaceAgent { .. } => { return Err(Error::TicketAssignmentConflict( - "Workspace agent principal cannot occupy the Coder role".to_string(), + "Workspace agent principal cannot occupy the Worker role".to_string(), )); } }; - let principal_json = serde_json::to_string(&record.principal).map_err(|error| { - Error::Store(format!("serialize Ticket assignment principal: {error}")) - })?; - let mut hasher = Sha256::new(); - for value in [ - "ticket-role-assignment:manual-start:v1", - record.workspace_id.as_str(), - record.ticket_id.as_str(), - principal_json.as_str(), - record.assigned_by.as_str(), - ] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - let fingerprint = hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); + let fingerprint = manual_ticket_assignment_fingerprint(record, binding_fingerprint)?; self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; if let Some(worker) = record.principal.worker() { ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; } - let existing_operation: Option<(String, Option)> = tx - .query_row( - "SELECT request_fingerprint, assignment_id FROM ticket_assignment_operations - WHERE workspace_id = ?1 AND operation_id = ?2", - params![record.workspace_id, operation_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((persisted, assignment_id)) = existing_operation { - if persisted != fingerprint { + let reserved = read_assignment_operation(&tx, &record.workspace_id, operation_id)?; + if let Some(receipt) = &reserved { + if receipt.request_fingerprint.as_deref() != Some(fingerprint.as_str()) + || receipt.action != "assign" || receipt.ticket_id != record.ticket_id + { return Err(Error::TicketAssignmentConflict(format!( - "operation `{operation_id}` was already used for different manual Coder assignment input" + "operation `{operation_id}` was already used for different manual Worker assignment input" ))); } - let assignment_id = assignment_id.ok_or_else(|| { - Error::TicketAssignmentConflict(format!( - "manual Coder operation `{operation_id}` has no result" - )) - })?; - return read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? - .ok_or_else(|| Error::TicketAssignmentConflict(format!( - "manual Coder assignment `{assignment_id}` no longer exists" + if receipt.claim_state == "failed" { + return Err(Error::TicketAssignmentConflict(format!( + "manual Worker claim `{operation_id}` failed: {}", + receipt.failure_reason.as_deref().unwrap_or("unknown failure") ))); + } + if let Some(assignment_id) = &receipt.assignment_id { + return read_ticket_role_assignment_by_id(&tx, &record.workspace_id, assignment_id)? + .ok_or_else(|| Error::TicketAssignmentConflict(format!( + "manual Worker assignment `{assignment_id}` no longer exists" + ))); + } + if receipt.claim_state != "pending" || receipt.worker != record.principal.worker() { + return Err(Error::TicketAssignmentConflict(format!("manual Worker claim `{operation_id}` is not a matching pending reservation"))); + } } + if let Some(recovery)=read_ticket_claim_recovery(&tx,&record.workspace_id,operation_id)? { + let item_edit:i64=tx.query_row("SELECT COALESCE(MAX(event_index),0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND kind='item_edit'",params![record.workspace_id,record.ticket_id],|r|r.get(0))?; + if recovery.item_revision!=format!("{}:{item_edit}",record.ticket_id) { + return Err(Error::TicketAssignmentConflict("Ticket changed during explicit Worker binding".into())); + } + for link in &recovery.effective_links { + let matches:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_workdir_links WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND workdir_id=?4 AND connection_id=?5 AND alias=?6 AND capabilities=?7 AND unlinked_at IS NULL)",params![record.workspace_id,link.worker.runtime_id,link.worker.worker_id,link.workdir_id,link.connection_id,link.alias,encode_workdir_link_capabilities(link.capabilities)?],|r|r.get(0))?; + if !matches { return Err(Error::TicketAssignmentConflict("manual claim Workdir connection changed".into())); } + } + let live:i64=tx.query_row("SELECT COUNT(*) FROM worker_workdir_links WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND unlinked_at IS NULL",params![record.workspace_id,runtime_id,worker_id],|r|r.get(0))?; + if live!=recovery.effective_links.len() as i64 { return Err(Error::TicketAssignmentConflict("manual claim Workdir connections changed".into())); } + } + ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, + record.principal.worker().as_ref(), operation_id)?; let state: String = tx.query_row( "SELECT workflow_state FROM typed_tickets @@ -8496,25 +8672,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; - if state != "ready" { + if !matches!(state.as_str(), "planning" | "ready" | "queued" | "inprogress") { return Err(Error::TicketAssignmentConflict(format!( - "manual Coder assignment requires ready Ticket; current state is `{state}`" + "manual Worker assignment requires planning, ready, queued, or inprogress Ticket; current state is `{state}`" ))); } - let (target_count, read_write_count): (i64, i64) = tx.query_row( - "SELECT COUNT(*), - COALESCE(SUM(CASE WHEN access = 'read_write' THEN 1 ELSE 0 END), 0) - FROM typed_ticket_targets - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![record.workspace_id, record.ticket_id], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - if target_count == 0 || read_write_count == 0 { - return Err(Error::TicketAssignmentConflict( - "manual Coder assignment requires at least one read_write repository target" - .to_string(), - )); - } let unresolved_blockers: i64 = tx.query_row( "SELECT COUNT(*) FROM typed_ticket_relations AS relation @@ -8535,43 +8697,52 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; if unresolved_blockers != 0 { return Err(Error::TicketAssignmentConflict( - "manual Coder assignment is blocked by unresolved Ticket relations".to_string(), + "manual Worker assignment is blocked by unresolved Ticket relations".to_string(), )); } let conflicting: i64 = tx.query_row( "SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 - AND role IN ('orchestrator', 'coder')", + AND role = 'worker'", params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; if conflicting != 0 { return Err(Error::TicketAssignmentConflict( - "manual Coder assignment requires no active Orchestrator or Coder assignment" - .to_string(), + "manual Worker assignment requires no unfinished Worker assignment".to_string(), )); } let previous: Option = tx.query_row( - "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'", + "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker'", params![record.workspace_id, record.ticket_id], |row| row.get(0)).optional()?; - tx.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'", + tx.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker'", params![record.workspace_id, record.ticket_id])?; - tx.execute( - "INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, role, principal_kind, - principal_id, runtime_id, worker_id, assignment_id, created_at, - request_fingerprint - ) VALUES (?1, ?2, 'assign', ?3, 'coder', ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![record.workspace_id, operation_id, record.ticket_id, - record.principal.kind(), principal_id, runtime_id, worker_id, - record.assignment_id, record.assigned_at, fingerprint], - )?; + if reserved.is_some() { + let updated = tx.execute("UPDATE ticket_assignment_operations SET assignment_id=?3, + claim_state='committed', principal_kind=?4, principal_id=?5 + WHERE workspace_id=?1 AND operation_id=?2 AND claim_state='pending' AND assignment_id IS NULL", + params![record.workspace_id,operation_id,record.assignment_id,record.principal.kind(),principal_id])?; + if updated != 1 { + return Err(Error::TicketAssignmentConflict("manual Worker claim is no longer pending".into())); + } + } else { + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id, operation_id, action, ticket_id, role, principal_kind, + principal_id, runtime_id, worker_id, assignment_id, created_at, + request_fingerprint + ) VALUES (?1, ?2, 'assign', ?3, 'worker', ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![record.workspace_id, operation_id, record.ticket_id, + record.principal.kind(), principal_id, runtime_id, worker_id, + record.assignment_id, record.assigned_at, fingerprint], + )?; + } tx.execute( "INSERT INTO ticket_worker_assignments ( workspace_id, ticket_id, assignment_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_by, assigned_at - ) VALUES (?1, ?2, ?3, 'coder', ?4, ?5, ?6, ?7, ?8, ?9)", + ) VALUES (?1, ?2, ?3, 'worker', ?4, ?5, ?6, ?7, ?8, ?9)", params![record.workspace_id, record.ticket_id, record.assignment_id, record.principal.kind(), principal_id, runtime_id, worker_id, record.assigned_by, record.assigned_at], @@ -8580,7 +8751,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO ticket_current_worker_assignments ( workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id, updated_at - ) VALUES (?1, ?2, 'coder', ?3, ?4, ?5, ?6, ?7, ?8)", + ) VALUES (?1, ?2, 'worker', ?3, ?4, ?5, ?6, ?7, ?8)", params![record.workspace_id, record.ticket_id, record.assignment_id, record.principal.kind(), principal_id, runtime_id, worker_id, record.assigned_at], @@ -8589,7 +8760,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO ticket_worker_assignment_events ( workspace_id, ticket_id, role, event_id, action, assignment_id, actor, created_at, operation_id, previous_assignment_id - ) VALUES (?1, ?2, 'coder', ?3, ?8, ?4, ?5, ?6, ?7, ?9)", + ) VALUES (?1, ?2, 'worker', ?3, ?8, ?4, ?5, ?6, ?7, ?9)", params![record.workspace_id, record.ticket_id, event_id, record.assignment_id, record.assigned_by, record.assigned_at, operation_id, if previous.is_some() { "reassigned" } else { "assigned" }, previous], @@ -8604,15 +8775,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO typed_ticket_events ( workspace_id, ticket_id, event_index, kind, author, at, from_state, to_state, reason, state_field, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, 'ready', 'inprogress', - 'manual Coder assignment accepted', 'state', 'State changed', '')", + ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, ?6, 'inprogress', + 'manual Worker assignment accepted', 'state', 'State changed', '')", params![record.workspace_id, record.ticket_id, event_index, - record.assigned_by, record.assigned_at], + record.assigned_by, record.assigned_at, state], )?; for (key, value) in [ ("event_id", event_id), ("assignment_id", record.assignment_id.as_str()), - ("assignment_role", "coder"), + ("assignment_role", "worker"), ("operation_id", operation_id), ("request_fingerprint", fingerprint.as_str()), ] { @@ -8626,12 +8797,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { let updated = tx.execute( "UPDATE typed_tickets SET workflow_state = 'inprogress', workflow_state_explicit = 1, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", - params![record.workspace_id, record.ticket_id, record.assigned_at], + WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = ?4", + params![record.workspace_id, record.ticket_id, record.assigned_at, state], )?; if updated != 1 { return Err(Error::TicketAssignmentConflict( - "Ticket state changed during manual Coder assignment".to_string(), + "Ticket state changed during manual Worker assignment".to_string(), )); } tx.commit()?; @@ -8671,7 +8842,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn cancel_current_ticket_coder_assignment( + fn cancel_current_ticket_worker_assignment( &self, workspace_id: &str, ticket_id: &str, @@ -8700,7 +8871,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &tx, workspace_id, ticket_id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, assignment_id, assignment_event_id, operation_id, @@ -8731,7 +8902,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { for (key, value) in [ ("event_id", state_event_id), ("assignment_id", assignment_id), - ("assignment_role", "coder"), + ("assignment_role", "worker"), ("operation_id", operation_id), ] { tx.execute( @@ -8764,7 +8935,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, ticket_id: &str, - ) -> Result> { + ) -> Result> { self.with_conn(|conn| { conn.query_row( current_ticket_worker_assignment_select_sql() @@ -8781,11 +8952,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn get_current_ticket_coder_assignment( + fn get_current_ticket_worker_assignment( &self, workspace_id: &str, ticket_id: &str, - ) -> Result> { + ) -> Result> { self.with_conn(|conn| { conn.query_row( current_ticket_worker_assignment_select_sql().as_str(), @@ -8797,16 +8968,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn set_current_ticket_coder_assignment( + fn set_current_ticket_worker_assignment( &self, - record: &TicketCoderAssignmentRecord, + record: &TicketWorkerAssignmentRecord, expected_assignment_id: Option<&str>, event_id: &str, operation_id: &str, allow_reassign: bool, ) -> Result { - self.with_conn(|conn| { - let tx = conn.unchecked_transaction()?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let removal_blocks_assignment: bool = tx.query_row( "SELECT EXISTS( SELECT 1 FROM worker_removal_operations @@ -8876,8 +9047,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.commit()?; return Ok(TicketWorkerAssignmentUpdate { current, previous }); } - reserved_operation = true; + if existing.claim_state == "failed" { + return Err(Error::TicketAssignmentConflict(format!("Ticket claim `{operation_id}` failed: {}", existing.failure_reason.as_deref().unwrap_or("unknown failure")))); + } + reserved_operation = true; } + ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, Some(&record.worker), operation_id)?; let previous = tx .query_row( current_ticket_worker_assignment_select_sql().as_str(), @@ -8886,7 +9061,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) .optional()?; let previous_active: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder')", + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker')", params![record.workspace_id, record.ticket_id], |row| row.get(0))?; if previous_active && !allow_reassign { return Err(Error::TicketAssignmentConflict(format!( @@ -8929,7 +9104,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"UPDATE ticket_current_worker_assignments SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6 - WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder'"#, + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'worker'"#, params![ record.workspace_id, record.ticket_id, @@ -8986,8 +9161,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if reserved_operation { let updated = tx.execute( r#"UPDATE ticket_assignment_operations - SET assignment_id = ?3 - WHERE workspace_id = ?1 AND operation_id = ?2 AND assignment_id IS NULL"#, + SET assignment_id = ?3, claim_state='committed' + WHERE workspace_id = ?1 AND operation_id = ?2 AND assignment_id IS NULL AND claim_state='pending'"#, params![record.workspace_id, operation_id, record.assignment_id], )?; if updated != 1 { @@ -9031,9 +9206,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { event_id: &str, actor: &str, created_at: &str, - ) -> Result> { - self.with_conn(|conn| { - let tx = conn.unchecked_transaction()?; + ) -> Result> { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; if let Some(existing) = read_assignment_operation(&tx, workspace_id, operation_id)? { if existing.action != "unassign" || existing.ticket_id != ticket_id @@ -9071,8 +9246,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.commit()?; return Ok(None); }; + ensure_no_competing_ticket_claim(&tx, workspace_id, ticket_id, Some(&previous.worker), operation_id)?; tx.execute( - "DELETE FROM ticket_current_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder'", + "DELETE FROM ticket_current_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'worker'", params![workspace_id, ticket_id], )?; tx.execute( @@ -9110,18 +9286,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn list_ticket_coder_assignment_events( + fn list_ticket_worker_assignment_events( &self, workspace_id: &str, ticket_id: &str, limit: usize, - ) -> Result> { + ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( r#"SELECT workspace_id, ticket_id, event_id, action, assignment_id, previous_assignment_id, actor, created_at FROM ticket_worker_assignment_events - WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder' + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'worker' ORDER BY created_at DESC, event_id DESC LIMIT ?3"#, )?; @@ -11910,7 +12086,7 @@ fn validate_ticket_assignment_role_principal( TicketAssignmentPrincipal::WorkspaceAgent { agent_key } if agent_key == "workspace-orchestrator" ), - TicketAssignmentRole::Coder => { + TicketAssignmentRole::Worker => { matches!(principal, TicketAssignmentPrincipal::Worker { .. }) } TicketAssignmentRole::Owner => matches!(principal, TicketAssignmentPrincipal::User { .. }), @@ -11946,6 +12122,13 @@ fn clear_current_ticket_role_assignment_in_tx( else { return Ok(false); }; + ensure_no_competing_ticket_claim( + tx, + workspace_id, + ticket_id, + current.principal.worker().as_ref(), + operation_id, + )?; let principal_json = serde_json::to_string(¤t.principal) .map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?; let mut hasher = Sha256::new(); @@ -12047,14 +12230,14 @@ fn current_ticket_worker_assignment_select_sql() -> String { AND a.ticket_id = current.ticket_id \ AND a.role = current.role \ AND a.assignment_id = current.assignment_id \ - WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = 'coder'" + WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = 'worker'" .to_owned() } fn read_ticket_worker_assignment_record( row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(TicketCoderAssignmentRecord { +) -> rusqlite::Result { + Ok(TicketWorkerAssignmentRecord { workspace_id: row.get(0)?, ticket_id: row.get(1)?, assignment_id: row.get(2)?, @@ -12066,8 +12249,8 @@ fn read_ticket_worker_assignment_record( fn read_ticket_worker_assignment_event_record( row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(TicketCoderAssignmentEventRecord { +) -> rusqlite::Result { + Ok(TicketWorkerAssignmentEventRecord { workspace_id: row.get(0)?, ticket_id: row.get(1)?, event_id: row.get(2)?, @@ -12079,8 +12262,77 @@ fn read_ticket_worker_assignment_event_record( }) } +/// Bind manual Runtime effects to the authenticated principal, actor, Ticket, +/// and normalized selected resource bindings. Assignment ids/timestamps are +/// result material and deliberately excluded so receipt replay is stable. +pub(crate) fn manual_ticket_assignment_fingerprint( + record: &TicketRoleAssignmentRecord, + binding_fingerprint: Option<&str>, +) -> Result { + let principal = serde_json::to_string(&record.principal) + .map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?; + let mut hasher = Sha256::new(); + for value in [ + "ticket-role-assignment:manual-start:v1", + record.workspace_id.as_str(), + record.ticket_id.as_str(), + principal.as_str(), + record.assigned_by.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + if let Some(binding) = binding_fingerprint { + hasher.update(b"workdir-bindings\0"); + hasher.update(binding.as_bytes()); + hasher.update([0]); + } + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +/// Admission fence shared by every direct assignment mutation. A pending +/// claim with an unknown Worker still owns its Ticket; binding it later adds +/// a Worker-wide fence. Only the operation that owns the claim may finalize it. +fn ensure_no_competing_ticket_claim( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + worker: Option<&RuntimeWorkerRef>, + operation_id: &str, +) -> Result<()> { + let competing: Option = conn + .query_row( + "SELECT operation_id FROM ticket_assignment_operations WHERE workspace_id=?1 + AND operation_id != ?2 AND claim_state='pending' AND assignment_id IS NULL + AND action IN ('assign','reassign') + AND (ticket_id=?3 OR (runtime_id=?4 AND worker_id=?5)) LIMIT 1", + params![ + workspace_id, + operation_id, + ticket_id, + worker.map(|w| w.runtime_id.as_str()), + worker.map(|w| w.worker_id.as_str()) + ], + |row| row.get(0), + ) + .optional()?; + if let Some(competing) = competing { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket or Worker has competing pending claim `{competing}`" + ))); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketAssignmentOperationRecord { + pub operation_id: String, + pub claim_state: String, + pub failure_reason: Option, pub action: String, pub ticket_id: String, pub runtime_id: Option, @@ -12090,6 +12342,29 @@ pub struct TicketAssignmentOperationRecord { pub request_fingerprint: Option, } +/// Immutable manual claim admission data used for recovery and compensation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct TicketWorkerClaimRecovery { + pub item_revision: String, + pub selected: Vec, + pub original_links: Vec, + pub effective_links: Vec, +} + +fn read_ticket_claim_recovery( + conn: &Connection, + workspace_id: &str, + operation_id: &str, +) -> Result> { + let encoded:Option=conn.query_row("SELECT binding_recovery_json FROM ticket_assignment_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|r.get(0)).optional()?.flatten(); + encoded + .map(|s| { + serde_json::from_str(&s) + .map_err(|e| Error::Store(format!("invalid manual claim recovery: {e}"))) + }) + .transpose() +} + fn read_assignment_operation( conn: &Connection, workspace_id: &str, @@ -12097,7 +12372,7 @@ fn read_assignment_operation( ) -> Result> { conn.query_row( r#"SELECT action, ticket_id, runtime_id, worker_id, assignment_id, expected_assignment_id, - request_fingerprint + request_fingerprint, claim_state, failure_reason, operation_id FROM ticket_assignment_operations WHERE workspace_id = ?1 AND operation_id = ?2"#, params![workspace_id, operation_id], @@ -12105,6 +12380,9 @@ fn read_assignment_operation( let runtime_id: Option = row.get(2)?; let worker_id: Option = row.get(3)?; Ok(TicketAssignmentOperationRecord { + operation_id: row.get(9)?, + claim_state: row.get(7)?, + failure_reason: row.get(8)?, action: row.get(0)?, ticket_id: row.get(1)?, runtime_id: runtime_id.clone(), @@ -12133,7 +12411,7 @@ fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: fn require_expected_ticket_assignment( ticket_id: &str, - current: Option<&TicketCoderAssignmentRecord>, + current: Option<&TicketWorkerAssignmentRecord>, expected_assignment_id: Option<&str>, ) -> Result<()> { let Some(expected_assignment_id) = expected_assignment_id else { @@ -15288,7 +15566,16 @@ fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> { let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; rows.collect::>>()? }; - let schema = include_str!("latest_schema.sql"); + // The retained v82 migration keeps its original coder role contract. + let schema = include_str!("latest_schema.sql") + .replace( + "role TEXT NOT NULL DEFAULT 'worker'", + "role TEXT NOT NULL DEFAULT 'coder'", + ) + .replace( + "role IN ('orchestrator', 'worker'", + "role IN ('orchestrator', 'coder'", + ); let start = schema .find("CREATE TABLE ticket_current_worker_assignments (") .ok_or_else(|| Error::Store("Missing assignment schema".into()))?; @@ -15363,6 +15650,106 @@ fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> { result } +fn migrate_ticket_worker_v82_to_v83(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 82 { + return Err(Error::Store( + "Ticket Worker migration requires schema 82".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + let legacy = + conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + // Rebuild all role-bearing assignment tables together: merely renaming + // the current role would break its historical composite foreign key. + let schema = include_str!("latest_schema.sql"); + let mut dependents = Vec::new(); + for table in [ + "ticket_assignment_operations", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + ] { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; + let rows = stmt.query_map([table], |row| row.get::<_, String>(0))?; + dependents.extend(rows.collect::>>()?); + drop(stmt); + let declaration = format!("CREATE TABLE {table} ("); + let start = schema + .find(&declaration) + .ok_or_else(|| Error::Store(format!("Missing assignment schema for {table}")))?; + let end = schema[start..].find(';').ok_or_else(|| { + Error::Store(format!("Missing assignment schema end for {table}")) + })? + start + + 1; + let replacement = format!("{table}_v83"); + tx.execute_batch(&schema[start..end].replacen( + &format!("CREATE TABLE {table}"), + &format!("CREATE TABLE {replacement}"), + 1, + ))?; + let columns = table_columns(&tx, table)?; + let values = columns + .iter() + .map(|column| { + if column == "role" { + "CASE role WHEN 'coder' THEN 'worker' ELSE role END".to_string() + } else { + format!("\"{column}\"") + } + }) + .collect::>() + .join(", "); + let column_names = columns + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "); + // Only role-bearing historical columns are copied. New receipts + // classify legacy unfinished assign operations conservatively. + tx.execute_batch(&format!( + "INSERT INTO {replacement} ({column_names}) SELECT {values} FROM {table}; + DROP TABLE {table}; ALTER TABLE {replacement} RENAME TO {table};" + ))?; + if table == "ticket_assignment_operations" + && !columns.iter().any(|c| c == "claim_state") + { + tx.execute( + "UPDATE ticket_assignment_operations SET claim_state='pending' + WHERE action IN ('assign','reassign') AND assignment_id IS NULL", + [], + )?; + } + } + for sql in dependents { + tx.execute_batch(&sql.replace("'coder'", "'worker'"))?; + } + tx.execute( + "UPDATE typed_ticket_event_attributes SET value='worker' + WHERE key='assignment_role' AND value='coder'", + [], + )?; + let broken: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if broken != 0 { + return Err(Error::Store( + "Ticket work migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (83,'Generic Ticket Worker roles and durable claims')", [])?; + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "legacy_alter_table", legacy)?; + conn.pragma_update(None, "foreign_keys", foreign_keys)?; + result +} + fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 80 { return Err(Error::Store( @@ -17949,24 +18336,131 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "#, ) .unwrap(); - // Restore the actual old FK contract, not just the old unique index. - let dependents = { - let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL").unwrap(); - stmt.query_map([], |row| row.get::<_, String>(0)) + downgrade_ticket_worker_schema(conn, true); + } + + fn downgrade_ticket_worker_schema(conn: &Connection, restore_worker_fk: bool) { + // Restore the actual old role and FK contracts, not just the old + // unique index, so migration tests exercise real coder rows. + for table in [ + "ticket_assignment_operations", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + ] { + let dependents = { + let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL").unwrap(); + stmt.query_map([table], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + let sql: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1", + [table], + |row| row.get(0), + ) + .unwrap(); + let old_name = format!("{table}_old"); + let mut old_table = sql + .replacen( + &format!("CREATE TABLE {table}"), + &format!("CREATE TABLE {old_name}"), + 1, + ) + .replace( + "role TEXT NOT NULL DEFAULT 'worker'", + "role TEXT NOT NULL DEFAULT 'coder'", + ) + .replace( + "role IN ('orchestrator', 'worker'", + "role IN ('orchestrator', 'coder'", + ); + if table == "ticket_assignment_operations" { + old_table = old_table + .replace(" binding_recovery_json TEXT,\n", "") + .replace(" claim_state TEXT NOT NULL DEFAULT 'committed'\n CHECK(claim_state IN ('pending', 'failed', 'committed')),\n failure_reason TEXT,\n", "") + .replace(",\n CHECK(claim_state = 'committed' OR assignment_id IS NULL),\n CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))", ""); + } + if restore_worker_fk && table == "ticket_current_worker_assignments" { + old_table = old_table.replace(" CHECK(\n", " FOREIGN KEY(workspace_id,runtime_id,worker_id) REFERENCES worker_registry(workspace_id,runtime_id,worker_id) ON DELETE RESTRICT,\n CHECK(\n"); + } + conn.execute_batch(&old_table).unwrap(); + let old_columns = table_columns(conn, &old_name) .unwrap() - .collect::>>() - .unwrap() - }; - let table: String = conn.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name='ticket_current_worker_assignments'", [], |row| row.get(0)).unwrap(); - let old_table = table.replace("CREATE TABLE ticket_current_worker_assignments", "CREATE TABLE current_assignments_old") - .replace(" CHECK(\n", " FOREIGN KEY(workspace_id,runtime_id,worker_id) REFERENCES worker_registry(workspace_id,runtime_id,worker_id) ON DELETE RESTRICT,\n CHECK(\n"); - conn.execute_batch(&old_table).unwrap(); - conn.execute_batch("INSERT INTO current_assignments_old SELECT * FROM ticket_current_worker_assignments; DROP TABLE ticket_current_worker_assignments; ALTER TABLE current_assignments_old RENAME TO ticket_current_worker_assignments;").unwrap(); - for sql in dependents { - conn.execute_batch(&sql).unwrap(); + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "); + conn.execute_batch(&format!("INSERT INTO {old_name} SELECT {old_columns} FROM {table}; DROP TABLE {table}; ALTER TABLE {old_name} RENAME TO {table};")).unwrap(); + for sql in dependents { + conn.execute_batch(&sql.replace( + "role IN ('orchestrator', 'worker'", + "role IN ('orchestrator', 'coder'", + )) + .unwrap(); + } } } + #[test] + fn schema_v82_upgrades_coder_history_principals_and_unfinished_claims_to_worker() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("v82.db"); + prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS.iter().filter(|m| m.version <= 82) { + (migration.apply)(&conn).unwrap(); + } + conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('workspace-a','worker','runtime','Coder','builtin:coder','normal','t','t')", []).unwrap(); + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('workspace-a','ticket','ticket','Ticket','open','task','normal','','planning',1)", []).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('workspace-a','ticket','assignment','coder','worker','runtime','worker','owner','t')", []).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) VALUES('workspace-a','ticket','assignment','coder','worker','runtime','worker','t')", []).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,assignment_id,created_at) VALUES('workspace-a','committed','assign','ticket','coder','runtime','worker','assignment','t')", []).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at) VALUES('workspace-a','pending','assign','ticket','coder','runtime','worker','t')", []).unwrap(); + drop(conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 82); + assert_eq!( + plan.migrations + .iter() + .map(|s| s.version) + .collect::>(), + vec![83] + ); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let assignment = store + .get_active_ticket_worker_assignment("workspace-a", "ticket") + .unwrap() + .unwrap(); + assert_eq!( + assignment.worker, + RuntimeWorkerRef { + runtime_id: "runtime".into(), + worker_id: "worker".into() + } + ); + assert_eq!(assignment.assignment_id, "assignment"); + for (operation, state) in [("committed", "committed"), ("pending", "pending")] { + assert_eq!( + store + .get_ticket_assignment_operation("workspace-a", operation) + .unwrap() + .unwrap() + .claim_state, + state + ); + } + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?,83); + assert_eq!(conn.query_row("SELECT role FROM ticket_worker_assignments WHERE assignment_id='assignment'", [], |r| r.get::<_,String>(0))?,"worker"); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0); + Ok(()) + }).unwrap(); + } + fn prepare_schema_v52(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); @@ -18241,6 +18735,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 82, name: "Ticket responsibility and unfinished work separation".to_string() }, + WorkspaceSchemaMigrationStep { + version: 83, + name: "Generic Ticket Worker roles and durable claims".to_string() + }, ] ); @@ -18333,6 +18831,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()), (81, "Backend Job immutable resource serialization".to_string()), (82, "Ticket responsibility and unfinished work separation".to_string()), + (83, "Generic Ticket Worker roles and durable claims".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -18684,7 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78, 79, 80, 81, 82 + 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -18693,7 +19192,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 33); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 34); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -20810,7 +21309,23 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','1'); INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,action,assignment_id,actor,created_at) VALUES('workspace-a','closed-ticket','assigned-event','assigned','old-assignment','actor','1'); + INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) + VALUES('workspace-a','closed-ticket','historical-user','coder','user','owner','actor','0'), + ('workspace-a','closed-ticket','historical-contributor','contributor','user','owner','actor','0'); + INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) + VALUES('workspace-a','old-assignment-op','assign','closed-ticket','coder','user','owner','historical-user','0','original-fingerprint'); + INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,runtime_id,worker_id,created_at,request_fingerprint) + VALUES('workspace-a','old-pending-op','assign','closed-ticket','runtime-a','old-coder','0','pending-fingerprint'); "#).unwrap(); + assert_eq!( + conn.query_row( + "SELECT role FROM ticket_current_worker_assignments", + [], + |row| row.get::<_, String>(0) + ) + .unwrap(), + "coder" + ); let legacy_blockers = serde_json::json!([ {"kind":"hold"}, {"kind":"current_assignment","assignment_id":"current_assignment","ticket_id":"closed-ticket"} ]).to_string(); @@ -20819,6 +21334,58 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx migrate_assignment_work_v81_to_v82(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 82); apply_migrations(&conn).unwrap(); // canonical retry is a no-op + let pending = read_assignment_operation(&conn, "workspace-a", "old-pending-op") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert_eq!( + pending.request_fingerprint.as_deref(), + Some("pending-fingerprint") + ); + assert!(pending.failure_reason.is_none()); + assert_eq!( + read_assignment_operation(&conn, "workspace-a", "old-assignment-op") + .unwrap() + .unwrap() + .claim_state, + "committed" + ); + for table in [ + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + ] { + assert_eq!( + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE role='coder'"), + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0, + "{table}" + ); + } + let historical_user = + read_ticket_role_assignment_by_id(&conn, "workspace-a", "historical-user") + .unwrap() + .unwrap(); + assert_eq!(historical_user.role, TicketAssignmentRole::Worker); + assert_eq!( + historical_user.principal, + TicketAssignmentPrincipal::User { + account_id: "owner".into() + } + ); + assert_eq!( + read_ticket_role_assignment_by_id(&conn, "workspace-a", "historical-contributor") + .unwrap() + .unwrap() + .role, + TicketAssignmentRole::Contributor + ); + assert_eq!(conn.query_row("SELECT role,request_fingerprint,assignment_id FROM ticket_assignment_operations WHERE operation_id='old-assignment-op'", [], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))).unwrap(), ("worker".into(), "original-fingerprint".into(), "historical-user".into())); let (plan, fingerprint, state, encoded): (String,String,String,String) = conn.query_row( "SELECT plan_id,input_fingerprint,state,blockers_json FROM worker_removal_operations WHERE operation_id='old-op'", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); @@ -20864,6 +21431,710 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } + // Direct store fixture: explicit claims, not queue scheduling, are the + // boundary under test. Targets need not be materialized by a Runtime. + async fn manual_worker_start_store() -> (tempfile::TempDir, SqliteWorkspaceStore) { + let dir = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "w".into(), + owner_account_id: "owner-account".into(), + display_name: "W".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + store.with_conn(|conn| { + conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('w','r','worker','Worker','normal','1','1'); + INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,created_at,updated_at,source_kind,source_uri,source_fingerprint) + VALUES('w','main','main','git','git','file:///main','1','1','file','file:///main','sha256:fixture');")?; + Ok(()) + }).unwrap(); + (dir, store) + } + + fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) { + store.with_conn(|conn| { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at) + VALUES('w',?1,?1,'Ticket','open','task','normal','',?2,1,'1')", params![id,state])?; + Ok(()) + }).unwrap(); + } + + fn manual_worker_assignment( + ticket_id: &str, + assignment_id: &str, + ) -> TicketRoleAssignmentRecord { + TicketRoleAssignmentRecord { + workspace_id: "w".into(), + ticket_id: ticket_id.into(), + assignment_id: assignment_id.into(), + role: TicketAssignmentRole::Worker, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: "r".into(), + worker_id: "worker".into(), + }, + assigned_by: "owner-account".into(), + assigned_at: "2".into(), + } + } + + #[tokio::test] + async fn pending_claims_serialize_competing_ticket_and_known_worker_reservations() { + for (same_ticket, same_operation) in [(true, false), (false, false), (true, true)] { + let (dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "second", "ready"); + store.with_conn(|conn| { + conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('w','r','other','Other','normal','1','1');")?; + Ok(()) + }).unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let threads = (0..2) + .map(|index| { + let store = + SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + let ticket = if same_ticket || index == 0 { + "first" + } else { + "second" + }; + let worker = if same_ticket && !same_operation && index == 1 { + "other" + } else { + "worker" + }; + let index = if same_operation { 0 } else { index }; + store.reserve_ticket_assignment_operation( + "w", + &format!("operation-{index}"), + ticket, + "r", + Some(worker), + &format!("fingerprint-{index}"), + "1", + ) + }) + }) + .collect::>(); + barrier.wait(); + let results = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Ok(true))) + .count(), + 1 + ); + if same_operation { + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Ok(false))) + .count(), + 1 + ); + } else { + assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); + } + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_operations WHERE claim_state='pending'", [], |row| row.get::<_,i64>(0))?, 1); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |row| row.get::<_,i64>(0))?, 0); + Ok(()) + }).unwrap(); + } + } + + #[tokio::test] + async fn pending_claims_fence_all_assignment_writers_and_worker_binding_until_owner_commits() { + let (_dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "second", "ready"); + let first = manual_worker_assignment("first", "first-assignment"); + let orchestrator = TicketRoleAssignmentRecord { + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..first.clone() + }; + store + .set_current_ticket_role_assignment( + &orchestrator, + None, + "original-event", + "original-op", + false, + ) + .unwrap(); + assert!( + store + .reserve_ticket_assignment_operation( + "w", + "unknown", + "first", + "r", + None, + "fingerprint", + "1" + ) + .unwrap() + ); + assert!( + store + .rollback_ticket_assignment_operation("w", "original-op") + .is_err() + ); + assert!( + store + .clear_current_ticket_role_assignment( + "w", + "first", + TicketAssignmentRole::Orchestrator, + &orchestrator.assignment_id, + "clear-event", + "clear-op", + "actor", + "2", + None + ) + .is_err() + ); + assert!( + store + .set_current_ticket_role_assignment( + &orchestrator, + None, + "orchestrator-event", + "orchestrator-op", + false + ) + .is_err() + ); + assert!( + store + .start_ticket_with_worker_assignment(&first, "manual-event", "manual-op", None) + .is_err() + ); + assert!( + store + .reserve_ticket_assignment_operation( + "w", + "competitor", + "first", + "r", + Some("worker"), + "other-fingerprint", + "1" + ) + .is_err() + ); + // An unknown claim has a Ticket fence; binding a concrete Worker must + // add the Worker fence atomically without stealing another claim. + assert!( + store + .reserve_ticket_assignment_operation( + "w", + "known", + "second", + "r", + Some("worker"), + "known-fingerprint", + "1" + ) + .unwrap() + ); + assert!( + store + .bind_ticket_assignment_operation_worker("w", "unknown", "worker") + .is_err() + ); + store + .fail_ticket_assignment_operation("w", "known", "abandoned before dispatch") + .unwrap(); + store + .bind_ticket_assignment_operation_worker("w", "unknown", "worker") + .unwrap(); + let worker = RuntimeWorkerRef::new("r", "worker"); + assert_eq!( + store + .get_pending_ticket_assignment_operation_for_worker("w", &worker) + .unwrap() + .unwrap() + .operation_id, + "unknown" + ); + let second = manual_worker_assignment("second", "second-assignment"); + assert!( + store + .set_current_ticket_role_assignment(&second, None, "role-event", "role-op", false) + .is_err() + ); + assert!( + store + .start_ticket_with_worker_assignment(&second, "manual-event", "manual-op", None) + .is_err() + ); + let legacy = TicketWorkerAssignmentRecord { + workspace_id: "w".into(), + ticket_id: "second".into(), + assignment_id: "legacy-assignment".into(), + worker: worker.clone(), + assigned_by: "actor".into(), + assigned_at: "2".into(), + }; + assert!( + store + .set_current_ticket_worker_assignment( + &legacy, + None, + "legacy-event", + "legacy-op", + false + ) + .is_err() + ); + let owner = TicketWorkerAssignmentRecord { + ticket_id: "first".into(), + ..legacy + }; + store + .set_current_ticket_worker_assignment(&owner, None, "owner-event", "unknown", false) + .unwrap(); + assert_eq!( + store + .get_ticket_assignment_operation("w", "unknown") + .unwrap() + .unwrap() + .claim_state, + "committed" + ); + assert!( + store + .get_pending_ticket_assignment_operation_for_worker("w", &worker) + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn manual_claim_consumes_matching_binding_fingerprint_and_replays_committed_receipt() { + let (_dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + let record = manual_worker_assignment("first", "assignment"); + let fingerprint = + manual_ticket_assignment_fingerprint(&record, Some("bindings-a")).unwrap(); + assert!( + store + .reserve_ticket_assignment_operation( + "w", + "claim", + "first", + "r", + Some("worker"), + &fingerprint, + "1" + ) + .unwrap() + ); + assert!( + !store + .reserve_ticket_assignment_operation( + "w", + "claim", + "first", + "r", + Some("worker"), + &fingerprint, + "1" + ) + .unwrap() + ); + assert!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", Some("bindings-b")) + .is_err() + ); + assert_eq!( + store + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap() + .claim_state, + "pending" + ); + assert_eq!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", Some("bindings-a")) + .unwrap(), + record + ); + let regenerated = TicketRoleAssignmentRecord { + assignment_id: "retry-id".into(), + assigned_at: "3".into(), + ..record.clone() + }; + assert_eq!( + store + .start_ticket_with_worker_assignment( + ®enerated, + "retry-event", + "claim", + Some("bindings-a") + ) + .unwrap(), + record + ); + assert!( + store + .start_ticket_with_worker_assignment(®enerated, "retry-event", "claim", None) + .is_err() + ); + let receipt = store + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap(); + assert_eq!(receipt.claim_state, "committed"); + assert_eq!( + receipt.assignment_id.as_deref(), + Some(record.assignment_id.as_str()) + ); + assert_eq!( + receipt.request_fingerprint.as_deref(), + Some(fingerprint.as_str()) + ); + assert!( + store + .fail_ticket_assignment_operation("w", "claim", "too late") + .is_err() + ); + } + + #[tokio::test] + async fn manual_claim_recovery_survives_reopen_and_fences_stale_item_finalization() { + let (dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + let record = manual_worker_assignment("first", "assignment"); + let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); + store + .reserve_ticket_assignment_operation( + "w", + "claim", + "first", + "r", + Some("worker"), + &fingerprint, + "1", + ) + .unwrap(); + let recovery = TicketWorkerClaimRecovery { + item_revision: "first:0".into(), + selected: vec![], + original_links: vec![], + effective_links: vec![], + }; + store + .retain_ticket_claim_recovery("w", "claim", &fingerprint, &recovery) + .unwrap(); + store + .retain_ticket_claim_recovery("w", "claim", &fingerprint, &recovery) + .unwrap(); + let mut changed = recovery.clone(); + changed.item_revision = "first:1".into(); + assert!( + store + .retain_ticket_claim_recovery("w", "claim", &fingerprint, &changed) + .is_err() + ); + let reopened = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + assert_eq!( + reopened.get_ticket_claim_recovery("w", "claim").unwrap(), + Some(recovery) + ); + reopened.with_conn(|conn| { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket targets changed')",[])?; Ok(()) + }).unwrap(); + assert!( + reopened + .start_ticket_with_worker_assignment(&record, "event", "claim", Some("bindings")) + .unwrap_err() + .to_string() + .contains("Ticket changed") + ); + assert_eq!( + reopened + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap() + .claim_state, + "pending" + ); + assert!( + reopened + .get_active_ticket_worker_assignment("w", "first") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn manual_claim_failed_receipt_survives_reopen_and_rollback_without_restarting() { + let (dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + let record = manual_worker_assignment("first", "assignment"); + let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); + store + .reserve_ticket_assignment_operation( + "w", + "claim", + "first", + "r", + Some("worker"), + &fingerprint, + "1", + ) + .unwrap(); + store + .fail_ticket_assignment_operation("w", "claim", "binding compensation required") + .unwrap(); + store + .fail_ticket_assignment_operation("w", "claim", "must not replace original failure") + .unwrap(); + store + .rollback_ticket_assignment_operation("w", "claim") + .unwrap(); + let reopened = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + let receipt = reopened + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap(); + assert_eq!(receipt.claim_state, "failed"); + assert_eq!( + receipt.failure_reason.as_deref(), + Some("binding compensation required") + ); + assert_eq!( + receipt.request_fingerprint.as_deref(), + Some(fingerprint.as_str()) + ); + assert!( + !reopened + .reserve_ticket_assignment_operation( + "w", + "claim", + "first", + "r", + Some("worker"), + &fingerprint, + "1" + ) + .unwrap() + ); + assert!( + reopened + .start_ticket_with_worker_assignment(&record, "event", "claim", Some("bindings")) + .unwrap_err() + .to_string() + .contains("binding compensation required") + ); + assert!( + reopened + .get_pending_ticket_assignment_operation_for_worker( + "w", + &RuntimeWorkerRef::new("r", "worker") + ) + .unwrap() + .is_none() + ); + assert!( + reopened + .get_active_ticket_worker_assignment("w", "first") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn manual_worker_start_accepts_nonterminal_states_with_idempotent_assignment_and_audit() { + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + for user_principal in [false, true] { + let (_dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "ticket", state); + let mut record = manual_worker_assignment("ticket", "assignment"); + if user_principal { + record.principal = TicketAssignmentPrincipal::User { + account_id: "owner-account".into(), + }; + } + let result = + store.start_ticket_with_worker_assignment(&record, "event", "operation", None); + if matches!(state, "done" | "closed") { + assert!(result.is_err(), "{state}"); + } else { + assert_eq!(result.unwrap(), record, "{state}"); + let replay = TicketRoleAssignmentRecord { + assignment_id: "regenerated".into(), + assigned_at: "3".into(), + ..record.clone() + }; + assert_eq!( + store + .start_ticket_with_worker_assignment( + &replay, + "replay-event", + "operation", + None + ) + .unwrap(), + record + ); + assert_eq!( + store + .get_active_ticket_role_assignment( + "w", + "ticket", + TicketAssignmentRole::Worker + ) + .unwrap(), + Some(record) + ); + } + store.with_conn(|conn| { + let (persisted, events, assignments, operations): (String,i64,i64,i64) = conn.query_row( + "SELECT workflow_state, (SELECT COUNT(*) FROM typed_ticket_events), + (SELECT COUNT(*) FROM ticket_worker_assignments), (SELECT COUNT(*) FROM ticket_assignment_operations) + FROM typed_tickets WHERE ticket_id='ticket'", [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?; + if matches!(state, "done" | "closed") { + assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0)); + } else { + assert_eq!((persisted.as_str(), events, assignments, operations), ("inprogress",1,1,1)); + let transition: (String,String) = conn.query_row("SELECT from_state,to_state FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?)))?; + assert_eq!(transition, (state.into(), "inprogress".into())); + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='assignment_role'", [], |row| row.get::<_,String>(0))?, "worker"); + } + Ok(()) + }).unwrap(); + } + } + } + + #[tokio::test] + async fn manual_worker_start_rejects_busy_worker_without_mutation_and_requires_new_reopen_identity() + { + let (_dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "second", "queued"); + let first = manual_worker_assignment("first", "first-assignment"); + let second = manual_worker_assignment("second", "second-assignment"); + store + .start_ticket_with_worker_assignment(&first, "first-event", "first-operation", None) + .unwrap(); + assert!( + store + .start_ticket_with_worker_assignment( + &second, + "second-event", + "second-operation", + None + ) + .is_err() + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT workflow_state FROM typed_tickets WHERE ticket_id='second'", + [], + |row| row.get::<_, String>(0) + )?, + "queued" + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_operations", + [], + |row| row.get::<_, i64>(0) + )?, + 1 + ); + conn.execute_batch( + "UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='first'; + UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='first';", + )?; + Ok(()) + }) + .unwrap(); + // A replay returns history; it must not restore unfinished work. + assert_eq!( + store + .start_ticket_with_worker_assignment(&first, "replay", "first-operation", None) + .unwrap(), + first + ); + assert!( + store + .get_active_ticket_worker_assignment("w", "first") + .unwrap() + .is_none() + ); + assert!( + store + .start_ticket_with_worker_assignment(&first, "reuse-event", "reuse-operation", None) + .is_err() + ); + assert!( + store + .get_active_ticket_worker_assignment("w", "first") + .unwrap() + .is_none() + ); + let restart = TicketRoleAssignmentRecord { + assignment_id: "restart".into(), + ..first.clone() + }; + store + .start_ticket_with_worker_assignment( + &restart, + "restart-event", + "restart-operation", + None, + ) + .unwrap(); + assert_eq!( + store + .get_active_ticket_role_assignment("w", "first", TicketAssignmentRole::Worker) + .unwrap(), + Some(restart) + ); + let events = store + .list_ticket_worker_assignment_events("w", "first", 10) + .unwrap(); + assert_eq!( + events[0].previous_assignment_id.as_deref(), + Some(first.assignment_id.as_str()) + ); + assert_eq!(events[0].action, "reassigned"); + } + #[tokio::test] async fn close_retains_responsibility_without_occupying_new_work_and_reopen_requires_new_identity() { @@ -20889,7 +22160,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap(); let first = backend.create(ticket::NewTicket::new("First")).unwrap(); let second = backend.create(ticket::NewTicket::new("Second")).unwrap(); - let assignment = TicketCoderAssignmentRecord { + let assignment = TicketWorkerAssignmentRecord { workspace_id: "w".into(), ticket_id: first.id.clone(), assignment_id: "a1".into(), @@ -20898,16 +22169,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx assigned_at: "1".into(), }; store - .set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false) + .set_current_ticket_worker_assignment(&assignment, None, "e1", "op1", false) .unwrap(); - let next = TicketCoderAssignmentRecord { + let next = TicketWorkerAssignmentRecord { ticket_id: second.id.clone(), assignment_id: "a2".into(), ..assignment.clone() }; assert!( store - .set_current_ticket_coder_assignment(&next, None, "e2", "op2", false) + .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .is_err() ); backend @@ -20918,7 +22189,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); assert_eq!( store - .get_current_ticket_coder_assignment("w", &first.id) + .get_current_ticket_worker_assignment("w", &first.id) .unwrap(), Some(assignment.clone()) ); @@ -20929,7 +22200,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .is_none() ); store - .set_current_ticket_coder_assignment(&next, None, "e2", "op2", false) + .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .unwrap(); // Generic Ticket tools do not expose terminal -> planning. Seed the // authority state boundary to prove future/reconciled reopen is inert. @@ -20942,7 +22213,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); // Replay of the original operation reports its identity but does not revive work. store - .set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false) + .set_current_ticket_worker_assignment(&assignment, None, "e1", "op1", false) .unwrap(); assert!( store @@ -20950,13 +22221,13 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap() .is_none() ); - let restarted = TicketCoderAssignmentRecord { + let restarted = TicketWorkerAssignmentRecord { assignment_id: "a3".into(), ..assignment.clone() }; assert!( store - .set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false) + .set_current_ticket_worker_assignment(&restarted, None, "e3", "op3", false) .is_err(), "another Ticket still occupies the Worker" ); @@ -20964,7 +22235,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .close(second.id.clone().into(), ticket::MarkdownText::new("Ended")) .unwrap(); store - .set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false) + .set_current_ticket_worker_assignment(&restarted, None, "e3", "op3", false) .unwrap(); assert_eq!( store @@ -20973,7 +22244,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx Some(restarted) ); let events = store - .list_ticket_coder_assignment_events("w", &first.id, 10) + .list_ticket_worker_assignment_events("w", &first.id, 10) .unwrap(); assert_eq!(events[0].previous_assignment_id.as_deref(), Some("a1")); assert_eq!(events[0].action, "reassigned"); @@ -21024,7 +22295,7 @@ INSERT INTO worker_registry ( }) .unwrap(); - let first = TicketCoderAssignmentRecord { + let first = TicketWorkerAssignmentRecord { workspace_id: "workspace-a".to_string(), ticket_id: "ticket-1".to_string(), assignment_id: "assignment-1".to_string(), @@ -21033,13 +22304,13 @@ INSERT INTO worker_registry ( assigned_at: "2026-07-32T00:00:01Z".to_string(), }; let created = store - .set_current_ticket_coder_assignment(&first, None, "event-1", "operation-1", false) + .set_current_ticket_worker_assignment(&first, None, "event-1", "operation-1", false) .unwrap(); assert_eq!(created.current, first); assert_eq!(created.previous, None); let retried = store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { assignment_id: "ignored-retry-assignment".to_string(), ..first.clone() }, @@ -21052,15 +22323,15 @@ INSERT INTO worker_registry ( assert_eq!(retried.current, first); assert_eq!( store - .list_ticket_coder_assignment_events("workspace-a", "ticket-1", 10) + .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) .unwrap() .len(), 1, "idempotent retry must not append another assignment event" ); let implicit_reassign = store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { assignment_id: "implicit-reassign".to_string(), worker: RuntimeWorkerRef::new("runtime-1", "worker-other"), ..first.clone() @@ -21076,8 +22347,8 @@ INSERT INTO worker_registry ( Error::TicketAssignmentConflict(_) )); let worker_conflict = store - .set_current_ticket_coder_assignment( - &TicketCoderAssignmentRecord { + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { ticket_id: "ticket-2".to_string(), assignment_id: "worker-conflict".to_string(), ..first.clone() @@ -21093,7 +22364,7 @@ INSERT INTO worker_registry ( Error::TicketAssignmentConflict(_) )); - let second = TicketCoderAssignmentRecord { + let second = TicketWorkerAssignmentRecord { assignment_id: "assignment-2".to_string(), worker: RuntimeWorkerRef::new("runtime-2", "worker-2"), assigned_by: "user-2".to_string(), @@ -21101,7 +22372,7 @@ INSERT INTO worker_registry ( ..first.clone() }; let replaced = store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &second, Some("assignment-1"), "event-2", @@ -21112,7 +22383,7 @@ INSERT INTO worker_registry ( assert_eq!(replaced.current, second); assert_eq!(replaced.previous, Some(first.clone())); let replayed_reassignment = store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &second, Some("assignment-1"), "ignored-reassign-event", @@ -21123,7 +22394,7 @@ INSERT INTO worker_registry ( assert_eq!(replayed_reassignment, replaced); assert_eq!( store - .get_current_ticket_coder_assignment("workspace-a", "ticket-1") + .get_current_ticket_worker_assignment("workspace-a", "ticket-1") .unwrap(), Some(second.clone()) ); @@ -21194,7 +22465,7 @@ INSERT INTO worker_registry ( "worker-3", ) .unwrap(); - let reserved_assignment = TicketCoderAssignmentRecord { + let reserved_assignment = TicketWorkerAssignmentRecord { workspace_id: "workspace-a".to_string(), ticket_id: "ticket-3".to_string(), assignment_id: "assignment-3".to_string(), @@ -21203,7 +22474,7 @@ INSERT INTO worker_registry ( assigned_at: "2026-07-32T00:00:06Z".to_string(), }; let completed_reservation = store - .set_current_ticket_coder_assignment( + .set_current_ticket_worker_assignment( &reserved_assignment, None, "reserved-event", @@ -21221,7 +22492,7 @@ INSERT INTO worker_registry ( ); assert_eq!( store - .get_current_ticket_coder_assignment("workspace-a", "ticket-1") + .get_current_ticket_worker_assignment("workspace-a", "ticket-1") .unwrap(), None ); @@ -21236,13 +22507,13 @@ INSERT INTO worker_registry ( ); assert_eq!( store - .get_current_ticket_coder_assignment("workspace-a", "ticket-3") + .get_current_ticket_worker_assignment("workspace-a", "ticket-3") .unwrap(), None ); assert!( store - .list_ticket_coder_assignment_events("workspace-a", "ticket-3", 10) + .list_ticket_worker_assignment_events("workspace-a", "ticket-3", 10) .unwrap() .is_empty() ); @@ -21251,7 +22522,7 @@ INSERT INTO worker_registry ( .unwrap(); let events = store - .list_ticket_coder_assignment_events("workspace-a", "ticket-1", 10) + .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) .unwrap(); assert_eq!( events @@ -21381,7 +22652,7 @@ INSERT INTO worker_registry ( workspace_id: "workspace-role".to_string(), ticket_id: ticket.id.clone(), assignment_id: "coder-manual-1".to_string(), - role: TicketAssignmentRole::Coder, + role: TicketAssignmentRole::Worker, principal: TicketAssignmentPrincipal::Worker { runtime_id: "runtime-role".to_string(), worker_id: "worker-role".to_string(), @@ -21389,15 +22660,6 @@ INSERT INTO worker_registry ( assigned_by: "user".to_string(), assigned_at: "2026-09-01T00:01:00Z".to_string(), }; - assert!( - store - .start_ready_ticket_with_coder_assignment( - &coder, - "event-coder-conflict", - "op-coder-conflict", - ) - .is_err() - ); assert_eq!( ticket::TicketBackend::show(&backend, ticket.id.clone().into()) .unwrap() @@ -21421,7 +22683,7 @@ INSERT INTO worker_registry ( .unwrap() ); let started = store - .start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder") + .start_ticket_with_worker_assignment(&coder, "event-coder", "op-coder", None) .unwrap(); assert_eq!(started, coder); let replay_input = TicketRoleAssignmentRecord { @@ -21430,10 +22692,11 @@ INSERT INTO worker_registry ( ..coder.clone() }; let replayed = store - .start_ready_ticket_with_coder_assignment( + .start_ticket_with_worker_assignment( &replay_input, "event-coder-regenerated", "op-coder", + None, ) .unwrap(); assert_eq!(replayed, coder); @@ -21455,7 +22718,7 @@ INSERT INTO worker_registry ( .get_current_ticket_role_assignment( "workspace-role", &ticket.meta.id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, ) .unwrap(), Some(coder.clone()) @@ -21468,7 +22731,7 @@ INSERT INTO worker_registry ( ); assert!( store - .cancel_current_ticket_coder_assignment( + .cancel_current_ticket_worker_assignment( "workspace-role", &ticket.meta.id, "coder-manual-1", @@ -21483,7 +22746,7 @@ INSERT INTO worker_registry ( ); assert!( store - .cancel_current_ticket_coder_assignment( + .cancel_current_ticket_worker_assignment( "workspace-role", &ticket.meta.id, "coder-manual-1", @@ -21516,7 +22779,7 @@ INSERT INTO worker_registry ( .get_current_ticket_role_assignment( "workspace-role", &ticket.meta.id, - TicketAssignmentRole::Coder, + TicketAssignmentRole::Worker, ) .unwrap() .is_none() diff --git a/openapi/server-api.json b/openapi/server-api.json index 7c6774cd1..56b5011ad 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -7657,7 +7657,7 @@ "enum": [ "intake", "orchestrator", - "coder", + "worker", "reviewer" ], "type": "string" @@ -8871,6 +8871,13 @@ }, "principal": { "$ref": "#/components/schemas/TicketAssignmentPrincipal" + }, + "workdir_bindings": { + "default": [], + "items": { + "$ref": "#/components/schemas/TicketWorkerAttachmentBinding" + }, + "type": "array" } }, "required": [ @@ -10098,7 +10105,7 @@ "can_queue": { "type": "boolean" }, - "can_start_manual_coder": { + "can_start_manual_worker": { "type": "boolean" }, "can_unassign_orchestrator": { @@ -10115,7 +10122,7 @@ "can_assign_orchestrator", "can_unassign_orchestrator", "can_queue", - "can_start_manual_coder", + "can_start_manual_worker", "queue_tickets", "blockers" ], @@ -10195,7 +10202,7 @@ "TicketAssignmentRole": { "enum": [ "orchestrator", - "coder", + "worker", "owner", "contributor" ], @@ -10360,7 +10367,7 @@ "null" ] }, - "current_coder": { + "current_worker": { "anyOf": [ { "$ref": "#/components/schemas/TicketAssignmentSummary" @@ -11526,7 +11533,7 @@ "null" ] }, - "current_coder": { + "current_worker": { "anyOf": [ { "$ref": "#/components/schemas/TicketAssignmentSummary" @@ -12423,6 +12430,27 @@ } ] }, + "TicketWorkerAttachmentBinding": { + "additionalProperties": false, + "description": "Selected existing live Workdir attachment, including its current connection\nidentity. The Workspace authority validates the selection against live\nattachments; callers must select them explicitly, and use an empty list\nonly when the Worker has no live Workdirs.", + "properties": { + "alias": { + "type": "string" + }, + "connection_id": { + "type": "string" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "alias", + "working_directory_id", + "connection_id" + ], + "type": "object" + }, "TicketWorkflowState": { "enum": [ "planning", @@ -15643,7 +15671,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0" + "x-yoi-source-digest": "sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933" }, "openapi": "3.1.0", "paths": { diff --git a/resources/profiles/ticket-worker.dcdl b/resources/profiles/ticket-worker.dcdl new file mode 100644 index 000000000..4da55b41e --- /dev/null +++ b/resources/profiles/ticket-worker.dcdl @@ -0,0 +1,17 @@ +import "./default.dcdl" // { + slug = "ticket-worker"; + description = "General Ticket work with typed progress and completion tools."; + engine = { + instruction = "role.ticket_worker"; + }; + + feature = { + ticket = { + enabled = true; + authoring = false; + thread = true; + intake = false; + workflow = false; + }; + }; +} diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index 991e702e9..a5a903411 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -15,6 +15,7 @@ commonWorkerOrchestration = import "./common/worker-orchestration.md"; commonWorkspace = import "./common/workspace.md"; commonWriting = import "./common/writing.md"; roleCoder = import "./role/coder.md"; +roleTicketWorker = import "./role/ticket-worker.md"; roleIntake = import "./role/intake.md"; roleOrchestrator = import "./role/orchestrator.md"; roleReviewer = import "./role/reviewer.md"; @@ -55,6 +56,7 @@ in }; role = { coder = roleCoder.content; + ticket_worker = roleTicketWorker.content; intake = roleIntake.content; orchestrator = roleOrchestrator.content; reviewer = roleReviewer.content; diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 34db081a0..bfb556b66 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. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable repository-derived aliases and grant Backend-derived write capability to every `read_write` target. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step 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 from any required `read_write` target work branch, guarded integration of the approved linked Merge Requests, and guarded Ticket completion to the Workspace Orchestrator by default. 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. Explicitly start bounded Ticket work with `SpawnTicketWorker`, selecting a registered `profile`, an `initial_request`, an optional `flow`, and alias-keyed `workdir_attachments`. Work assignment is independent from the selected Profile: ordinary investigation can use `builtin:default` without a Flow or Workdir; Git implementation can select `builtin:coder` with `builtin:coder-review`. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Do not first record `inprogress`: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded 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. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md new file mode 100644 index 000000000..ad39a3415 --- /dev/null +++ b/resources/prompts/role/ticket-worker.md @@ -0,0 +1,7 @@ +You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required. + +Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. + +Use `TicketComment` for useful natural-language progress, results, blockers, and decisions that need attention. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the requested work meets its acceptance criteria, use `CompleteTicket` with a natural-language reason describing the result and supporting evidence, and optional typed references when relevant. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. + +Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it. diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 6b0103cdc..7bfebc5ca 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 2f7807e1b..5ed83fa35 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index f49480ac0..902f3c282 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check @@ -109,7 +109,7 @@ export type RuntimeSourceSummary = { identity_authority: RuntimeIdentityAuthorit export type RuntimeSummary = { arch: string; diagnostics?: Array; host_ids?: Array; kind: string; label: string; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; }; -export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "coder" | "reviewer"; +export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "worker" | "reviewer"; export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked"; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 09444105b..9a6cd8e3b 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check @@ -81,17 +81,17 @@ export type ReviewDecision = "approve" | "request_changes"; export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; }; -export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; }; +export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; workdir_bindings?: Array; }; -export type TicketActionEligibility = { blockers: Array; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array; }; +export type TicketActionEligibility = { blockers: Array; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array; }; export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; -export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor"; +export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor"; export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -139,4 +139,6 @@ export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | export type TicketTargetAccess = "read_only" | "read_write"; +export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; }; + export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 92c3b1338..38127d28d 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) +// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check