feat: gate ticket readiness on validated targets

This commit is contained in:
2026-08-17 14:23:47 +09:00
parent 063f203efe
commit a2cd860199
11 changed files with 1106 additions and 375 deletions
+3 -3
View File
@@ -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, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
ShutdownAfterIdleRequest, TicketMarkReadyShutdownHook, 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
// TicketIntakeReady turn has fully settled back to Idle. The request
// TicketMarkReady 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(TicketIntakeReadyShutdownHook::new(
worker.add_post_tool_call_hook(TicketMarkReadyShutdownHook::new(
shutdown_after_idle.clone(),
is_ticket_intake_role(worker.runtime_ticket_role()),
));
+23 -31
View File
@@ -381,7 +381,6 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -389,7 +388,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketMarkReady"];
#[cfg(test)]
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
@@ -398,7 +397,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketClose",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -409,6 +407,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
@@ -419,6 +418,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketQueue",
"TicketWorkflowState",
"TicketClose",
"TicketDependencyCheck",
@@ -894,16 +894,15 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?),
),
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => Self::request_unit(
TicketBackendOperation::MarkReady { id, request } => Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })),
),
format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
Some(serde_json::to_value(request).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}"))
})?),
)
.map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
@@ -1119,22 +1118,15 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
}
}
fn mark_intake_ready(
fn mark_ready(
&self,
id: TicketIdOrSlug,
summary: TicketIntakeSummary,
change: TicketStateChange,
) -> TicketResult<()> {
match self.invoke(TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
})? {
TicketBackendOperationResult::Unit => Ok(()),
other => Err(TicketError::Conflict(format!(
"unexpected ticket backend response: {other:?}"
))),
}
request: ticket::TicketMarkReady,
) -> TicketResult<Ticket> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::MarkReady { id, request }),
TicketBackendOperationResult::Ticket
)
}
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
@@ -1303,13 +1295,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(), 9);
assert_eq!(tool_names.len(), 8);
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(), 10);
assert_eq!(workflow_names.len(), 11);
assert!(
workflow_names.len() < 12,
"workflow catalog must stay below the prior broad catalog"
@@ -1390,7 +1382,7 @@ mod tests {
.collect::<Vec<_>>();
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 =
@@ -1406,7 +1398,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());
@@ -1519,8 +1511,8 @@ 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 == "TicketIntakeReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
assert!(
!installed
+12 -12
View File
@@ -9,7 +9,7 @@ use ticket::config::TicketRole;
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
const TICKET_INTAKE_READY_TOOL_NAME: &str = "TicketIntakeReady";
const TICKET_MARK_READY_TOOL_NAME: &str = "TicketMarkReady";
#[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 TicketIntakeReadyShutdownHook {
pub(crate) struct TicketMarkReadyShutdownHook {
shutdown_after_idle: ShutdownAfterIdleRequest,
eligible_ticket_intake_role: bool,
}
impl TicketIntakeReadyShutdownHook {
impl TicketMarkReadyShutdownHook {
pub(crate) fn new(
shutdown_after_idle: ShutdownAfterIdleRequest,
eligible_ticket_intake_role: bool,
@@ -60,7 +60,7 @@ impl TicketIntakeReadyShutdownHook {
fn observe_tool_result(&self, info: &ToolResultSummary) {
if self.eligible_ticket_intake_role
&& info.tool_name == TICKET_INTAKE_READY_TOOL_NAME
&& info.tool_name == TICKET_MARK_READY_TOOL_NAME
&& !info.is_error
{
self.shutdown_after_idle.request();
@@ -69,7 +69,7 @@ impl TicketIntakeReadyShutdownHook {
}
#[async_trait]
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
impl Hook<PostToolCall> for TicketMarkReadyShutdownHook {
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 = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
hook.observe_tool_result(&tool_result(TICKET_MARK_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 = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, true));
hook.observe_tool_result(&tool_result(TICKET_MARK_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 = TicketIntakeReadyShutdownHook::new(request.clone(), false);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), false);
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
hook.observe_tool_result(&tool_result(TICKET_MARK_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 = TicketIntakeReadyShutdownHook::new(request.clone(), true);
let hook = TicketMarkReadyShutdownHook::new(request.clone(), true);
hook.observe_tool_result(&tool_result("ShowTicket", false));