166 lines
4.7 KiB
Rust
166 lines
4.7 KiB
Rust
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
};
|
|
|
|
use async_trait::async_trait;
|
|
use protocol::PodStatus;
|
|
use ticket::config::TicketRole;
|
|
|
|
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
|
|
|
|
const TICKET_INTAKE_READY_TOOL_NAME: &str = "TicketIntakeReady";
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct ShutdownAfterIdleRequest {
|
|
requested: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ShutdownAfterIdleRequest {
|
|
pub(crate) fn request(&self) {
|
|
self.requested.store(true, Ordering::Release);
|
|
}
|
|
|
|
pub(crate) fn take(&self) -> bool {
|
|
self.requested.swap(false, Ordering::AcqRel)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn is_requested(&self) -> bool {
|
|
self.requested.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_ticket_intake_role(role: Option<&str>) -> bool {
|
|
matches!(role.and_then(TicketRole::parse), Some(TicketRole::Intake))
|
|
}
|
|
|
|
pub(crate) fn take_shutdown_request_after_status(
|
|
shutdown_after_idle: &ShutdownAfterIdleRequest,
|
|
status: PodStatus,
|
|
) -> bool {
|
|
status == PodStatus::Idle && shutdown_after_idle.take()
|
|
}
|
|
|
|
pub(crate) struct TicketIntakeReadyShutdownHook {
|
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
|
eligible_ticket_intake_role: bool,
|
|
}
|
|
|
|
impl TicketIntakeReadyShutdownHook {
|
|
pub(crate) fn new(
|
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
|
eligible_ticket_intake_role: bool,
|
|
) -> Self {
|
|
Self {
|
|
shutdown_after_idle,
|
|
eligible_ticket_intake_role,
|
|
}
|
|
}
|
|
|
|
fn observe_tool_result(&self, info: &ToolResultSummary) {
|
|
if self.eligible_ticket_intake_role
|
|
&& info.tool_name == TICKET_INTAKE_READY_TOOL_NAME
|
|
&& !info.is_error
|
|
{
|
|
self.shutdown_after_idle.request();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
|
|
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
|
|
self.observe_tool_result(info);
|
|
HookPostToolAction::Continue
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use llm_worker::tool::ToolOutput;
|
|
|
|
fn tool_result(name: &str, is_error: bool) -> ToolResultSummary {
|
|
ToolResultSummary {
|
|
call_id: "tool-1".to_string(),
|
|
tool_name: name.to_string(),
|
|
is_error,
|
|
output: ToolOutput {
|
|
summary: "result".to_string(),
|
|
content: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn successful_ticket_intake_ready_schedules_shutdown_after_idle_for_intake_role() {
|
|
let request = ShutdownAfterIdleRequest::default();
|
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
|
|
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
|
|
|
|
assert!(request.is_requested());
|
|
assert!(request.take());
|
|
assert!(!request.is_requested());
|
|
}
|
|
|
|
#[test]
|
|
fn failed_ticket_intake_ready_does_not_schedule_shutdown_after_idle() {
|
|
let request = ShutdownAfterIdleRequest::default();
|
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
|
|
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, true));
|
|
|
|
assert!(!request.is_requested());
|
|
}
|
|
|
|
#[test]
|
|
fn non_intake_role_does_not_schedule_shutdown_after_idle() {
|
|
let request = ShutdownAfterIdleRequest::default();
|
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), false);
|
|
|
|
hook.observe_tool_result(&tool_result(TICKET_INTAKE_READY_TOOL_NAME, false));
|
|
|
|
assert!(!request.is_requested());
|
|
}
|
|
|
|
#[test]
|
|
fn other_successful_tools_do_not_schedule_shutdown_after_idle() {
|
|
let request = ShutdownAfterIdleRequest::default();
|
|
let hook = TicketIntakeReadyShutdownHook::new(request.clone(), true);
|
|
|
|
hook.observe_tool_result(&tool_result("TicketShow", false));
|
|
|
|
assert!(!request.is_requested());
|
|
}
|
|
|
|
#[test]
|
|
fn only_ticket_intake_runtime_role_is_eligible() {
|
|
assert!(is_ticket_intake_role(Some("intake")));
|
|
assert!(!is_ticket_intake_role(Some("orchestrator")));
|
|
assert!(!is_ticket_intake_role(Some("coder")));
|
|
assert!(!is_ticket_intake_role(Some("reviewer")));
|
|
assert!(!is_ticket_intake_role(Some("unknown")));
|
|
assert!(!is_ticket_intake_role(None));
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_after_idle_is_taken_only_after_idle_status() {
|
|
let request = ShutdownAfterIdleRequest::default();
|
|
request.request();
|
|
|
|
assert!(!take_shutdown_request_after_status(
|
|
&request,
|
|
PodStatus::Running
|
|
));
|
|
assert!(request.is_requested());
|
|
|
|
assert!(take_shutdown_request_after_status(
|
|
&request,
|
|
PodStatus::Idle
|
|
));
|
|
assert!(!request.is_requested());
|
|
}
|
|
}
|