diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index d44656cc..daf7350d 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -529,6 +529,8 @@ pub struct TicketMarkReady { pub reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intake_summary: Option, } impl TicketTargetEdit { @@ -590,6 +592,16 @@ fn mark_ready_fingerprint( if let Some(reason) = request.reason.as_deref() { digest.update(reason.as_bytes()); } + if let Some(summary) = request.intake_summary.as_ref() { + digest.update(b"\0intake-summary\0"); + digest.update(summary.body.as_str().as_bytes()); + for reference in &summary.references { + digest.update(b"\0"); + digest.update(reference.kind.as_bytes()); + digest.update(b":"); + digest.update(reference.target.as_bytes()); + } + } digest .finalize() .iter() @@ -3598,6 +3610,28 @@ impl TicketBackend for SqliteTicketBackend { .unwrap_or("implementation target validated") .to_owned(); let at = now_utc(); + if let Some(mut summary) = request.intake_summary.clone() { + validate_intake_summary(&summary)?; + summary.author = request.author.clone().or(summary.author); + self.insert_event( + conn, + &ticket_id, + &TicketEvent { + kind: TicketEventKind::IntakeSummary, + author: summary.author, + at: None, + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some(TicketEventKind::IntakeSummary.heading()), + body: summary.body, + references: summary.references, + attributes: BTreeMap::new(), + }, + )?; + } self.insert_event( conn, &ticket_id, @@ -4252,7 +4286,11 @@ impl TicketBackend for LocalTicketBackend { target.repository_id, target.ref_selector )), ); - change.author = request.author.or_else(|| Some(default_author())); + change.author = request.author.clone().or_else(|| Some(default_author())); + if let Some(mut summary) = request.intake_summary { + summary.author = request.author.clone().or(summary.author); + self.append_intake_summary_event(&dir, &summary)?; + } self.append_state_changed_event_with_attributes( &dir, &change, @@ -6960,6 +6998,7 @@ state: planning operation_key: "sqlite-ready".to_owned(), reason: Some("target accepted".to_owned()), author: Some("test".to_owned()), + intake_summary: None, }; let ready = backend .mark_ready( @@ -7377,6 +7416,7 @@ state: planning operation_key: "test-flow-ready".to_owned(), reason: Some("ready_for_queue".to_owned()), author: Some("test".to_owned()), + intake_summary: None, }, ) .unwrap(); @@ -7648,6 +7688,7 @@ state: planning operation_key: "ready-op-1".to_owned(), reason: Some("accepted".to_owned()), author: Some("intake".to_owned()), + intake_summary: None, }; let first = backend @@ -7679,6 +7720,7 @@ state: planning operation_key: "ready-op-1".to_owned(), reason: Some("different".to_owned()), author: Some("intake".to_owned()), + intake_summary: None, }, ), Err(TicketError::OperationFingerprintMismatch { .. }) diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 158f9d8b..87ee84b2 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -69,7 +69,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 19] = [ +pub const TICKET_TOOL_NAMES: [&str; 20] = [ "TicketCreate", "TicketEditItem", "QueryTicket", @@ -79,6 +79,7 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [ "TicketDecision", "TicketImplementationReport", "TicketMarkReady", + "TicketIntakeReady", "TicketQueue", "TicketWorkflowState", "TicketClose", @@ -100,7 +101,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ "TicketCreate", "TicketEditItem", "TicketComment", @@ -108,6 +109,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ "TicketDecision", "TicketImplementationReport", "TicketMarkReady", + "TicketIntakeReady", "TicketQueue", "TicketWorkflowState", "TicketClose", @@ -136,6 +138,9 @@ const IMPLEMENTATION_REPORT_DESCRIPTION: &str = const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ The backend atomically validates and normalizes the persisted repository/ref target, records one typed \ state_changed event, and transitions planning -> ready. `reason` is optional."; +const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ +The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \ +state_changed event, effective target, and planning -> ready transition atomically."; const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \ and rejects unresolved blocking relations."; @@ -176,6 +181,7 @@ fn base_tool_description(name: &str) -> &'static str { "TicketDecision" => DECISION_DESCRIPTION, "TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION, "TicketMarkReady" => MARK_READY_DESCRIPTION, + "TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketQueue" => QUEUE_DESCRIPTION, "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, "TicketClose" => CLOSE_DESCRIPTION, @@ -563,6 +569,17 @@ struct TicketMarkReadyParams { reason: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketIntakeReadyParams { + /// Ticket id. + ticket: String, + /// Concise bounded intake summary appended before the ready transition. + intake_summary: String, + /// Optional reason attached to the state_changed event. + #[serde(default)] + reason: Option, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketQueueParams { /// Ticket id. @@ -832,6 +849,11 @@ struct TicketMarkReadyTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketIntakeReadyTool { + backend: TicketToolBackend, +} + #[derive(Clone)] struct TicketQueueTool { backend: TicketToolBackend, @@ -1138,6 +1160,7 @@ impl Tool for TicketMarkReadyTool { operation_key: format!("ticket-mark-ready:{}", ctx.call_id), reason: params.reason, author: None, + intake_summary: None, }, ) .map_err(|error| backend_error("TicketMarkReady", error))?; @@ -1154,6 +1177,39 @@ impl Tool for TicketMarkReadyTool { } } +#[async_trait] +impl Tool for TicketIntakeReadyTool { + async fn execute( + &self, + input_json: &str, + ctx: llm_engine::tool::ToolExecutionContext, + ) -> Result { + let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?; + let ticket = self + .backend + .mark_ready( + TicketIdOrSlug::Query(params.ticket.clone()), + TicketMarkReady { + operation_key: format!("ticket-intake-ready:{}", ctx.call_id), + reason: params.reason, + author: None, + intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)), + }, + ) + .map_err(|error| backend_error("TicketIntakeReady", error))?; + Ok(json_output( + format!("Marked ticket {} state ready after intake", params.ticket), + json!({ + "ticket": ticket.meta.id, + "state": ticket.meta.workflow_state.as_str(), + "repository_id": ticket.meta.repository_id, + "ref_selector": ticket.meta.ref_selector, + "ok": true + }), + )) + } +} + #[async_trait] impl Tool for TicketQueueTool { async fn execute( @@ -1728,6 +1784,7 @@ fn input_schema(name: &str) -> Value { serde_json::to_value(schemars::schema_for!(TicketThreadEventParams)) } "TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)), + "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), "TicketWorkflowState" => { serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) @@ -1776,6 +1833,7 @@ impl_from_backend!(TicketPlanTool); impl_from_backend!(TicketDecisionTool); impl_from_backend!(TicketImplementationReportTool); impl_from_backend!(TicketMarkReadyTool); +impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketQueueTool); impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketCloseTool); @@ -1803,6 +1861,7 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketMarkReady", backend.clone()), + tool_definition::("TicketIntakeReady", backend.clone()), tool_definition::("TicketQueue", backend.clone()), tool_definition::("TicketWorkflowState", backend.clone()), tool_definition::("TicketClose", backend.clone()), @@ -1897,6 +1956,7 @@ mod tests { "TicketDecision", "TicketImplementationReport", "TicketMarkReady", + "TicketIntakeReady", "TicketQueue", "TicketWorkflowState", "TicketClose", @@ -2558,6 +2618,38 @@ mod tests { ); } + #[tokio::test] + async fn ticket_intake_ready_records_summary_with_validated_target() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let mut input = NewTicket::new("Intake Workflow"); + input.repository_id = Some("main".to_owned()); + let created = backend.create(input).unwrap(); + tool_by_name(backend.clone(), "TicketIntakeReady") + .execute( + &json!({ + "ticket": created.id.clone(), + "intake_summary": "Requirements and target are accepted.", + "reason": "intake_complete" + }) + .to_string(), + Default::default(), + ) + .await + .unwrap(); + let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); + assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(record.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + record + .events + .iter() + .filter(|event| event.kind == TicketEventKind::IntakeSummary) + .count(), + 1 + ); + } + #[tokio::test] async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() { let temp = TempDir::new().unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 67d3ea01..ebb07952 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -18,7 +18,7 @@ use crate::runtime::dir::RuntimeDir; use crate::segment_log_sink::SegmentLogSink; use crate::shared_state::WorkerSharedState; use crate::shutdown_after_idle::{ - ShutdownAfterIdleRequest, TicketMarkReadyShutdownHook, is_ticket_intake_role, + ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role, take_shutdown_request_after_status, }; use crate::spawn::registry::SpawnedWorkerRegistry; @@ -423,11 +423,11 @@ impl WorkerController { .await?; // Intake role Workers self-terminate only after a successful - // TicketMarkReady turn has fully settled back to Idle. The request + // TicketIntakeReady turn has fully settled back to Idle. The request // is transient controller state, not model-visible context or ticket // claim metadata. let shutdown_after_idle = ShutdownAfterIdleRequest::default(); - worker.add_post_tool_call_hook(TicketMarkReadyShutdownHook::new( + worker.add_post_tool_call_hook(TicketIntakeReadyShutdownHook::new( shutdown_after_idle.clone(), is_ticket_intake_role(worker.runtime_ticket_role()), )); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 32283d6f..4b2e7d16 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -381,6 +381,8 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", + "TicketMarkReady", + "TicketQueue", "TicketClose", "TicketRelationRecord", "TicketRelationRemove", @@ -388,7 +390,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; -const INTAKE_TOOL_NAMES: &[&str] = &["TicketMarkReady"]; +const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; #[cfg(test)] const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ @@ -397,6 +399,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", + "TicketMarkReady", + "TicketQueue", "TicketClose", "TicketRelationRecord", "TicketRelationRemove", @@ -407,7 +411,6 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketQueue", "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", @@ -418,7 +421,6 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ - "TicketQueue", "TicketWorkflowState", "TicketClose", "TicketDependencyCheck", @@ -1295,13 +1297,13 @@ mod tests { assert_eq!(show.name, "ShowTicket"); assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); - assert_eq!(tool_names.len(), 8); + assert_eq!(tool_names.len(), 10); assert!( tool_names.len() < 13, "authoring catalog must stay below the prior broad catalog" ); let workflow_names = TicketFeatureAccess::workflow().tool_names(); - assert_eq!(workflow_names.len(), 11); + assert_eq!(workflow_names.len(), 10); assert!( workflow_names.len() < 12, "workflow catalog must stay below the prior broad catalog" @@ -1382,7 +1384,7 @@ mod tests { .collect::>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); - assert!(!workspace_tools.contains(&"TicketQueue")); + assert!(workspace_tools.contains(&"TicketQueue")); assert!(!workspace_tools.contains(&"TicketWorkflowState")); let orchestration = @@ -1398,7 +1400,7 @@ mod tests { assert!(orchestration_tools.contains(&"TicketRelationRecord")); assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); assert!(!orchestration_tools.contains(&"TicketEditItem")); - assert!(orchestration_tools.contains(&"TicketQueue")); + assert!(!orchestration_tools.contains(&"TicketQueue")); let work_report = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report()); @@ -1511,8 +1513,9 @@ language = "Japanese" assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES); assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); - assert!(!installed.iter().any(|tool| *tool == "TicketQueue")); - assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady")); + assert!(installed.iter().any(|tool| *tool == "TicketQueue")); + assert!(installed.iter().any(|tool| *tool == "TicketMarkReady")); + assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState")); assert!( !installed diff --git a/crates/worker/src/shutdown_after_idle.rs b/crates/worker/src/shutdown_after_idle.rs index 2f4d1fb3..bae83158 100644 --- a/crates/worker/src/shutdown_after_idle.rs +++ b/crates/worker/src/shutdown_after_idle.rs @@ -9,7 +9,7 @@ use ticket::config::TicketRole; use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary}; -const TICKET_MARK_READY_TOOL_NAME: &str = "TicketMarkReady"; +const TICKET_INTAKE_READY_TOOL_NAME: &str = "TicketIntakeReady"; #[derive(Clone, Default)] pub(crate) struct ShutdownAfterIdleRequest { @@ -42,12 +42,12 @@ pub(crate) fn take_shutdown_request_after_status( status == WorkerStatus::Idle && shutdown_after_idle.take() } -pub(crate) struct TicketMarkReadyShutdownHook { +pub(crate) struct TicketIntakeReadyShutdownHook { shutdown_after_idle: ShutdownAfterIdleRequest, eligible_ticket_intake_role: bool, } -impl TicketMarkReadyShutdownHook { +impl TicketIntakeReadyShutdownHook { pub(crate) fn new( shutdown_after_idle: ShutdownAfterIdleRequest, eligible_ticket_intake_role: bool, @@ -60,7 +60,7 @@ impl TicketMarkReadyShutdownHook { fn observe_tool_result(&self, info: &ToolResultSummary) { if self.eligible_ticket_intake_role - && info.tool_name == TICKET_MARK_READY_TOOL_NAME + && info.tool_name == TICKET_INTAKE_READY_TOOL_NAME && !info.is_error { self.shutdown_after_idle.request(); @@ -69,7 +69,7 @@ impl TicketMarkReadyShutdownHook { } #[async_trait] -impl Hook for TicketMarkReadyShutdownHook { +impl Hook for TicketIntakeReadyShutdownHook { async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction { self.observe_tool_result(info); HookPostToolAction::Continue @@ -98,9 +98,9 @@ mod tests { #[test] fn successful_ticket_intake_ready_schedules_shutdown_after_idle_for_intake_role() { let request = ShutdownAfterIdleRequest::default(); - let hook = TicketMarkReadyShutdownHook::new(request.clone(), true); + let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true); - hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false)); + hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false)); assert!(request.is_requested()); assert!(request.take()); @@ -110,9 +110,9 @@ mod tests { #[test] fn failed_ticket_intake_ready_does_not_schedule_shutdown_after_idle() { let request = ShutdownAfterIdleRequest::default(); - let hook = TicketMarkReadyShutdownHook::new(request.clone(), true); + let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true); - hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, true)); + hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, true)); assert!(!request.is_requested()); } @@ -120,9 +120,9 @@ mod tests { #[test] fn non_intake_role_does_not_schedule_shutdown_after_idle() { let request = ShutdownAfterIdleRequest::default(); - let hook = TicketMarkReadyShutdownHook::new(request.clone(), false); + let hook = TicketIntakeReadyShutdownHook::new(request.clone(), false); - hook.observe_tool_result(&tool_result(TICKET_MARK_READY_TOOL_NAME, false)); + hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false)); assert!(!request.is_requested()); } @@ -130,7 +130,7 @@ mod tests { #[test] fn other_successful_tools_do_not_schedule_shutdown_after_idle() { let request = ShutdownAfterIdleRequest::default(); - let hook = TicketMarkReadyShutdownHook::new(request.clone(), true); + let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true); hook.observe_tool_result(&tool_result("ShowTicket", false)); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7c91bd5f..a843004d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3312,6 +3312,7 @@ async fn scoped_mark_ticket_ready_from_browser( operation_key: request.operation_key, reason: request.reason, author: Some("web".to_owned()), + intake_summary: None, }, ) .map_err(Error::from)?; @@ -3660,6 +3661,8 @@ struct TicketMarkReadyRequest { operation_key: String, #[serde(default)] reason: Option, + #[serde(default)] + intake_summary: Option, } async fn scoped_set_ticket_state_field( @@ -3717,6 +3720,7 @@ async fn scoped_mark_ticket_ready( operation_key: request.operation_key, reason: request.reason, author: None, + intake_summary: request.intake_summary, }, }, ) @@ -4538,7 +4542,12 @@ fn bind_worker_ticket_operation_source( | TicketBackendOperation::SetStateField { change, .. } | TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), - TicketBackendOperation::MarkReady { request, .. } => request.author = Some(author), + TicketBackendOperation::MarkReady { request, .. } => { + request.author = Some(author.clone()); + if let Some(summary) = request.intake_summary.as_mut() { + summary.author = Some(author); + } + } TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author, TicketBackendOperation::AddTicketRelation { relation, .. } => { relation.author = Some(author) @@ -13896,6 +13905,7 @@ mod tests { operation_key: "ready-server-test".to_owned(), reason: Some("target accepted".to_owned()), author: Some("test".to_owned()), + intake_summary: None, }; let ready = backend .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone()) @@ -13940,6 +13950,7 @@ mod tests { operation_key: "missing-repository".to_owned(), reason: None, author: None, + intake_summary: None, }, ), Err(ticket::TicketError::UnknownTargetRepository(_)) @@ -14072,6 +14083,7 @@ mod tests { operation_key: "notification-ready".to_owned(), reason: Some("ready for implementation".to_owned()), author: None, + intake_summary: None, }, }, TicketBackendOperation::QueueReady { @@ -14988,6 +15000,7 @@ mod tests { Json(TicketMarkReadyRequest { operation_key: "browser-ready".to_owned(), reason: Some("intake complete".to_owned()), + intake_summary: None, }), ) .await