Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917cc222a3 | ||
|
|
c83461508b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
0496cd907b |
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
|
|||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
|
||||||
use serde::Serialize;
|
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
use session_store::FsWorkerStore;
|
use session_store::FsWorkerStore;
|
||||||
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
|
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
|
||||||
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
|
|||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
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 SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
|
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
|
||||||
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
@@ -911,6 +906,7 @@ struct OrchestratorActiveWorkItem {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct OrchestratorQueuedWorkItem {
|
struct OrchestratorQueuedWorkItem {
|
||||||
id: String,
|
id: String,
|
||||||
|
resource_key: Option<String>,
|
||||||
title: String,
|
title: String,
|
||||||
classification: OrchestratorQueuedClassification,
|
classification: OrchestratorQueuedClassification,
|
||||||
waiting_reason: Option<String>,
|
waiting_reason: Option<String>,
|
||||||
@@ -975,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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct PanelRowHitBox {
|
struct PanelRowHitBox {
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
@@ -1326,7 +1306,16 @@ impl DashboardApp {
|
|||||||
if self.orchestrator_work_set.is_empty() {
|
if self.orchestrator_work_set.is_empty() {
|
||||||
self.refresh_orchestrator_work_set();
|
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
|
if self
|
||||||
.orchestrator_queue_attention
|
.orchestrator_queue_attention
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -3661,6 +3650,7 @@ fn derive_orchestrator_work_set(
|
|||||||
};
|
};
|
||||||
Some(OrchestratorQueuedWorkItem {
|
Some(OrchestratorQueuedWorkItem {
|
||||||
id: ticket.id.clone(),
|
id: ticket.id.clone(),
|
||||||
|
resource_key: ticket.resource_key.clone(),
|
||||||
title: ticket.title.clone(),
|
title: ticket.title.clone(),
|
||||||
classification,
|
classification,
|
||||||
waiting_reason,
|
waiting_reason,
|
||||||
@@ -3744,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn orchestrator_queue_attention_notice(
|
fn orchestrator_queue_attention_notice(
|
||||||
panel: &WorkspacePanelViewModel,
|
|
||||||
work_set: &OrchestratorWorkSet,
|
work_set: &OrchestratorWorkSet,
|
||||||
) -> Option<OrchestratorQueueAttentionNotice> {
|
) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
|
||||||
if work_set.has_active_inprogress() {
|
if work_set.has_active_inprogress() {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let actionable = work_set.actionable_queued();
|
let actionable = work_set.actionable_queued();
|
||||||
if actionable.is_empty() {
|
if actionable.is_empty() {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let waiting = work_set
|
let waiting = work_set
|
||||||
.queued
|
.queued
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|item| item.waiting_reason.is_some())
|
.filter(|item| item.waiting_reason.is_some());
|
||||||
.collect::<Vec<_>>();
|
let tickets = actionable
|
||||||
let ticket_count = actionable.len() + waiting.len();
|
.into_iter()
|
||||||
let actionable_tickets = actionable
|
.chain(waiting)
|
||||||
.iter()
|
.map(|item| {
|
||||||
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS)
|
let resource_key = item
|
||||||
.map(|item| orchestrator_queue_template_ticket(item))
|
.resource_key
|
||||||
.collect::<Vec<_>>();
|
.clone()
|
||||||
let remaining_capacity =
|
.ok_or("queued Ticket is missing its required resource key")?;
|
||||||
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len());
|
worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
|
||||||
let waiting_tickets = waiting
|
.map_err(|_| "queued Ticket has an invalid resource key")
|
||||||
.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),
|
|
||||||
})
|
})
|
||||||
.ok()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS);
|
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);
|
let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
|
||||||
Some(OrchestratorQueueAttentionNotice {
|
Ok(Some(OrchestratorQueueAttentionNotice {
|
||||||
message,
|
message,
|
||||||
fingerprint,
|
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(
|
fn render_orchestrator_queue_attention_template(
|
||||||
context: &OrchestratorQueueTemplateContext,
|
context: &worker::OrchestratorQueueAttentionContext,
|
||||||
) -> Result<String, worker::CatalogError> {
|
) -> Result<String, worker::CatalogError> {
|
||||||
worker::PromptCatalog::builtins_only()?
|
worker::PromptCatalog::builtins_only()?
|
||||||
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
|
.orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn orchestrator_work_set_detail(
|
fn orchestrator_work_set_detail(
|
||||||
|
|||||||
@@ -2972,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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)]);
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||||
app.panel.rows = vec![panel_test_ticket_row(
|
app.panel.rows = vec![panel_test_ticket_row(
|
||||||
"00001QUEUE",
|
"00001QUEUE",
|
||||||
@@ -2992,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
|||||||
request
|
request
|
||||||
.notice
|
.notice
|
||||||
.message
|
.message
|
||||||
.starts_with("Workspace Dashboard observed")
|
.starts_with("Queued Tickets require attention:")
|
||||||
);
|
);
|
||||||
assert!(request.notice.message.contains("00001QUEUE"));
|
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||||
assert!(request.notice.message.contains("new_queued"));
|
assert!(
|
||||||
assert!(request.notice.message.contains("queued -> inprogress"));
|
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]
|
#[test]
|
||||||
@@ -3086,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
|
|||||||
.prepare_orchestrator_queue_attention_notice()
|
.prepare_orchestrator_queue_attention_notice()
|
||||||
.expect("planned queued work should prompt after active work clears");
|
.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!(
|
assert!(
|
||||||
!request
|
!request
|
||||||
.notice
|
.notice
|
||||||
@@ -3141,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
|
|||||||
.prepare_orchestrator_queue_attention_notice()
|
.prepare_orchestrator_queue_attention_notice()
|
||||||
.expect("queued ticket state should be rediscovered safely");
|
.expect("queued ticket state should be rediscovered safely");
|
||||||
|
|
||||||
assert!(request.notice.message.contains("new_queued"));
|
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||||
assert!(request.notice.message.contains("00001QUEUE"));
|
assert!(!request.notice.message.contains("new_queued"));
|
||||||
|
assert!(!request.notice.message.contains("00001QUEUE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ impl WorkspaceHttpObjectiveBackend {
|
|||||||
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
|
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
|
||||||
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
|
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
|
||||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
|
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
|
||||||
|
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||||
let url = format!("{}/ticket-links", self.objective_url(id));
|
let url = format!("{}/ticket-links", self.objective_url(id));
|
||||||
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||||
self.client.as_ref(),
|
self.client.as_ref(),
|
||||||
@@ -159,7 +160,7 @@ impl WorkspaceHttpObjectiveBackend {
|
|||||||
.map_err(backend_error)?;
|
.map_err(backend_error)?;
|
||||||
Ok(objective_output(
|
Ok(objective_output(
|
||||||
format!(
|
format!(
|
||||||
"Linked ticket {ticket_id} to objective {}",
|
"Linked ticket {ticket_resource_key} to objective {}",
|
||||||
&response.resource_key
|
&response.resource_key
|
||||||
),
|
),
|
||||||
response,
|
response,
|
||||||
@@ -172,19 +173,46 @@ impl WorkspaceHttpObjectiveBackend {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
|
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
|
||||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
|
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
|
||||||
|
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||||
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
|
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
|
||||||
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||||
.await
|
.await
|
||||||
.map_err(backend_error)?;
|
.map_err(backend_error)?;
|
||||||
Ok(objective_output(
|
Ok(objective_output(
|
||||||
format!(
|
format!(
|
||||||
"Unlinked ticket {ticket_id} from objective {}",
|
"Unlinked ticket {ticket_resource_key} from objective {}",
|
||||||
&response.resource_key
|
&response.resource_key
|
||||||
),
|
),
|
||||||
response,
|
response,
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
|
||||||
|
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||||
|
let response: serde_json::Value = decode_response(
|
||||||
|
self.client
|
||||||
|
.execute(WorkspaceRequest::get(format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{ticket_reference}"
|
||||||
|
)))
|
||||||
|
.map_err(WorkspaceObjectiveBackendError::from)
|
||||||
|
.map_err(backend_error)?,
|
||||||
|
)
|
||||||
|
.map_err(backend_error)?;
|
||||||
|
response
|
||||||
|
.get("resource_key")
|
||||||
|
.or_else(|| {
|
||||||
|
response
|
||||||
|
.get("meta")
|
||||||
|
.and_then(|meta| meta.get("resource_key"))
|
||||||
|
})
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn objective_url(&self, id: &str) -> String {
|
fn objective_url(&self, id: &str) -> String {
|
||||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||||
format!("/api/w/{workspace_id}/objectives/{id}")
|
format!("/api/w/{workspace_id}/objectives/{id}")
|
||||||
@@ -257,8 +285,14 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
|
|||||||
serde_json::from_str(&response.body).map_err(Into::into)
|
serde_json::from_str(&response.body).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
|
||||||
|
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
|
||||||
|
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||||
if !response.resource_key.starts_with("O-") {
|
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||||
return Err(ToolError::ExecutionFailed(
|
return Err(ToolError::ExecutionFailed(
|
||||||
"required O- human key is unavailable".to_string(),
|
"required O- human key is unavailable".to_string(),
|
||||||
));
|
));
|
||||||
@@ -624,6 +658,11 @@ struct ObjectiveDetail {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use agen::tool::ToolDefinition;
|
use agen::tool::ToolDefinition;
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::TcpListener,
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||||
let mut names = definitions
|
let mut names = definitions
|
||||||
@@ -670,4 +709,83 @@ mod tests {
|
|||||||
let link = link_ticket_schema();
|
let link = link_ticket_schema();
|
||||||
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
for mutation in ["POST", "DELETE"] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
|
||||||
|
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
response_body.len(),
|
||||||
|
response_body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(request.starts_with(&format!(
|
||||||
|
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
|
||||||
|
)));
|
||||||
|
let response_body = serde_json::json!({
|
||||||
|
"resource_key": "O-3",
|
||||||
|
"title": "Objective",
|
||||||
|
"state": "active"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
response_body.len(),
|
||||||
|
response_body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||||
|
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||||
|
));
|
||||||
|
|
||||||
|
let linked = backend
|
||||||
|
.link_ticket(ObjectiveLinkTicketInput {
|
||||||
|
id: "O-3".to_string(),
|
||||||
|
ticket_id: "00001INTERNAL".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let unlinked = backend
|
||||||
|
.unlink_ticket(ObjectiveUnlinkTicketInput {
|
||||||
|
id: "O-3".to_string(),
|
||||||
|
ticket_id: "00001INTERNAL".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
server.join().unwrap();
|
||||||
|
for output in [linked, unlinked] {
|
||||||
|
assert!(output.summary.contains("T-7"));
|
||||||
|
assert!(!output.summary.contains("00001INTERNAL"));
|
||||||
|
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn objective_output_rejects_noncanonical_human_keys() {
|
||||||
|
let response = ObjectiveDetail {
|
||||||
|
resource_key: "O-internal".to_string(),
|
||||||
|
title: "Objective".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
};
|
||||||
|
assert!(objective_output("created".to_string(), response).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -789,6 +789,32 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_ticket_resource_key(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
base: &str,
|
||||||
|
reference: &TicketIdOrSlug,
|
||||||
|
) -> TicketResult<String> {
|
||||||
|
let response: Value = Self::request(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("{base}/{}", Self::ticket_path(reference)),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
response
|
||||||
|
.get("resource_key")
|
||||||
|
.or_else(|| {
|
||||||
|
response
|
||||||
|
.get("meta")
|
||||||
|
.and_then(|meta| meta.get("resource_key"))
|
||||||
|
})
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn request_unit(
|
fn request_unit(
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
method: WorkspaceRequestMethod,
|
method: WorkspaceRequestMethod,
|
||||||
@@ -958,12 +984,13 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
})?),
|
})?),
|
||||||
),
|
),
|
||||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||||
let source_reference = match &id {
|
let source_resource_key =
|
||||||
TicketIdOrSlug::Id(value)
|
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||||
| TicketIdOrSlug::Slug(value)
|
let target_resource_key = Self::resolve_ticket_resource_key(
|
||||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
client.clone(),
|
||||||
};
|
&base,
|
||||||
let target_reference = relation.target.clone();
|
&TicketIdOrSlug::Id(relation.target.clone()),
|
||||||
|
)?;
|
||||||
let mut relation: TicketRelation = Self::request(
|
let mut relation: TicketRelation = Self::request(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -972,31 +999,29 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||||
})?),
|
})?),
|
||||||
)?;
|
)?;
|
||||||
relation.ticket_id = source_reference;
|
relation.ticket_id = source_resource_key;
|
||||||
relation.target = target_reference;
|
relation.target = target_resource_key;
|
||||||
relation.author = "workspace".to_string();
|
relation.author = "workspace".to_string();
|
||||||
Ok(TicketBackendOperationResult::Relation(relation))
|
Ok(TicketBackendOperationResult::Relation(relation))
|
||||||
}
|
}
|
||||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||||
let source_reference = match &id {
|
let source_resource_key =
|
||||||
TicketIdOrSlug::Id(value)
|
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||||
| TicketIdOrSlug::Slug(value)
|
let target_resource_key =
|
||||||
| TicketIdOrSlug::Query(value) => value.clone(),
|
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
|
||||||
};
|
|
||||||
let target = match target {
|
let target = match target {
|
||||||
TicketIdOrSlug::Id(value)
|
TicketIdOrSlug::Id(value)
|
||||||
| TicketIdOrSlug::Slug(value)
|
| TicketIdOrSlug::Slug(value)
|
||||||
| TicketIdOrSlug::Query(value) => value,
|
| TicketIdOrSlug::Query(value) => value,
|
||||||
};
|
};
|
||||||
let target_reference = target.clone();
|
|
||||||
let mut relation: TicketRelation = Self::request(
|
let mut relation: TicketRelation = Self::request(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Delete,
|
WorkspaceRequestMethod::Delete,
|
||||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||||
Some(serde_json::json!({ "kind": kind, "target": target })),
|
Some(serde_json::json!({ "kind": kind, "target": target })),
|
||||||
)?;
|
)?;
|
||||||
relation.ticket_id = source_reference;
|
relation.ticket_id = source_resource_key;
|
||||||
relation.target = target_reference;
|
relation.target = target_resource_key;
|
||||||
relation.author = "workspace".to_string();
|
relation.author = "workspace".to_string();
|
||||||
Ok(TicketBackendOperationResult::Relation(relation))
|
Ok(TicketBackendOperationResult::Relation(relation))
|
||||||
}
|
}
|
||||||
@@ -1825,11 +1850,102 @@ provider = "github"
|
|||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
for (expected_path, resource_key) in [
|
||||||
|
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||||
|
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(request.starts_with(expected_path));
|
||||||
|
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(), body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(
|
||||||
|
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
|
||||||
|
);
|
||||||
|
let body = serde_json::to_string(&TicketRelation {
|
||||||
|
ticket_id: "01SOURCE".to_string(),
|
||||||
|
kind: TicketRelationKind::DependsOn,
|
||||||
|
target: "01TARGET".to_string(),
|
||||||
|
note: None,
|
||||||
|
author: "worker-internal".to_string(),
|
||||||
|
at: "2026-08-06T00:00:00Z".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||||
|
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
|
||||||
|
));
|
||||||
|
|
||||||
|
let relation = backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
TicketIdOrSlug::Id("01SOURCE".to_string()),
|
||||||
|
NewTicketRelation {
|
||||||
|
kind: TicketRelationKind::DependsOn,
|
||||||
|
target: "01TARGET".to_string(),
|
||||||
|
note: None,
|
||||||
|
author: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
server.join().unwrap();
|
||||||
|
assert_eq!(relation.ticket_id, "T-1");
|
||||||
|
assert_eq!(relation.target, "T-2");
|
||||||
|
assert_eq!(relation.author, "workspace");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
|
for (expected_path, resource_key) in [
|
||||||
|
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||||
|
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 8192];
|
||||||
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||||
|
assert!(request.starts_with(expected_path));
|
||||||
|
let response_body = serde_json::json!({
|
||||||
|
"meta": {"resource_key": resource_key}
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
response_body.len(),
|
||||||
|
response_body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
let mut buffer = [0_u8; 8192];
|
let mut buffer = [0_u8; 8192];
|
||||||
let len = stream.read(&mut buffer).unwrap();
|
let len = stream.read(&mut buffer).unwrap();
|
||||||
@@ -1870,8 +1986,8 @@ provider = "github"
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
assert_eq!(removed.ticket_id, "01SOURCE");
|
assert_eq!(removed.ticket_id, "T-1");
|
||||||
assert_eq!(removed.target, "01TARGET");
|
assert_eq!(removed.target, "T-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ pub use manifest::{
|
|||||||
};
|
};
|
||||||
pub use model_client::{ProviderError, build_client};
|
pub use model_client::{ProviderError, build_client};
|
||||||
pub use prompt::catalog::{
|
pub use prompt::catalog::{
|
||||||
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
|
CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
|
||||||
prompt_schema_source,
|
OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
|
||||||
|
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
|
||||||
};
|
};
|
||||||
pub use prompt::source::PromptCatalogSource;
|
pub use prompt::source::PromptCatalogSource;
|
||||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
|
|||||||
@@ -141,8 +141,93 @@ impl WorkerPrompt {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum CatalogError {
|
pub enum CatalogError {
|
||||||
|
#[error("queued Ticket resource key is missing or invalid")]
|
||||||
|
InvalidQueueAttentionResourceKey,
|
||||||
#[error("failed to build builtin Prompt source tree: {0}")]
|
#[error("failed to build builtin Prompt source tree: {0}")]
|
||||||
BuiltinTree(String),
|
BuiltinTree(String),
|
||||||
#[error("failed to evaluate builtin Prompt source tree: {0}")]
|
#[error("failed to evaluate builtin Prompt source tree: {0}")]
|
||||||
@@ -319,6 +404,14 @@ impl PromptCatalog {
|
|||||||
self.render_name(key, Value::from_serialize(context))
|
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> {
|
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
|
||||||
let template = self
|
let template = self
|
||||||
.env
|
.env
|
||||||
@@ -653,6 +746,62 @@ mod tests {
|
|||||||
assert!(reviewer.contains("target-only movement does not invalidate approval"));
|
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]
|
#[test]
|
||||||
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
||||||
let invalid = BTreeMap::from([
|
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<
|
static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock<
|
||||||
worker_runtime::auth::RuntimeIdentityMaterial,
|
worker_runtime::auth::RuntimeIdentityMaterial,
|
||||||
> = std::sync::LazyLock::new(|| {
|
> = std::sync::LazyLock::new(|| {
|
||||||
@@ -7257,25 +7255,21 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let shown = queued
|
let attention_context = match orchestrator_queue_attention_context(
|
||||||
.iter()
|
&api.config.workspace_id,
|
||||||
.take(ORCHESTRATOR_ATTENTION_TICKET_LIMIT)
|
&api.config.workspace_id,
|
||||||
.map(|ticket| {
|
&queued,
|
||||||
format!(
|
) {
|
||||||
"- {} — {}",
|
Ok(context) => context,
|
||||||
bounded_orchestrator_attention_text(&ticket.id, 80),
|
Err(error) => {
|
||||||
bounded_orchestrator_attention_text(&ticket.title, 240)
|
tracing::warn!(
|
||||||
)
|
workspace_id = %api.config.workspace_id,
|
||||||
})
|
candidate_count = queued.len(),
|
||||||
.collect::<Vec<_>>()
|
diagnostic = error,
|
||||||
.join("\n");
|
"orchestrator backlog attention projection rejected"
|
||||||
let omitted = queued
|
);
|
||||||
.len()
|
return;
|
||||||
.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 Ok(Some(config_state)) = api
|
let Ok(Some(config_state)) = api
|
||||||
.config_store
|
.config_store
|
||||||
@@ -7292,16 +7286,19 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else {
|
let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = match catalog.render_serializable(
|
let content = match catalog.orchestrator_queue_attention(
|
||||||
ORCHESTRATOR_ATTENTION_PROMPT_NAME,
|
worker::OrchestratorQueueAttentionPrompt::Server,
|
||||||
&BTreeMap::from([
|
&attention_context,
|
||||||
("omitted_line", omitted_line.as_str()),
|
|
||||||
("workspace_id", api.config.workspace_id.as_str()),
|
|
||||||
("ticket_lines", shown.as_str()),
|
|
||||||
]),
|
|
||||||
) {
|
) {
|
||||||
Ok(content) => content,
|
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
|
let accepted = api
|
||||||
.runtime
|
.runtime
|
||||||
@@ -7321,20 +7318,26 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bounded_orchestrator_attention_text(input: &str, max_chars: usize) -> String {
|
fn orchestrator_queue_attention_context(
|
||||||
let mut output = String::new();
|
expected_workspace_id: &str,
|
||||||
for (index, character) in input.chars().enumerate() {
|
candidate_workspace_id: &str,
|
||||||
if index == max_chars {
|
tickets: &[ticket::TicketSummary],
|
||||||
output.push('…');
|
) -> std::result::Result<worker::OrchestratorQueueAttentionContext, &'static str> {
|
||||||
break;
|
if candidate_workspace_id != expected_workspace_id {
|
||||||
}
|
return Err("foreign_workspace_ticket_projection");
|
||||||
output.push(if character.is_control() {
|
|
||||||
' '
|
|
||||||
} else {
|
|
||||||
character
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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(
|
fn require_online_workspace_orchestrator_source(
|
||||||
@@ -19615,7 +19618,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() {
|
async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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 backend = browser_ticket_backend(&api).unwrap();
|
||||||
let mut input = ticket::NewTicket::new("Recover queued work");
|
let mut input = ticket::NewTicket::new("Recover queued work");
|
||||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
@@ -19634,6 +19638,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(started.online);
|
assert!(started.online);
|
||||||
|
let startup_inputs = execution.take_inputs();
|
||||||
|
assert_eq!(startup_inputs.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
api.orchestrator_attention_fingerprint
|
api.orchestrator_attention_fingerprint
|
||||||
.lock()
|
.lock()
|
||||||
@@ -19669,6 +19675,76 @@ mod tests {
|
|||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some(ticket_ref.id.as_str())
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
Workspace Orchestrator attention: authoritative Ticket state still contains queued work after the previous turn or after Server recovery.
|
Queued Tickets require attention:
|
||||||
|
{% for ticket in tickets -%}
|
||||||
Workspace: {{workspace_id}}
|
- {{ ticket.resource_key }} {{ separator }} {{ ticket.title }}
|
||||||
Remaining queued Tickets (bounded):
|
{% endfor -%}
|
||||||
{{ticket_lines}}
|
{% if omitted_ticket_count > 0 -%}
|
||||||
{{omitted_line}}
|
Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||||
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.
|
{% 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.
|
Queued Tickets require attention:
|
||||||
|
{% for ticket in tickets -%}
|
||||||
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.
|
- {{ ticket.resource_key }} {{ separator }} {{ ticket.title }}
|
||||||
|
|
||||||
Workspace: {{ workspace }}
|
|
||||||
|
|
||||||
Actionable queued Tickets:
|
|
||||||
{% for ticket in actionable_tickets -%}
|
|
||||||
- {{ ticket.id }} — {{ ticket.title }} [{{ ticket.classification }}]
|
|
||||||
{% endfor -%}
|
{% 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 -%}
|
{% 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 -%}
|
{% 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.
|
||||||
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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user