Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
0496cd907b |
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
|
||||
use serde::Serialize;
|
||||
use session_store::FsStore;
|
||||
use session_store::FsWorkerStore;
|
||||
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
|
||||
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
|
||||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
|
||||
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
|
||||
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
@@ -581,6 +576,7 @@ pub(crate) enum IntakeRegistryUpdate {
|
||||
pub(crate) struct ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: PathBuf,
|
||||
ticket_id: String,
|
||||
ticket_key: String,
|
||||
user_instruction: String,
|
||||
followup: ReadyTicketPlanningReturnFollowup,
|
||||
}
|
||||
@@ -910,6 +906,7 @@ struct OrchestratorActiveWorkItem {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OrchestratorQueuedWorkItem {
|
||||
id: String,
|
||||
resource_key: Option<String>,
|
||||
title: String,
|
||||
classification: OrchestratorQueuedClassification,
|
||||
waiting_reason: Option<String>,
|
||||
@@ -974,22 +971,6 @@ impl OrchestratorQueueAttentionNoticeResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OrchestratorQueueTemplateContext {
|
||||
workspace: String,
|
||||
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
|
||||
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
|
||||
omitted_ticket_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OrchestratorQueueTemplateTicket {
|
||||
id: String,
|
||||
title: String,
|
||||
classification: &'static str,
|
||||
waiting_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PanelRowHitBox {
|
||||
rect: Rect,
|
||||
@@ -1325,7 +1306,16 @@ impl DashboardApp {
|
||||
if self.orchestrator_work_set.is_empty() {
|
||||
self.refresh_orchestrator_work_set();
|
||||
}
|
||||
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?;
|
||||
let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
|
||||
Ok(Some(notice)) => notice,
|
||||
Ok(None) => return None,
|
||||
Err(error) => {
|
||||
self.notice = Some(format!(
|
||||
"Orchestrator queued-work attention not delivered: {error}"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if self
|
||||
.orchestrator_queue_attention
|
||||
.as_ref()
|
||||
@@ -2042,11 +2032,18 @@ impl DashboardApp {
|
||||
return None;
|
||||
};
|
||||
let ticket_id = ticket.id.clone();
|
||||
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||
Ok(ticket_key) => ticket_key.to_string(),
|
||||
Err(error) => {
|
||||
self.notice = Some(error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut context =
|
||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||
context.user_instruction = Some(format!(
|
||||
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||
"Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||
));
|
||||
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
|
||||
Ok(store) => store,
|
||||
@@ -2059,7 +2056,7 @@ impl DashboardApp {
|
||||
Ok(Some(claim)) => {
|
||||
let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
|
||||
self.notice = Some(existing_ticket_claim_notice(
|
||||
&ticket_id,
|
||||
&ticket_key,
|
||||
&claim.worker_name,
|
||||
status,
|
||||
));
|
||||
@@ -2087,7 +2084,7 @@ impl DashboardApp {
|
||||
self.sending = true;
|
||||
self.notice = Some(format!(
|
||||
"Launching Ticket Intake for {} as {}…",
|
||||
ticket_id, planned.worker_name
|
||||
ticket_key, planned.worker_name
|
||||
));
|
||||
Some(IntakeLaunchRequest {
|
||||
context,
|
||||
@@ -2158,10 +2155,17 @@ impl DashboardApp {
|
||||
return None;
|
||||
};
|
||||
let ticket_id = ticket.id.clone();
|
||||
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||
Ok(ticket_key) => ticket_key.to_string(),
|
||||
Err(error) => {
|
||||
self.notice = Some(error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if ticket.workflow_state != TicketWorkflowState::Ready {
|
||||
self.notice = Some(format!(
|
||||
"Ticket {} is {}; expected ready before returning to planning.",
|
||||
ticket_id,
|
||||
ticket_key,
|
||||
ticket.workflow_state.as_str()
|
||||
));
|
||||
return None;
|
||||
@@ -2213,7 +2217,7 @@ impl DashboardApp {
|
||||
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
|
||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
|
||||
&ticket_id,
|
||||
&ticket_key,
|
||||
&user_instruction,
|
||||
));
|
||||
let peer_registration = self.prepare_intake_peer_registration(&mut context);
|
||||
@@ -2237,11 +2241,12 @@ impl DashboardApp {
|
||||
self.sending = true;
|
||||
self.notice = Some(format!(
|
||||
"Returning ready Ticket {} to planning for refinement…",
|
||||
ticket_id
|
||||
ticket_key
|
||||
));
|
||||
Some(ReadyTicketPlanningReturnRequest {
|
||||
workspace_root,
|
||||
ticket_id,
|
||||
ticket_key,
|
||||
user_instruction,
|
||||
followup,
|
||||
})
|
||||
@@ -3645,6 +3650,7 @@ fn derive_orchestrator_work_set(
|
||||
};
|
||||
Some(OrchestratorQueuedWorkItem {
|
||||
id: ticket.id.clone(),
|
||||
resource_key: ticket.resource_key.clone(),
|
||||
title: ticket.title.clone(),
|
||||
classification,
|
||||
waiting_reason,
|
||||
@@ -3728,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
|
||||
}
|
||||
|
||||
fn orchestrator_queue_attention_notice(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
work_set: &OrchestratorWorkSet,
|
||||
) -> Option<OrchestratorQueueAttentionNotice> {
|
||||
) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
|
||||
if work_set.has_active_inprogress() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let actionable = work_set.actionable_queued();
|
||||
if actionable.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let waiting = work_set
|
||||
.queued
|
||||
.iter()
|
||||
.filter(|item| item.waiting_reason.is_some())
|
||||
.collect::<Vec<_>>();
|
||||
let ticket_count = actionable.len() + waiting.len();
|
||||
let actionable_tickets = actionable
|
||||
.iter()
|
||||
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS)
|
||||
.map(|item| orchestrator_queue_template_ticket(item))
|
||||
.collect::<Vec<_>>();
|
||||
let remaining_capacity =
|
||||
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len());
|
||||
let waiting_tickets = waiting
|
||||
.iter()
|
||||
.take(remaining_capacity)
|
||||
.map(|item| orchestrator_queue_template_ticket(item))
|
||||
.collect::<Vec<_>>();
|
||||
let rendered =
|
||||
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
|
||||
workspace: bounded_progress_text(
|
||||
&panel.header.workspace_label,
|
||||
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
|
||||
),
|
||||
actionable_tickets,
|
||||
waiting_tickets,
|
||||
omitted_ticket_count: ticket_count
|
||||
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
|
||||
.filter(|item| item.waiting_reason.is_some());
|
||||
let tickets = actionable
|
||||
.into_iter()
|
||||
.chain(waiting)
|
||||
.map(|item| {
|
||||
let resource_key = item
|
||||
.resource_key
|
||||
.clone()
|
||||
.ok_or("queued Ticket is missing its required resource key")?;
|
||||
worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
|
||||
.map_err(|_| "queued Ticket has an invalid resource key")
|
||||
})
|
||||
.ok()?;
|
||||
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS);
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let context = worker::OrchestratorQueueAttentionContext::new(tickets);
|
||||
let message = render_orchestrator_queue_attention_template(&context)
|
||||
.map_err(|_| "queued-work attention prompt rendering failed")?;
|
||||
let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
|
||||
Some(OrchestratorQueueAttentionNotice {
|
||||
Ok(Some(OrchestratorQueueAttentionNotice {
|
||||
message,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
fn orchestrator_queue_template_ticket(
|
||||
item: &&OrchestratorQueuedWorkItem,
|
||||
) -> OrchestratorQueueTemplateTicket {
|
||||
OrchestratorQueueTemplateTicket {
|
||||
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
|
||||
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
|
||||
classification: item.classification.as_str(),
|
||||
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
|
||||
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
|
||||
}),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_orchestrator_queue_attention_template(
|
||||
context: &OrchestratorQueueTemplateContext,
|
||||
context: &worker::OrchestratorQueueAttentionContext,
|
||||
) -> Result<String, worker::CatalogError> {
|
||||
worker::PromptCatalog::builtins_only()?
|
||||
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
|
||||
.orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
|
||||
}
|
||||
|
||||
fn orchestrator_work_set_detail(
|
||||
@@ -3918,21 +3898,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String {
|
||||
fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
|
||||
let resource_key = resource_key.ok_or_else(|| {
|
||||
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
|
||||
.to_string()
|
||||
})?;
|
||||
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
sequence.map(|_| resource_key).ok_or_else(|| {
|
||||
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
||||
"Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
|
||||
fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String {
|
||||
fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
"Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3961,10 +3955,12 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
let ticket = backend
|
||||
.show(id.clone())
|
||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||
let ticket_key =
|
||||
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
|
||||
return Err(TicketActionError::Stale(format!(
|
||||
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
|
||||
ticket.meta.id,
|
||||
ticket_key,
|
||||
ticket.meta.workflow_state.as_str()
|
||||
)));
|
||||
}
|
||||
@@ -3973,7 +3969,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
TicketWorkflowState::Planning.as_str(),
|
||||
"panel_return_to_planning",
|
||||
MarkdownText::from(build_ready_ticket_refinement_thread_body(
|
||||
&ticket.meta.id,
|
||||
ticket_key,
|
||||
&request.user_instruction,
|
||||
)),
|
||||
);
|
||||
@@ -3987,7 +3983,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
|
||||
ticket.meta.id
|
||||
ticket_key
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
|
||||
}
|
||||
@@ -3997,19 +3993,19 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
socket_path,
|
||||
} => {
|
||||
let message =
|
||||
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction);
|
||||
build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
|
||||
match send_notify_only(&socket_path, message, true).await {
|
||||
Ok(()) => ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
},
|
||||
Err(error) => ReadyTicketPlanningReturnOutcome {
|
||||
notice: bounded_panel_diagnostic(format!(
|
||||
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
|
||||
ticket.meta.id, worker_name, error
|
||||
ticket_key, worker_name, error
|
||||
)),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
},
|
||||
@@ -4020,7 +4016,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
|
||||
}
|
||||
@@ -4029,7 +4025,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: bounded_panel_diagnostic(format!(
|
||||
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
)),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
}
|
||||
|
||||
@@ -390,6 +390,7 @@ fn planning_return_request(
|
||||
ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: temp.path().to_path_buf(),
|
||||
ticket_id,
|
||||
ticket_key: "T-482".to_string(),
|
||||
user_instruction: instruction.to_string(),
|
||||
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
|
||||
worker_name: "stale-intake".to_string(),
|
||||
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
|
||||
};
|
||||
|
||||
assert_eq!(request.ticket_id, "20260608-000123-ready");
|
||||
assert_eq!(request.ticket_key, "T-1");
|
||||
assert_eq!(request.user_instruction, "clarify expected behavior");
|
||||
assert!(matches!(
|
||||
request.followup,
|
||||
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
|
||||
let request = ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: temp.path().to_path_buf(),
|
||||
ticket_id: ticket_id.clone(),
|
||||
ticket_key: "T-482".to_string(),
|
||||
user_instruction: "launch intake after state change".to_string(),
|
||||
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
|
||||
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
|
||||
@@ -2969,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
||||
fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
app.panel.rows = vec![panel_test_ticket_row(
|
||||
"00001QUEUE",
|
||||
@@ -2989,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.starts_with("Workspace Dashboard observed")
|
||||
.starts_with("Queued Tickets require attention:")
|
||||
);
|
||||
assert!(request.notice.message.contains("00001QUEUE"));
|
||||
assert!(request.notice.message.contains("new_queued"));
|
||||
assert!(request.notice.message.contains("queued -> inprogress"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.contains("Reread the current Ticket state before acting")
|
||||
);
|
||||
assert!(
|
||||
!request
|
||||
.notice
|
||||
.message
|
||||
.contains(&app.panel.header.workspace_label)
|
||||
);
|
||||
for hidden in [
|
||||
"00001QUEUE",
|
||||
"Workspace:",
|
||||
"workspace_id",
|
||||
"new_queued",
|
||||
"bounded",
|
||||
"queued -> inprogress",
|
||||
] {
|
||||
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
let mut row = panel_test_ticket_row(
|
||||
"00001QUEUE",
|
||||
"Queued work",
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"queued",
|
||||
);
|
||||
row.ticket.as_mut().unwrap().resource_key = None;
|
||||
app.panel.rows = vec![row];
|
||||
app.refresh_orchestrator_work_set();
|
||||
|
||||
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
||||
assert_eq!(
|
||||
app.notice.as_deref(),
|
||||
Some(
|
||||
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_attention_truncates_only_when_tickets_are_omitted() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
|
||||
.map(|index| {
|
||||
let mut row = panel_test_ticket_row(
|
||||
&format!("opaque-{index}"),
|
||||
&format!("Queued work {index}"),
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"queued",
|
||||
);
|
||||
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
app.refresh_orchestrator_work_set();
|
||||
|
||||
let request = app
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("bounded queued-work attention");
|
||||
|
||||
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
|
||||
assert!(!request.notice.message.contains("T-21"));
|
||||
assert!(
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.contains("were omitted from this notice: 1")
|
||||
);
|
||||
assert!(!request.notice.message.contains("opaque-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3083,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("planned queued work should prompt after active work clears");
|
||||
|
||||
assert!(request.notice.message.contains("planned_queued"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(!request.notice.message.contains("planned_queued"));
|
||||
assert!(!request.notice.message.contains("00001QUEUE"));
|
||||
assert!(
|
||||
!request
|
||||
.notice
|
||||
@@ -3138,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("queued ticket state should be rediscovered safely");
|
||||
|
||||
assert!(request.notice.message.contains("new_queued"));
|
||||
assert!(request.notice.message.contains("00001QUEUE"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(!request.notice.message.contains("new_queued"));
|
||||
assert!(!request.notice.message.contains("00001QUEUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3425,6 +3507,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
|
||||
assert!(!app.panel_diagnostic_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
|
||||
const INTERNAL_ID: &str = "00001KZVNXFNK";
|
||||
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
|
||||
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
|
||||
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
|
||||
|
||||
for projection in [&thread, &launch, ¬ify] {
|
||||
assert!(projection.contains("T-482"));
|
||||
assert!(!projection.contains(INTERNAL_ID));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
|
||||
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
|
||||
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
|
||||
assert!(required_ticket_handoff_key(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_line(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
|
||||
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
|
||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||
})?;
|
||||
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
|
||||
let workflow_state = self
|
||||
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||
let ticket = self
|
||||
.ticket_service
|
||||
.workflow_state(&ticket_id)
|
||||
.ticket_handoff(&ticket_ref)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !matches!(
|
||||
workflow_state,
|
||||
ticket.workflow_state,
|
||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
||||
) {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||
workflow_state.as_str()
|
||||
"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")?;
|
||||
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)?,
|
||||
relative_cwd,
|
||||
profile: CODER_PROFILE.to_string(),
|
||||
ticket_id: Some(ticket_id.clone()),
|
||||
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
|
||||
display_name: format!("Coder · {ticket_id}"),
|
||||
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_id}.")),
|
||||
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
||||
],
|
||||
})
|
||||
.await
|
||||
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Spawned Coder for Ticket {ticket_id}"),
|
||||
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -201,21 +202,31 @@ mod tests {
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingTicketService;
|
||||
|
||||
impl TicketService for RecordingTicketService {
|
||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
Ok(TicketWorkflowState::Queued)
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-482");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: TicketWorkflowState::Queued,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedTicketService(TicketWorkflowState);
|
||||
|
||||
impl TicketService for FixedTicketService {
|
||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
Ok(self.0)
|
||||
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: self.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +258,7 @@ mod tests {
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "00001KZXN51C7",
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"working_directory_id": "workdir-1"
|
||||
})
|
||||
@@ -265,16 +276,20 @@ mod tests {
|
||||
request.operation_id.as_deref(),
|
||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||
);
|
||||
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
|
||||
assert_eq!(request.display_name, "Coder · T-482");
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string()
|
||||
},
|
||||
Segment::text("Implement Ticket 00001KZXN51C7.")
|
||||
Segment::text("Implement Ticket T-482.")
|
||||
]
|
||||
);
|
||||
assert!(!request.display_name.contains("00001KZXN51C7"));
|
||||
assert!(request.initial_submit.iter().all(|segment| {
|
||||
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -267,7 +267,20 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
pub trait TicketService: Send + Sync {
|
||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketHandoff {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
struct BackendTicketService {
|
||||
@@ -275,10 +288,18 @@ struct BackendTicketService {
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
self.backend
|
||||
.show(ticket_id.into())
|
||||
.map(|ticket| ticket.meta.workflow_state)
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
let ticket = self.backend.show(ticket_ref.into())?;
|
||||
let resource_key = ticket
|
||||
.meta
|
||||
.resource_key
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
|
||||
Ok(TicketHandoff {
|
||||
id: ticket.meta.id,
|
||||
resource_key,
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1770,6 +1791,14 @@ provider = "github"
|
||||
assert_eq!(removed.target, "01TARGET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
|
||||
assert!(!is_canonical_ticket_resource_key(invalid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
@@ -33,8 +33,9 @@ pub use manifest::{
|
||||
};
|
||||
pub use model_client::{ProviderError, build_client};
|
||||
pub use prompt::catalog::{
|
||||
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
|
||||
prompt_schema_source,
|
||||
CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
|
||||
OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
|
||||
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
|
||||
};
|
||||
pub use prompt::source::PromptCatalogSource;
|
||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
|
||||
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
TicketEventCompanionNotice,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
|
||||
@@ -122,7 +121,6 @@ impl WorkerPrompt {
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
|
||||
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
|
||||
}
|
||||
}
|
||||
@@ -139,13 +137,97 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::TicketEventCompanionNotice,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
}
|
||||
|
||||
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrchestratorQueueAttentionTicket {
|
||||
resource_key: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionTicket {
|
||||
pub fn new(
|
||||
resource_key: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
) -> Result<Self, CatalogError> {
|
||||
let resource_key = resource_key.into();
|
||||
if !is_ticket_resource_key(&resource_key) {
|
||||
return Err(CatalogError::InvalidQueueAttentionResourceKey);
|
||||
}
|
||||
Ok(Self {
|
||||
resource_key,
|
||||
title: bounded_queue_attention_text(&title.into(), 240),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared model-visible context for every Orchestrator backlog attention renderer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrchestratorQueueAttentionContext {
|
||||
tickets: Vec<OrchestratorQueueAttentionTicket>,
|
||||
separator: &'static str,
|
||||
omitted_ticket_count: usize,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionContext {
|
||||
pub const MAX_TICKETS: usize = 20;
|
||||
|
||||
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
|
||||
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
|
||||
Self {
|
||||
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
|
||||
separator: "—",
|
||||
omitted_ticket_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt-catalog entries that must share the same backlog-attention body contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrchestratorQueueAttentionPrompt {
|
||||
Server,
|
||||
Tui,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionPrompt {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Server => "internal.workspace_orchestrator_queue_attention",
|
||||
Self::Tui => "panel.orchestrator_idle_queue_notice",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ticket_resource_key(input: &str) -> bool {
|
||||
input.len() <= 32
|
||||
&& input.strip_prefix("T-").is_some_and(|suffix| {
|
||||
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
|
||||
let mut output = String::new();
|
||||
for (index, character) in input.chars().enumerate() {
|
||||
if index == max_chars {
|
||||
output.push('…');
|
||||
break;
|
||||
}
|
||||
output.push(if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CatalogError {
|
||||
#[error("queued Ticket resource key is missing or invalid")]
|
||||
InvalidQueueAttentionResourceKey,
|
||||
#[error("failed to build builtin Prompt source tree: {0}")]
|
||||
BuiltinTree(String),
|
||||
#[error("failed to evaluate builtin Prompt source tree: {0}")]
|
||||
@@ -322,6 +404,14 @@ impl PromptCatalog {
|
||||
self.render_name(key, Value::from_serialize(context))
|
||||
}
|
||||
|
||||
pub fn orchestrator_queue_attention(
|
||||
&self,
|
||||
prompt: OrchestratorQueueAttentionPrompt,
|
||||
context: &OrchestratorQueueAttentionContext,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render_serializable(prompt.key(), context)
|
||||
}
|
||||
|
||||
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
|
||||
let template = self
|
||||
.env
|
||||
@@ -593,6 +683,12 @@ mod tests {
|
||||
fn builtin_dcdl_catalog_loads() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
assert!(!catalog.projection.templates.is_empty());
|
||||
assert!(
|
||||
!catalog
|
||||
.projection
|
||||
.templates
|
||||
.contains_key("worker.ticket_event_companion_notice")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -650,6 +746,62 @@ mod tests {
|
||||
assert!(reviewer.contains("target-only movement does not invalidate approval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
|
||||
.map(|index| {
|
||||
OrchestratorQueueAttentionTicket::new(
|
||||
format!("T-{index}"),
|
||||
format!("Ticket {index}\nwith control\u{7}"),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let context = OrchestratorQueueAttentionContext::new(tickets);
|
||||
let server = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
|
||||
.unwrap();
|
||||
let tui = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(server, tui);
|
||||
assert!(server.starts_with("Queued Tickets require attention:"));
|
||||
assert!(server.contains("- T-1 — Ticket 1 with control "));
|
||||
assert!(!server.contains("T-21"));
|
||||
assert!(server.contains("were omitted from this notice: 1"));
|
||||
assert!(server.contains("Re-query current Ticket authority"));
|
||||
assert!(server.contains("Reread the current Ticket state before acting"));
|
||||
for secret in [
|
||||
"workspace_id",
|
||||
"Workspace:",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"bounded",
|
||||
] {
|
||||
assert!(!server.contains(secret), "leaked {secret}: {server}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let context = OrchestratorQueueAttentionContext::new(vec![
|
||||
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
|
||||
]);
|
||||
let rendered = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("- T-541 — Attention contract"));
|
||||
assert!(!rendered.contains("omitted"));
|
||||
assert!(matches!(
|
||||
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
|
||||
Err(CatalogError::InvalidQueueAttentionResourceKey)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
||||
let invalid = BTreeMap::from([
|
||||
|
||||
@@ -327,8 +327,6 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option<Pat
|
||||
}
|
||||
}
|
||||
|
||||
const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20;
|
||||
const ORCHESTRATOR_ATTENTION_PROMPT_NAME: &str = "internal.workspace_orchestrator_queue_attention";
|
||||
static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock<
|
||||
worker_runtime::auth::RuntimeIdentityMaterial,
|
||||
> = std::sync::LazyLock::new(|| {
|
||||
@@ -6414,9 +6412,15 @@ fn worker_ticket_source_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_notification_content(ticket_id: &str, current_state: &str) -> String {
|
||||
fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
|
||||
let sequence = resource_key.strip_prefix("T-")?;
|
||||
(!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.then_some(resource_key)
|
||||
}
|
||||
|
||||
fn ticket_notification_content(resource_key: &str, current_state: &str) -> String {
|
||||
format!(
|
||||
"Ticket notification: ticket_id={ticket_id} current_state={current_state}. Reread the Ticket before acting."
|
||||
"Ticket {resource_key} changed to {current_state}. Reread the current Ticket before acting."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6498,6 +6502,16 @@ fn notify_ticket_recipients(
|
||||
current_state: &str,
|
||||
source: Option<RuntimeWorkerRef>,
|
||||
) {
|
||||
let Ok(Some(resource_key)) =
|
||||
api.store
|
||||
.resource_key(workspace_id, WorkspaceResourceKind::Ticket, ticket_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(resource_key) = canonical_ticket_resource_key(&resource_key) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut recipients = Vec::new();
|
||||
if let Some(assignment) = api
|
||||
.store
|
||||
@@ -6518,7 +6532,7 @@ fn notify_ticket_recipients(
|
||||
recipients.sort();
|
||||
recipients.dedup();
|
||||
|
||||
let content = ticket_notification_content(ticket_id, current_state);
|
||||
let content = ticket_notification_content(resource_key, current_state);
|
||||
for recipient in recipients {
|
||||
if source.as_ref().is_some_and(|source| source == &recipient) {
|
||||
continue;
|
||||
@@ -7241,25 +7255,21 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shown = queued
|
||||
.iter()
|
||||
.take(ORCHESTRATOR_ATTENTION_TICKET_LIMIT)
|
||||
.map(|ticket| {
|
||||
format!(
|
||||
"- {} — {}",
|
||||
bounded_orchestrator_attention_text(&ticket.id, 80),
|
||||
bounded_orchestrator_attention_text(&ticket.title, 240)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let omitted = queued
|
||||
.len()
|
||||
.saturating_sub(ORCHESTRATOR_ATTENTION_TICKET_LIMIT);
|
||||
let omitted_line = if omitted == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("Additional queued Tickets omitted from this notice: {omitted}\n")
|
||||
let attention_context = match orchestrator_queue_attention_context(
|
||||
&api.config.workspace_id,
|
||||
&api.config.workspace_id,
|
||||
&queued,
|
||||
) {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
workspace_id = %api.config.workspace_id,
|
||||
candidate_count = queued.len(),
|
||||
diagnostic = error,
|
||||
"orchestrator backlog attention projection rejected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok(Some(config_state)) = api
|
||||
.config_store
|
||||
@@ -7276,16 +7286,19 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
||||
let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else {
|
||||
return;
|
||||
};
|
||||
let content = match catalog.render_serializable(
|
||||
ORCHESTRATOR_ATTENTION_PROMPT_NAME,
|
||||
&BTreeMap::from([
|
||||
("omitted_line", omitted_line.as_str()),
|
||||
("workspace_id", api.config.workspace_id.as_str()),
|
||||
("ticket_lines", shown.as_str()),
|
||||
]),
|
||||
let content = match catalog.orchestrator_queue_attention(
|
||||
worker::OrchestratorQueueAttentionPrompt::Server,
|
||||
&attention_context,
|
||||
) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
workspace_id = %api.config.workspace_id,
|
||||
diagnostic = %error,
|
||||
"orchestrator backlog attention rendering failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let accepted = api
|
||||
.runtime
|
||||
@@ -7305,20 +7318,26 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_orchestrator_attention_text(input: &str, max_chars: usize) -> String {
|
||||
let mut output = String::new();
|
||||
for (index, character) in input.chars().enumerate() {
|
||||
if index == max_chars {
|
||||
output.push('…');
|
||||
break;
|
||||
}
|
||||
output.push(if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
});
|
||||
fn orchestrator_queue_attention_context(
|
||||
expected_workspace_id: &str,
|
||||
candidate_workspace_id: &str,
|
||||
tickets: &[ticket::TicketSummary],
|
||||
) -> std::result::Result<worker::OrchestratorQueueAttentionContext, &'static str> {
|
||||
if candidate_workspace_id != expected_workspace_id {
|
||||
return Err("foreign_workspace_ticket_projection");
|
||||
}
|
||||
output
|
||||
let tickets = tickets
|
||||
.iter()
|
||||
.map(|ticket| {
|
||||
let resource_key = ticket
|
||||
.resource_key
|
||||
.clone()
|
||||
.ok_or("missing_ticket_resource_key")?;
|
||||
worker::OrchestratorQueueAttentionTicket::new(resource_key, ticket.title.clone())
|
||||
.map_err(|_| "invalid_ticket_resource_key")
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(worker::OrchestratorQueueAttentionContext::new(tickets))
|
||||
}
|
||||
|
||||
fn require_online_workspace_orchestrator_source(
|
||||
@@ -18216,15 +18235,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_notification_projection_exposes_only_ticket_and_current_state() {
|
||||
fn ticket_notification_requires_canonical_ticket_resource_key() {
|
||||
assert_eq!(canonical_ticket_resource_key("T-429"), Some("T-429"));
|
||||
for invalid in ["", "00001KZ9SR97B", "T-", "T-key", "O-429"] {
|
||||
assert_eq!(canonical_ticket_resource_key(invalid), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_notification_projection_exposes_only_resource_key_and_current_state() {
|
||||
const INTERNAL_ID: &str = "00001KZ9SR97B";
|
||||
for current_state in ["queued", "inprogress"] {
|
||||
let content = ticket_notification_content("00001KZ9SR97B", current_state);
|
||||
let content = ticket_notification_content("T-429", current_state);
|
||||
assert_eq!(
|
||||
content,
|
||||
format!(
|
||||
"Ticket notification: ticket_id=00001KZ9SR97B current_state={current_state}. Reread the Ticket before acting."
|
||||
"Ticket T-429 changed to {current_state}. Reread the current Ticket before acting."
|
||||
)
|
||||
);
|
||||
assert!(!content.contains(INTERNAL_ID));
|
||||
for forbidden in [
|
||||
"workspace_id",
|
||||
"event_sequence",
|
||||
@@ -18402,9 +18431,13 @@ mod tests {
|
||||
assert_eq!(inputs.len(), expected_states.len());
|
||||
for ((recipient, content), current_state) in inputs.iter().zip(expected_states) {
|
||||
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
|
||||
assert!(!content.contains(&ticket.id));
|
||||
assert_eq!(
|
||||
content,
|
||||
&ticket_notification_content(&ticket.id, current_state)
|
||||
&ticket_notification_content(
|
||||
ticket.resource_key.as_deref().unwrap(),
|
||||
current_state,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19520,7 +19553,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
notifications[0].1,
|
||||
ticket_notification_content(
|
||||
ticket_ref.id.as_str(),
|
||||
ticket_ref.resource_key.as_deref().unwrap(),
|
||||
TicketWorkflowState::Queued.as_str()
|
||||
)
|
||||
);
|
||||
@@ -19585,7 +19618,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
init_clean_git_workspace(dir.path());
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Recover queued work");
|
||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
@@ -19604,6 +19638,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(started.online);
|
||||
let startup_inputs = execution.take_inputs();
|
||||
assert_eq!(startup_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
api.orchestrator_attention_fingerprint
|
||||
.lock()
|
||||
@@ -19639,6 +19675,76 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some(ticket_ref.id.as_str())
|
||||
);
|
||||
let notifications = execution.take_inputs();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].0.worker_id.to_string(), worker_id);
|
||||
let content = ¬ifications[0].1;
|
||||
assert!(content.starts_with("Queued Tickets require attention:"));
|
||||
assert!(
|
||||
content.contains(&format!(
|
||||
"- {} — Recover queued work",
|
||||
ticket_ref.resource_key.as_deref().unwrap()
|
||||
)),
|
||||
"unexpected notification body: {content:?}"
|
||||
);
|
||||
assert!(content.contains("Reread the current Ticket state before acting"));
|
||||
assert!(!content.contains(ticket_ref.id.as_str()));
|
||||
assert!(!content.contains(TEST_WORKSPACE_ID));
|
||||
assert!(!content.contains("bounded"));
|
||||
assert!(!content.contains("omitted"));
|
||||
|
||||
let candidates = backend
|
||||
.list(ticket::TicketListQuery::states([
|
||||
ticket::TicketListState::Queued,
|
||||
]))
|
||||
.unwrap();
|
||||
let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS
|
||||
+ 1)
|
||||
.map(|index| {
|
||||
let mut candidate = candidates[0].clone();
|
||||
candidate.id = format!("opaque-{index}");
|
||||
candidate.resource_key = Some(format!("T-{index}"));
|
||||
candidate.title = format!("Queued {index}");
|
||||
candidate
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let truncated = orchestrator_queue_attention_context(
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_WORKSPACE_ID,
|
||||
&truncated_candidates,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = worker::PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.orchestrator_queue_attention(
|
||||
worker::OrchestratorQueueAttentionPrompt::Server,
|
||||
&truncated,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rendered.contains("- T-20 — Queued 20"));
|
||||
assert!(!rendered.contains("T-21"));
|
||||
assert!(rendered.contains("were omitted from this notice: 1"));
|
||||
assert!(!rendered.contains("opaque-"));
|
||||
|
||||
truncated_candidates[0].resource_key = None;
|
||||
assert_eq!(
|
||||
orchestrator_queue_attention_context(
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_WORKSPACE_ID,
|
||||
&truncated_candidates
|
||||
)
|
||||
.unwrap_err(),
|
||||
"missing_ticket_resource_key"
|
||||
);
|
||||
assert_eq!(
|
||||
orchestrator_queue_attention_context(
|
||||
TEST_WORKSPACE_ID,
|
||||
"foreign-workspace",
|
||||
&truncated_candidates
|
||||
)
|
||||
.unwrap_err(),
|
||||
"foreign_workspace_ticket_projection"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -30,7 +30,6 @@ internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||
workerTicketEventCompanionNotice = import "./worker/ticket_event_companion_notice.md";
|
||||
in
|
||||
{
|
||||
default_prompt = defaultDocument.content;
|
||||
@@ -69,7 +68,4 @@ in
|
||||
panel = {
|
||||
orchestrator_idle_queue_notice = panelOrchestratorIdleQueueNotice.content;
|
||||
};
|
||||
worker = {
|
||||
ticket_event_companion_notice = workerTicketEventCompanionNotice.content;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
Workspace Orchestrator attention: authoritative Ticket state still contains queued work after the previous turn or after Server recovery.
|
||||
|
||||
Workspace: {{workspace_id}}
|
||||
Remaining queued Tickets (bounded):
|
||||
{{ticket_lines}}
|
||||
{{omitted_line}}
|
||||
Reread the listed Tickets, their relations, orchestration plans, current assignments, Workers, and Workdirs before acting. Continue only work already authorized by the human `ready -> queued` transition. Do not drain the queue automatically and do not create duplicate assignments, Workers, Workdirs, or merges. If no Ticket is currently actionable, record the durable waiting reason on the authoritative Ticket or orchestration plan and stop. For an actionable queued Ticket, call the guarded `SpawnTicketCoder` operation without first changing Ticket state; it records `queued -> inprogress` only after the Coder, initial input, current assignment, and Workdir finalization are durably accepted.
|
||||
Queued Tickets require attention:
|
||||
{% for ticket in tickets -%}
|
||||
- {{ ticket.resource_key }} {{ separator }} {{ ticket.title }}
|
||||
{% endfor -%}
|
||||
{% if omitted_ticket_count > 0 -%}
|
||||
Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
{% endif -%}
|
||||
Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work.
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present.
|
||||
|
||||
This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted.
|
||||
|
||||
Workspace: {{ workspace }}
|
||||
|
||||
Actionable queued Tickets:
|
||||
{% for ticket in actionable_tickets -%}
|
||||
- {{ ticket.id }} — {{ ticket.title }} [{{ ticket.classification }}]
|
||||
Queued Tickets require attention:
|
||||
{% for ticket in tickets -%}
|
||||
- {{ ticket.resource_key }} {{ separator }} {{ ticket.title }}
|
||||
{% endfor -%}
|
||||
|
||||
{% if waiting_tickets | length > 0 -%}
|
||||
Queued Tickets retained in the session work set but currently waiting:
|
||||
{% for ticket in waiting_tickets -%}
|
||||
- {{ ticket.id }} — {{ ticket.title }} [{{ ticket.classification }}]: {{ ticket.waiting_reason }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% if omitted_ticket_count > 0 -%}
|
||||
Additional queued Tickets omitted from this bounded notice: {{ omitted_ticket_count }}
|
||||
Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
{% endif -%}
|
||||
|
||||
Preserve the existing human gate, dependency/conflict/capacity/dirty-workspace checks, and duplicate-start checks using actual Ticket state, role/session claims, visible Workers, and worktrees.
|
||||
Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Ticket event notice (weak; auto_run=false)
|
||||
ticket: {{ ticket_id }}
|
||||
title: {{ title }}
|
||||
state: {{ state }}
|
||||
event: {{ event_kind }}
|
||||
summary: {{ summary }}
|
||||
ref: {{ ref_path }}
|
||||
Reference in New Issue
Block a user