Compare commits
18
Commits
374449e663
...
7aa06afc45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa06afc45 | ||
|
|
1515a2fb86 | ||
|
|
ec798c58d7 | ||
|
|
e365189276 | ||
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
7edc588202 | ||
|
|
c83461508b | ||
|
|
4c876a201b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
d5c3a68a37 | ||
|
|
2b33b9158d | ||
|
|
b31642e284 | ||
|
|
3a7a3307ef | ||
|
|
975b4fa700 |
@@ -32,10 +32,10 @@ async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||
let output = Engine::new(client)
|
||||
.system_prompt("You are a concise assistant.")
|
||||
.run(&mut history, "Explain typed state in one sentence.")
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
let mut engine = output.engine;
|
||||
let _result = engine.run(&mut history, "Give a Rust example.").await?;
|
||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::{Engine, EngineResult, History};
|
||||
use agen::{Engine, EngineRunExit, StopReason};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -29,7 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let engine = Engine::new(client);
|
||||
let mut history = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
println!("🚀 Starting Engine...");
|
||||
println!("💡 Will cancel after 2 seconds\n");
|
||||
@@ -46,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("📡 Sending request to LLM...");
|
||||
|
||||
match engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||
Ok(out) => match out.result {
|
||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Task error: {}", e);
|
||||
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
|
||||
match output.result {
|
||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
println!("🔒 Turn limit reached")
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||
}
|
||||
|
||||
println!("\n✨ Demo complete!");
|
||||
|
||||
@@ -39,7 +39,7 @@ use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine, History,
|
||||
Engine, EngineRunExit, StopReason,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
@@ -451,6 +451,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Create Engine
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
@@ -474,16 +475,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
|
||||
|
||||
let mut history = History::new();
|
||||
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
match engine.run(&mut history, &prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let output = engine.run(&mut history, &prompt).await;
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
@@ -502,13 +498,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut locked = match engine.run(&mut history, first_input).await {
|
||||
Ok(out) => out.engine,
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let output = engine.run(&mut history, first_input).await;
|
||||
let mut locked = output.engine;
|
||||
|
||||
loop {
|
||||
print!("\n👤 You: ");
|
||||
@@ -527,11 +518,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
break;
|
||||
}
|
||||
|
||||
match locked.run(&mut history, input).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
}
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
||||
locked.run(&mut history, input).await
|
||||
{
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-84
@@ -70,24 +70,50 @@ pub struct EngineConfig {
|
||||
_private: (),
|
||||
}
|
||||
|
||||
/// Engine execution result (status)
|
||||
/// Legacy serializable outcome used by the Worker session-log compatibility boundary.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EngineResult {
|
||||
/// Completed (waiting for user input)
|
||||
Finished,
|
||||
/// Paused (can be resumed)
|
||||
Paused,
|
||||
/// Turn limit reached (max_turns exceeded)
|
||||
LimitReached,
|
||||
/// Yielded to caller for external processing (e.g. context compaction).
|
||||
///
|
||||
/// Distinct from `Paused`: internal machinery, not user-facing. The
|
||||
/// caller is expected to perform some side work and then call `resume()`
|
||||
/// to continue the turn loop.
|
||||
Yielded,
|
||||
}
|
||||
|
||||
/// The public termination boundary for one logical engine run.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineRunExit {
|
||||
Finished,
|
||||
Paused,
|
||||
Yielded,
|
||||
Interrupted(StopReason),
|
||||
}
|
||||
|
||||
/// A typed reason why an engine run could not finish normally.
|
||||
#[derive(Debug)]
|
||||
pub enum StopReason {
|
||||
LimitReached,
|
||||
ContextWindowExceeded,
|
||||
Cancelled,
|
||||
Unexpected(EngineError),
|
||||
}
|
||||
|
||||
impl From<Result<EngineResult, EngineError>> for EngineRunExit {
|
||||
fn from(result: Result<EngineResult, EngineError>) -> Self {
|
||||
match result {
|
||||
Ok(EngineResult::Finished) => Self::Finished,
|
||||
Ok(EngineResult::Paused) => Self::Paused,
|
||||
Ok(EngineResult::Yielded) => Self::Yielded,
|
||||
Ok(EngineResult::LimitReached) => Self::Interrupted(StopReason::LimitReached),
|
||||
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
|
||||
Self::Interrupted(StopReason::ContextWindowExceeded)
|
||||
}
|
||||
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
|
||||
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`Engine::run`] or [`Engine::resume`].
|
||||
///
|
||||
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
||||
@@ -95,7 +121,7 @@ pub struct EngineRunOutput<C: LlmClient, A = ()> {
|
||||
/// The Engine, now in Locked state.
|
||||
pub engine: Engine<C, Locked, A>,
|
||||
/// Outcome of the turn.
|
||||
pub result: EngineResult,
|
||||
pub result: EngineRunExit,
|
||||
}
|
||||
|
||||
/// Internal: tool execution result
|
||||
@@ -237,8 +263,6 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
|
||||
/// Request configuration (max_tokens, temperature, etc.)
|
||||
request_config: RequestConfig,
|
||||
/// Whether the previous run was interrupted
|
||||
last_run_interrupted: bool,
|
||||
/// Cancel notification channel (for interrupting execution)
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
cancel_rx: mpsc::Receiver<()>,
|
||||
@@ -270,10 +294,6 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
}
|
||||
|
||||
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
fn reset_interruption_state(&mut self) {
|
||||
self.last_run_interrupted = false;
|
||||
}
|
||||
|
||||
fn start_logical_run(&mut self) {
|
||||
self.active_run_turn_count = Some(0);
|
||||
}
|
||||
@@ -809,11 +829,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
self.try_cancelled()
|
||||
}
|
||||
|
||||
/// Whether the previous run was interrupted
|
||||
pub fn last_run_interrupted(&self) -> bool {
|
||||
self.last_run_interrupted
|
||||
}
|
||||
|
||||
/// Generate list of ToolDefinitions for LLM from registered tools
|
||||
fn build_tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tool_server.tool_definitions_sorted()
|
||||
@@ -906,7 +921,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
self.last_run_interrupted = true;
|
||||
let reason = match &err {
|
||||
EngineError::Aborted(reason) => reason.clone(),
|
||||
EngineError::Cancelled => "Cancelled".to_string(),
|
||||
@@ -1004,11 +1018,9 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
continue;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreToolAction::Pause => {
|
||||
self.last_run_interrupted = true;
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1073,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
info!("Tool execution cancelled");
|
||||
}
|
||||
self.timeline.abort_current_block();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
};
|
||||
@@ -1083,7 +1094,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
match self.interceptor.post_tool_call(&mut info).await {
|
||||
PostToolAction::Continue => {}
|
||||
PostToolAction::Abort(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
}
|
||||
@@ -1141,7 +1151,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
history: &mut History<A>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
self.reset_interruption_state();
|
||||
let tool_definitions = self.build_tool_definitions();
|
||||
|
||||
info!(
|
||||
@@ -1167,7 +1176,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
if self.try_cancelled() {
|
||||
info!("Execution cancelled");
|
||||
self.timeline.abort_current_block();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
@@ -1180,7 +1188,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
max_turns = max,
|
||||
"Logical run turn limit reached"
|
||||
);
|
||||
self.last_run_interrupted = false;
|
||||
return Ok(EngineResult::LimitReached);
|
||||
}
|
||||
|
||||
@@ -1274,7 +1281,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
for cb in &self.turn_end_cbs {
|
||||
cb(current_turn);
|
||||
}
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreRequestAction::YieldWith(items) => {
|
||||
@@ -1284,7 +1290,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
for cb in &self.turn_end_cbs {
|
||||
cb(current_turn);
|
||||
}
|
||||
self.last_run_interrupted = true;
|
||||
return Ok(EngineResult::Yielded);
|
||||
}
|
||||
PreRequestAction::Yield => {
|
||||
@@ -1292,7 +1297,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
for cb in &self.turn_end_cbs {
|
||||
cb(current_turn);
|
||||
}
|
||||
self.last_run_interrupted = true;
|
||||
return Ok(EngineResult::Yielded);
|
||||
}
|
||||
PreRequestAction::ContinueWith(items) => {
|
||||
@@ -1337,7 +1341,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
if let StreamCompletion::Interrupted { reason } = stream_outcome {
|
||||
stream_continuations += 1;
|
||||
if stream_continuations > MAX_STREAM_CONTINUATIONS {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Client(ClientError::Api {
|
||||
status: None,
|
||||
code: None,
|
||||
@@ -1390,7 +1393,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
let turn_end_context = history.items_cloned();
|
||||
match self.interceptor.on_turn_end(&turn_end_context).await {
|
||||
TurnEndAction::Finish => {
|
||||
self.last_run_interrupted = false;
|
||||
return Ok(EngineResult::Finished);
|
||||
}
|
||||
TurnEndAction::ContinueWithMessages(additional) => {
|
||||
@@ -1398,7 +1400,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
continue;
|
||||
}
|
||||
TurnEndAction::Pause => {
|
||||
self.last_run_interrupted = true;
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
}
|
||||
@@ -1451,7 +1452,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}),
|
||||
);
|
||||
self.timeline.abort_current_block();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
};
|
||||
@@ -1483,7 +1483,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}),
|
||||
);
|
||||
self.timeline.abort_current_block();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
};
|
||||
@@ -1525,7 +1524,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
|
||||
let next_failed_attempt = failed_attempt + 1;
|
||||
if next_failed_attempt >= policy.max_attempts || !is_retryable(&err) {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Client(err));
|
||||
}
|
||||
|
||||
@@ -1534,7 +1532,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
.unwrap_or_else(|| policy.backoff(failed_attempt));
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed + wait > policy.total_timeout {
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Client(err));
|
||||
}
|
||||
|
||||
@@ -1563,7 +1560,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
info!("Cancelled during LLM retry backoff");
|
||||
}
|
||||
self.timeline.abort_current_block();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -1606,7 +1602,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
let event = match result {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
self.last_run_interrupted = true;
|
||||
// 部分情報でも発火しておく(料金会計用)
|
||||
self.timeline.flush_usage();
|
||||
return Ok(StreamCompletion::Interrupted {
|
||||
@@ -1627,7 +1622,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
if let Event::Error(err) = &event {
|
||||
self.timeline.abort_current_block();
|
||||
self.timeline.flush_usage();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Client(ClientError::Api {
|
||||
status: None,
|
||||
code: err.code.clone(),
|
||||
@@ -1645,7 +1639,6 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}
|
||||
self.timeline.abort_current_block();
|
||||
self.timeline.flush_usage();
|
||||
self.last_run_interrupted = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -1666,10 +1659,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
tool_calls: Vec<ToolCall>,
|
||||
) -> Result<Option<EngineResult>, EngineError> {
|
||||
match self.execute_tools(tool_calls).await {
|
||||
Ok(ToolExecutionResult::Paused) => {
|
||||
self.last_run_interrupted = true;
|
||||
Ok(Some(EngineResult::Paused))
|
||||
}
|
||||
Ok(ToolExecutionResult::Paused) => Ok(Some(EngineResult::Paused)),
|
||||
Ok(ToolExecutionResult::Completed(results)) => {
|
||||
// Route per-result pushes through the callback path so
|
||||
// observers see each tool result as it lands.
|
||||
@@ -1685,10 +1675,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
self.append_history_items(history, items, annotate)?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
self.last_run_interrupted = true;
|
||||
Err(err)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1735,7 +1722,6 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
tool_result_cbs: Vec::new(),
|
||||
history_append_cbs: Vec::new(),
|
||||
request_config: RequestConfig::default(),
|
||||
last_run_interrupted: false,
|
||||
cancel_tx,
|
||||
cancel_rx,
|
||||
tool_output_limits: None,
|
||||
@@ -1921,14 +1907,6 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
self.max_turns = max_turns;
|
||||
}
|
||||
|
||||
/// Set the last_run_interrupted flag (for session restoration)
|
||||
pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
|
||||
self.last_run_interrupted = interrupted;
|
||||
if !interrupted {
|
||||
self.active_run_turn_count = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply configuration (reserved for future extensions)
|
||||
#[allow(dead_code)]
|
||||
pub fn config(self, _config: EngineConfig) -> Self {
|
||||
@@ -1945,15 +1923,15 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
history: &mut History<A>,
|
||||
user_input: impl Into<String>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineRunOutput<C, A>, EngineError> {
|
||||
) -> EngineRunOutput<C, A> {
|
||||
let mut locked = self.lock(history);
|
||||
let result = locked
|
||||
.run_with_annotation(history, user_input, annotate)
|
||||
.await?;
|
||||
Ok(EngineRunOutput {
|
||||
.await;
|
||||
EngineRunOutput {
|
||||
engine: locked,
|
||||
result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume from Paused, consuming self and transitioning to Locked.
|
||||
@@ -1963,13 +1941,13 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
self,
|
||||
history: &mut History<A>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineRunOutput<C, A>, EngineError> {
|
||||
) -> EngineRunOutput<C, A> {
|
||||
let mut locked = self.lock(history);
|
||||
let result = locked.resume_with_annotation(history, annotate).await?;
|
||||
Ok(EngineRunOutput {
|
||||
let result = locked.resume_with_annotation(history, annotate).await;
|
||||
EngineRunOutput {
|
||||
engine: locked,
|
||||
result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock and transition to Locked state
|
||||
@@ -2016,7 +1994,6 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
@@ -2062,17 +2039,14 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
|
||||
self,
|
||||
history: &mut History<()>,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<EngineRunOutput<C>, EngineError> {
|
||||
) -> EngineRunOutput<C> {
|
||||
let mut annotate = unit_history_annotation;
|
||||
self.run_with_annotation(history, user_input, &mut annotate)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resume using unit annotations.
|
||||
pub async fn resume(
|
||||
self,
|
||||
history: &mut History<()>,
|
||||
) -> Result<EngineRunOutput<C>, EngineError> {
|
||||
pub async fn resume(self, history: &mut History<()>) -> EngineRunOutput<C> {
|
||||
let mut annotate = unit_history_annotation;
|
||||
self.resume_with_annotation(history, &mut annotate).await
|
||||
}
|
||||
@@ -2088,15 +2062,23 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
history: &mut History<A>,
|
||||
user_input: impl Into<String>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> EngineRunExit {
|
||||
self.run_result_with_annotation(history, user_input.into(), annotate)
|
||||
.await
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn run_result_with_annotation(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
user_input: String,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
// Supplying new user input abandons any paused/yielded logical run.
|
||||
self.active_run_turn_count = None;
|
||||
self.reset_interruption_state();
|
||||
// Interceptor: on_prompt_submit
|
||||
let mut user_item = Item::user_message(user_input);
|
||||
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
|
||||
PromptAction::Cancel(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
return self
|
||||
.finalize_interruption(Err(EngineError::Aborted(reason)))
|
||||
.await;
|
||||
@@ -2115,15 +2097,22 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Resume execution (from Paused state)
|
||||
///
|
||||
/// Resumes turn processing from current state without adding a new user message.
|
||||
/// Resume execution (from Paused state).
|
||||
pub async fn resume_with_annotation(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> EngineRunExit {
|
||||
self.resume_result_with_annotation(history, annotate)
|
||||
.await
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn resume_result_with_annotation(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
self.reset_interruption_state();
|
||||
self.ensure_logical_run();
|
||||
let result = self.run_turn_loop(history, annotate).await;
|
||||
let result = self.finalize_interruption(result).await;
|
||||
@@ -2169,7 +2158,6 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
@@ -2191,14 +2179,14 @@ impl<C: LlmClient> Engine<C, Locked, ()> {
|
||||
&mut self,
|
||||
history: &mut History<()>,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
) -> EngineRunExit {
|
||||
let mut annotate = unit_history_annotation;
|
||||
self.run_with_annotation(history, user_input, &mut annotate)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resume using unit annotations.
|
||||
pub async fn resume(&mut self, history: &mut History<()>) -> Result<EngineResult, EngineError> {
|
||||
pub async fn resume(&mut self, history: &mut History<()>) -> EngineRunExit {
|
||||
let mut annotate = unit_history_annotation;
|
||||
self.resume_with_annotation(history, &mut annotate).await
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ pub mod usage_record;
|
||||
pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||
ToolRegistryError,
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
|
||||
@@ -18,6 +18,9 @@ pub enum ClientError {
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// The provider rejected the request because it exceeded the model context window.
|
||||
/// Classified only from a structured provider error code, never message text.
|
||||
ContextWindowExceeded,
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
|
||||
}
|
||||
write!(f, ": {}", message)
|
||||
}
|
||||
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
|
||||
ClientError::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
ClientError::ContextWindowExceeded
|
||||
| ClientError::Json(_)
|
||||
| ClientError::Sse(_)
|
||||
| ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
matches!(error, ClientError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text)
|
||||
.to_string();
|
||||
if code.as_deref() == Some("context_length_exceeded") {
|
||||
return ClientError::ContextWindowExceeded;
|
||||
}
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
|
||||
@@ -38,10 +38,9 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
|
||||
assert!(matches!(output.result, agen::EngineResult::Finished));
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history.entries()[0].annotation, "1:user");
|
||||
assert_eq!(history.entries()[1].annotation, "2:assistant");
|
||||
|
||||
@@ -8,11 +8,11 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||
use agen::llm_client::retry::RetryPolicy;
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, History};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn test_callback_llm_retry_event() {
|
||||
max_attempts: 2,
|
||||
total_timeout: Duration::from_secs(1),
|
||||
});
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = notices.clone();
|
||||
@@ -67,7 +67,10 @@ async fn test_callback_llm_retry_event() {
|
||||
});
|
||||
|
||||
let result = engine.run(&mut history, "retry once").await;
|
||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"engine should succeed after one retry"
|
||||
);
|
||||
|
||||
let notices = notices.lock().unwrap();
|
||||
assert_eq!(notices.len(), 1);
|
||||
@@ -92,7 +95,7 @@ async fn test_callback_text_block_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -110,9 +113,12 @@ async fn test_callback_text_block_events() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
let deltas = text_deltas.lock().unwrap();
|
||||
assert_eq!(deltas.len(), 2);
|
||||
@@ -139,7 +145,7 @@ async fn test_callback_tool_call_complete() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -157,7 +163,7 @@ async fn test_callback_tool_call_complete() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Weather please").await;
|
||||
|
||||
let starts = tool_starts.lock().unwrap();
|
||||
@@ -186,7 +192,7 @@ async fn test_callback_turn_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -201,9 +207,9 @@ async fn test_callback_turn_events() {
|
||||
ends.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Do something").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
||||
|
||||
let starts = turn_starts.lock().unwrap();
|
||||
let ends = turn_ends.lock().unwrap();
|
||||
@@ -258,7 +264,7 @@ async fn test_callback_tool_result_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
@@ -335,7 +341,7 @@ async fn test_callback_tool_result_error_path() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
@@ -380,7 +386,7 @@ async fn test_callback_usage_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -389,7 +395,7 @@ async fn test_callback_usage_events() {
|
||||
usages.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Hello").await;
|
||||
|
||||
let usages = usage_events.lock().unwrap();
|
||||
|
||||
@@ -9,8 +9,8 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use agen::Engine;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, History};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -134,12 +134,15 @@ async fn test_engine_simple_text_response() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
||||
let result = engine.run(&mut history, "Hello").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that Engine can correctly process responses containing tool calls
|
||||
@@ -157,7 +160,7 @@ async fn test_engine_tool_call() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Register tool
|
||||
let weather_tool = MockWeatherTool::new();
|
||||
@@ -199,12 +202,15 @@ async fn test_engine_with_programmatic_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Mutable::run consumes self, returns tuple
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
||||
|
||||
@@ -14,7 +14,7 @@ use agen::interceptor::{
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError, EngineResult, History};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -202,10 +202,10 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
});
|
||||
|
||||
let mut engine = engine.lock(&history);
|
||||
let error = engine.run(&mut history, "use the tool").await.unwrap_err();
|
||||
let exit = engine.run(&mut history, "use the tool").await;
|
||||
|
||||
assert!(
|
||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(history.len(), 1);
|
||||
@@ -284,7 +284,7 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||
let _out = engine.run(&mut history, "Hi there").await?;
|
||||
let _out = engine.run(&mut history, "Hi there").await;
|
||||
|
||||
// History is updated
|
||||
let entries = history.entries();
|
||||
@@ -333,12 +333,12 @@ async fn test_locked_multi_turn_history_accumulation() {
|
||||
|
||||
// Turn 1
|
||||
let result1 = locked_engine.run(&mut history, "Hello!").await;
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// Turn 2
|
||||
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 4); // 2 * (user + assistant)
|
||||
|
||||
// Verify history contents
|
||||
@@ -403,10 +403,7 @@ async fn test_locked_prefix_len_tracking() {
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||
|
||||
// Execute turn
|
||||
locked_engine
|
||||
.run(&mut history, "New message")
|
||||
.await
|
||||
.unwrap();
|
||||
locked_engine.run(&mut history, "New message").await;
|
||||
|
||||
// History grows but locked_prefix_len remains unchanged
|
||||
assert_eq!(history.len(), 4); // 2 + 2
|
||||
@@ -442,13 +439,16 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
// First run consumes Mutable, returns EngineRunOutput
|
||||
let mut engine = engine.run(&mut history, "First").await?.engine;
|
||||
let mut engine = engine.run(&mut history, "First").await.engine;
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
|
||||
// Subsequent runs on Locked take &mut self
|
||||
engine.run(&mut history, "Second").await?;
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "Second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.llm_call_count(), 2);
|
||||
|
||||
@@ -538,7 +538,10 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
engine.register_tool(tool_a.definition());
|
||||
|
||||
let mut locked = engine.lock(&history);
|
||||
locked.run(&mut history, "first").await.expect("first run");
|
||||
assert!(matches!(
|
||||
locked.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
@@ -546,10 +549,10 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
unlocked.register_tool(tool_b.definition());
|
||||
|
||||
let mut relocked = unlocked.lock(&history);
|
||||
relocked
|
||||
.run(&mut history, "second")
|
||||
.await
|
||||
.expect("second run");
|
||||
assert!(matches!(
|
||||
relocked.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
||||
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||
@@ -651,54 +654,55 @@ impl Interceptor for ContinueTurnOnce {
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_turns_is_scoped_to_each_fresh_run() {
|
||||
let mut history: History = History::new();
|
||||
let responses = vec![completed_text_events(), completed_text_events()];
|
||||
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "first").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "second").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(YieldOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "start").await.unwrap(),
|
||||
EngineResult::Yielded
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
@@ -709,7 +713,6 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
];
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
@@ -717,18 +720,18 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "call it").await.unwrap(),
|
||||
EngineResult::Paused
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "call it").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
|
||||
@@ -736,6 +739,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let mut history: History = History::new();
|
||||
let tool_events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
@@ -747,7 +751,6 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
@@ -755,16 +758,16 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "pause").await.unwrap(),
|
||||
EngineResult::Paused
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "replace").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "replace").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
|
||||
@@ -772,18 +775,18 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(ContinueTurnOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "start").await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
@@ -791,18 +794,17 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_turn_count(7);
|
||||
engine.set_last_run_interrupted(true);
|
||||
engine.set_active_run_turn_count(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 7);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
@@ -513,7 +513,10 @@ async fn test_post_tool_call_modification() {
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run(&mut history, "Test modification").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
// Verify hook was called and content was modified
|
||||
let content = modified_content.lock().unwrap().clone();
|
||||
@@ -567,10 +570,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
|
||||
engine.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let _result = engine
|
||||
.run(&mut history, "Test synthetic result")
|
||||
.await
|
||||
.unwrap();
|
||||
let _result = engine.run(&mut history, "Test synthetic result").await;
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(history.items().any(|item| matches!(
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "question?").await.expect("run ok");
|
||||
let _out = engine.run(&mut history, "question?").await;
|
||||
|
||||
let entries = history.entries();
|
||||
// user / reasoning / assistant_message
|
||||
@@ -110,7 +110,7 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await.expect("run ok");
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let entries = history.entries();
|
||||
match &entries[1].item {
|
||||
@@ -156,7 +156,7 @@ async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await.expect("run ok");
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let entries = history.entries();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
@@ -218,7 +218,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
],
|
||||
);
|
||||
|
||||
let _ = engine.run(&mut history, "follow up").await.expect("run ok");
|
||||
let _ = engine.run(&mut history, "follow up").await;
|
||||
|
||||
let req = captured
|
||||
.lock()
|
||||
|
||||
@@ -923,6 +923,7 @@ pub enum WorkerStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn run_and_persist(
|
||||
session_id: session_store::SessionId,
|
||||
segment_id: session_store::SegmentId,
|
||||
input: &str,
|
||||
) -> (TestWorker, agen::EngineResult) {
|
||||
) -> (TestWorker, agen::EngineRunExit) {
|
||||
// Mirror Worker's run-entry contract: log the user input as segments
|
||||
// before the worker pushes its flattened user_message; save_delta
|
||||
// skips the resulting user_message item to avoid double-write.
|
||||
@@ -159,31 +159,49 @@ async fn run_and_persist(
|
||||
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
||||
|
||||
match &result {
|
||||
Ok(r) => {
|
||||
agen::EngineRunExit::Finished
|
||||
| agen::EngineRunExit::Paused
|
||||
| agen::EngineRunExit::Yielded => {
|
||||
let (legacy_result, interrupted) = match &result {
|
||||
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
|
||||
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
|
||||
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
|
||||
agen::EngineRunExit::Interrupted(_) => unreachable!(),
|
||||
};
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
r.clone(),
|
||||
worker.last_run_interrupted(),
|
||||
legacy_result,
|
||||
interrupted,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
agen::EngineResult::LimitReached,
|
||||
false,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
agen::EngineRunExit::Interrupted(reason) => {
|
||||
session_store::save_run_errored(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
e.to_string(),
|
||||
worker.last_run_interrupted(),
|
||||
format!("{reason:?}"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let r = result.unwrap();
|
||||
(worker, r)
|
||||
(worker, result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -326,7 +344,7 @@ async fn session_resume_after_pause() {
|
||||
.unwrap();
|
||||
|
||||
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
|
||||
assert!(matches!(result, agen::EngineResult::Paused));
|
||||
assert!(matches!(result, agen::EngineRunExit::Paused));
|
||||
|
||||
// Check RunCompleted is Paused
|
||||
let entries = store.read_all(sid, segid).unwrap();
|
||||
|
||||
+150
-16
@@ -410,7 +410,7 @@ struct TicketCreateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketEditItemParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional replacement title.
|
||||
#[serde(default)]
|
||||
@@ -539,7 +539,7 @@ impl QueryTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ShowTicketParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
|
||||
@@ -558,7 +558,7 @@ struct ShowTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketThreadEventParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown event body.
|
||||
body: String,
|
||||
@@ -566,7 +566,7 @@ struct TicketThreadEventParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
@@ -575,7 +575,7 @@ struct TicketMarkReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
intake_summary: String,
|
||||
@@ -586,13 +586,13 @@ struct TicketIntakeReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Expected current state. The backend rejects stale transitions.
|
||||
from: TicketWorkflowStateParam,
|
||||
@@ -606,7 +606,7 @@ struct TicketWorkflowStateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCloseParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown resolution written to resolution.md and thread.md.
|
||||
resolution: String,
|
||||
@@ -614,7 +614,7 @@ struct TicketCloseParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketDependencyCheckParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
@@ -646,7 +646,7 @@ struct TicketRelationRecordParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
/// Optional bounded rationale/note.
|
||||
#[serde(default)]
|
||||
@@ -659,7 +659,7 @@ struct TicketRelationRemoveParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind to remove.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
}
|
||||
|
||||
@@ -1223,10 +1223,17 @@ impl Tool for TicketQueueTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = default_author();
|
||||
let outcome = self
|
||||
let mut outcome = self
|
||||
.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
outcome.requested_ticket =
|
||||
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
|
||||
outcome.queued_tickets = outcome
|
||||
.queued_tickets
|
||||
.into_iter()
|
||||
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Queued {} ticket(s) for Orchestrator",
|
||||
@@ -1264,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
let ticket_ref =
|
||||
model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Transitioned ticket {} state {} -> {}",
|
||||
params.ticket,
|
||||
ticket_ref,
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
json!({
|
||||
"ticket": params.ticket,
|
||||
"ticket": ticket_ref,
|
||||
"from": from.as_str(),
|
||||
"to": to.as_str(),
|
||||
"state": to.as_str(),
|
||||
@@ -1296,9 +1305,10 @@ impl Tool for TicketCloseTool {
|
||||
MarkdownText::new(params.resolution),
|
||||
)
|
||||
.map_err(|error| backend_error("TicketClose", error))?;
|
||||
let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?;
|
||||
Ok(json_output(
|
||||
format!("Closed ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
|
||||
format!("Closed ticket {ticket_ref}"),
|
||||
json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1525,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_ticket_reference(
|
||||
backend: &TicketToolBackend,
|
||||
reference: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<String, ToolError> {
|
||||
let ticket = backend
|
||||
.show(TicketIdOrSlug::Id(reference.to_string()))
|
||||
.map_err(|error| backend_error(tool_name, error))?;
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||
"{tool_name} failed: required Ticket human key is unavailable"
|
||||
))),
|
||||
None => Ok(ticket.meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
@@ -1922,6 +1955,12 @@ mod tests {
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
|
||||
.unwrap()
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = definition();
|
||||
tool
|
||||
@@ -2549,6 +2588,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let inner = sqlite_backend(&temp);
|
||||
let mut dependency_input = NewTicket::new("Dependency");
|
||||
dependency_input.repository_id = Some("main".to_string());
|
||||
let dependency = inner.create(dependency_input).unwrap();
|
||||
let mut target_input = NewTicket::new("Target");
|
||||
target_input.repository_id = Some("main".to_string());
|
||||
let target = inner.create(target_input).unwrap();
|
||||
inner
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(target.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: dependency.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for id in [&dependency.id, &target.id] {
|
||||
inner
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ready-{id}"),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let target_key = target.resource_key.clone().unwrap();
|
||||
let dependency_key = dependency.resource_key.clone().unwrap();
|
||||
let backend = inner;
|
||||
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
|
||||
let workflow = tool_by_name(
|
||||
TicketToolBackend::new(backend.clone()),
|
||||
"TicketWorkflowState",
|
||||
);
|
||||
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
|
||||
|
||||
let queued = queue
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone()}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queued.summary.contains("2 ticket(s)"));
|
||||
let queued_content = queued.content.unwrap();
|
||||
assert!(queued_content.contains(&target_key));
|
||||
assert!(queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&target.id));
|
||||
assert!(!queued_content.contains(&dependency.id));
|
||||
|
||||
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
|
||||
let transitioned = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": target.id.clone(),
|
||||
"from": from,
|
||||
"to": to,
|
||||
"reason": "test_transition",
|
||||
"body": "transitioned",
|
||||
"author": "tester"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transitioned.summary.contains(&target_key));
|
||||
assert!(!transitioned.summary.contains(&target.id));
|
||||
let content = transitioned.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
let closed = close
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(closed.summary.contains(&target_key));
|
||||
assert!(!closed.summary.contains(&target.id));
|
||||
let content = closed.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1016,7 +1016,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
app.clear_queued_inputs();
|
||||
Some(Method::Cancel)
|
||||
}
|
||||
WorkerStatus::Idle => Some(Method::Shutdown),
|
||||
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
|
||||
}),
|
||||
KeyCode::Char('d') if ctrl => {
|
||||
app.quit = true;
|
||||
|
||||
@@ -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);
|
||||
@@ -911,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>,
|
||||
@@ -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)]
|
||||
struct PanelRowHitBox {
|
||||
rect: Rect,
|
||||
@@ -1326,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()
|
||||
@@ -3661,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,
|
||||
@@ -3744,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(
|
||||
@@ -5236,6 +5200,7 @@ fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) {
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)),
|
||||
None => ("live", Style::default().fg(Color::DarkGray)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2972,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",
|
||||
@@ -2992,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]
|
||||
@@ -3086,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
|
||||
@@ -3141,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]
|
||||
|
||||
@@ -1530,6 +1530,7 @@ fn worker_status_label(entry: &WorkerListEntry) -> &'static str {
|
||||
Some(WorkerStatus::Idle) => "live idle",
|
||||
Some(WorkerStatus::Running) => "live running",
|
||||
Some(WorkerStatus::Paused) => "live paused",
|
||||
Some(WorkerStatus::Stopped) => "live stopped",
|
||||
None => "live",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2742,6 +2742,7 @@ impl RuntimeState {
|
||||
protocol::WorkerStatus::Running => Some(WorkerStatus::Running),
|
||||
protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle),
|
||||
protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused),
|
||||
protocol::WorkerStatus::Stopped => Some(WorkerStatus::Stopped),
|
||||
},
|
||||
protocol::Event::RunEnd { result } => match result {
|
||||
protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
|
||||
@@ -3104,7 +3105,7 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-b", None),
|
||||
protocol::WorkerStatus::Idle,
|
||||
protocol::WorkerStatus::Stopped,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1546,7 +1546,9 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
|
||||
match status {
|
||||
WorkerStatus::Running => WorkerExecutionRunState::Busy,
|
||||
WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy,
|
||||
WorkerStatus::Idle | WorkerStatus::Paused => WorkerExecutionRunState::Idle,
|
||||
WorkerStatus::Idle | WorkerStatus::Paused | WorkerStatus::Stopped => {
|
||||
WorkerExecutionRunState::Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
WorkerRunResult::Finished => println!("(finished)"),
|
||||
WorkerRunResult::Paused => println!("(paused)"),
|
||||
WorkerRunResult::LimitReached => println!("(turn limit reached)"),
|
||||
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
|
||||
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1354,7 +1354,7 @@ async fn controller_loop<C, St>(
|
||||
});
|
||||
}
|
||||
},
|
||||
WorkerStatus::Idle => {
|
||||
WorkerStatus::Idle | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::NotRunning,
|
||||
message: "Worker is not running".into(),
|
||||
@@ -1395,7 +1395,7 @@ async fn controller_loop<C, St>(
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message:
|
||||
@@ -1409,7 +1409,7 @@ async fn controller_loop<C, St>(
|
||||
WorkerStatus::Idle | WorkerStatus::Paused => {
|
||||
emit_rewind_targets(&worker, &event_tx)
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
@@ -1438,7 +1438,7 @@ async fn controller_loop<C, St>(
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
@@ -1650,6 +1650,26 @@ where
|
||||
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
|
||||
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
||||
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
|
||||
WorkerRunResult::Interrupted { .. } if pause_requested => {
|
||||
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
||||
return (WorkerStatus::Paused, shutdown_requested);
|
||||
}
|
||||
WorkerRunResult::Interrupted { code, message } => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code,
|
||||
message: message.clone(),
|
||||
});
|
||||
if parent_originated {
|
||||
crate::ipc::event::fire_and_forget(
|
||||
parent_socket.cloned(),
|
||||
protocol::WorkerEvent::Errored {
|
||||
worker_name: self_name.to_string(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
return (WorkerStatus::Idle, shutdown_requested);
|
||||
}
|
||||
};
|
||||
let _ = event_tx.send(Event::RunEnd { result: run_result });
|
||||
if parent_originated && matches!(run_result, RunResult::Finished) {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod memory_extract;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
mod resource_projection;
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
@@ -14,6 +14,8 @@ use serde_json::json;
|
||||
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
use super::resource_projection::{project_objective_detail, project_objective_query};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpObjectiveBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
|
||||
Ok(ToolOutput {
|
||||
summary: "Queried Objectives".to_string(),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
@@ -58,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
|
||||
let objective_ref = response.objective_ref().to_string();
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Read objective {id}"),
|
||||
summary: format!("Read objective {objective_ref}"),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -84,7 +89,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Created objective {}", response.id),
|
||||
format!("Created objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -112,7 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Edited objective {}", response.id),
|
||||
format!("Edited objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -134,7 +139,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Updated objective {} state", response.id),
|
||||
format!("Updated objective {} state", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.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 response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Linked ticket {ticket_id} to objective {}", response.id),
|
||||
format!(
|
||||
"Linked ticket {ticket_resource_key} to objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -165,16 +174,46 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.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 response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
|
||||
format!(
|
||||
"Unlinked ticket {ticket_resource_key} from objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
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 {
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
format!("/api/w/{workspace_id}/objectives/{id}")
|
||||
@@ -185,7 +224,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
pub enum WorkspaceObjectiveBackendError {
|
||||
#[error("workspace objective backend request failed: {0}")]
|
||||
Request(#[from] crate::worker::WorkspaceClientError),
|
||||
#[error("workspace objective backend returned HTTP {status}: {body}")]
|
||||
#[error("workspace objective backend returned HTTP {status}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
@@ -247,10 +286,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
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> {
|
||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
let projected = serde_json::json!({
|
||||
"objective": &response.resource_key,
|
||||
"title": response.title,
|
||||
"state": response.state,
|
||||
});
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
|
||||
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -260,7 +315,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.contains('/') {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{tool_name} requires non-empty canonical id without '/'"
|
||||
"{tool_name} requires a non-empty Objective reference without '/'"
|
||||
)));
|
||||
}
|
||||
Ok(id)
|
||||
@@ -411,9 +466,9 @@ const EDIT_DESCRIPTION: &str =
|
||||
const SET_STATE_DESCRIPTION: &str =
|
||||
"Set an Objective state through Backend Workspace API authority.";
|
||||
const LINK_TICKET_DESCRIPTION: &str =
|
||||
"Link a Ticket id to an Objective through Backend Workspace API authority.";
|
||||
"Link a Ticket reference to an Objective through Backend Workspace API authority.";
|
||||
const UNLINK_TICKET_DESCRIPTION: &str =
|
||||
"Unlink a Ticket id from an Objective through Backend Workspace API authority.";
|
||||
"Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
|
||||
|
||||
fn list_schema() -> serde_json::Value {
|
||||
json!({
|
||||
@@ -422,7 +477,7 @@ fn list_schema() -> serde_json::Value {
|
||||
"properties":{
|
||||
"query":{"type":["string","null"]},
|
||||
"states":{"type":"array","items":{"type":"string"},"default":[]},
|
||||
"linked_ticket_id":{"type":["string","null"]},
|
||||
"linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
|
||||
"updated_after":{"type":["string","null"]},
|
||||
"updated_before":{"type":["string","null"]},
|
||||
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
|
||||
@@ -438,7 +493,7 @@ fn show_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
|
||||
"event_cursor":{"type":["string","null"]}
|
||||
}
|
||||
@@ -454,7 +509,7 @@ fn create_schema() -> serde_json::Value {
|
||||
"title":{"type":"string","minLength":1},
|
||||
"body_md":{"type":"string"},
|
||||
"state":{"type":"string","default":"active"},
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"}}
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -465,7 +520,7 @@ fn edit_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"title":{"type":["string","null"]},
|
||||
"old_string":{"type":["string","null"]},
|
||||
"new_string":{"type":["string","null"]},
|
||||
@@ -480,7 +535,7 @@ fn set_state_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id","state"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"state":{"type":"string","minLength":1}
|
||||
}
|
||||
})
|
||||
@@ -500,8 +555,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required": required,
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"ticket_id":{"type":"string"}
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -595,21 +650,20 @@ fn default_state() -> String {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveDetail {
|
||||
id: String,
|
||||
resource_key: String,
|
||||
title: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
record_source: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||
let mut names = definitions
|
||||
@@ -656,4 +710,135 @@ mod tests {
|
||||
let link = link_ticket_schema();
|
||||
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_show_summary_uses_projected_human_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
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/objectives/00001INTERNAL/show HTTP/1.1")
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
"events": [],
|
||||
"event_page": {"next_cursor": null, "has_more": false}
|
||||
})
|
||||
.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 backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
|
||||
let output = backend
|
||||
.show(ShowObjectiveInput {
|
||||
id: "00001INTERNAL".to_string(),
|
||||
event_limit: None,
|
||||
event_cursor: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(output.summary, "Read objective O-3");
|
||||
assert!(!output.summary.contains("00001INTERNAL"));
|
||||
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketQueryResponse {
|
||||
tickets: Vec<ModelTicketQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketQueryItem {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
workspace_action_priority: Option<String>,
|
||||
matched_fields: Vec<String>,
|
||||
snippet: Option<String>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
linked_objectives: Vec<String>,
|
||||
relation_count: usize,
|
||||
blocker_count: usize,
|
||||
unresolved_blocker_count: usize,
|
||||
unresolved_review_count: usize,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketDetail {
|
||||
ticket: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
thread: Vec<ModelTicketEvent>,
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
actions: Option<ModelTicketActions>,
|
||||
event_page: Option<ModelEventPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveQueryResponse {
|
||||
objectives: Vec<ModelObjectiveQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveQueryItem {
|
||||
objective: String,
|
||||
title: String,
|
||||
summary: Option<String>,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveDetail {
|
||||
objective: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<ModelTicketSummary>,
|
||||
events: Vec<ModelObjectiveEvent>,
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
impl ModelObjectiveDetail {
|
||||
pub(super) fn objective_ref(&self) -> &str {
|
||||
&self.objective
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct ModelTicketRelations {
|
||||
outgoing: Vec<ModelRelation>,
|
||||
incoming: Vec<ModelRelation>,
|
||||
blockers: Vec<ModelBlocker>,
|
||||
notices: Vec<ModelNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelRelation {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
note: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelBlocker {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
state: Option<String>,
|
||||
resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelNotice {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveSummary {
|
||||
objective: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketSummary {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelAssignment {
|
||||
role: String,
|
||||
principal: String,
|
||||
assigned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEvidenceEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
created_at: Option<String>,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelMergeRequest {
|
||||
state: String,
|
||||
selector_from: Option<String>,
|
||||
selector_to: String,
|
||||
review_status: String,
|
||||
subject_ref: Option<String>,
|
||||
review_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvidence {
|
||||
has_merge_request: bool,
|
||||
has_current_subject_ref: bool,
|
||||
has_review_request: bool,
|
||||
has_commit: bool,
|
||||
review_status: Option<String>,
|
||||
approved_current_subject: bool,
|
||||
unresolved_request_changes: bool,
|
||||
complete_for_integration: bool,
|
||||
missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketActions {
|
||||
can_assign_orchestrator: bool,
|
||||
can_unassign_orchestrator: bool,
|
||||
can_queue: bool,
|
||||
can_start_manual_coder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEvent {
|
||||
kind: String,
|
||||
created_at: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||
let root = object(&value, "Ticket query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
let tickets = array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_ticket_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelTicketQueryResponse {
|
||||
tickets,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||
let item = object(value, "Ticket query item")?;
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: human_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||
matched_fields: string_array(item, "matched_fields")?,
|
||||
snippet: optional_string(item, "snippet")?,
|
||||
current_coder: item
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?,
|
||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "O-"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relation_count: usize_field(item, "relation_count")?,
|
||||
blocker_count: usize_field(item, "blocker_count")?,
|
||||
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
||||
merge_request: item
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||
let root = object(&value, "Ticket detail response")?;
|
||||
let current_coder = root
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?;
|
||||
let assignments = array_field(root, "assignments")?
|
||||
.iter()
|
||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: human_ref(root, "resource_key", "T-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
thread: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_ticket_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relations: project_relations(root.get("relations"))?,
|
||||
linked_objectives: array_field(root, "linked_objectives")?
|
||||
.iter()
|
||||
.map(project_objective_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
assignments,
|
||||
current_coder,
|
||||
implementation_reports: array_field(root, "implementation_reports")?
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: root
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
||||
actions: root
|
||||
.get("action_eligibility")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_actions)
|
||||
.transpose()?,
|
||||
event_page: root
|
||||
.get("event_page")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_event_page)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
|
||||
let root = object(&value, "Objective query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
Ok(ModelObjectiveQueryResponse {
|
||||
objectives: array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_objective_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
|
||||
let item = object(value, "Objective query item")?;
|
||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "T-"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelObjectiveQueryItem {
|
||||
objective: human_ref(item, "resource_key", "O-")?,
|
||||
title: string_field(item, "title")?,
|
||||
summary: optional_string(item, "snippet")?,
|
||||
state: string_field(item, "state")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||
let root = object(&value, "Objective detail response")?;
|
||||
Ok(ModelObjectiveDetail {
|
||||
objective: human_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
linked_tickets: array_field(root, "linked_ticket_summaries")?
|
||||
.iter()
|
||||
.map(project_ticket_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
events: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_objective_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
event_page: project_objective_event_page(
|
||||
root.get("event_page")
|
||||
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
let worker = object(value, "Worker summary")?;
|
||||
Ok(ModelWorkerSummary {
|
||||
worker: human_ref(worker, "worker_resource_key", "W-")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
Ok(ModelTicketEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(body)) => Some(body.clone()),
|
||||
Some(_) => return Err("invalid Ticket event body".to_string()),
|
||||
},
|
||||
created_at: optional_string(event, "at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(ModelTicketRelations::default());
|
||||
};
|
||||
let relations = object(value, "Ticket relations")?;
|
||||
Ok(ModelTicketRelations {
|
||||
outgoing: array_field(relations, "outgoing")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "target_resource_key", "kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
incoming: array_field(relations, "incoming")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
blockers: array_field(relations, "blockers")?
|
||||
.iter()
|
||||
.map(project_blocker)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
notices: array_field(relations, "notices")?
|
||||
.iter()
|
||||
.map(project_notice)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relation(
|
||||
value: &Value,
|
||||
ticket_key: &str,
|
||||
kind_key: &str,
|
||||
) -> Result<ModelRelation, String> {
|
||||
let relation = object(value, "Ticket relation")?;
|
||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
||||
let kind = if kind_key == "kind" {
|
||||
relation_data
|
||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
||||
.and_then(|data| string_field(data, "kind"))?
|
||||
} else {
|
||||
string_field(relation, kind_key)?
|
||||
};
|
||||
let note = match relation_data {
|
||||
Some(data) => optional_string(data, "note")?,
|
||||
None => optional_string(relation, "note")?,
|
||||
};
|
||||
let created_at = match relation_data {
|
||||
Some(data) => optional_string(data, "at")?,
|
||||
None => optional_string(relation, "at")?,
|
||||
};
|
||||
Ok(ModelRelation {
|
||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||
let blocker = object(value, "Ticket blocker")?;
|
||||
Ok(ModelBlocker {
|
||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
kind: string_field(blocker, "relation_kind")?,
|
||||
state: optional_string(blocker, "blocking_state")?,
|
||||
resolved: bool_field(blocker, "resolved")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
|
||||
let notice = object(value, "Ticket notice")?;
|
||||
Ok(ModelNotice {
|
||||
kind: string_field(notice, "kind")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||
let summary = object(value, "Objective summary")?;
|
||||
Ok(ModelObjectiveSummary {
|
||||
objective: human_ref(summary, "resource_key", "O-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
let summary = object(value, "Ticket summary")?;
|
||||
Ok(ModelTicketSummary {
|
||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_assignment(
|
||||
value: &Value,
|
||||
current_coder: Option<&ModelWorkerSummary>,
|
||||
) -> Result<ModelAssignment, String> {
|
||||
let assignment = object(value, "Ticket assignment")?;
|
||||
let principal = object_field(assignment, "principal")?;
|
||||
let kind = string_field(principal, "kind")?;
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| {
|
||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
||||
})?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
"user" => "user".to_string(),
|
||||
other => format!("source:{other}"),
|
||||
};
|
||||
Ok(ModelAssignment {
|
||||
role: string_field(assignment, "role")?,
|
||||
principal,
|
||||
assigned_at: string_field(assignment, "assigned_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
|
||||
let event = object(value, "Ticket evidence event")?;
|
||||
Ok(ModelEvidenceEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: optional_string(event, "at")?,
|
||||
excerpt: string_field(event, "excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
let merge = object(value, "Merge Request summary")?;
|
||||
Ok(ModelMergeRequest {
|
||||
state: string_field(merge, "state")?,
|
||||
selector_from: optional_string(merge, "selector_from")?,
|
||||
selector_to: string_field(merge, "selector_to")?,
|
||||
review_status: string_field(merge, "review_status")?,
|
||||
subject_ref: optional_string(merge, "subject_ref")?,
|
||||
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
let evidence = object(value, "Ticket evidence")?;
|
||||
Ok(ModelTicketEvidence {
|
||||
has_merge_request: bool_field(evidence, "has_merge_request")?,
|
||||
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
|
||||
has_review_request: bool_field(evidence, "has_review_request")?,
|
||||
has_commit: bool_field(evidence, "has_commit")?,
|
||||
review_status: optional_string(evidence, "review_status")?,
|
||||
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
|
||||
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||
missing: string_array(evidence, "missing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
let actions = object(value, "Ticket actions")?;
|
||||
Ok(ModelTicketActions {
|
||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||
can_queue: bool_field(actions, "can_queue")?,
|
||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
|
||||
let page = object(value, "Ticket event page")?;
|
||||
Ok(ModelEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
|
||||
let event = object(value, "Objective event")?;
|
||||
let body = optional_string(event, "body")?;
|
||||
Ok(ModelObjectiveEvent {
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: string_field(event, "created_at")?,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
|
||||
let page = object(value, "Objective event page")?;
|
||||
Ok(ModelObjectiveEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("{context} must be an object"))
|
||||
}
|
||||
|
||||
fn object_field<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<&'a Map<String, Value>, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match object.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(value)) => Ok(Some(value.clone())),
|
||||
Some(_) => Err(format!("invalid {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
|
||||
array_field(object, key)?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("invalid {key}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
||||
validate_human_ref(value, prefix)
|
||||
}
|
||||
|
||||
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
if valid {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("required {prefix} human key is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_human_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
"id": "00001M10HW6BV",
|
||||
"resource_key": "O-543",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z",
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496",
|
||||
"title": "Ticket",
|
||||
"state": "done",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
}],
|
||||
"events": [{
|
||||
"sequence": 3,
|
||||
"event_ref": "objective-event-3",
|
||||
"kind": "linked_ticket",
|
||||
"created_at": "2026-01-02T00:00:00Z",
|
||||
"body": "linked"
|
||||
}],
|
||||
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
|
||||
})).expect("projection");
|
||||
let json = serde_json::to_value(projected).expect("serialize");
|
||||
let text = json.to_string();
|
||||
assert!(text.contains("O-543"));
|
||||
assert!(text.contains("T-496"));
|
||||
assert!(!text.contains("00001M10HW6BV"));
|
||||
assert!(!text.contains("00001M0E82D1V"));
|
||||
assert!(!text.contains("event_ref"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
|
||||
let ticket = project_ticket_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543",
|
||||
"title": "Ticket",
|
||||
"state": "inprogress",
|
||||
"readiness": null,
|
||||
"priority": "high",
|
||||
"created_at": null,
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"workspace_action_priority": "active_work",
|
||||
"matched_fields": ["title"],
|
||||
"snippet": "Ticket",
|
||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||
"linked_objective_keys": ["O-6"],
|
||||
"relation_count": 0,
|
||||
"blocker_count": 0,
|
||||
"unresolved_blocker_count": 0,
|
||||
"unresolved_review_count": 0,
|
||||
"evidence": {
|
||||
"has_merge_request": false,
|
||||
"has_current_subject_ref": false,
|
||||
"has_review_request": false,
|
||||
"has_commit": false,
|
||||
"review_status": null,
|
||||
"approved_current_subject": false,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
},
|
||||
"merge_request": null
|
||||
}]
|
||||
})).expect("Ticket query projection");
|
||||
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
|
||||
assert!(ticket_json.contains("T-543"));
|
||||
assert!(ticket_json.contains("O-6"));
|
||||
assert!(ticket_json.contains("W-12"));
|
||||
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
|
||||
assert!(!ticket_json.contains("runtime-internal"));
|
||||
assert!(!ticket_json.contains("worker-internal"));
|
||||
|
||||
let objective = project_objective_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001OBJECTIVEINTERNAL",
|
||||
"resource_key": "O-6",
|
||||
"title": "Objective",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"matched_fields": [],
|
||||
"snippet": null,
|
||||
"linked_ticket_count": 1,
|
||||
"linked_tickets": ["00001TICKETINTERNAL"],
|
||||
"linked_ticket_keys": ["T-543"]
|
||||
}]
|
||||
}))
|
||||
.expect("Objective query projection");
|
||||
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
|
||||
assert!(objective_json.contains("O-6"));
|
||||
assert!(objective_json.contains("T-543"));
|
||||
assert!(objective_json.contains("\"summary\":null"));
|
||||
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
|
||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_resource_projection_rejects_noncanonical_keys() {
|
||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_projection_fails_closed_without_worker_resource_key() {
|
||||
let error = project_worker(&json!({"worker_resource_key": null}))
|
||||
.expect_err("missing W-key must fail");
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use crate::feature::{
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
|
||||
use super::resource_projection::{project_ticket_detail, project_ticket_query};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketReadKind {
|
||||
Query,
|
||||
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
|
||||
/// stale_after_rescope, and missing_evidence.
|
||||
#[serde(default)]
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceShowTicketInput {
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
id: String,
|
||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
||||
event_limit: Option<usize>,
|
||||
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"Workspace Ticket API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned invalid JSON: {error}"
|
||||
))
|
||||
})?;
|
||||
let content = match self.kind {
|
||||
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
||||
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
WorkspaceTicketReadKind::Show => serde_json::to_string(
|
||||
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
}
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().to_string(),
|
||||
content: Some(response.body),
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -733,14 +752,69 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&response.body)
|
||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
||||
})?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn canonicalize_ticket_references(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for value in object.values_mut() {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
if let Some(resource_key) = object
|
||||
.get("resource_key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
&& object.contains_key("id")
|
||||
{
|
||||
object.insert("id".to_string(), Value::String(resource_key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
@@ -760,8 +834,8 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Unit)
|
||||
@@ -802,12 +876,22 @@ impl WorkspaceHttpTicketBackend {
|
||||
Ok(TicketBackendOperationResult::Tickets(tickets))
|
||||
}
|
||||
TicketBackendOperation::Show { id } => {
|
||||
let ticket = Self::request(
|
||||
let ticket: Ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/record", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
if !ticket
|
||||
.meta
|
||||
.resource_key
|
||||
.as_deref()
|
||||
.is_some_and(is_canonical_ticket_resource_key)
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"required Ticket human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::Create { input } => {
|
||||
@@ -910,7 +994,14 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let relation = Self::request(
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key = Self::resolve_ticket_resource_key(
|
||||
client.clone(),
|
||||
&base,
|
||||
&TicketIdOrSlug::Id(relation.target.clone()),
|
||||
)?;
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
@@ -918,20 +1009,30 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
relation.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
|
||||
let target = match target {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
let relation = Self::request(
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "kind": kind, "target": target })),
|
||||
)?;
|
||||
relation.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
@@ -1266,6 +1367,23 @@ mod tests {
|
||||
.expect("tool exists")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
|
||||
let mut value = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "T-42",
|
||||
"nested": {
|
||||
"id": "00002INTERNAL",
|
||||
"resource_key": "T-43"
|
||||
},
|
||||
"body": "user-authored 00003BODY stays unchanged"
|
||||
});
|
||||
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
|
||||
assert_eq!(value["id"], "T-42");
|
||||
assert_eq!(value["nested"]["id"], "T-43");
|
||||
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||
@@ -1742,11 +1860,102 @@ provider = "github"
|
||||
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]
|
||||
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
||||
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 (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 buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
@@ -1787,8 +1996,8 @@ provider = "github"
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(removed.ticket_id, "01SOURCE");
|
||||
assert_eq!(removed.target, "01TARGET");
|
||||
assert_eq!(removed.ticket_id, "T-1");
|
||||
assert_eq!(removed.target, "T-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::timeline::event::UsageEvent;
|
||||
use agen::{Engine, llm_client::LlmClient};
|
||||
use agen::{Engine, EngineError, llm_client::LlmClient};
|
||||
use manifest::{Scope, WorkerManifest};
|
||||
use protocol::{Event, InFlightSnapshot, WorkerStatus};
|
||||
use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
|
||||
@@ -211,12 +211,28 @@ where
|
||||
on_cancel_sender(worker.engine_mut().cancel_sender());
|
||||
|
||||
match worker.run_text(&input).await {
|
||||
Ok(lifecycle) => Ok(InternalWorkerResult {
|
||||
Ok(lifecycle @ WorkerRunResult::Finished)
|
||||
| Ok(lifecycle @ WorkerRunResult::Paused)
|
||||
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
lifecycle,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Ok(WorkerRunResult::LimitReached) => Err(InternalWorkerError {
|
||||
source: WorkerError::Engine(EngineError::Aborted(
|
||||
"internal Worker reached its turn limit".to_string(),
|
||||
)),
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Ok(WorkerRunResult::Interrupted { message, .. }) => Err(InternalWorkerError {
|
||||
source: WorkerError::Engine(EngineError::Aborted(message)),
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Err(source) => Err(InternalWorkerError {
|
||||
source,
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
@@ -244,6 +260,7 @@ impl Default for InternalWorkerVisibility {
|
||||
pub(crate) enum InternalWorkerSessionStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Failed,
|
||||
@@ -254,9 +271,10 @@ impl InternalWorkerSessionStatus {
|
||||
match self {
|
||||
Self::Idle => 0,
|
||||
Self::Running => 1,
|
||||
Self::Stopping => 2,
|
||||
Self::Stopped => 3,
|
||||
Self::Failed => 4,
|
||||
Self::Paused => 2,
|
||||
Self::Stopping => 3,
|
||||
Self::Stopped => 4,
|
||||
Self::Failed => 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,13 +282,35 @@ impl InternalWorkerSessionStatus {
|
||||
match value {
|
||||
0 => Self::Idle,
|
||||
1 => Self::Running,
|
||||
2 => Self::Stopping,
|
||||
3 => Self::Stopped,
|
||||
2 => Self::Paused,
|
||||
3 => Self::Stopping,
|
||||
4 => Self::Stopped,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_internal_turn_result(
|
||||
result: Result<WorkerRunResult, WorkerError>,
|
||||
) -> (InternalWorkerSessionStatus, Option<String>) {
|
||||
match result {
|
||||
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
|
||||
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
|
||||
Ok(WorkerRunResult::LimitReached) => (
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
Some("internal Worker reached its turn limit".to_string()),
|
||||
),
|
||||
Ok(WorkerRunResult::Interrupted { message, .. }) => {
|
||||
(InternalWorkerSessionStatus::Stopped, Some(message))
|
||||
}
|
||||
Ok(WorkerRunResult::RolledBack) => (
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
Some("internal Worker run was cancelled before AI output".to_string()),
|
||||
),
|
||||
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum InternalWorkerSessionError {
|
||||
#[error("failed to build internal Worker session: {message}")]
|
||||
@@ -365,10 +405,11 @@ impl InternalWorkerSessionHandle {
|
||||
entries,
|
||||
status: match self.status() {
|
||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
|
||||
InternalWorkerSessionStatus::Stopping
|
||||
| InternalWorkerSessionStatus::Stopped
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Paused,
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
|
||||
},
|
||||
error: self.last_error.lock().unwrap().clone(),
|
||||
in_flight,
|
||||
@@ -400,6 +441,7 @@ impl InternalWorkerSessionHandle {
|
||||
.map_err(
|
||||
|current| match InternalWorkerSessionStatus::decode(current) {
|
||||
InternalWorkerSessionStatus::Running
|
||||
| InternalWorkerSessionStatus::Paused
|
||||
| InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy,
|
||||
InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => {
|
||||
InternalWorkerSessionError::Stopped
|
||||
@@ -749,13 +791,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut run => {
|
||||
let (turn_status, error) = match result {
|
||||
Ok(_) => (InternalWorkerSessionStatus::Idle, None),
|
||||
Err(error) => (
|
||||
InternalWorkerSessionStatus::Failed,
|
||||
Some(error.to_string()),
|
||||
),
|
||||
};
|
||||
let (turn_status, error) = classify_internal_turn_result(result);
|
||||
actor_in_flight.clear();
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
@@ -764,11 +800,20 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
});
|
||||
}
|
||||
let protocol_status = match turn_status {
|
||||
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
|
||||
InternalWorkerSessionStatus::Running
|
||||
| InternalWorkerSessionStatus::Stopping => {
|
||||
unreachable!("run completion cannot remain active")
|
||||
}
|
||||
};
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: protocol_status,
|
||||
});
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
@@ -782,7 +827,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
let _ = (&mut run).await;
|
||||
actor_in_flight.clear();
|
||||
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
|
||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
|
||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
state_changed.notify_waiters();
|
||||
let _ = done.send(());
|
||||
@@ -808,7 +853,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: WorkerStatus::Paused,
|
||||
status: WorkerStatus::Stopped,
|
||||
});
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
state_changed.notify_waiters();
|
||||
@@ -1118,6 +1163,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailingClient;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for FailingClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
Err(ClientError::Config(
|
||||
"intentional internal failure".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CancelBeforeAiClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
@@ -1231,6 +1296,77 @@ permission = "write"
|
||||
assert_eq!(result.identity.kind, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_turn_result_mapping_is_exhaustive() {
|
||||
let cases = [
|
||||
(
|
||||
WorkerRunResult::Finished,
|
||||
InternalWorkerSessionStatus::Idle,
|
||||
false,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::Paused,
|
||||
InternalWorkerSessionStatus::Paused,
|
||||
false,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::LimitReached,
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::Interrupted {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message: "cancelled".to_string(),
|
||||
},
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::RolledBack,
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (result, expected_status, expects_error) in cases {
|
||||
let (status, error) = classify_internal_turn_result(Ok(result));
|
||||
assert_eq!(status, expected_status);
|
||||
assert_eq!(error.is_some(), expects_error);
|
||||
}
|
||||
|
||||
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
|
||||
EngineError::Aborted("fatal".to_string()),
|
||||
)));
|
||||
assert_eq!(status, InternalWorkerSessionStatus::Failed);
|
||||
assert!(error.is_some_and(|message| message.contains("fatal")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fatal_internal_run_transitions_to_stopped_protocol_status() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut internal_spec = spec(calls, &[]);
|
||||
internal_spec.client = Box::new(FailingClient);
|
||||
|
||||
let handle = spawn_internal_worker_session(internal_spec)
|
||||
.await
|
||||
.expect("spawn failing Internal Worker session");
|
||||
assert_eq!(
|
||||
handle.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
);
|
||||
assert_eq!(handle.status(), InternalWorkerSessionStatus::Stopped);
|
||||
assert_eq!(handle.protocol_snapshot().status, WorkerStatus::Stopped);
|
||||
assert!(
|
||||
handle
|
||||
.last_error
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.is_some_and(|message| message.contains("intentional internal failure"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -34,8 +34,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};
|
||||
|
||||
@@ -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)]
|
||||
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}")]
|
||||
@@ -319,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
|
||||
@@ -653,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([
|
||||
|
||||
@@ -499,7 +499,10 @@ impl Tool for SubWorkerSpawnTool {
|
||||
InternalWorkerVisibility::ParentClient,
|
||||
Some(child_registry.clone()),
|
||||
Some(Arc::new(move |status| {
|
||||
if status == InternalWorkerSessionStatus::Failed {
|
||||
if matches!(
|
||||
status,
|
||||
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
|
||||
) {
|
||||
if let Some(registry) = registry.upgrade() {
|
||||
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
|
||||
tracing::warn!(
|
||||
@@ -1282,16 +1285,16 @@ extract_threshold = 4000
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.session.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Failed
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
assert!(
|
||||
spawner_scope.snapshot().is_writable(&workspace_root),
|
||||
"Failed terminal child must release its delegated Workdir session"
|
||||
"Stopped terminal child must release its delegated Workdir session"
|
||||
);
|
||||
assert!(
|
||||
!record.workdir_delegation.is_active(),
|
||||
"failed child must revoke cloned scoped sessions"
|
||||
"stopped child must revoke cloned scoped sessions"
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_some());
|
||||
|
||||
|
||||
+297
-76
@@ -9,8 +9,10 @@ use agen::llm_client::RequestConfig;
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::llm_client::types::Role;
|
||||
use agen::state::Mutable;
|
||||
use agen::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord};
|
||||
use agen::{History, HistoryEntry, Item};
|
||||
use agen::{
|
||||
Engine, EngineError, EngineResult, EngineRunExit, History, HistoryEntry, Item, StopReason,
|
||||
ToolOutputLimits, UsageRecord,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use session_store::{
|
||||
LogEntry, PromptRenderProvenance, SegmentId, SessionExtension, SessionId, Store, StoreError,
|
||||
@@ -80,8 +82,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||
#[cfg(test)]
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, Event, RewindSummary,
|
||||
RewindTarget, RewindTargetId, Segment,
|
||||
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, ErrorCode, Event,
|
||||
RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||
};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::broadcast;
|
||||
@@ -1050,6 +1052,8 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
engine: Option<Engine<C, Mutable, SessionHistoryMetadata>>,
|
||||
/// Sole live authority for committed model-visible history.
|
||||
session: WorkerSession,
|
||||
/// Worker-owned interruption recovery marker.
|
||||
last_run_interrupted: bool,
|
||||
store: St,
|
||||
/// Optional write-through hook for name-keyed Worker metadata. Production
|
||||
/// constructors install this from the same FsStore that owns the session
|
||||
@@ -1258,6 +1262,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
manifest: self.manifest.clone(),
|
||||
engine: Some(worker),
|
||||
session: self.session.clone(),
|
||||
last_run_interrupted: false,
|
||||
store: self.store.clone(),
|
||||
worker_metadata_writer: None,
|
||||
segment_state: self.segment_state.clone(),
|
||||
@@ -1449,6 +1454,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
last_run_interrupted: false,
|
||||
store,
|
||||
worker_metadata_writer: None,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
@@ -1934,13 +1940,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(LogEntry::UserInput { segments, .. }) = entries.get(target.user_input_entry_index)
|
||||
else {
|
||||
let input = match entries.get(target.user_input_entry_index) {
|
||||
Some(LogEntry::UserInput { segments, .. })
|
||||
| Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
|
||||
_ => {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target is no longer a user message".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let input = segments.clone();
|
||||
let truncate_entries = rewind_truncate_entries(&entries, target.user_input_entry_index);
|
||||
let retained = entries[..truncate_entries].to_vec();
|
||||
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
@@ -1967,8 +1975,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
self.session.replace_history(history_entries);
|
||||
self.engine_mut().set_request_config(state.config);
|
||||
self.engine_mut().set_turn_count(state.turn_count);
|
||||
self.engine_mut()
|
||||
.set_last_run_interrupted(state.last_run_interrupted);
|
||||
self.last_run_interrupted = state.last_run_interrupted;
|
||||
self.engine_mut()
|
||||
.set_active_run_turn_count(state.active_run_turn_count);
|
||||
self.user_segments = state.user_segments;
|
||||
@@ -2559,7 +2566,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
pending_attachments,
|
||||
usage_history_len,
|
||||
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
|
||||
last_run_interrupted: self.engine().last_run_interrupted(),
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
active_run_turn_count: self.engine().active_run_turn_count(),
|
||||
flow_runtime_state: self
|
||||
.flow_runtime_state
|
||||
@@ -2571,10 +2578,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
|
||||
fn should_rollback_empty_turn(
|
||||
&self,
|
||||
result: &Result<EngineResult, EngineError>,
|
||||
result: &EngineRunExit,
|
||||
snapshot: &EmptyTurnRollbackSnapshot,
|
||||
) -> bool {
|
||||
if !matches!(result, Err(EngineError::Cancelled)) {
|
||||
if !matches!(result, EngineRunExit::Interrupted(StopReason::Cancelled)) {
|
||||
return false;
|
||||
}
|
||||
if self.ai_activity_counter.load(Ordering::SeqCst) != snapshot.ai_activity_count {
|
||||
@@ -2592,8 +2599,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
) -> Result<(), StoreError> {
|
||||
self.session.history_mut().truncate(snapshot.history_len);
|
||||
self.session.note_mutation();
|
||||
self.engine_mut()
|
||||
.set_last_run_interrupted(snapshot.last_run_interrupted);
|
||||
self.last_run_interrupted = snapshot.last_run_interrupted;
|
||||
self.engine_mut()
|
||||
.set_active_run_turn_count(snapshot.active_run_turn_count);
|
||||
*self
|
||||
@@ -2919,13 +2925,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// Clearing the interrupted flag also ends the old logical-run budget and
|
||||
/// must happen before `prepare_for_run`: proactive compaction checkpoints
|
||||
/// only resumable runs, never the run this invocation is abandoning.
|
||||
fn prepare_interrupted_history_for_fresh_run(&mut self) -> Result<(), WorkerError>
|
||||
where
|
||||
St: Clone + 'static,
|
||||
{
|
||||
if self.engine().last_run_interrupted() {
|
||||
fn prepare_interrupted_history_for_fresh_run(&mut self) -> Result<(), WorkerError> {
|
||||
if self.last_run_interrupted {
|
||||
self.apply_interrupt_prep()?;
|
||||
self.engine_mut().set_last_run_interrupted(false);
|
||||
self.last_run_interrupted = false;
|
||||
self.engine_mut().set_active_run_turn_count(None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2937,10 +2941,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// short. Called from `Worker::run` when the worker's
|
||||
/// `last_run_interrupted` flag is set (i.e. the Worker just transitioned
|
||||
/// out of Paused via a new user input).
|
||||
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError>
|
||||
where
|
||||
St: Clone + 'static,
|
||||
{
|
||||
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
|
||||
let tool_result_summary = self
|
||||
.prompts()
|
||||
.load_full()
|
||||
@@ -2958,17 +2959,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
&tool_result_summary,
|
||||
);
|
||||
if !closures.is_empty() {
|
||||
let mut annotate = history_annotator(
|
||||
self.log_writer_handle(),
|
||||
Vec::new(),
|
||||
self.pending_committed_history.clone(),
|
||||
let subject = worker_subject(self.session.session_id());
|
||||
for item in closures {
|
||||
let entry = HistoryEntry::new(
|
||||
item,
|
||||
new_history_metadata(
|
||||
WorkerHistoryProvenance::ToolOutput {
|
||||
worker: subject.clone(),
|
||||
},
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (engine, session) = (
|
||||
self.engine.as_mut().expect("worker present"),
|
||||
&mut self.session,
|
||||
);
|
||||
engine.append_history_with(session.history_mut(), closures, &mut annotate)?;
|
||||
session.note_mutation();
|
||||
self.commit_entry(LogEntry::AnnotatedToolResult {
|
||||
ts: segment_log::now_millis(),
|
||||
entry: to_logged_history_entry(&entry),
|
||||
})?;
|
||||
self.session.history_mut().push_entry(entry);
|
||||
self.session.note_mutation();
|
||||
}
|
||||
}
|
||||
let interrupt_prompt_provenance =
|
||||
self.prompt_render_provenance("internal.interrupt_system_note");
|
||||
@@ -2988,21 +2996,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
})?;
|
||||
let interrupt_entry =
|
||||
HistoryEntry::new(agen::Item::system_message(system_note), interrupt_metadata);
|
||||
let mut annotate = history_annotator(
|
||||
self.log_writer_handle(),
|
||||
vec![interrupt_entry.clone()],
|
||||
self.pending_committed_history.clone(),
|
||||
);
|
||||
let (engine, session) = (
|
||||
self.engine.as_mut().expect("worker present"),
|
||||
&mut self.session,
|
||||
);
|
||||
engine.append_history_with(
|
||||
session.history_mut(),
|
||||
std::iter::once(interrupt_entry.item),
|
||||
&mut annotate,
|
||||
)?;
|
||||
session.note_mutation();
|
||||
self.session.history_mut().push_entry(interrupt_entry);
|
||||
self.session.note_mutation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3013,16 +3008,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// future input is treated as a normal new turn instead of a resume.
|
||||
/// The explicit `PausedTurnAbandoned` marker preserves durable lifecycle
|
||||
/// semantics without claiming another `run` / `resume` completed.
|
||||
pub fn cancel_paused_turn(&mut self) -> Result<(), WorkerError>
|
||||
where
|
||||
St: Clone + 'static,
|
||||
{
|
||||
if !self.engine().last_run_interrupted() {
|
||||
pub fn cancel_paused_turn(&mut self) -> Result<(), WorkerError> {
|
||||
if !self.last_run_interrupted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.apply_interrupt_prep()?;
|
||||
self.engine_mut().set_last_run_interrupted(false);
|
||||
self.last_run_interrupted = false;
|
||||
self.commit_entry(LogEntry::PausedTurnAbandoned {
|
||||
ts: segment_log::now_millis(),
|
||||
})?;
|
||||
@@ -3245,7 +3237,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// `Yielded`), so restore remains consistent.
|
||||
async fn handle_worker_result(
|
||||
&mut self,
|
||||
result: Result<EngineResult, EngineError>,
|
||||
result: EngineRunExit,
|
||||
history_before: usize,
|
||||
) -> Result<WorkerRunResult, WorkerError>
|
||||
where
|
||||
@@ -3253,18 +3245,39 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
{
|
||||
self.persist_turn(history_before, &result).await?;
|
||||
|
||||
if matches!(result, Ok(EngineResult::Yielded)) {
|
||||
if matches!(result, EngineRunExit::Yielded) {
|
||||
self.last_run_interrupted = true;
|
||||
return self.do_compact_and_resume().await;
|
||||
}
|
||||
|
||||
if result.is_ok() {
|
||||
if !matches!(result, EngineRunExit::Interrupted(_)) {
|
||||
if let Some(ref state) = self.compact_state {
|
||||
state.set_just_compacted(false);
|
||||
}
|
||||
}
|
||||
result
|
||||
.map(WorkerRunResult::from)
|
||||
.map_err(WorkerError::Engine)
|
||||
|
||||
match result {
|
||||
EngineRunExit::Finished => {
|
||||
self.last_run_interrupted = false;
|
||||
Ok(WorkerRunResult::Finished)
|
||||
}
|
||||
EngineRunExit::Paused => {
|
||||
self.last_run_interrupted = true;
|
||||
Ok(WorkerRunResult::Paused)
|
||||
}
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
self.last_run_interrupted = false;
|
||||
Ok(WorkerRunResult::LimitReached)
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => {
|
||||
self.last_run_interrupted = true;
|
||||
Ok(WorkerRunResult::Interrupted {
|
||||
code: stop_reason_error_code(&reason),
|
||||
message: stop_reason_message(&reason),
|
||||
})
|
||||
}
|
||||
EngineRunExit::Yielded => unreachable!("yielded handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_compaction_lifecycle(
|
||||
@@ -3477,7 +3490,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
async fn persist_turn(
|
||||
&mut self,
|
||||
history_before: usize,
|
||||
result: &Result<EngineResult, EngineError>,
|
||||
result: &EngineRunExit,
|
||||
) -> Result<(), StoreError> {
|
||||
// Per-item commits for AssistantItem / ToolResult / SystemItem
|
||||
// entries are expected to have landed synchronously: the
|
||||
@@ -3575,22 +3588,43 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
.push(record);
|
||||
}
|
||||
|
||||
let interrupted = self.engine.as_ref().unwrap().last_run_interrupted();
|
||||
let interrupted = matches!(
|
||||
result,
|
||||
EngineRunExit::Paused
|
||||
| EngineRunExit::Yielded
|
||||
| EngineRunExit::Interrupted(StopReason::Cancelled)
|
||||
| EngineRunExit::Interrupted(StopReason::ContextWindowExceeded)
|
||||
| EngineRunExit::Interrupted(StopReason::Unexpected(_))
|
||||
);
|
||||
let active_run_turn_count = self.engine.as_ref().unwrap().active_run_turn_count();
|
||||
match result {
|
||||
Ok(r) => {
|
||||
EngineRunExit::Finished | EngineRunExit::Paused | EngineRunExit::Yielded => {
|
||||
let result = match result {
|
||||
EngineRunExit::Finished => EngineResult::Finished,
|
||||
EngineRunExit::Paused => EngineResult::Paused,
|
||||
EngineRunExit::Yielded => EngineResult::Yielded,
|
||||
EngineRunExit::Interrupted(_) => unreachable!(),
|
||||
};
|
||||
self.commit_entry(LogEntry::RunCompleted {
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted,
|
||||
result: r.clone(),
|
||||
result,
|
||||
active_run_turn_count,
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
self.commit_entry(LogEntry::RunCompleted {
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted: false,
|
||||
result: EngineResult::LimitReached,
|
||||
active_run_turn_count,
|
||||
})?;
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => {
|
||||
self.commit_entry(LogEntry::RunErrored {
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted,
|
||||
message: e.to_string(),
|
||||
message: stop_reason_message(reason),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -4825,6 +4859,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
fn extract_internal_worker_lifecycle_error(lifecycle: &WorkerRunResult) -> Option<WorkerError> {
|
||||
match lifecycle {
|
||||
WorkerRunResult::RolledBack => Some(WorkerError::Engine(EngineError::Cancelled)),
|
||||
WorkerRunResult::Interrupted { message, .. } => {
|
||||
Some(WorkerError::Engine(EngineError::Aborted(message.clone())))
|
||||
}
|
||||
WorkerRunResult::Finished | WorkerRunResult::Paused | WorkerRunResult::LimitReached => None,
|
||||
}
|
||||
}
|
||||
@@ -5091,6 +5128,7 @@ where
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
last_run_interrupted: false,
|
||||
store,
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
@@ -5174,6 +5212,7 @@ where
|
||||
manifest,
|
||||
engine: Some(engine),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
last_run_interrupted: false,
|
||||
store,
|
||||
worker_metadata_writer: None,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
@@ -5292,6 +5331,7 @@ where
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
last_run_interrupted: false,
|
||||
store,
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
@@ -5601,7 +5641,6 @@ where
|
||||
);
|
||||
worker.set_request_config(state.config.clone());
|
||||
worker.set_turn_count(state.turn_count);
|
||||
worker.set_last_run_interrupted(state.last_run_interrupted);
|
||||
worker.set_active_run_turn_count(state.active_run_turn_count);
|
||||
if anchored_on_summary {
|
||||
worker.set_cache_anchor(Some(0));
|
||||
@@ -5617,6 +5656,7 @@ where
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, restored_history_entries),
|
||||
last_run_interrupted: state.last_run_interrupted,
|
||||
store,
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
|
||||
@@ -5857,8 +5897,34 @@ fn restore_manifest_from_worker_metadata_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_reason_error_code(reason: &StopReason) -> ErrorCode {
|
||||
match reason {
|
||||
StopReason::ContextWindowExceeded | StopReason::Unexpected(EngineError::Client(_)) => {
|
||||
ErrorCode::ProviderError
|
||||
}
|
||||
StopReason::Unexpected(EngineError::Tool(_)) => ErrorCode::ToolError,
|
||||
StopReason::LimitReached
|
||||
| StopReason::Cancelled
|
||||
| StopReason::Unexpected(
|
||||
EngineError::Aborted(_)
|
||||
| EngineError::Cancelled
|
||||
| EngineError::ConfigWarnings(_)
|
||||
| EngineError::HistoryAppend(_),
|
||||
) => ErrorCode::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_reason_message(reason: &StopReason) -> String {
|
||||
match reason {
|
||||
StopReason::LimitReached => "engine turn limit reached".to_string(),
|
||||
StopReason::ContextWindowExceeded => "model context window reached".to_string(),
|
||||
StopReason::Cancelled => "engine run cancelled".to_string(),
|
||||
StopReason::Unexpected(error) => format!("unexpected engine failure: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a Worker run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkerRunResult {
|
||||
/// The LLM finished its turn normally.
|
||||
Finished,
|
||||
@@ -5866,6 +5932,8 @@ pub enum WorkerRunResult {
|
||||
Paused,
|
||||
/// The worker reached its configured max_turns limit.
|
||||
LimitReached,
|
||||
/// The run was interrupted by a known or unexpected terminal cause.
|
||||
Interrupted { code: ErrorCode, message: String },
|
||||
/// The submit-time user turn was rolled back because no AI output was materialized.
|
||||
RolledBack,
|
||||
}
|
||||
@@ -6150,7 +6218,11 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<Rew
|
||||
let mut turn_index = 0usize;
|
||||
let mut targets = Vec::new();
|
||||
for (entry_index, entry) in entries.iter().enumerate() {
|
||||
if let LogEntry::UserInput { segments, ts, .. } = entry {
|
||||
let (segments, ts) = match entry {
|
||||
LogEntry::UserInput { segments, ts, .. }
|
||||
| LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
||||
_ => continue,
|
||||
};
|
||||
turn_index += 1;
|
||||
let truncate_entries = rewind_truncate_entries(entries, entry_index);
|
||||
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
@@ -6171,7 +6243,6 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<Rew
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
targets.reverse();
|
||||
targets
|
||||
}
|
||||
@@ -6191,8 +6262,9 @@ fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize)
|
||||
|
||||
fn suffix_has_tool_side_effects(entries: &[LogEntry]) -> bool {
|
||||
entries.iter().any(|entry| match entry {
|
||||
LogEntry::ToolResult { .. } => true,
|
||||
LogEntry::ToolResult { .. } | LogEntry::AnnotatedToolResult { .. } => true,
|
||||
LogEntry::AssistantItem { item, .. } => logged_item_is_tool_call(item),
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => logged_item_is_tool_call(&entry.item),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
@@ -7401,12 +7473,12 @@ mod build_summary_prompt_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
worker.ensure_segment_head().unwrap();
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
worker.last_run_interrupted = true;
|
||||
worker.engine_mut().set_active_run_turn_count(Some(3));
|
||||
|
||||
worker.prepare_interrupted_history_for_fresh_run().unwrap();
|
||||
|
||||
assert!(!worker.engine().last_run_interrupted());
|
||||
assert!(!worker.last_run_interrupted);
|
||||
assert_eq!(worker.engine().active_run_turn_count(), None);
|
||||
let checkpoint = active_run_checkpoint_entry(
|
||||
worker.engine().active_run_turn_count(),
|
||||
@@ -7445,7 +7517,7 @@ mod build_summary_prompt_tests {
|
||||
.unwrap();
|
||||
worker.ensure_segment_head().unwrap();
|
||||
worker.engine_mut().set_turn_count(7);
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
worker.last_run_interrupted = true;
|
||||
worker.engine_mut().set_active_run_turn_count(Some(3));
|
||||
|
||||
let session_id = worker.session_id();
|
||||
@@ -7682,6 +7754,56 @@ mod build_summary_prompt_tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn append_annotated_user_turn(
|
||||
worker: &Worker<NoopClient, session_store::FsStore>,
|
||||
ts: u64,
|
||||
text: &str,
|
||||
) -> Vec<SessionHistoryMetadata> {
|
||||
let user = history_entry(
|
||||
Item::user_message(text),
|
||||
WorkerHistoryProvenance::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
);
|
||||
let assistant = history_entry(
|
||||
Item::assistant_message(format!("answer: {text}")),
|
||||
WorkerHistoryProvenance::ModelOutput {
|
||||
worker: worker_subject(worker.session.session_id()),
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
worker,
|
||||
LogEntry::Invoke {
|
||||
ts,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
worker,
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: ts + 1,
|
||||
segments: vec![Segment::text(text)],
|
||||
extensions: Vec::new(),
|
||||
history: vec![to_logged_history_entry(&user)],
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
worker,
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: ts + 2,
|
||||
entry: to_logged_history_entry(&assistant),
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
worker,
|
||||
LogEntry::TurnEnd {
|
||||
ts: ts + 3,
|
||||
turn_count: 1,
|
||||
},
|
||||
);
|
||||
vec![user.annotation, assistant.annotation]
|
||||
}
|
||||
|
||||
fn append_user_turn(worker: &Worker<NoopClient, session_store::FsStore>, ts: u64, text: &str) {
|
||||
append_test_entry(
|
||||
worker,
|
||||
@@ -7792,6 +7914,105 @@ mod build_summary_prompt_tests {
|
||||
assert_eq!(worker.history()[0].as_text().unwrap(), "first message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn annotated_history_rewind_commits_authoritative_prefix() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
let expected_metadata = append_annotated_user_turn(&worker, 10, "first message");
|
||||
append_annotated_user_turn(&worker, 20, "second message");
|
||||
append_test_entry(
|
||||
&worker,
|
||||
LogEntry::AnnotatedToolResult {
|
||||
ts: 30,
|
||||
entry: session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-v2".into(),
|
||||
summary: "side effect".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
is_error: false,
|
||||
},
|
||||
metadata: new_history_metadata(
|
||||
WorkerHistoryProvenance::ToolOutput {
|
||||
worker: worker_subject(worker.session.session_id()),
|
||||
},
|
||||
None,
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].preview, "second message");
|
||||
assert!(targets[0].truncate_entries > 0);
|
||||
assert!(targets[0].warning.is_some());
|
||||
|
||||
let applied = worker
|
||||
.rewind_to(targets[0].id.clone(), head_entries)
|
||||
.unwrap();
|
||||
assert_eq!(applied.summary.truncated_to_entries, 5);
|
||||
assert!(matches!(
|
||||
applied.entries.first(),
|
||||
Some(LogEntry::AnnotatedSegmentStart { .. })
|
||||
));
|
||||
let retained_metadata = worker
|
||||
.session_history()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(retained_metadata, expected_metadata);
|
||||
|
||||
let source_location = worker.segment_state.location();
|
||||
let persisted_prefix = worker
|
||||
.store
|
||||
.read_all(source_location.session_id, source_location.segment_id)
|
||||
.unwrap();
|
||||
let restored_prefix = restore_history_entries(
|
||||
source_location.session_id,
|
||||
source_location.segment_id,
|
||||
&persisted_prefix,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored_prefix
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
expected_metadata
|
||||
);
|
||||
|
||||
// Simulate a stale concurrent writer so the next head check forks.
|
||||
append_test_entry(
|
||||
&worker,
|
||||
LogEntry::Extension {
|
||||
ts: 31,
|
||||
domain: "test.concurrent-writer".into(),
|
||||
payload: serde_json::json!({"value": true}),
|
||||
},
|
||||
);
|
||||
worker.ensure_segment_head().unwrap();
|
||||
let fork_location = worker.segment_state.location();
|
||||
assert_ne!(fork_location.segment_id, source_location.segment_id);
|
||||
let fork_entries = worker
|
||||
.store
|
||||
.read_all(fork_location.session_id, fork_location.segment_id)
|
||||
.unwrap();
|
||||
let fork_history = restore_history_entries(
|
||||
fork_location.session_id,
|
||||
fork_location.segment_id,
|
||||
&fork_entries,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fork_history
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
expected_metadata
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_apply_rejects_stale_head() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
@@ -7917,7 +8138,7 @@ mod build_summary_prompt_tests {
|
||||
})
|
||||
.unwrap();
|
||||
worker.set_history_for_test(vec![dangling_call]);
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
worker.last_run_interrupted = true;
|
||||
|
||||
worker
|
||||
.run_for_notification(protocol::InvokeKind::Notify)
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::records::{
|
||||
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
|
||||
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
|
||||
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||
summarize_body, truncate_body, validate_project_id,
|
||||
summarize_body, truncate_body,
|
||||
};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
|
||||
predicates.push(format!("o.updated_at<{value}"));
|
||||
}
|
||||
if let Some(value) = &query.linked_ticket_id {
|
||||
let value = bind(SqlValue::Text(value.clone()));
|
||||
let resolved = self
|
||||
.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
value,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
|
||||
let value = bind(SqlValue::Text(resolved));
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
|
||||
}
|
||||
let relevance_rank = if let Some(text) =
|
||||
@@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
let linked_ticket_keys = linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let body_md = record.body_md.clone();
|
||||
let objective = ObjectiveSummary {
|
||||
resource_key: self
|
||||
@@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
items.push(objective_query_item(
|
||||
objective,
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
query.query.as_deref(),
|
||||
&body_md,
|
||||
));
|
||||
@@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
|
||||
validate_objective_title(&input.title)?;
|
||||
validate_objective_state(&input.state)?;
|
||||
for ticket_id in &input.linked_tickets {
|
||||
validate_project_id(ticket_id)?;
|
||||
}
|
||||
let linked_tickets = input
|
||||
.linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_reference| {
|
||||
self.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
ticket_reference,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let now = now_rfc3339();
|
||||
let objective_id = allocate_record_id(
|
||||
unix_epoch_millis_now().map_err(|err| {
|
||||
@@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
self.store.upsert_objective(&record)?;
|
||||
let links = input
|
||||
.linked_tickets
|
||||
let links = linked_tickets
|
||||
.into_iter()
|
||||
.map(|ticket_id| ObjectiveTicketLinkRecord {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
@@ -2283,12 +2305,18 @@ fn ticket_query_item(
|
||||
.iter()
|
||||
.map(|objective| objective.id.clone())
|
||||
.collect(),
|
||||
linked_objective_keys: detail
|
||||
.linked_objectives
|
||||
.iter()
|
||||
.map(|objective| objective.resource_key.clone())
|
||||
.collect(),
|
||||
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
|
||||
blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
||||
evidence: detail.evidence.clone(),
|
||||
merge_request: detail.merge_request.clone(),
|
||||
current_coder: detail.current_coder.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor(
|
||||
fn objective_query_item(
|
||||
objective: ObjectiveSummary,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_keys: Vec<String>,
|
||||
text: Option<&str>,
|
||||
body_md: &str,
|
||||
) -> ObjectiveQueryItem {
|
||||
@@ -2426,6 +2455,7 @@ fn objective_query_item(
|
||||
snippet,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(!objective.revision.is_empty());
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
|
||||
let objective_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
query: Some("Control plane".to_string()),
|
||||
linked_ticket_id: Some("00000000001J2".to_string()),
|
||||
linked_ticket_id: Some(linked_ticket_key.clone()),
|
||||
limit: Some(1),
|
||||
..ObjectiveQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(objective_query.items.len(), 1);
|
||||
assert_eq!(objective_query.items[0].linked_ticket_count, 1);
|
||||
assert_eq!(
|
||||
objective_query.items[0].linked_ticket_keys,
|
||||
vec![linked_ticket_key]
|
||||
);
|
||||
assert_eq!(objective_query.page.limit, 1);
|
||||
let body_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
@@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
title: "Create Objective".to_string(),
|
||||
body_md: "Alpha body".to_string(),
|
||||
state: "active".to_string(),
|
||||
linked_tickets: vec!["00000000001J2".to_string()],
|
||||
linked_tickets: vec!["T-1".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(created.title, "Create Objective");
|
||||
@@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
assert_eq!(state.state, "paused");
|
||||
assert_eq!(
|
||||
authority
|
||||
.link_objective_ticket(&created.id, "00000000001J3")
|
||||
.link_objective_ticket(&created.id, "T-2")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J2", "00000000001J3"]
|
||||
);
|
||||
assert_eq!(
|
||||
authority
|
||||
.unlink_objective_ticket(&created.id, "00000000001J2")
|
||||
.unlink_objective_ticket(&created.id, "T-1")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J3"]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use workspace_api::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
|
||||
ObjectiveSummary, QueryPage,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -387,12 +384,16 @@ pub struct TicketQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub matching_event: Option<TicketEvidenceEvent>,
|
||||
pub linked_objective_ids: Vec<String>,
|
||||
#[ts(skip)]
|
||||
pub linked_objective_keys: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub blocker_count: usize,
|
||||
pub unresolved_blocker_count: usize,
|
||||
pub unresolved_review_count: usize,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
#[ts(skip)]
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub linked_ticket_count: usize,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -521,10 +523,6 @@ mod typescript_tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_body(body: &str) -> String {
|
||||
let summary = body
|
||||
.lines()
|
||||
|
||||
@@ -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(|| {
|
||||
@@ -7257,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
|
||||
@@ -7292,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
|
||||
@@ -7321,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;
|
||||
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.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(
|
||||
@@ -19615,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);
|
||||
@@ -19634,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()
|
||||
@@ -19669,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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
|
||||
|
||||
export type CompletionKind = "file";
|
||||
|
||||
export type WorkerStatus = "idle" | "running" | "paused";
|
||||
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
|
||||
|
||||
export type TurnResult = "finished" | "paused";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
workspaceWorkersStore,
|
||||
type SidebarWorker,
|
||||
} from './worker-subscription';
|
||||
import { canShowWorkerInSidebar } from './workers';
|
||||
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
|
||||
|
||||
const COLLAPSED_WORKER_COUNT = 6;
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<ul class="nav-list" aria-label="Workers">
|
||||
{#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||
{@const activity = sidebarWorkerActivity(worker)}
|
||||
<li>
|
||||
<a
|
||||
href={href}
|
||||
@@ -77,11 +78,11 @@
|
||||
aria-current={currentPath === href ? 'page' : undefined}
|
||||
>
|
||||
<span class="worker-status-indicator">
|
||||
{#if worker.state === 'running'}
|
||||
{#if activity === 'worker-running'}
|
||||
<span class="worker-status-spinner"><Spinner label="Running" /></span>
|
||||
{:else if worker.has_running_internal_workers}
|
||||
{:else if activity === 'subworker-running'}
|
||||
<span class="worker-status-spinner is-subworker"><Spinner label="SubWorker running" /></span>
|
||||
{:else if worker.state === 'idle'}
|
||||
{:else if activity === 'idle'}
|
||||
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -14,13 +14,18 @@ declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function worker(runtimeId: string, workerId: string, revision: number): SubscriptionWorker {
|
||||
function worker(
|
||||
runtimeId: string,
|
||||
workerId: string,
|
||||
revision: number,
|
||||
hasRunningInternalWorkers = false,
|
||||
): SubscriptionWorker {
|
||||
return {
|
||||
worker_id: workerId,
|
||||
runtime_id: runtimeId,
|
||||
subject_revision: revision,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: false,
|
||||
has_running_internal_workers: hasRunningInternalWorkers,
|
||||
workspace_id: 'workspace-test',
|
||||
display_name: null,
|
||||
profile: null,
|
||||
@@ -88,3 +93,28 @@ Deno.test('workspace Worker reducer ignores stale events and removes composite s
|
||||
assertEquals(projection.workers.size, 0);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 4);
|
||||
});
|
||||
|
||||
Deno.test('fatal child stop replaces the running-child sidebar projection', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 1, true));
|
||||
projection.revisions.set('runtime-a:1', 1);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1', 2, false) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(projection.workers.get('runtime-a:1')?.has_running_internal_workers, false);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 2);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
canOpenWorkerConsole,
|
||||
canShowWorkerInSidebar,
|
||||
compareWorkersForSidebar,
|
||||
sidebarWorkerActivity,
|
||||
} from "./workers.ts";
|
||||
import type { Worker } from "./types.ts";
|
||||
|
||||
@@ -77,3 +78,21 @@ Deno.test("sidebar workers sort running then idle then stopped", () => {
|
||||
workers.sort(compareWorkersForSidebar);
|
||||
assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "2,1,4,3");
|
||||
});
|
||||
|
||||
Deno.test("fatal child stop clears the sidebar SubWorker spinner activity", () => {
|
||||
const parent = { state: "idle", has_running_internal_workers: true };
|
||||
assertEquals(sidebarWorkerActivity(parent), "subworker-running");
|
||||
|
||||
parent.has_running_internal_workers = false;
|
||||
assertEquals(sidebarWorkerActivity(parent), "idle");
|
||||
});
|
||||
|
||||
Deno.test("stopped parents do not fall back to the idle indicator", () => {
|
||||
assertEquals(
|
||||
sidebarWorkerActivity({
|
||||
state: "stopped",
|
||||
has_running_internal_workers: false,
|
||||
}),
|
||||
"none",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import type { Worker } from './types';
|
||||
|
||||
export type SidebarWorkerActivity =
|
||||
| 'worker-running'
|
||||
| 'subworker-running'
|
||||
| 'idle'
|
||||
| 'none';
|
||||
|
||||
type WorkerActivitySource = Pick<Worker, 'state'> & {
|
||||
has_running_internal_workers: boolean;
|
||||
};
|
||||
|
||||
export function sidebarWorkerActivity(
|
||||
worker: WorkerActivitySource,
|
||||
): SidebarWorkerActivity {
|
||||
if (worker.state === 'running') return 'worker-running';
|
||||
if (worker.has_running_internal_workers) return 'subworker-running';
|
||||
if (worker.state === 'idle') return 'idle';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
||||
return worker.implementation.kind !== 'backend_worker_registry';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user