Compare commits
38 Commits
365b8c34fd
...
7880672737
| Author | SHA1 | Date | |
|---|---|---|---|
| 7880672737 | |||
| c6d9b7f405 | |||
| 2bb69ae7f6 | |||
| cbb4c4dec4 | |||
| 420e83bea3 | |||
| 7ecd58814c | |||
| a8e9a091f8 | |||
| 9df9dc1863 | |||
| 3c086b7497 | |||
| 20f55a3c61 | |||
| 7cfa5503df | |||
| 32be379f54 | |||
| 8b2f16e009 | |||
| 3784cc8bbf | |||
| 3457167931 | |||
| d9984f33c2 | |||
| 35b13a98df | |||
| 74f792da1a | |||
| 26d8a5d9be | |||
| 41ce27f038 | |||
| f7d4b12e7f | |||
| 6083121574 | |||
| 92a9c1416c | |||
| 3cb1138e84 | |||
| 2b4bdda89c | |||
| 834d21723b | |||
| 3658242bbc | |||
| eda4c4ce47 | |||
| 7265e83e44 | |||
| 22df14a66c | |||
| 0e4ab1d496 | |||
| 4450e1da9d | |||
| 5d104a1cc6 | |||
| b2efd2906f | |||
| 98522911a4 | |||
| 01c41ae86c | |||
| 9fe2799732 | |||
| 2c3eddd218 |
|
|
@ -32,10 +32,16 @@ pub enum PromptAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Action before an LLM request.
|
/// Action before an LLM request.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum PreRequestAction {
|
pub enum PreRequestAction {
|
||||||
/// Proceed normally.
|
/// Proceed normally.
|
||||||
Continue,
|
Continue,
|
||||||
|
/// Proceed after appending these items to durable worker history.
|
||||||
|
///
|
||||||
|
/// This is for upper-layer budget/status nudges that the model may react
|
||||||
|
/// to: the items are committed before the request so later turns can see
|
||||||
|
/// why the worker changed course.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
/// Cancel with a reason (treated as an error).
|
/// Cancel with a reason (treated as an error).
|
||||||
Cancel(String),
|
Cancel(String),
|
||||||
/// Yield control to the caller for external processing.
|
/// Yield control to the caller for external processing.
|
||||||
|
|
@ -149,11 +155,12 @@ pub trait Interceptor: Send + Sync {
|
||||||
|
|
||||||
/// Called before each LLM request. The context starts as a clone
|
/// Called before each LLM request. The context starts as a clone
|
||||||
/// of `worker.history` (after `pending_history_appends` and the
|
/// of `worker.history` (after `pending_history_appends` and the
|
||||||
/// Worker's own prune projection have been applied) and can be
|
/// Worker's own prune projection have been applied).
|
||||||
/// further modified for that single request only — mutations here
|
///
|
||||||
/// are **not** persisted back to history. Use
|
/// Direct mutations to `context` remain request-local and are not persisted.
|
||||||
/// [`Self::pending_history_appends`] for inputs that need to land
|
/// If an interceptor derives a human/model-visible nudge from the current
|
||||||
/// in history.
|
/// request context, return [`PreRequestAction::ContinueWith`] so the Worker
|
||||||
|
/// commits it to history before the request is sent.
|
||||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ impl Default for RetryPolicy {
|
||||||
base: Duration::from_millis(500),
|
base: Duration::from_millis(500),
|
||||||
cap: Duration::from_secs(10),
|
cap: Duration::from_secs(10),
|
||||||
max_attempts: 4,
|
max_attempts: 4,
|
||||||
total_timeout: Duration::from_secs(30),
|
total_timeout: Duration::from_secs(40),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ mod tests {
|
||||||
assert_eq!(p.base, Duration::from_millis(500));
|
assert_eq!(p.base, Duration::from_millis(500));
|
||||||
assert_eq!(p.cap, Duration::from_secs(10));
|
assert_eq!(p.cap, Duration::from_secs(10));
|
||||||
assert_eq!(p.max_attempts, 4);
|
assert_eq!(p.max_attempts, 4);
|
||||||
assert_eq!(p.total_timeout, Duration::from_secs(30));
|
assert_eq!(p.total_timeout, Duration::from_secs(40));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use eventsource_stream::Eventsource;
|
use eventsource_stream::Eventsource;
|
||||||
|
|
@ -14,6 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt};
|
||||||
use reqwest::header::{
|
use reqwest::header::{
|
||||||
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
||||||
};
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use super::auth::{AuthProvider, AuthRequirement};
|
use super::auth::{AuthProvider, AuthRequirement};
|
||||||
use super::capability::ModelCapability;
|
use super::capability::ModelCapability;
|
||||||
|
|
@ -23,7 +24,7 @@ use super::event::Event;
|
||||||
use super::scheme::Scheme;
|
use super::scheme::Scheme;
|
||||||
use super::types::{Request, RequestConfig};
|
use super::types::{Request, RequestConfig};
|
||||||
|
|
||||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
|
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
|
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
|
||||||
|
|
@ -192,16 +193,71 @@ impl<S: Scheme> HttpTransport<S> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = serde_json::to_vec(body)?;
|
let raw = serde_json::to_vec(body)?;
|
||||||
|
let raw_json_bytes = raw.len();
|
||||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(raw), 3)
|
let compressed = zstd::stream::encode_all(std::io::Cursor::new(raw), 3)
|
||||||
.map_err(|e| ClientError::Config(format!("failed to zstd-compress request: {e}")))?;
|
.map_err(|e| ClientError::Config(format!("failed to zstd-compress request: {e}")))?;
|
||||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||||
Ok(RequestBody::CompressedJson(compressed))
|
Ok(RequestBody::CompressedJson {
|
||||||
|
bytes: compressed,
|
||||||
|
raw_json_bytes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RequestBody {
|
enum RequestBody {
|
||||||
Json(serde_json::Value),
|
Json(serde_json::Value),
|
||||||
CompressedJson(Vec<u8>),
|
CompressedJson {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
raw_json_bytes: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestBody {
|
||||||
|
fn encoding(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Json(_) => "json",
|
||||||
|
Self::CompressedJson { .. } => "zstd",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_json_bytes(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||||
|
Self::CompressedJson { raw_json_bytes, .. } => Some(*raw_json_bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire_bytes(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Json(body) => serde_json::to_vec(body).ok().map(|bytes| bytes.len()),
|
||||||
|
Self::CompressedJson { bytes, .. } => Some(bytes.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_kind(auth: &ResolvedAuth) -> &'static str {
|
||||||
|
match auth {
|
||||||
|
ResolvedAuth::None => "none",
|
||||||
|
ResolvedAuth::ApiKey(_) => "api_key",
|
||||||
|
ResolvedAuth::Custom(_) => "custom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_transport_trace(request: &Request, label: &str, data: Value) {
|
||||||
|
if let Some(trace) = &request.transport_trace {
|
||||||
|
trace.emit(label, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_value_kind(value: &Value) -> &'static str {
|
||||||
|
match value {
|
||||||
|
Value::Null => "null",
|
||||||
|
Value::Bool(_) => "bool",
|
||||||
|
Value::Number(_) => "number",
|
||||||
|
Value::String(_) => "string",
|
||||||
|
Value::Array(_) => "array",
|
||||||
|
Value::Object(_) => "object",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn response_with_timeout(
|
async fn response_with_timeout(
|
||||||
|
|
@ -273,27 +329,175 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||||
|
let total_started = Instant::now();
|
||||||
|
let path = self.scheme.path(&self.model_id);
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_start",
|
||||||
|
json!({
|
||||||
|
"model": &self.model_id,
|
||||||
|
"path": path,
|
||||||
|
"auth_kind": auth_kind(&self.auth),
|
||||||
|
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||||
|
"codex_backend": self.is_codex_backend(),
|
||||||
|
"cache_key_present": request.cache_key.is_some(),
|
||||||
|
"stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let url = self.build_url();
|
let url = self.build_url();
|
||||||
let mut headers = self.build_headers().await?;
|
let headers_started = Instant::now();
|
||||||
self.apply_stream_headers(&mut headers, &request)?;
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_headers_start",
|
||||||
|
json!({
|
||||||
|
"auth_kind": auth_kind(&self.auth),
|
||||||
|
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut headers = match self.build_headers().await {
|
||||||
|
Ok(headers) => {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_headers_done",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||||
|
"headers_len": headers.len(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_headers_error",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": headers_started.elapsed().as_millis() as u64,
|
||||||
|
"error": error.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream_headers_started = Instant::now();
|
||||||
|
if let Err(error) = self.apply_stream_headers(&mut headers, &request) {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_stream_headers_error",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||||
|
"error": error.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_stream_headers_done",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": stream_headers_started.elapsed().as_millis() as u64,
|
||||||
|
"headers_len": headers.len(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let body_started = Instant::now();
|
||||||
|
emit_transport_trace(&request, "transport_body_build_start", json!({}));
|
||||||
let body = self
|
let body = self
|
||||||
.scheme
|
.scheme
|
||||||
.build_request_body(&self.model_id, &request, &self.capability);
|
.build_request_body(&self.model_id, &request, &self.capability);
|
||||||
let request_body = self.encode_request_body(&body, &mut headers)?;
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_body_build_done",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
||||||
|
"body_kind": json_value_kind(&body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let encode_started = Instant::now();
|
||||||
|
let request_body = match self.encode_request_body(&body, &mut headers) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(error) => {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_body_encode_error",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||||
|
"error": error.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_body_encode_done",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": encode_started.elapsed().as_millis() as u64,
|
||||||
|
"encoding": request_body.encoding(),
|
||||||
|
"raw_json_bytes": request_body.raw_json_bytes(),
|
||||||
|
"wire_bytes": request_body.wire_bytes(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let builder = self.http_client.post(&url).headers(headers);
|
let builder = self.http_client.post(&url).headers(headers);
|
||||||
let builder = match request_body {
|
let builder = match request_body {
|
||||||
RequestBody::Json(body) => builder.json(&body),
|
RequestBody::Json(body) => builder.json(&body),
|
||||||
RequestBody::CompressedJson(body) => builder.body(body),
|
RequestBody::CompressedJson { bytes, .. } => builder.body(bytes),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let send_started = Instant::now();
|
||||||
|
emit_transport_trace(&request, "transport_http_send_start", json!({}));
|
||||||
let response =
|
let response =
|
||||||
response_with_timeout(builder.send(), DEFAULT_STREAM_OPEN_TIMEOUT, "stream_open")
|
match response_with_timeout(builder.send(), DEFAULT_STREAM_OPEN_TIMEOUT, "stream_open")
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_http_headers_received",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||||
|
"status": response.status().as_u16(),
|
||||||
|
"success": response.status().is_success(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_http_send_error",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": send_started.elapsed().as_millis() as u64,
|
||||||
|
"error": error.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_http_status_error",
|
||||||
|
json!({
|
||||||
|
"status": response.status().as_u16(),
|
||||||
|
"retry_after_present": response.headers().get(RETRY_AFTER).is_some(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
return Err(classify_error_response(response).await);
|
return Err(classify_error_response(response).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_transport_trace(
|
||||||
|
&request,
|
||||||
|
"transport_stream_ready",
|
||||||
|
json!({
|
||||||
|
"elapsed_ms": total_started.elapsed().as_millis() as u64,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let scheme = self.scheme.clone();
|
let scheme = self.scheme.clone();
|
||||||
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||||
let event_stream = byte_stream.eventsource();
|
let event_stream = byte_stream.eventsource();
|
||||||
|
|
@ -449,9 +653,14 @@ mod tests {
|
||||||
assert_eq!(headers.get("x-client-request-id").unwrap(), "segment-123");
|
assert_eq!(headers.get("x-client-request-id").unwrap(), "segment-123");
|
||||||
assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "zstd");
|
assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "zstd");
|
||||||
|
|
||||||
let RequestBody::CompressedJson(compressed) = encoded else {
|
let RequestBody::CompressedJson {
|
||||||
|
bytes: compressed,
|
||||||
|
raw_json_bytes,
|
||||||
|
} = encoded
|
||||||
|
else {
|
||||||
panic!("Codex backend request body must be zstd-compressed");
|
panic!("Codex backend request body must be zstd-compressed");
|
||||||
};
|
};
|
||||||
|
assert!(raw_json_bytes > 0);
|
||||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
||||||
let decoded: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
let decoded: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
//! - ToolResult items (tool results)
|
//! - ToolResult items (tool results)
|
||||||
//! - Reasoning items (extended thinking)
|
//! - Reasoning items (extended thinking)
|
||||||
|
|
||||||
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
fn is_false(value: &bool) -> bool {
|
fn is_false(value: &bool) -> bool {
|
||||||
|
|
@ -23,6 +25,35 @@ pub type ItemId = String;
|
||||||
/// Call ID type for linking function calls to their outputs
|
/// Call ID type for linking function calls to their outputs
|
||||||
pub type CallId = String;
|
pub type CallId = String;
|
||||||
|
|
||||||
|
/// Callback sink for request-local transport lifecycle diagnostics.
|
||||||
|
///
|
||||||
|
/// This is carried on [`Request`] so generic [`crate::llm_client::LlmClient`]
|
||||||
|
/// implementations can emit fine-grained transport milestones without widening
|
||||||
|
/// the trait method signature. The callback must never receive request body
|
||||||
|
/// contents or secret header values.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RequestTrace {
|
||||||
|
callback: Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestTrace {
|
||||||
|
pub fn new(callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static) -> Self {
|
||||||
|
Self {
|
||||||
|
callback: Arc::new(callback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit(&self, label: &str, data: serde_json::Value) {
|
||||||
|
(self.callback)(label, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for RequestTrace {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("RequestTrace").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Conversation item - the primary unit of conversation history
|
/// Conversation item - the primary unit of conversation history
|
||||||
///
|
///
|
||||||
/// Items represent discrete elements in a conversation. Tool calls and reasoning
|
/// Items represent discrete elements in a conversation. Tool calls and reasoning
|
||||||
|
|
@ -497,6 +528,9 @@ pub struct Request {
|
||||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||||
/// `prompt_cache_key` を持たない provider は無視する。
|
/// `prompt_cache_key` を持たない provider は無視する。
|
||||||
pub cache_key: Option<String>,
|
pub cache_key: Option<String>,
|
||||||
|
/// Request-local diagnostics sink for transport lifecycle tracing.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub transport_trace: Option<RequestTrace>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Request {
|
impl Request {
|
||||||
|
|
@ -547,6 +581,15 @@ impl Request {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a request-local transport trace callback.
|
||||||
|
pub fn transport_trace(
|
||||||
|
mut self,
|
||||||
|
callback: impl Fn(&str, serde_json::Value) + Send + Sync + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.transport_trace = Some(RequestTrace::new(callback));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set max tokens
|
/// Set max tokens
|
||||||
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
|
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
|
||||||
self.config.max_tokens = Some(max_tokens);
|
self.config.max_tokens = Some(max_tokens);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::{marker::PhantomData, time::Instant};
|
use std::{marker::PhantomData, sync::Arc, time::Instant};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -207,7 +207,7 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||||
stream_event_cbs: Vec<Box<dyn Fn(usize, usize, &Event) + Send + Sync>>,
|
stream_event_cbs: Vec<Box<dyn Fn(usize, usize, &Event) + Send + Sync>>,
|
||||||
/// Pre-stream lifecycle callbacks for debugging stalls before provider
|
/// Pre-stream lifecycle callbacks for debugging stalls before provider
|
||||||
/// stream events become visible.
|
/// stream events become visible.
|
||||||
lifecycle_trace_cbs: Vec<Box<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
|
lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
|
||||||
/// Non-fatal warning callbacks. Invoked when the Worker wants to
|
/// Non-fatal warning callbacks. Invoked when the Worker wants to
|
||||||
/// surface an advisory message to the upper layer (e.g. Pod) so it
|
/// surface an advisory message to the upper layer (e.g. Pod) so it
|
||||||
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
||||||
|
|
@ -435,7 +435,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
&mut self,
|
&mut self,
|
||||||
callback: impl Fn(usize, usize, &str, &Value) + Send + Sync + 'static,
|
callback: impl Fn(usize, usize, &str, &Value) + Send + Sync + 'static,
|
||||||
) {
|
) {
|
||||||
self.lifecycle_trace_cbs.push(Box::new(callback));
|
self.lifecycle_trace_cbs.push(Arc::new(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_lifecycle_trace(&self, turn: usize, llm_call: usize, label: &str, data: Value) {
|
fn emit_lifecycle_trace(&self, turn: usize, llm_call: usize, label: &str, data: Value) {
|
||||||
|
|
@ -444,6 +444,19 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attach_transport_trace(&self, request: Request, turn: usize, llm_call: usize) -> Request {
|
||||||
|
if self.lifecycle_trace_cbs.is_empty() {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
let callbacks = self.lifecycle_trace_cbs.clone();
|
||||||
|
request.transport_trace(move |label, data| {
|
||||||
|
for cb in &callbacks {
|
||||||
|
cb(turn, llm_call, label, &data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a non-fatal warning callback.
|
/// Register a non-fatal warning callback.
|
||||||
///
|
///
|
||||||
/// The callback is invoked with a short human-readable message
|
/// The callback is invoked with a short human-readable message
|
||||||
|
|
@ -1169,6 +1182,10 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
self.last_run_interrupted = true;
|
self.last_run_interrupted = true;
|
||||||
return Ok(WorkerResult::Yielded);
|
return Ok(WorkerResult::Yielded);
|
||||||
}
|
}
|
||||||
|
PreRequestAction::ContinueWith(items) => {
|
||||||
|
self.append_history_items(items.clone());
|
||||||
|
request_context.extend(items);
|
||||||
|
}
|
||||||
PreRequestAction::Continue => {}
|
PreRequestAction::Continue => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1194,6 +1211,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
"build_request_done",
|
"build_request_done",
|
||||||
self.request_trace_payload(&request),
|
self.request_trace_payload(&request),
|
||||||
);
|
);
|
||||||
|
let request = self.attach_transport_trace(request, current_turn, current_llm_call);
|
||||||
let stream_outcome = self
|
let stream_outcome = self
|
||||||
.stream_response(request, current_turn, current_llm_call)
|
.stream_response(request, current_turn, current_llm_call)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -125,18 +125,34 @@ pub struct CompactionConfigPartial {
|
||||||
pub prune_protected_tokens: Option<u64>,
|
pub prune_protected_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prune_min_savings: Option<u64>,
|
pub prune_min_savings: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_threshold")]
|
||||||
|
pub threshold: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_request_threshold")]
|
||||||
|
pub request_threshold: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_retained_tokens")]
|
||||||
|
pub retained_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_threshold: Option<u64>,
|
pub overview_target_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_request_threshold: Option<u64>,
|
pub overview_warning_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_retained_tokens: Option<u64>,
|
pub overview_deadline_tokens: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_worker_max_input_tokens")]
|
||||||
|
pub worker_context_max_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_auto_read_budget: Option<u64>,
|
pub finish_warning_remaining_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_worker_max_input_tokens: Option<u64>,
|
pub final_reserve_tokens: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_worker_max_turns")]
|
||||||
|
pub worker_max_turns: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_worker_max_turns: Option<u32>,
|
pub summary_target_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary_max_tokens: Option<u64>,
|
||||||
|
#[serde(default, alias = "compact_auto_read_budget")]
|
||||||
|
pub auto_read_budget_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub result_context_max_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: Option<ModelManifest>,
|
pub model: Option<ModelManifest>,
|
||||||
}
|
}
|
||||||
|
|
@ -386,22 +402,32 @@ impl CompactionConfigPartial {
|
||||||
Self {
|
Self {
|
||||||
prune_protected_tokens: upper.prune_protected_tokens.or(self.prune_protected_tokens),
|
prune_protected_tokens: upper.prune_protected_tokens.or(self.prune_protected_tokens),
|
||||||
prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
|
prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
|
||||||
compact_threshold: upper.compact_threshold.or(self.compact_threshold),
|
threshold: upper.threshold.or(self.threshold),
|
||||||
compact_request_threshold: upper
|
request_threshold: upper.request_threshold.or(self.request_threshold),
|
||||||
.compact_request_threshold
|
retained_tokens: upper.retained_tokens.or(self.retained_tokens),
|
||||||
.or(self.compact_request_threshold),
|
overview_target_tokens: upper.overview_target_tokens.or(self.overview_target_tokens),
|
||||||
compact_retained_tokens: upper
|
overview_warning_tokens: upper
|
||||||
.compact_retained_tokens
|
.overview_warning_tokens
|
||||||
.or(self.compact_retained_tokens),
|
.or(self.overview_warning_tokens),
|
||||||
compact_auto_read_budget: upper
|
overview_deadline_tokens: upper
|
||||||
.compact_auto_read_budget
|
.overview_deadline_tokens
|
||||||
.or(self.compact_auto_read_budget),
|
.or(self.overview_deadline_tokens),
|
||||||
compact_worker_max_input_tokens: upper
|
worker_context_max_tokens: upper
|
||||||
.compact_worker_max_input_tokens
|
.worker_context_max_tokens
|
||||||
.or(self.compact_worker_max_input_tokens),
|
.or(self.worker_context_max_tokens),
|
||||||
compact_worker_max_turns: upper
|
finish_warning_remaining_tokens: upper
|
||||||
.compact_worker_max_turns
|
.finish_warning_remaining_tokens
|
||||||
.or(self.compact_worker_max_turns),
|
.or(self.finish_warning_remaining_tokens),
|
||||||
|
final_reserve_tokens: upper.final_reserve_tokens.or(self.final_reserve_tokens),
|
||||||
|
worker_max_turns: upper.worker_max_turns.or(self.worker_max_turns),
|
||||||
|
summary_target_tokens: upper.summary_target_tokens.or(self.summary_target_tokens),
|
||||||
|
summary_max_tokens: upper.summary_max_tokens.or(self.summary_max_tokens),
|
||||||
|
auto_read_budget_tokens: upper
|
||||||
|
.auto_read_budget_tokens
|
||||||
|
.or(self.auto_read_budget_tokens),
|
||||||
|
result_context_max_tokens: upper
|
||||||
|
.result_context_max_tokens
|
||||||
|
.or(self.result_context_max_tokens),
|
||||||
model: merge_option(self.model, upper.model, ModelManifest::merge),
|
model: merge_option(self.model, upper.model, ModelManifest::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -544,20 +570,42 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
.prune_protected_tokens
|
.prune_protected_tokens
|
||||||
.unwrap_or(defaults::PRUNE_PROTECTED_TOKENS),
|
.unwrap_or(defaults::PRUNE_PROTECTED_TOKENS),
|
||||||
prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
|
prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
|
||||||
compact_threshold: c.compact_threshold,
|
threshold: c.threshold,
|
||||||
compact_request_threshold: c.compact_request_threshold,
|
request_threshold: c.request_threshold,
|
||||||
compact_retained_tokens: c
|
retained_tokens: c
|
||||||
.compact_retained_tokens
|
.retained_tokens
|
||||||
.unwrap_or(defaults::COMPACT_RETAINED_TOKENS),
|
.unwrap_or(defaults::COMPACT_RETAINED_TOKENS),
|
||||||
compact_auto_read_budget: c
|
overview_target_tokens: c
|
||||||
.compact_auto_read_budget
|
.overview_target_tokens
|
||||||
.unwrap_or(defaults::COMPACT_AUTO_READ_BUDGET),
|
.unwrap_or(defaults::COMPACT_OVERVIEW_TARGET_TOKENS),
|
||||||
compact_worker_max_input_tokens: c
|
overview_warning_tokens: c
|
||||||
.compact_worker_max_input_tokens
|
.overview_warning_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_OVERVIEW_WARNING_TOKENS),
|
||||||
|
overview_deadline_tokens: c
|
||||||
|
.overview_deadline_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_OVERVIEW_DEADLINE_TOKENS),
|
||||||
|
worker_context_max_tokens: c
|
||||||
|
.worker_context_max_tokens
|
||||||
.unwrap_or(defaults::COMPACT_WORKER_MAX_INPUT_TOKENS),
|
.unwrap_or(defaults::COMPACT_WORKER_MAX_INPUT_TOKENS),
|
||||||
compact_worker_max_turns: c
|
finish_warning_remaining_tokens: c
|
||||||
.compact_worker_max_turns
|
.finish_warning_remaining_tokens
|
||||||
.or(defaults::COMPACT_WORKER_MAX_TURNS),
|
.unwrap_or(defaults::COMPACT_FINISH_WARNING_REMAINING_TOKENS),
|
||||||
|
final_reserve_tokens: c
|
||||||
|
.final_reserve_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_FINAL_RESERVE_TOKENS),
|
||||||
|
worker_max_turns: c.worker_max_turns.or(defaults::COMPACT_WORKER_MAX_TURNS),
|
||||||
|
summary_target_tokens: c
|
||||||
|
.summary_target_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_SUMMARY_TARGET_TOKENS),
|
||||||
|
summary_max_tokens: c
|
||||||
|
.summary_max_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_SUMMARY_MAX_TOKENS),
|
||||||
|
auto_read_budget_tokens: c
|
||||||
|
.auto_read_budget_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_AUTO_READ_BUDGET),
|
||||||
|
result_context_max_tokens: c
|
||||||
|
.result_context_max_tokens
|
||||||
|
.unwrap_or(defaults::COMPACT_RESULT_CONTEXT_MAX_TOKENS),
|
||||||
model: c.model,
|
model: c.model,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -984,7 +1032,7 @@ mod tests {
|
||||||
fn merge_option_struct_field_wise() {
|
fn merge_option_struct_field_wise() {
|
||||||
let lower = PodManifestConfig {
|
let lower = PodManifestConfig {
|
||||||
compaction: Some(CompactionConfigPartial {
|
compaction: Some(CompactionConfigPartial {
|
||||||
compact_threshold: Some(50_000),
|
threshold: Some(50_000),
|
||||||
prune_protected_tokens: Some(5_000),
|
prune_protected_tokens: Some(5_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
|
@ -992,14 +1040,14 @@ mod tests {
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = PodManifestConfig {
|
||||||
compaction: Some(CompactionConfigPartial {
|
compaction: Some(CompactionConfigPartial {
|
||||||
compact_threshold: Some(80_000),
|
threshold: Some(80_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
let c = merged.compaction.unwrap();
|
let c = merged.compaction.unwrap();
|
||||||
assert_eq!(c.compact_threshold, Some(80_000));
|
assert_eq!(c.threshold, Some(80_000));
|
||||||
// field from lower retained when upper has None
|
// field from lower retained when upper has None
|
||||||
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
||||||
}
|
}
|
||||||
|
|
@ -1122,27 +1170,27 @@ stop_sequences = ["\n\n", "</stop>"]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_compact_worker_max_turns() {
|
fn from_toml_accepts_worker_max_turns() {
|
||||||
let cfg = PodManifestConfig::from_toml(
|
let cfg = PodManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[compaction]
|
[compaction]
|
||||||
compact_worker_max_turns = 7
|
worker_max_turns = 7
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(cfg.compaction.unwrap().compact_worker_max_turns, Some(7));
|
assert_eq!(cfg.compaction.unwrap().worker_max_turns, Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn try_from_compaction_defaults_compact_worker_max_turns() {
|
fn try_from_compaction_defaults_worker_max_turns() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.compaction = Some(CompactionConfigPartial::default());
|
cfg.compaction = Some(CompactionConfigPartial::default());
|
||||||
|
|
||||||
let manifest = PodManifest::try_from(cfg).unwrap();
|
let manifest = PodManifest::try_from(cfg).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.compaction.unwrap().compact_worker_max_turns,
|
manifest.compaction.unwrap().worker_max_turns,
|
||||||
defaults::COMPACT_WORKER_MAX_TURNS
|
defaults::COMPACT_WORKER_MAX_TURNS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,23 @@ pub const PRUNE_MIN_SAVINGS: u64 = 4096;
|
||||||
/// Token budget retained (unchanged) at the tail of the history across
|
/// Token budget retained (unchanged) at the tail of the history across
|
||||||
/// a compact. Items whose cumulative token count fits within this budget
|
/// a compact. Items whose cumulative token count fits within this budget
|
||||||
/// starting from the end are kept verbatim; the rest are summarised.
|
/// starting from the end are kept verbatim; the rest are summarised.
|
||||||
/// See [`crate::CompactionConfig::compact_retained_tokens`].
|
/// See [`crate::CompactionConfig::retained_tokens`].
|
||||||
pub const COMPACT_RETAINED_TOKENS: u64 = 8000;
|
pub const COMPACT_RETAINED_TOKENS: u64 = 8000;
|
||||||
|
|
||||||
|
/// Target size for the deterministic compact overview/index fed to the
|
||||||
|
/// compact worker. Exceeding this target is tolerated.
|
||||||
|
/// See [`crate::CompactionConfig::overview_target_tokens`].
|
||||||
|
pub const COMPACT_OVERVIEW_TARGET_TOKENS: u64 = 8_000;
|
||||||
|
|
||||||
|
/// Warning threshold for compact overview/index size. Compaction continues.
|
||||||
|
/// See [`crate::CompactionConfig::overview_warning_tokens`].
|
||||||
|
pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000;
|
||||||
|
|
||||||
|
/// Hard deterministic-overview deadline. When exceeded, overview generation
|
||||||
|
/// falls back to a coarser index before the compact worker is started.
|
||||||
|
/// See [`crate::CompactionConfig::overview_deadline_tokens`].
|
||||||
|
pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
|
||||||
|
|
||||||
/// Default instruction asset reference used when `worker.instruction`
|
/// Default instruction asset reference used when `worker.instruction`
|
||||||
/// is omitted. See the `PromptLoader` prefix addressing scheme for the
|
/// is omitted. See the `PromptLoader` prefix addressing scheme for the
|
||||||
/// `$insomnia/` / `$user/` / `$workspace/` namespaces.
|
/// `$insomnia/` / `$user/` / `$workspace/` namespaces.
|
||||||
|
|
@ -42,19 +56,39 @@ pub const WORKER_LANGUAGE: &str =
|
||||||
/// session after compaction. Limits how much raw file text the
|
/// session after compaction. Limits how much raw file text the
|
||||||
/// compact worker can pull into the compacted context via
|
/// compact worker can pull into the compacted context via
|
||||||
/// `mark_read_required`. See
|
/// `mark_read_required`. See
|
||||||
/// [`crate::CompactionConfig::compact_auto_read_budget`].
|
/// [`crate::CompactionConfig::auto_read_budget_tokens`].
|
||||||
pub const COMPACT_AUTO_READ_BUDGET: u64 = 8000;
|
pub const COMPACT_AUTO_READ_BUDGET: u64 = 8000;
|
||||||
|
|
||||||
/// Current prompt-occupancy cap for the compact worker's own LLM
|
/// Current prompt-occupancy cap for the compact worker's own LLM
|
||||||
/// calls. Exceeding this aborts the compact run (circuit-breaker
|
/// calls. Exceeding this aborts the compact run (circuit-breaker
|
||||||
/// path). See
|
/// path). See [`crate::CompactionConfig::worker_context_max_tokens`].
|
||||||
/// [`crate::CompactionConfig::compact_worker_max_input_tokens`].
|
|
||||||
pub const COMPACT_WORKER_MAX_INPUT_TOKENS: u64 = 50_000;
|
pub const COMPACT_WORKER_MAX_INPUT_TOKENS: u64 = 50_000;
|
||||||
|
|
||||||
|
/// Remaining compact-worker context threshold that triggers an instruction
|
||||||
|
/// to stop exploring and call `write_summary`.
|
||||||
|
/// See [`crate::CompactionConfig::finish_warning_remaining_tokens`].
|
||||||
|
pub const COMPACT_FINISH_WARNING_REMAINING_TOKENS: u64 = 8_000;
|
||||||
|
|
||||||
|
/// Context reserve preserved for final summary/tool closing turns.
|
||||||
|
/// See [`crate::CompactionConfig::final_reserve_tokens`].
|
||||||
|
pub const COMPACT_FINAL_RESERVE_TOKENS: u64 = 4_000;
|
||||||
|
|
||||||
/// Optional maximum compact-worker tool-loop depth. `None` means unlimited.
|
/// Optional maximum compact-worker tool-loop depth. `None` means unlimited.
|
||||||
/// See [`crate::CompactionConfig::compact_worker_max_turns`].
|
/// See [`crate::CompactionConfig::worker_max_turns`].
|
||||||
pub const COMPACT_WORKER_MAX_TURNS: Option<u32> = Some(20);
|
pub const COMPACT_WORKER_MAX_TURNS: Option<u32> = Some(20);
|
||||||
|
|
||||||
|
/// Target size for the `write_summary` text. Used in prompt/nudge text.
|
||||||
|
/// See [`crate::CompactionConfig::summary_target_tokens`].
|
||||||
|
pub const COMPACT_SUMMARY_TARGET_TOKENS: u64 = 2_000;
|
||||||
|
|
||||||
|
/// Hard validation cap for the final `write_summary` text.
|
||||||
|
/// See [`crate::CompactionConfig::summary_max_tokens`].
|
||||||
|
pub const COMPACT_SUMMARY_MAX_TOKENS: u64 = 4_000;
|
||||||
|
|
||||||
|
/// Dry-run cap for the compacted session's initial request context.
|
||||||
|
/// See [`crate::CompactionConfig::result_context_max_tokens`].
|
||||||
|
pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
|
||||||
|
|
||||||
/// Number of recently-touched files fed to the compact worker as
|
/// Number of recently-touched files fed to the compact worker as
|
||||||
/// default references.
|
/// default references.
|
||||||
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
||||||
|
|
|
||||||
|
|
@ -363,8 +363,8 @@ pub struct CompactionConfig {
|
||||||
/// Checked by the Controller after each run. When current occupancy
|
/// Checked by the Controller after each run. When current occupancy
|
||||||
/// exceeds this value, compact runs before the next turn. `None`
|
/// exceeds this value, compact runs before the next turn. `None`
|
||||||
/// disables the between-turns check.
|
/// disables the between-turns check.
|
||||||
#[serde(default)]
|
#[serde(default, alias = "compact_threshold")]
|
||||||
pub compact_threshold: Option<u64>,
|
pub threshold: Option<u64>,
|
||||||
|
|
||||||
/// Safety-net (between-requests) compaction threshold.
|
/// Safety-net (between-requests) compaction threshold.
|
||||||
///
|
///
|
||||||
|
|
@ -373,32 +373,76 @@ pub struct CompactionConfig {
|
||||||
/// Controller can compact before the next LLM request. `None`
|
/// Controller can compact before the next LLM request. `None`
|
||||||
/// disables the between-requests check.
|
/// disables the between-requests check.
|
||||||
///
|
///
|
||||||
/// Expected relation: `compact_threshold < compact_request_threshold`
|
/// Expected relation: `threshold < request_threshold` (proactive triggers
|
||||||
/// (proactive triggers before safety net). A reversed configuration
|
/// before safety net). A reversed configuration is accepted but logged as
|
||||||
/// is accepted but logged as a warning.
|
/// a warning.
|
||||||
#[serde(default)]
|
#[serde(default, alias = "compact_request_threshold")]
|
||||||
pub compact_request_threshold: Option<u64>,
|
pub request_threshold: Option<u64>,
|
||||||
|
|
||||||
/// Token budget retained verbatim at the tail of the history after
|
/// Token budget retained verbatim at the tail of the history after
|
||||||
/// compaction. Measured against the occupancy estimate from
|
/// compaction. Measured against the occupancy estimate from
|
||||||
/// `UsageRecord` history; turn boundaries are ignored.
|
/// `UsageRecord` history; turn boundaries are ignored.
|
||||||
#[serde(default = "default_compact_retained_tokens")]
|
#[serde(default = "default_retained_tokens", alias = "compact_retained_tokens")]
|
||||||
pub compact_retained_tokens: u64,
|
pub retained_tokens: u64,
|
||||||
|
|
||||||
/// Aggregate token budget for auto-read file contents injected into
|
/// Target size for the deterministic overview/index fed to the compact
|
||||||
/// the compacted session by the compact worker.
|
/// worker. Overshooting this target is not an error.
|
||||||
#[serde(default = "default_compact_auto_read_budget")]
|
#[serde(default = "default_overview_target_tokens")]
|
||||||
pub compact_auto_read_budget: u64,
|
pub overview_target_tokens: u64,
|
||||||
|
|
||||||
|
/// Warning threshold for deterministic overview/index size.
|
||||||
|
#[serde(default = "default_overview_warning_tokens")]
|
||||||
|
pub overview_warning_tokens: u64,
|
||||||
|
|
||||||
|
/// Deadline threshold for deterministic overview/index generation.
|
||||||
|
/// Oversized overviews fall back to a coarser deterministic index.
|
||||||
|
#[serde(default = "default_overview_deadline_tokens")]
|
||||||
|
pub overview_deadline_tokens: u64,
|
||||||
|
|
||||||
/// Current prompt-occupancy cap for the compact worker's own LLM
|
/// Current prompt-occupancy cap for the compact worker's own LLM
|
||||||
/// requests. Exceeding this aborts the compact run.
|
/// requests. Exceeding this aborts the compact run.
|
||||||
#[serde(default = "default_compact_worker_max_input_tokens")]
|
#[serde(
|
||||||
pub compact_worker_max_input_tokens: u64,
|
default = "default_worker_context_max_tokens",
|
||||||
|
alias = "compact_worker_max_input_tokens"
|
||||||
|
)]
|
||||||
|
pub worker_context_max_tokens: u64,
|
||||||
|
|
||||||
|
/// Remaining compact-worker context threshold that triggers a warning and
|
||||||
|
/// an instruction to stop exploring and call `write_summary`.
|
||||||
|
#[serde(default = "default_finish_warning_remaining_tokens")]
|
||||||
|
pub finish_warning_remaining_tokens: u64,
|
||||||
|
|
||||||
|
/// Context reserve preserved for final summary/tool closing turns.
|
||||||
|
#[serde(default = "default_final_reserve_tokens")]
|
||||||
|
pub final_reserve_tokens: u64,
|
||||||
|
|
||||||
/// Optional maximum compact-worker tool-loop depth. `None` leaves the
|
/// Optional maximum compact-worker tool-loop depth. `None` leaves the
|
||||||
/// worker unlimited; the default bounds runaway short-context loops.
|
/// worker unlimited; the default bounds runaway short-context loops.
|
||||||
#[serde(default = "default_compact_worker_max_turns")]
|
#[serde(
|
||||||
pub compact_worker_max_turns: Option<u32>,
|
default = "default_worker_max_turns",
|
||||||
|
alias = "compact_worker_max_turns"
|
||||||
|
)]
|
||||||
|
pub worker_max_turns: Option<u32>,
|
||||||
|
|
||||||
|
/// Target size for the `write_summary` text. Used in prompt/nudge text.
|
||||||
|
#[serde(default = "default_summary_target_tokens")]
|
||||||
|
pub summary_target_tokens: u64,
|
||||||
|
|
||||||
|
/// Hard validation cap for the final `write_summary` text.
|
||||||
|
#[serde(default = "default_summary_max_tokens")]
|
||||||
|
pub summary_max_tokens: u64,
|
||||||
|
|
||||||
|
/// Aggregate token budget for auto-read file contents injected into
|
||||||
|
/// the compacted session by the compact worker.
|
||||||
|
#[serde(
|
||||||
|
default = "default_auto_read_budget_tokens",
|
||||||
|
alias = "compact_auto_read_budget"
|
||||||
|
)]
|
||||||
|
pub auto_read_budget_tokens: u64,
|
||||||
|
|
||||||
|
/// Dry-run cap for the compacted session's initial request context.
|
||||||
|
#[serde(default = "default_result_context_max_tokens")]
|
||||||
|
pub result_context_max_tokens: u64,
|
||||||
|
|
||||||
/// Optional model for the compactor (summary) LLM.
|
/// Optional model for the compactor (summary) LLM.
|
||||||
/// If omitted, the main model is cloned via `clone_boxed()`.
|
/// If omitted, the main model is cloned via `clone_boxed()`.
|
||||||
|
|
@ -412,30 +456,62 @@ fn default_prune_protected_tokens() -> u64 {
|
||||||
fn default_prune_min_savings() -> u64 {
|
fn default_prune_min_savings() -> u64 {
|
||||||
defaults::PRUNE_MIN_SAVINGS
|
defaults::PRUNE_MIN_SAVINGS
|
||||||
}
|
}
|
||||||
fn default_compact_retained_tokens() -> u64 {
|
fn default_retained_tokens() -> u64 {
|
||||||
defaults::COMPACT_RETAINED_TOKENS
|
defaults::COMPACT_RETAINED_TOKENS
|
||||||
}
|
}
|
||||||
fn default_compact_auto_read_budget() -> u64 {
|
fn default_overview_target_tokens() -> u64 {
|
||||||
defaults::COMPACT_AUTO_READ_BUDGET
|
defaults::COMPACT_OVERVIEW_TARGET_TOKENS
|
||||||
}
|
}
|
||||||
fn default_compact_worker_max_input_tokens() -> u64 {
|
fn default_overview_warning_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_OVERVIEW_WARNING_TOKENS
|
||||||
|
}
|
||||||
|
fn default_overview_deadline_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_OVERVIEW_DEADLINE_TOKENS
|
||||||
|
}
|
||||||
|
fn default_worker_context_max_tokens() -> u64 {
|
||||||
defaults::COMPACT_WORKER_MAX_INPUT_TOKENS
|
defaults::COMPACT_WORKER_MAX_INPUT_TOKENS
|
||||||
}
|
}
|
||||||
fn default_compact_worker_max_turns() -> Option<u32> {
|
fn default_finish_warning_remaining_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_FINISH_WARNING_REMAINING_TOKENS
|
||||||
|
}
|
||||||
|
fn default_final_reserve_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_FINAL_RESERVE_TOKENS
|
||||||
|
}
|
||||||
|
fn default_worker_max_turns() -> Option<u32> {
|
||||||
defaults::COMPACT_WORKER_MAX_TURNS
|
defaults::COMPACT_WORKER_MAX_TURNS
|
||||||
}
|
}
|
||||||
|
fn default_summary_target_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_SUMMARY_TARGET_TOKENS
|
||||||
|
}
|
||||||
|
fn default_summary_max_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_SUMMARY_MAX_TOKENS
|
||||||
|
}
|
||||||
|
fn default_auto_read_budget_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_AUTO_READ_BUDGET
|
||||||
|
}
|
||||||
|
fn default_result_context_max_tokens() -> u64 {
|
||||||
|
defaults::COMPACT_RESULT_CONTEXT_MAX_TOKENS
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for CompactionConfig {
|
impl Default for CompactionConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
prune_protected_tokens: default_prune_protected_tokens(),
|
prune_protected_tokens: default_prune_protected_tokens(),
|
||||||
prune_min_savings: default_prune_min_savings(),
|
prune_min_savings: default_prune_min_savings(),
|
||||||
compact_threshold: None,
|
threshold: None,
|
||||||
compact_request_threshold: None,
|
request_threshold: None,
|
||||||
compact_retained_tokens: default_compact_retained_tokens(),
|
retained_tokens: default_retained_tokens(),
|
||||||
compact_auto_read_budget: default_compact_auto_read_budget(),
|
overview_target_tokens: default_overview_target_tokens(),
|
||||||
compact_worker_max_input_tokens: default_compact_worker_max_input_tokens(),
|
overview_warning_tokens: default_overview_warning_tokens(),
|
||||||
compact_worker_max_turns: default_compact_worker_max_turns(),
|
overview_deadline_tokens: default_overview_deadline_tokens(),
|
||||||
|
worker_context_max_tokens: default_worker_context_max_tokens(),
|
||||||
|
finish_warning_remaining_tokens: default_finish_warning_remaining_tokens(),
|
||||||
|
final_reserve_tokens: default_final_reserve_tokens(),
|
||||||
|
worker_max_turns: default_worker_max_turns(),
|
||||||
|
summary_target_tokens: default_summary_target_tokens(),
|
||||||
|
summary_max_tokens: default_summary_max_tokens(),
|
||||||
|
auto_read_budget_tokens: default_auto_read_budget_tokens(),
|
||||||
|
result_context_max_tokens: default_result_context_max_tokens(),
|
||||||
model: None,
|
model: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -592,15 +668,15 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_compaction_config() {
|
fn parse_compaction_config() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\ncompact_threshold = 80000\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n");
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.prune_protected_tokens, 8000);
|
assert_eq!(c.prune_protected_tokens, 8000);
|
||||||
assert_eq!(c.prune_min_savings, 4096);
|
assert_eq!(c.prune_min_savings, 4096);
|
||||||
assert_eq!(c.compact_threshold, Some(80000));
|
assert_eq!(c.threshold, Some(80000));
|
||||||
assert_eq!(c.compact_request_threshold, None);
|
assert_eq!(c.request_threshold, None);
|
||||||
assert_eq!(c.compact_retained_tokens, 8000);
|
assert_eq!(c.retained_tokens, 8000);
|
||||||
assert_eq!(c.compact_worker_max_turns, Some(20));
|
assert_eq!(c.worker_max_turns, Some(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -618,11 +694,11 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
compact_worker_max_turns = 7\n"
|
worker_max_turns = 7\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.compact_worker_max_turns, Some(7));
|
assert_eq!(c.worker_max_turns, Some(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -630,13 +706,13 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
compact_threshold = 80000\n\
|
threshold = 80000\n\
|
||||||
compact_request_threshold = 90000\n"
|
request_threshold = 90000\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.compact_threshold, Some(80000));
|
assert_eq!(c.threshold, Some(80000));
|
||||||
assert_eq!(c.compact_request_threshold, Some(90000));
|
assert_eq!(c.request_threshold, Some(90000));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -644,12 +720,12 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
compact_request_threshold = 90000\n"
|
request_threshold = 90000\n"
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
let c = manifest.compaction.unwrap();
|
let c = manifest.compaction.unwrap();
|
||||||
assert_eq!(c.compact_threshold, None);
|
assert_eq!(c.threshold, None);
|
||||||
assert_eq!(c.compact_request_threshold, Some(90000));
|
assert_eq!(c.request_threshold, Some(90000));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -657,7 +733,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[compaction]\n\
|
[compaction]\n\
|
||||||
compact_threshold = 80000\n\n\
|
threshold = 80000\n\n\
|
||||||
[compaction.model]\n\
|
[compaction.model]\n\
|
||||||
scheme = \"gemini\"\n\
|
scheme = \"gemini\"\n\
|
||||||
model_id = \"gemini-2.0-flash\"\n"
|
model_id = \"gemini-2.0-flash\"\n"
|
||||||
|
|
|
||||||
|
|
@ -279,11 +279,11 @@ mod tests {
|
||||||
fn runtime_dir_prefers_xdg_runtime_dir() {
|
fn runtime_dir_prefers_xdg_runtime_dir() {
|
||||||
let _g = EnvGuard::new(&[
|
let _g = EnvGuard::new(&[
|
||||||
("HOME", Some("/h")),
|
("HOME", Some("/h")),
|
||||||
("XDG_RUNTIME_DIR", Some("/run/user/1000")),
|
("XDG_RUNTIME_DIR", Some("/xdg-runtime")),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime_dir().unwrap(),
|
runtime_dir().unwrap(),
|
||||||
PathBuf::from("<runtime-dir>")
|
PathBuf::from("/xdg-runtime/insomnia")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@
|
||||||
//! compacted session's opening system messages.
|
//! compacted session's opening system messages.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::Item;
|
use llm_worker::Item;
|
||||||
use llm_worker::interceptor::{Interceptor, PreRequestAction};
|
use llm_worker::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tools::ScopedFs;
|
use tools::ScopedFs;
|
||||||
|
|
||||||
|
|
@ -83,6 +84,39 @@ struct SummaryParams {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input to `search_session_log`.
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct SearchSessionParams {
|
||||||
|
/// Case-insensitive substring to search in compact-target history.
|
||||||
|
pub query: String,
|
||||||
|
/// 0-based item offset to start searching from.
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
/// Maximum number of hits to return.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input to `read_session_items`.
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct ReadSessionParams {
|
||||||
|
/// 0-based compact-target history item offset.
|
||||||
|
pub offset: usize,
|
||||||
|
/// Maximum number of items to return.
|
||||||
|
pub limit: usize,
|
||||||
|
/// `compact` omits tool arguments/full results; `full` includes message text and tool result content.
|
||||||
|
#[serde(default = "default_session_read_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_session_read_mode() -> String {
|
||||||
|
"compact".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_TOOL_MAX_OUTPUT_TOKENS: u64 = 12_000;
|
||||||
|
const SESSION_SEARCH_MAX_RESULTS: usize = 50;
|
||||||
|
const SESSION_READ_MAX_ITEMS: usize = 80;
|
||||||
|
|
||||||
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
|
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
|
||||||
next session starts with it already read. Use this for files the next task needs in full. \
|
next session starts with it already read. Use this for files the next task needs in full. \
|
||||||
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
|
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
|
||||||
|
|
@ -97,6 +131,236 @@ const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Su
|
||||||
replace the previous content; only the last call is used. Must be called before the compact run \
|
replace the previous content; only the last call is used. Must be called before the compact run \
|
||||||
ends or compaction fails.";
|
ends or compaction fails.";
|
||||||
|
|
||||||
|
const SEARCH_SESSION_DESCRIPTION: &str = "Search the compact-target session history by \
|
||||||
|
case-insensitive substring. Returns item indexes and compact snippets. Use this when the initial \
|
||||||
|
overview is not enough to identify which part of the session matters. Results are bounded; narrow \
|
||||||
|
the query if important details are omitted.";
|
||||||
|
|
||||||
|
const READ_SESSION_DESCRIPTION: &str = "Read a bounded range of compact-target session history \
|
||||||
|
items by 0-based index. mode='compact' omits tool arguments, full tool results, and reasoning \
|
||||||
|
bodies; mode='full' includes message text and tool result content but still remains bounded. Use \
|
||||||
|
this to verify details before writing the summary.";
|
||||||
|
|
||||||
|
struct SessionLogToolState {
|
||||||
|
items: Arc<Vec<Item>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchSessionLogTool {
|
||||||
|
state: Arc<SessionLogToolState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SearchSessionLogTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: SearchSessionParams = serde_json::from_str(input_json).map_err(|e| {
|
||||||
|
ToolError::InvalidArgument(format!("invalid search_session_log input: {e}"))
|
||||||
|
})?;
|
||||||
|
let query = params.query.trim().to_lowercase();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"search_session_log query must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let offset = params.offset.unwrap_or(0).min(self.state.items.len());
|
||||||
|
let limit = params
|
||||||
|
.limit
|
||||||
|
.unwrap_or(20)
|
||||||
|
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for (idx, item) in self.state.items.iter().enumerate().skip(offset) {
|
||||||
|
let haystack = session_item_search_text(item).to_lowercase();
|
||||||
|
if haystack.contains(&query) {
|
||||||
|
hits.push(format_session_item(
|
||||||
|
idx,
|
||||||
|
item,
|
||||||
|
SessionReadMode::Compact,
|
||||||
|
600,
|
||||||
|
));
|
||||||
|
if hits.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut content = hits.join("\n\n");
|
||||||
|
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||||
|
let summary = if hits.is_empty() {
|
||||||
|
format!("No session log hits for {query:?} from item offset {offset}.")
|
||||||
|
} else if truncated {
|
||||||
|
format!(
|
||||||
|
"Found {} session log hit(s) for {query:?}; output truncated. Narrow the query.",
|
||||||
|
hits.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Found {} session log hit(s) for {query:?}.", hits.len())
|
||||||
|
};
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: (!content.is_empty()).then_some(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReadSessionItemsTool {
|
||||||
|
state: Arc<SessionLogToolState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ReadSessionItemsTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: ReadSessionParams = serde_json::from_str(input_json).map_err(|e| {
|
||||||
|
ToolError::InvalidArgument(format!("invalid read_session_items input: {e}"))
|
||||||
|
})?;
|
||||||
|
let mode = SessionReadMode::parse(¶ms.mode)?;
|
||||||
|
let offset = params.offset.min(self.state.items.len());
|
||||||
|
let limit = params.limit.clamp(1, SESSION_READ_MAX_ITEMS);
|
||||||
|
let end = offset.saturating_add(limit).min(self.state.items.len());
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
for idx in offset..end {
|
||||||
|
blocks.push(format_session_item(
|
||||||
|
idx,
|
||||||
|
&self.state.items[idx],
|
||||||
|
mode,
|
||||||
|
4_000,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut content = blocks.join("\n\n");
|
||||||
|
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||||
|
let summary = if truncated {
|
||||||
|
format!(
|
||||||
|
"Read session items {offset}..{end} in {mode:?} mode; output truncated. Narrow the range."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Read session items {offset}..{end} in {mode:?} mode.")
|
||||||
|
};
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: (!content.is_empty()).then_some(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SessionReadMode {
|
||||||
|
Compact,
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionReadMode {
|
||||||
|
fn parse(value: &str) -> Result<Self, ToolError> {
|
||||||
|
match value {
|
||||||
|
"compact" => Ok(Self::Compact),
|
||||||
|
"full" => Ok(Self::Full),
|
||||||
|
other => Err(ToolError::InvalidArgument(format!(
|
||||||
|
"invalid read_session_items mode {other:?}; expected 'compact' or 'full'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_item_search_text(item: &Item) -> String {
|
||||||
|
match item {
|
||||||
|
Item::Message { role, content, .. } => format!(
|
||||||
|
"{:?} {}",
|
||||||
|
role,
|
||||||
|
content
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.as_text())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
),
|
||||||
|
Item::ToolCall {
|
||||||
|
name, arguments, ..
|
||||||
|
} => format!("tool_call {name} {arguments}"),
|
||||||
|
Item::ToolResult {
|
||||||
|
summary, content, ..
|
||||||
|
} => format!(
|
||||||
|
"tool_result {summary} {}",
|
||||||
|
content.as_deref().unwrap_or_default()
|
||||||
|
),
|
||||||
|
Item::Reasoning { text, summary, .. } => format!("reasoning {text} {}", summary.join(" ")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_session_item(idx: usize, item: &Item, mode: SessionReadMode, max_chars: usize) -> String {
|
||||||
|
match item {
|
||||||
|
Item::Message { role, content, .. } => {
|
||||||
|
let text = content
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.as_text())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
format!(
|
||||||
|
"[{idx} Message {:?}] {}",
|
||||||
|
role,
|
||||||
|
truncate_chars(&text, max_chars)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Item::ToolCall {
|
||||||
|
name, arguments, ..
|
||||||
|
} => match mode {
|
||||||
|
SessionReadMode::Compact => format!("[{idx} ToolCall] {name} (arguments omitted)"),
|
||||||
|
SessionReadMode::Full => format!(
|
||||||
|
"[{idx} ToolCall] {name}\narguments: {}",
|
||||||
|
truncate_chars(arguments, max_chars)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Item::ToolResult {
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => match mode {
|
||||||
|
SessionReadMode::Compact => format!(
|
||||||
|
"[{idx} ToolResult{}] {} (content omitted)",
|
||||||
|
if *is_error { " error" } else { "" },
|
||||||
|
truncate_chars(summary, 800)
|
||||||
|
),
|
||||||
|
SessionReadMode::Full => format!(
|
||||||
|
"[{idx} ToolResult{}] {}\ncontent: {}",
|
||||||
|
if *is_error { " error" } else { "" },
|
||||||
|
truncate_chars(summary, 800),
|
||||||
|
truncate_chars(content.as_deref().unwrap_or(""), max_chars)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Item::Reasoning { summary, .. } => match mode {
|
||||||
|
SessionReadMode::Compact => format!(
|
||||||
|
"[{idx} Reasoning] {} (body omitted)",
|
||||||
|
truncate_chars(&summary.join(" "), 800)
|
||||||
|
),
|
||||||
|
SessionReadMode::Full => format!(
|
||||||
|
"[{idx} Reasoning] {} (body omitted)",
|
||||||
|
truncate_chars(&summary.join(" "), 800)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||||
|
if text.chars().count() <= max_chars {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let mut out = text.chars().take(max_chars).collect::<String>();
|
||||||
|
out.push_str("… [truncated]");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
|
||||||
|
let max_bytes = max_tokens.saturating_mul(4) as usize;
|
||||||
|
if text.len() <= max_bytes {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut cut = 0;
|
||||||
|
for (idx, _) in text.char_indices() {
|
||||||
|
if idx > max_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cut = idx;
|
||||||
|
}
|
||||||
|
text.truncate(cut);
|
||||||
|
text.push_str("\n… [session tool output truncated]");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
struct MarkReadRequiredTool {
|
struct MarkReadRequiredTool {
|
||||||
fs: ScopedFs,
|
fs: ScopedFs,
|
||||||
ctx: Arc<Mutex<CompactWorkerContext>>,
|
ctx: Arc<Mutex<CompactWorkerContext>>,
|
||||||
|
|
@ -246,14 +510,93 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interceptor that aborts the compact worker when its current prompt
|
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||||
/// occupancy estimate crosses `max_input_tokens`. The estimate uses the same
|
let state = Arc::new(SessionLogToolState { items });
|
||||||
/// `UsageRecord` + `llm_worker::token_counter::total_tokens` path as the main
|
Arc::new(move || {
|
||||||
/// Pod compaction thresholds, so prompt-cache hits are not counted cumulatively
|
let schema = schemars::schema_for!(SearchSessionParams);
|
||||||
/// across turns.
|
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||||
|
let meta = ToolMeta::new("search_session_log")
|
||||||
|
.description(SEARCH_SESSION_DESCRIPTION)
|
||||||
|
.input_schema(schema_value);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
||||||
|
state: state.clone(),
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||||
|
let state = Arc::new(SessionLogToolState { items });
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema = schemars::schema_for!(ReadSessionParams);
|
||||||
|
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||||
|
let meta = ToolMeta::new("read_session_items")
|
||||||
|
.description(READ_SESSION_DESCRIPTION)
|
||||||
|
.input_schema(schema_value);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
||||||
|
state: state.clone(),
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interceptor that monitors compact-worker context occupancy.
|
||||||
|
///
|
||||||
|
/// `max_input_tokens` remains the hard circuit breaker. Before that point,
|
||||||
|
/// the interceptor can persist a system warning into worker history telling
|
||||||
|
/// the model to stop broad exploration and call `write_summary`, and can block
|
||||||
|
/// additional exploratory tool calls once the final reserve is reached.
|
||||||
pub(crate) struct CompactWorkerInterceptor {
|
pub(crate) struct CompactWorkerInterceptor {
|
||||||
pub usage_tracker: Arc<UsageTracker>,
|
pub usage_tracker: Arc<UsageTracker>,
|
||||||
pub max_input_tokens: u64,
|
pub max_input_tokens: u64,
|
||||||
|
pub finish_warning_remaining_tokens: u64,
|
||||||
|
pub final_reserve_tokens: u64,
|
||||||
|
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
||||||
|
warning_sent: AtomicBool,
|
||||||
|
last_remaining_tokens: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactWorkerInterceptor {
|
||||||
|
pub(crate) fn new(
|
||||||
|
usage_tracker: Arc<UsageTracker>,
|
||||||
|
max_input_tokens: u64,
|
||||||
|
finish_warning_remaining_tokens: u64,
|
||||||
|
final_reserve_tokens: u64,
|
||||||
|
on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
usage_tracker,
|
||||||
|
max_input_tokens,
|
||||||
|
finish_warning_remaining_tokens,
|
||||||
|
final_reserve_tokens,
|
||||||
|
on_warning,
|
||||||
|
warning_sent: AtomicBool::new(false),
|
||||||
|
last_remaining_tokens: AtomicU64::new(max_input_tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_emit_warning(&self, remaining: u64) -> Option<Item> {
|
||||||
|
let warning_threshold = self.finish_warning_remaining_tokens;
|
||||||
|
let reserve_threshold = self.final_reserve_tokens;
|
||||||
|
let should_warn = (warning_threshold > 0 && remaining <= warning_threshold)
|
||||||
|
|| (reserve_threshold > 0 && remaining <= reserve_threshold);
|
||||||
|
if !should_warn || self.warning_sent.swap(true, Ordering::AcqRel) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = format!(
|
||||||
|
"compact worker context budget is low ({remaining}/{} tokens remaining). \
|
||||||
|
Stop broad exploration now, read only if absolutely necessary, then call \
|
||||||
|
`write_summary` with the final structured summary.",
|
||||||
|
self.max_input_tokens
|
||||||
|
);
|
||||||
|
if let Some(cb) = self.on_warning.as_ref() {
|
||||||
|
cb(message.clone());
|
||||||
|
}
|
||||||
|
Some(Item::system_message(format!(
|
||||||
|
"[Compact worker budget warning]\n\n{message}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -268,9 +611,31 @@ impl Interceptor for CompactWorkerInterceptor {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
|
||||||
|
self.last_remaining_tokens
|
||||||
|
.store(remaining, Ordering::Release);
|
||||||
|
if let Some(item) = self.maybe_emit_warning(remaining) {
|
||||||
|
self.usage_tracker.note_request(context.len() + 1);
|
||||||
|
return PreRequestAction::ContinueWith(vec![item]);
|
||||||
|
}
|
||||||
|
|
||||||
self.usage_tracker.note_request(context.len());
|
self.usage_tracker.note_request(context.len());
|
||||||
PreRequestAction::Continue
|
PreRequestAction::Continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||||
|
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
|
||||||
|
return PreToolAction::Continue;
|
||||||
|
}
|
||||||
|
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
|
||||||
|
if remaining > self.final_reserve_tokens {
|
||||||
|
return PreToolAction::Continue;
|
||||||
|
}
|
||||||
|
PreToolAction::SyntheticResult(ToolResult::error(
|
||||||
|
info.call.id.clone(),
|
||||||
|
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crude bytes→tokens estimate; good enough for budget accounting.
|
/// Crude bytes→tokens estimate; good enough for budget accounting.
|
||||||
|
|
@ -301,10 +666,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compact_worker_interceptor_uses_occupancy_not_cumulative_usage() {
|
async fn compact_worker_interceptor_uses_occupancy_not_cumulative_usage() {
|
||||||
let tracker = Arc::new(UsageTracker::new());
|
let tracker = Arc::new(UsageTracker::new());
|
||||||
let interceptor = CompactWorkerInterceptor {
|
let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 150, 0, 0, None);
|
||||||
usage_tracker: tracker.clone(),
|
|
||||||
max_input_tokens: 150,
|
|
||||||
};
|
|
||||||
let mut context = vec![Item::user_message("hello")];
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -327,13 +689,40 @@ mod tests {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn compact_worker_interceptor_warns_before_hard_cap() {
|
||||||
|
let tracker = Arc::new(UsageTracker::new());
|
||||||
|
let warnings = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let captured = warnings.clone();
|
||||||
|
let interceptor = CompactWorkerInterceptor::new(
|
||||||
|
tracker.clone(),
|
||||||
|
150,
|
||||||
|
60,
|
||||||
|
20,
|
||||||
|
Some(Arc::new(move |message| {
|
||||||
|
captured.lock().unwrap().push(message);
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
interceptor.pre_llm_request(&mut context).await,
|
||||||
|
PreRequestAction::Continue
|
||||||
|
));
|
||||||
|
tracker.record_usage(&make_usage(100));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
interceptor.pre_llm_request(&mut context).await,
|
||||||
|
PreRequestAction::ContinueWith(items)
|
||||||
|
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
|
||||||
|
));
|
||||||
|
assert_eq!(warnings.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compact_worker_interceptor_cancels_when_occupancy_exceeds_cap() {
|
async fn compact_worker_interceptor_cancels_when_occupancy_exceeds_cap() {
|
||||||
let tracker = Arc::new(UsageTracker::new());
|
let tracker = Arc::new(UsageTracker::new());
|
||||||
let interceptor = CompactWorkerInterceptor {
|
let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 99, 0, 0, None);
|
||||||
usage_tracker: tracker.clone(),
|
|
||||||
max_input_tokens: 99,
|
|
||||||
};
|
|
||||||
let mut context = vec![Item::user_message("hello")];
|
let mut context = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -420,6 +809,45 @@ mod tests {
|
||||||
assert_eq!(guard.references[0], PathBuf::from(p));
|
assert_eq!(guard.references[0], PathBuf::from(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_session_log_returns_bounded_hits_without_full_tool_content() {
|
||||||
|
let items = Arc::new(vec![
|
||||||
|
Item::user_message("investigate compact failure"),
|
||||||
|
Item::tool_result_with_content(
|
||||||
|
"call-1",
|
||||||
|
"read trace with compact failure",
|
||||||
|
"very large raw trace body with secret detail",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
||||||
|
state: Arc::new(SessionLogToolState { items }),
|
||||||
|
});
|
||||||
|
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
||||||
|
let out = tool.execute(&input).await.unwrap();
|
||||||
|
let content = out.content.unwrap();
|
||||||
|
|
||||||
|
assert!(content.contains("investigate compact failure"));
|
||||||
|
assert!(content.contains("read trace with compact failure"));
|
||||||
|
assert!(!content.contains("secret detail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_session_items_full_mode_can_read_tool_result_content() {
|
||||||
|
let items = Arc::new(vec![Item::tool_result_with_content(
|
||||||
|
"call-1",
|
||||||
|
"read trace",
|
||||||
|
"raw trace detail",
|
||||||
|
)]);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
||||||
|
state: Arc::new(SessionLogToolState { items }),
|
||||||
|
});
|
||||||
|
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
||||||
|
let out = tool.execute(&input).await.unwrap();
|
||||||
|
let content = out.content.unwrap();
|
||||||
|
|
||||||
|
assert!(content.contains("raw trace detail"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slice_lines_handles_offset_and_limit() {
|
fn slice_lines_handles_offset_and_limit() {
|
||||||
let text = "a\nb\nc\nd";
|
let text = "a\nb\nc\nd";
|
||||||
|
|
|
||||||
|
|
@ -548,10 +548,20 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
|
||||||
Ok(Ok(stream)) => {
|
Ok(Ok(stream)) => {
|
||||||
let (r, _w) = stream.into_split();
|
let (r, _w) = stream.into_split();
|
||||||
let mut reader = JsonLineReader::new(r);
|
let mut reader = JsonLineReader::new(r);
|
||||||
let status = match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
|
let mut status = None;
|
||||||
Ok(Ok(Some(Event::Snapshot { status, .. }))) => Some(status),
|
loop {
|
||||||
_ => None,
|
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
|
||||||
};
|
Ok(Ok(Some(Event::Snapshot {
|
||||||
|
status: snapshot_status,
|
||||||
|
..
|
||||||
|
}))) => {
|
||||||
|
status = Some(snapshot_status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(Some(Event::Alert(_)))) => continue,
|
||||||
|
Ok(Ok(Some(_))) | Ok(Ok(None)) | Ok(Err(_)) | Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
LiveInfo {
|
LiveInfo {
|
||||||
socket_path: socket_path.to_path_buf(),
|
socket_path: socket_path.to_path_buf(),
|
||||||
reachable: true,
|
reachable: true,
|
||||||
|
|
@ -755,8 +765,8 @@ mod tests {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use manifest::{Permission, ScopeRule};
|
use manifest::{Permission, ScopeRule};
|
||||||
use protocol::Greeting;
|
|
||||||
use protocol::stream::JsonLineWriter;
|
use protocol::stream::JsonLineWriter;
|
||||||
|
use protocol::{Alert, AlertLevel, AlertSource, Greeting};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
FsStore, PodSpawnedChild, PodSpawnedScopeRule, new_segment_id, new_session_id,
|
FsStore, PodSpawnedChild, PodSpawnedScopeRule, new_segment_id, new_session_id,
|
||||||
};
|
};
|
||||||
|
|
@ -931,6 +941,48 @@ mod tests {
|
||||||
live_listener.abort();
|
live_listener.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn probe_socket_reads_status_after_replayed_alert() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let socket = root.path().join("pod.sock");
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
|
writer
|
||||||
|
.write(&Event::Alert(Alert {
|
||||||
|
level: AlertLevel::Warn,
|
||||||
|
source: AlertSource::Pod,
|
||||||
|
message: "replayed alert".into(),
|
||||||
|
timestamp_ms: 0,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
writer
|
||||||
|
.write(&Event::Snapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
greeting: Greeting {
|
||||||
|
pod_name: "alerted".into(),
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
provider: "test".into(),
|
||||||
|
model: "test".into(),
|
||||||
|
scope_summary: String::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
context_window: 0,
|
||||||
|
context_tokens: 0,
|
||||||
|
},
|
||||||
|
status: PodStatus::Paused,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let info = probe_socket(&socket).await;
|
||||||
|
assert!(info.reachable);
|
||||||
|
assert!(matches!(info.status, Some(PodStatus::Paused)));
|
||||||
|
handle.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn child(name: &str, socket_path: &Path) -> PodSpawnedChild {
|
fn child(name: &str, socket_path: &Path) -> PodSpawnedChild {
|
||||||
PodSpawnedChild {
|
PodSpawnedChild {
|
||||||
pod_name: name.to_string(),
|
pod_name: name.to_string(),
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
scope: SharedScope,
|
scope: SharedScope,
|
||||||
hook_builder: HookRegistryBuilder,
|
hook_builder: HookRegistryBuilder,
|
||||||
interceptor_installed: bool,
|
interceptor_installed: bool,
|
||||||
/// Shared compaction state (present when compact_threshold is configured).
|
/// Shared compaction state (present when threshold is configured).
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
/// Per-LLM-request Usage tracker. Always present after construction.
|
/// Per-LLM-request Usage tracker. Always present after construction.
|
||||||
/// Captures `(history_len, UsageEvent)` pairs during a run; drained
|
/// Captures `(history_len, UsageEvent)` pairs during a run; drained
|
||||||
|
|
@ -1121,8 +1121,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
/// Install the hook-based interceptor on the Worker if not already done.
|
/// Install the hook-based interceptor on the Worker if not already done.
|
||||||
///
|
///
|
||||||
/// When either compaction threshold (`compact_threshold` or
|
/// When either compaction threshold (`threshold` or
|
||||||
/// `compact_request_threshold`) is configured in the manifest, allocates
|
/// `request_threshold`) is configured in the manifest, allocates
|
||||||
/// a shared [`CompactState`] and wires the interceptor to read current
|
/// a shared [`CompactState`] and wires the interceptor to read current
|
||||||
/// occupancy through the `UsageRecord` timeline.
|
/// occupancy through the `UsageRecord` timeline.
|
||||||
fn ensure_interceptor_installed(&mut self) {
|
fn ensure_interceptor_installed(&mut self) {
|
||||||
|
|
@ -1141,13 +1141,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.manifest
|
.manifest
|
||||||
.compaction
|
.compaction
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| {
|
.map(|c| (c.threshold, c.request_threshold, c.retained_tokens))
|
||||||
(
|
|
||||||
c.compact_threshold,
|
|
||||||
c.compact_request_threshold,
|
|
||||||
c.compact_retained_tokens,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None, manifest::defaults::COMPACT_RETAINED_TOKENS));
|
.unwrap_or((None, None, manifest::defaults::COMPACT_RETAINED_TOKENS));
|
||||||
|
|
||||||
let tracker_for_usage = self.usage_tracker.clone();
|
let tracker_for_usage = self.usage_tracker.clone();
|
||||||
|
|
@ -1161,7 +1155,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
warn!(
|
warn!(
|
||||||
post_run_threshold = post,
|
post_run_threshold = post,
|
||||||
request_threshold = req,
|
request_threshold = req,
|
||||||
"compact_threshold > compact_request_threshold; \
|
"threshold > request_threshold; \
|
||||||
proactive check will never fire before the safety net"
|
proactive check will never fire before the safety net"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2124,12 +2118,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let retained = state
|
let retained = state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.retained_tokens())
|
.map(|s| s.retained_tokens())
|
||||||
.or_else(|| {
|
.or_else(|| self.manifest.compaction.as_ref().map(|c| c.retained_tokens))
|
||||||
self.manifest
|
|
||||||
.compaction
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.compact_retained_tokens)
|
|
||||||
})
|
|
||||||
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
||||||
let current_tokens = self.total_tokens().tokens;
|
let current_tokens = self.total_tokens().tokens;
|
||||||
let cut = self.split_for_retained(retained);
|
let cut = self.split_for_retained(retained);
|
||||||
|
|
@ -2307,7 +2296,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, PodError> {
|
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, PodError> {
|
||||||
use crate::compact::worker::{
|
use crate::compact::worker::{
|
||||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
||||||
mark_read_required_tool, write_summary_tool,
|
mark_read_required_tool, read_session_items_tool, search_session_log_tool,
|
||||||
|
write_summary_tool,
|
||||||
};
|
};
|
||||||
use crate::fs_view::PodFsView;
|
use crate::fs_view::PodFsView;
|
||||||
|
|
||||||
|
|
@ -2324,21 +2314,49 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
// Compaction-related knobs. Fall through to manifest defaults when
|
// Compaction-related knobs. Fall through to manifest defaults when
|
||||||
// `[compaction]` is omitted entirely.
|
// `[compaction]` is omitted entirely.
|
||||||
let (auto_read_budget, compact_worker_max_input_tokens, compact_worker_max_turns) = self
|
let (
|
||||||
|
auto_read_budget,
|
||||||
|
worker_context_max_tokens,
|
||||||
|
finish_warning_remaining_tokens,
|
||||||
|
final_reserve_tokens,
|
||||||
|
worker_max_turns,
|
||||||
|
overview_target_tokens,
|
||||||
|
overview_warning_tokens,
|
||||||
|
overview_deadline_tokens,
|
||||||
|
summary_target_tokens,
|
||||||
|
summary_max_tokens,
|
||||||
|
result_context_max_tokens,
|
||||||
|
) = self
|
||||||
.manifest
|
.manifest
|
||||||
.compaction
|
.compaction
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
(
|
(
|
||||||
c.compact_auto_read_budget,
|
c.auto_read_budget_tokens,
|
||||||
c.compact_worker_max_input_tokens,
|
c.worker_context_max_tokens,
|
||||||
c.compact_worker_max_turns,
|
c.finish_warning_remaining_tokens,
|
||||||
|
c.final_reserve_tokens,
|
||||||
|
c.worker_max_turns,
|
||||||
|
c.overview_target_tokens,
|
||||||
|
c.overview_warning_tokens,
|
||||||
|
c.overview_deadline_tokens,
|
||||||
|
c.summary_target_tokens,
|
||||||
|
c.summary_max_tokens,
|
||||||
|
c.result_context_max_tokens,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or((
|
.unwrap_or((
|
||||||
manifest::defaults::COMPACT_AUTO_READ_BUDGET,
|
manifest::defaults::COMPACT_AUTO_READ_BUDGET,
|
||||||
manifest::defaults::COMPACT_WORKER_MAX_INPUT_TOKENS,
|
manifest::defaults::COMPACT_WORKER_MAX_INPUT_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_FINISH_WARNING_REMAINING_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_FINAL_RESERVE_TOKENS,
|
||||||
manifest::defaults::COMPACT_WORKER_MAX_TURNS,
|
manifest::defaults::COMPACT_WORKER_MAX_TURNS,
|
||||||
|
manifest::defaults::COMPACT_OVERVIEW_TARGET_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_OVERVIEW_WARNING_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_OVERVIEW_DEADLINE_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_SUMMARY_TARGET_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_SUMMARY_MAX_TOKENS,
|
||||||
|
manifest::defaults::COMPACT_RESULT_CONTEXT_MAX_TOKENS,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Default references: the N most-recently-touched files in the
|
// Default references: the N most-recently-touched files in the
|
||||||
|
|
@ -2358,7 +2376,33 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
&items_to_summarise,
|
&items_to_summarise,
|
||||||
&default_refs,
|
&default_refs,
|
||||||
Some(task_snapshot_text.as_str()),
|
Some(task_snapshot_text.as_str()),
|
||||||
|
SummaryInputOptions {
|
||||||
|
overview_target_tokens,
|
||||||
|
overview_warning_tokens,
|
||||||
|
overview_deadline_tokens,
|
||||||
|
summary_target_tokens,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
if summary_input.warning_exceeded {
|
||||||
|
self.alert(
|
||||||
|
AlertLevel::Warn,
|
||||||
|
AlertSource::Compactor,
|
||||||
|
format!(
|
||||||
|
"compact overview is larger than expected (≈{} tokens; warning threshold {})",
|
||||||
|
summary_input.overview_tokens, overview_warning_tokens
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if summary_input.deadline_fallback_used {
|
||||||
|
self.alert(
|
||||||
|
AlertLevel::Warn,
|
||||||
|
AlertSource::Compactor,
|
||||||
|
format!(
|
||||||
|
"compact overview exceeded deadline ({} tokens); using coarse fallback",
|
||||||
|
overview_deadline_tokens
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Worker-side state collected by the compact worker's tool calls.
|
// Worker-side state collected by the compact worker's tool calls.
|
||||||
let ctx = Arc::new(std::sync::Mutex::new(CompactWorkerContext::with_budget(
|
let ctx = Arc::new(std::sync::Mutex::new(CompactWorkerContext::with_budget(
|
||||||
|
|
@ -2390,21 +2434,32 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
tracker.record_usage(event);
|
tracker.record_usage(event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
summary_worker.set_interceptor(CompactWorkerInterceptor {
|
let compactor_warning_cb = self.alerter.clone().map(|alerter| {
|
||||||
usage_tracker: summary_usage_tracker,
|
Arc::new(move |message: String| {
|
||||||
max_input_tokens: compact_worker_max_input_tokens,
|
alerter.alert(AlertLevel::Warn, AlertSource::Compactor, message);
|
||||||
|
}) as Arc<dyn Fn(String) + Send + Sync>
|
||||||
});
|
});
|
||||||
summary_worker.set_max_turns(compact_worker_max_turns);
|
summary_worker.set_interceptor(CompactWorkerInterceptor::new(
|
||||||
|
summary_usage_tracker,
|
||||||
|
worker_context_max_tokens,
|
||||||
|
finish_warning_remaining_tokens,
|
||||||
|
final_reserve_tokens,
|
||||||
|
compactor_warning_cb,
|
||||||
|
));
|
||||||
|
summary_worker.set_max_turns(worker_max_turns);
|
||||||
|
|
||||||
// Tools: read_file (shared scope, fresh tracker) + the three
|
// Tools: read_file (shared scope, fresh tracker), bounded session
|
||||||
// compact-specific tools that populate `ctx`.
|
// history exploration, and compact-specific tools that populate `ctx`.
|
||||||
|
let compact_target_items = Arc::new(items_to_summarise.clone());
|
||||||
summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker));
|
summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker));
|
||||||
|
summary_worker.register_tool(search_session_log_tool(compact_target_items.clone()));
|
||||||
|
summary_worker.register_tool(read_session_items_tool(compact_target_items));
|
||||||
summary_worker.register_tool(mark_read_required_tool(scoped_fs.clone(), ctx.clone()));
|
summary_worker.register_tool(mark_read_required_tool(scoped_fs.clone(), ctx.clone()));
|
||||||
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
||||||
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
||||||
|
|
||||||
let out = summary_worker
|
let out = summary_worker
|
||||||
.run(summary_input)
|
.run(summary_input.text)
|
||||||
.await
|
.await
|
||||||
.map_err(PodError::Worker)?;
|
.map_err(PodError::Worker)?;
|
||||||
let mut locked_worker = out.worker;
|
let mut locked_worker = out.worker;
|
||||||
|
|
@ -2439,11 +2494,32 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let _ = locked_worker.run(prompt).await.map_err(PodError::Worker)?;
|
let _ = locked_worker.run(prompt).await.map_err(PodError::Worker)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
let summary_text = final_ctx
|
let mut summary_text = final_ctx
|
||||||
.summary
|
.summary
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or(PodError::CompactSummaryMissing)?;
|
.ok_or(PodError::CompactSummaryMissing)?;
|
||||||
|
let mut summary_tokens = estimate_text_tokens(summary_text.len());
|
||||||
|
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
|
||||||
|
let prompt = format!(
|
||||||
|
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
|
||||||
|
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||||
|
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
||||||
|
);
|
||||||
|
let _ = locked_worker.run(prompt).await.map_err(PodError::Worker)?;
|
||||||
|
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
|
summary_text = final_ctx
|
||||||
|
.summary
|
||||||
|
.clone()
|
||||||
|
.ok_or(PodError::CompactSummaryMissing)?;
|
||||||
|
summary_tokens = estimate_text_tokens(summary_text.len());
|
||||||
|
if summary_tokens > summary_max_tokens {
|
||||||
|
return Err(PodError::CompactSummaryTooLarge {
|
||||||
|
tokens: summary_tokens,
|
||||||
|
max: summary_max_tokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-read each auto-read target via the Pod FS view. Errors are
|
// Re-read each auto-read target via the Pod FS view. Errors are
|
||||||
// logged and skipped inside `render_auto_read` rather than
|
// logged and skipped inside `render_auto_read` rather than
|
||||||
|
|
@ -2515,6 +2591,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
tools::task::snapshot_overview(&self.task_store.list()),
|
tools::task::snapshot_overview(&self.task_store.list()),
|
||||||
task_snapshot_text.clone(),
|
task_snapshot_text.clone(),
|
||||||
));
|
));
|
||||||
|
let result_estimate = llm_worker::token_counter::total_tokens(&new_history, &[]);
|
||||||
|
if result_context_max_tokens > 0 && result_estimate.tokens > result_context_max_tokens {
|
||||||
|
return Err(PodError::CompactResultContextTooLarge {
|
||||||
|
tokens: result_estimate.tokens,
|
||||||
|
max: result_context_max_tokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Build the SegmentStart entry for the new compacted segment.
|
// Build the SegmentStart entry for the new compacted segment.
|
||||||
// Inherits the source Segment's session_id so the compacted
|
// Inherits the source Segment's session_id so the compacted
|
||||||
|
|
@ -4008,19 +4091,56 @@ impl From<WorkerResult> for PodRunResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct SummaryInputOptions {
|
||||||
|
overview_target_tokens: u64,
|
||||||
|
overview_warning_tokens: u64,
|
||||||
|
overview_deadline_tokens: u64,
|
||||||
|
summary_target_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SummaryInputBuild {
|
||||||
|
text: String,
|
||||||
|
overview_tokens: u64,
|
||||||
|
warning_exceeded: bool,
|
||||||
|
deadline_fallback_used: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the compact worker's input: default-reference instructions,
|
/// Build the compact worker's input: default-reference instructions,
|
||||||
/// the list of recently-touched files, and the pruned conversation
|
/// the list of recently-touched files, task snapshot, and a bounded overview
|
||||||
/// produced by [`build_summary_prompt`].
|
/// rather than a prefix-wide transcript.
|
||||||
fn build_summary_input(
|
fn build_summary_input(
|
||||||
items: &[Item],
|
items: &[Item],
|
||||||
default_refs: &[PathBuf],
|
default_refs: &[PathBuf],
|
||||||
task_snapshot: Option<&str>,
|
task_snapshot: Option<&str>,
|
||||||
) -> String {
|
options: SummaryInputOptions,
|
||||||
let mut out = String::new();
|
) -> SummaryInputBuild {
|
||||||
out.push_str(
|
let overview = build_summary_overview(
|
||||||
"Summarise the conversation below into a structured summary and nominate \
|
items,
|
||||||
files the next session needs.\n\n",
|
options.overview_target_tokens,
|
||||||
|
options.overview_deadline_tokens,
|
||||||
);
|
);
|
||||||
|
let overview_tokens = estimate_text_tokens(overview.len());
|
||||||
|
let warning_exceeded =
|
||||||
|
options.overview_warning_tokens > 0 && overview_tokens > options.overview_warning_tokens;
|
||||||
|
let deadline_fallback_used =
|
||||||
|
options.overview_deadline_tokens > 0 && overview_tokens > options.overview_deadline_tokens;
|
||||||
|
let overview = if deadline_fallback_used {
|
||||||
|
build_coarse_summary_overview(items, options.overview_deadline_tokens)
|
||||||
|
} else {
|
||||||
|
overview
|
||||||
|
};
|
||||||
|
let overview_tokens = estimate_text_tokens(overview.len());
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Summarise this session into a structured summary of about {} tokens and \
|
||||||
|
nominate files the next session needs. The conversation below is a \
|
||||||
|
bounded overview/index, not the full transcript. Use tools to inspect \
|
||||||
|
current files when deciding auto-read/reference output.\n\n",
|
||||||
|
options.summary_target_tokens
|
||||||
|
));
|
||||||
if !default_refs.is_empty() {
|
if !default_refs.is_empty() {
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"These files were touched recently in this session. Use `read_file` \
|
"These files were touched recently in this session. Use `read_file` \
|
||||||
|
|
@ -4045,47 +4165,166 @@ fn build_summary_input(
|
||||||
out.push_str(task_snapshot);
|
out.push_str(task_snapshot);
|
||||||
out.push_str("\n\n");
|
out.push_str("\n\n");
|
||||||
}
|
}
|
||||||
out.push_str("## Conversation\n");
|
out.push_str("## Conversation overview/index\n");
|
||||||
out.push_str(&build_summary_prompt(items));
|
out.push_str(&overview);
|
||||||
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
||||||
|
|
||||||
|
SummaryInputBuild {
|
||||||
|
text: out,
|
||||||
|
overview_tokens,
|
||||||
|
warning_exceeded,
|
||||||
|
deadline_fallback_used,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_summary_overview(items: &[Item], target_tokens: u64, deadline_tokens: u64) -> String {
|
||||||
|
let target_bytes = token_budget_bytes(target_tokens).max(1024);
|
||||||
|
let deadline_bytes = token_budget_bytes(deadline_tokens).max(target_bytes);
|
||||||
|
let mut out = String::new();
|
||||||
|
write_overview_header(items, &mut out);
|
||||||
|
out.push_str("\n## Recent user/assistant/system messages\n");
|
||||||
|
|
||||||
|
let mut selected = Vec::new();
|
||||||
|
let mut omitted_messages = 0usize;
|
||||||
|
for (idx, item) in items.iter().enumerate().rev() {
|
||||||
|
let Some(entry) = message_overview_entry(idx, item, 2_000) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let projected = out
|
||||||
|
.len()
|
||||||
|
.saturating_add(selected.iter().map(String::len).sum::<usize>())
|
||||||
|
.saturating_add(entry.len())
|
||||||
|
.saturating_add(2);
|
||||||
|
if projected > target_bytes && !selected.is_empty() {
|
||||||
|
omitted_messages += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selected.push(entry);
|
||||||
|
if projected >= target_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected.reverse();
|
||||||
|
for entry in selected {
|
||||||
|
out.push_str(&entry);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
if omitted_messages > 0 {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"[Overview omitted {omitted_messages} older message(s) to stay near target.]\n\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
append_tool_index(items, &mut out, target_bytes, deadline_bytes);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format conversation items into a text prompt for the summary Worker.
|
fn build_coarse_summary_overview(items: &[Item], deadline_tokens: u64) -> String {
|
||||||
///
|
let deadline_bytes = token_budget_bytes(deadline_tokens).max(1024);
|
||||||
/// The summary should capture decisions and user intent, not recreate code.
|
let mut out = String::new();
|
||||||
/// File contents and tool IO belong in auto-read / references, not in the
|
write_overview_header(items, &mut out);
|
||||||
/// summary input. So this strips:
|
out.push_str("\n## Coarse recent message index\n");
|
||||||
/// - `ToolCall.arguments` (keep only the tool name)
|
for (idx, item) in items.iter().enumerate().rev() {
|
||||||
/// - `ToolResult.content` (keep only the summary line)
|
let Some(entry) = message_overview_entry(idx, item, 240) else {
|
||||||
/// - `Reasoning` entirely (intermediate thought, superseded by decisions)
|
continue;
|
||||||
fn build_summary_prompt(items: &[Item]) -> String {
|
};
|
||||||
let mut lines = Vec::new();
|
if out.len().saturating_add(entry.len()).saturating_add(2) > deadline_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push_str(&entry);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_overview_header(items: &[Item], out: &mut String) {
|
||||||
|
let mut messages = 0usize;
|
||||||
|
let mut tool_calls = 0usize;
|
||||||
|
let mut tool_results = 0usize;
|
||||||
|
let mut reasoning = 0usize;
|
||||||
for item in items {
|
for item in items {
|
||||||
match item {
|
match item {
|
||||||
Item::Message { role, content, .. } => {
|
Item::Message { .. } => messages += 1,
|
||||||
let role_label = match role {
|
Item::ToolCall { .. } => tool_calls += 1,
|
||||||
llm_worker::Role::User => "User",
|
Item::ToolResult { .. } => tool_results += 1,
|
||||||
llm_worker::Role::Assistant => "Assistant",
|
Item::Reasoning { .. } => reasoning += 1,
|
||||||
llm_worker::Role::System => "System",
|
|
||||||
};
|
|
||||||
let text: String = content
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.as_text())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("");
|
|
||||||
lines.push(format!("[{role_label}] {text}"));
|
|
||||||
}
|
|
||||||
Item::ToolCall { name, .. } => {
|
|
||||||
lines.push(format!("[ToolCall] {name}"));
|
|
||||||
}
|
|
||||||
Item::ToolResult { summary, .. } => {
|
|
||||||
lines.push(format!("[ToolResult] {summary}"));
|
|
||||||
}
|
|
||||||
Item::Reasoning { .. } => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.join("\n\n")
|
out.push_str(&format!(
|
||||||
|
"Items summarized: {} total; {messages} message(s), {tool_calls} tool call(s), \
|
||||||
|
{tool_results} tool result(s), {reasoning} reasoning item(s). Tool call \
|
||||||
|
arguments, tool result full content, and reasoning bodies are omitted from \
|
||||||
|
this initial input.\n",
|
||||||
|
items.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_tool_index(items: &[Item], out: &mut String, target_bytes: usize, deadline_bytes: usize) {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for (idx, item) in items.iter().enumerate().rev() {
|
||||||
|
match item {
|
||||||
|
Item::ToolCall { name, .. } => entries.push(format!("[{idx} ToolCall] {name}")),
|
||||||
|
Item::ToolResult { summary, .. } => entries.push(format!(
|
||||||
|
"[{idx} ToolResult] {}",
|
||||||
|
truncate_chars(summary, 240)
|
||||||
|
)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if entries.len() >= 24 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.reverse();
|
||||||
|
out.push_str("## Recent tool index (content omitted)\n");
|
||||||
|
for entry in entries {
|
||||||
|
let projected = out.len().saturating_add(entry.len()).saturating_add(1);
|
||||||
|
if projected > deadline_bytes || (projected > target_bytes && out.contains("ToolResult")) {
|
||||||
|
out.push_str("[Additional tool index entries omitted.]\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push_str(&entry);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_overview_entry(idx: usize, item: &Item, max_chars: usize) -> Option<String> {
|
||||||
|
let Item::Message { role, content, .. } = item else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let role_label = match role {
|
||||||
|
llm_worker::Role::User => "User",
|
||||||
|
llm_worker::Role::Assistant => "Assistant",
|
||||||
|
llm_worker::Role::System => "System",
|
||||||
|
};
|
||||||
|
let text: String = content
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.as_text())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
Some(format!(
|
||||||
|
"[{idx} {role_label}] {}",
|
||||||
|
truncate_chars(&text, max_chars)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||||
|
if text.chars().count() <= max_chars {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let mut out = text.chars().take(max_chars).collect::<String>();
|
||||||
|
out.push_str("… [truncated]");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_text_tokens(bytes: usize) -> u64 {
|
||||||
|
(bytes as u64).div_ceil(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_budget_bytes(tokens: u64) -> usize {
|
||||||
|
tokens.saturating_mul(4).min(usize::MAX as u64) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pod errors.
|
/// Pod errors.
|
||||||
|
|
@ -4125,6 +4364,12 @@ pub enum PodError {
|
||||||
#[error("compact worker did not produce a summary (write_summary was never called)")]
|
#[error("compact worker did not produce a summary (write_summary was never called)")]
|
||||||
CompactSummaryMissing,
|
CompactSummaryMissing,
|
||||||
|
|
||||||
|
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
||||||
|
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
||||||
|
|
||||||
|
#[error("compacted result context too large: {tokens} tokens exceeds max {max}")]
|
||||||
|
CompactResultContextTooLarge { tokens: u64, max: u64 },
|
||||||
|
|
||||||
#[error("invalid system prompt template: {source}")]
|
#[error("invalid system prompt template: {source}")]
|
||||||
InvalidSystemPromptTemplate {
|
InvalidSystemPromptTemplate {
|
||||||
#[source]
|
#[source]
|
||||||
|
|
@ -4409,6 +4654,21 @@ mod memory_worker_event_tests {
|
||||||
mod build_summary_prompt_tests {
|
mod build_summary_prompt_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn test_summary_input(items: &[Item]) -> String {
|
||||||
|
build_summary_input(
|
||||||
|
items,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
SummaryInputOptions {
|
||||||
|
overview_target_tokens: 512,
|
||||||
|
overview_warning_tokens: 1024,
|
||||||
|
overview_deadline_tokens: 2048,
|
||||||
|
summary_target_tokens: 256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.text
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_tool_call_arguments() {
|
fn strips_tool_call_arguments() {
|
||||||
let items = vec![Item::tool_call_json(
|
let items = vec![Item::tool_call_json(
|
||||||
|
|
@ -4416,8 +4676,8 @@ mod build_summary_prompt_tests {
|
||||||
"read_file",
|
"read_file",
|
||||||
serde_json::json!({ "path": "src/main.rs" }),
|
serde_json::json!({ "path": "src/main.rs" }),
|
||||||
)];
|
)];
|
||||||
let prompt = build_summary_prompt(&items);
|
let prompt = test_summary_input(&items);
|
||||||
assert_eq!(prompt, "[ToolCall] read_file");
|
assert!(prompt.contains("[0 ToolCall] read_file"));
|
||||||
assert!(!prompt.contains("src/main.rs"));
|
assert!(!prompt.contains("src/main.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4428,8 +4688,8 @@ mod build_summary_prompt_tests {
|
||||||
"read 3 lines",
|
"read 3 lines",
|
||||||
"fn main() { println!(\"hello\"); }",
|
"fn main() { println!(\"hello\"); }",
|
||||||
)];
|
)];
|
||||||
let prompt = build_summary_prompt(&items);
|
let prompt = test_summary_input(&items);
|
||||||
assert_eq!(prompt, "[ToolResult] read 3 lines");
|
assert!(prompt.contains("[0 ToolResult] read 3 lines"));
|
||||||
assert!(!prompt.contains("println"));
|
assert!(!prompt.contains("println"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4440,13 +4700,50 @@ mod build_summary_prompt_tests {
|
||||||
Item::reasoning("internal deliberation"),
|
Item::reasoning("internal deliberation"),
|
||||||
Item::assistant_message("hello"),
|
Item::assistant_message("hello"),
|
||||||
];
|
];
|
||||||
let prompt = build_summary_prompt(&items);
|
let prompt = test_summary_input(&items);
|
||||||
assert!(prompt.contains("[User] hi"));
|
assert!(prompt.contains("[0 User] hi"));
|
||||||
assert!(prompt.contains("[Assistant] hello"));
|
assert!(prompt.contains("[2 Assistant] hello"));
|
||||||
assert!(!prompt.contains("Reasoning"));
|
assert!(!prompt.contains("Reasoning"));
|
||||||
assert!(!prompt.contains("deliberation"));
|
assert!(!prompt.contains("deliberation"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_warning_does_not_drop_input() {
|
||||||
|
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||||
|
let built = build_summary_input(
|
||||||
|
&items,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
SummaryInputOptions {
|
||||||
|
overview_target_tokens: 10,
|
||||||
|
overview_warning_tokens: 100,
|
||||||
|
overview_deadline_tokens: 2_000,
|
||||||
|
summary_target_tokens: 256,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(built.warning_exceeded);
|
||||||
|
assert!(!built.deadline_fallback_used);
|
||||||
|
assert!(built.text.contains("[0 User]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overview_deadline_falls_back_to_coarse_index() {
|
||||||
|
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||||
|
let built = build_summary_input(
|
||||||
|
&items,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
SummaryInputOptions {
|
||||||
|
overview_target_tokens: 10,
|
||||||
|
overview_warning_tokens: 10,
|
||||||
|
overview_deadline_tokens: 100,
|
||||||
|
summary_target_tokens: 256,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(built.deadline_fallback_used);
|
||||||
|
assert!(built.text.contains("## Coarse recent message index"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_manifest_generation_settings_become_request_config() {
|
fn worker_manifest_generation_settings_become_request_config() {
|
||||||
let manifest = WorkerManifest {
|
let manifest = WorkerManifest {
|
||||||
|
|
@ -4478,8 +4775,9 @@ mod build_summary_prompt_tests {
|
||||||
Item::user_message("fix the bug"),
|
Item::user_message("fix the bug"),
|
||||||
Item::assistant_message("done"),
|
Item::assistant_message("done"),
|
||||||
];
|
];
|
||||||
let prompt = build_summary_prompt(&items);
|
let prompt = test_summary_input(&items);
|
||||||
assert_eq!(prompt, "[User] fix the bug\n\n[Assistant] done");
|
assert!(prompt.contains("[0 User] fix the bug"));
|
||||||
|
assert!(prompt.contains("[1 Assistant] done"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ impl Tool for SendToPodTool {
|
||||||
"pod `{}` is already running a turn; wait for it to finish and retry",
|
"pod `{}` is already running a turn; wait for it to finish and retry",
|
||||||
input.name
|
input.name
|
||||||
)),
|
)),
|
||||||
|
SendRunError::Rejected { code, message } => ToolError::ExecutionFailed(format!(
|
||||||
|
"pod `{}` rejected the run with {code:?}: {message}",
|
||||||
|
input.name
|
||||||
|
)),
|
||||||
SendRunError::Io(msg) => {
|
SendRunError::Io(msg) => {
|
||||||
ToolError::ExecutionFailed(format!("send to `{}`: {msg}", input.name))
|
ToolError::ExecutionFailed(format!("send to `{}`: {msg}", input.name))
|
||||||
}
|
}
|
||||||
|
|
@ -370,16 +374,20 @@ pub(crate) enum SendRunError {
|
||||||
/// Target Pod responded with `Error { AlreadyRunning }` — the
|
/// Target Pod responded with `Error { AlreadyRunning }` — the
|
||||||
/// caller can retry once the current turn ends.
|
/// caller can retry once the current turn ends.
|
||||||
AlreadyRunning,
|
AlreadyRunning,
|
||||||
/// Any other failure (connect / write / read / unexpected EOF).
|
/// Target Pod explicitly rejected the run after delivery reached the
|
||||||
|
/// controller.
|
||||||
|
Rejected { code: ErrorCode, message: String },
|
||||||
|
/// Transport, protocol, timeout, or unexpected EOF before acceptance
|
||||||
|
/// evidence was observed.
|
||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `Method::Run` to the target and read back events until we see
|
/// Write `Method::Run` to the target and read back events until we see
|
||||||
/// evidence that the controller accepted the run (`UserMessage`,
|
/// evidence that the controller accepted the run (`UserMessage`,
|
||||||
/// `TurnStart`, or a user-send `InvokeStart`) or rejected it with
|
/// `TurnStart`, or a user-send `InvokeStart`) or rejected it. The connect-time
|
||||||
/// `Error { AlreadyRunning }`. Any connect-time Snapshot or replayed alerts
|
/// event prelude is drained before sending the method so large Snapshots and
|
||||||
/// that precede the response are skipped. Times out per-read so a stuck Pod
|
/// large Run payloads cannot block each other on the same socket. Times out
|
||||||
/// doesn't hang the tool.
|
/// per operation so a stuck Pod doesn't hang the tool.
|
||||||
pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRunError> {
|
pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRunError> {
|
||||||
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
|
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
|
||||||
.await
|
.await
|
||||||
|
|
@ -388,6 +396,31 @@ pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result
|
||||||
let (r, w) = stream.into_split();
|
let (r, w) = stream.into_split();
|
||||||
let mut writer = JsonLineWriter::new(w);
|
let mut writer = JsonLineWriter::new(w);
|
||||||
let mut reader = JsonLineReader::new(r);
|
let mut reader = JsonLineReader::new(r);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
||||||
|
.await
|
||||||
|
.map_err(|_| SendRunError::Io("read initial Snapshot timed out".into()))?
|
||||||
|
.map_err(|e| SendRunError::Io(format!("read initial Snapshot: {e}")))?;
|
||||||
|
match event {
|
||||||
|
Some(Event::Snapshot { .. }) => break,
|
||||||
|
Some(Event::Alert(_)) => continue,
|
||||||
|
Some(Event::Error {
|
||||||
|
code: ErrorCode::AlreadyRunning,
|
||||||
|
..
|
||||||
|
}) => return Err(SendRunError::AlreadyRunning),
|
||||||
|
Some(Event::Error { code, message }) => {
|
||||||
|
return Err(SendRunError::Rejected { code, message });
|
||||||
|
}
|
||||||
|
Some(_) => continue,
|
||||||
|
None => {
|
||||||
|
return Err(SendRunError::Io(
|
||||||
|
"connection closed before initial Snapshot".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
SOCKET_OP_TIMEOUT,
|
SOCKET_OP_TIMEOUT,
|
||||||
writer.write(&Method::Run {
|
writer.write(&Method::Run {
|
||||||
|
|
@ -400,26 +433,23 @@ pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result
|
||||||
loop {
|
loop {
|
||||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| SendRunError::Io("read timed out".into()))?
|
.map_err(|_| SendRunError::Io("read response timed out".into()))?
|
||||||
.map_err(|e| SendRunError::Io(format!("read: {e}")))?;
|
.map_err(|e| SendRunError::Io(format!("read response: {e}")))?;
|
||||||
match event {
|
match event {
|
||||||
Some(Event::Error {
|
Some(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
..
|
..
|
||||||
}) => return Err(SendRunError::AlreadyRunning),
|
}) => return Err(SendRunError::AlreadyRunning),
|
||||||
Some(Event::Error { code, message }) => {
|
Some(Event::Error { code, message }) => {
|
||||||
return Err(SendRunError::Io(format!(
|
return Err(SendRunError::Rejected { code, message });
|
||||||
"pod returned {code:?}: {message}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
Some(Event::InvokeStart {
|
Some(Event::InvokeStart {
|
||||||
kind: InvokeKind::UserSend,
|
kind: InvokeKind::UserSend,
|
||||||
})
|
})
|
||||||
| Some(Event::UserMessage { .. })
|
| Some(Event::UserMessage { .. })
|
||||||
| Some(Event::TurnStart { .. }) => return Ok(()),
|
| Some(Event::TurnStart { .. }) => return Ok(()),
|
||||||
// Alerts, Snapshot, and other pre-turn events can precede the
|
// Other post-Snapshot events can race with the controller's
|
||||||
// controller's response; keep reading until the Run is accepted
|
// response; keep reading until the Run is accepted or rejected.
|
||||||
// or rejected.
|
|
||||||
Some(_) => continue,
|
Some(_) => continue,
|
||||||
None => return Err(SendRunError::Io("connection closed before response".into())),
|
None => return Err(SendRunError::Io("connection closed before response".into())),
|
||||||
}
|
}
|
||||||
|
|
@ -618,6 +648,47 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_run_and_confirm_drains_alert_and_large_snapshot_before_large_run() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let socket = tmp.path().join("pod.sock");
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
let large_snapshot_payload = "s".repeat(2 * 1024 * 1024);
|
||||||
|
let large_run_payload = "r".repeat(2 * 1024 * 1024);
|
||||||
|
let received = serve_initial_events_then_run_ack(
|
||||||
|
listener,
|
||||||
|
vec![
|
||||||
|
Event::Alert(Alert {
|
||||||
|
level: AlertLevel::Warn,
|
||||||
|
source: AlertSource::Pod,
|
||||||
|
message: "replayed alert".into(),
|
||||||
|
timestamp_ms: 0,
|
||||||
|
}),
|
||||||
|
snapshot(vec![
|
||||||
|
serde_json::json!({ "payload": large_snapshot_payload }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
Event::InvokeStart {
|
||||||
|
kind: InvokeKind::UserSend,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
send_run_and_confirm(&socket, large_run_payload.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let method = received.await.unwrap().expect("expected method");
|
||||||
|
match method {
|
||||||
|
Method::Run { input } => {
|
||||||
|
assert_eq!(
|
||||||
|
protocol::Segment::flatten_to_text(&input),
|
||||||
|
large_run_payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Run, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_run_and_confirm_reports_already_running() {
|
async fn send_run_and_confirm_reports_already_running() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,9 @@ fn spawn_delivery_error(pod_name: &str, err: SendRunError) -> ToolError {
|
||||||
SendRunError::AlreadyRunning => ToolError::ExecutionFailed(format!(
|
SendRunError::AlreadyRunning => ToolError::ExecutionFailed(format!(
|
||||||
"spawned pod `{pod_name}` rejected its initial task as already running; the pod remains registered and can be inspected or stopped"
|
"spawned pod `{pod_name}` rejected its initial task as already running; the pod remains registered and can be inspected or stopped"
|
||||||
)),
|
)),
|
||||||
|
SendRunError::Rejected { code, message } => ToolError::ExecutionFailed(format!(
|
||||||
|
"spawned pod `{pod_name}` rejected its initial task with {code:?}: {message}; the pod remains registered and can be inspected or stopped"
|
||||||
|
)),
|
||||||
SendRunError::Io(msg) => ToolError::ExecutionFailed(format!(
|
SendRunError::Io(msg) => ToolError::ExecutionFailed(format!(
|
||||||
"spawned pod `{pod_name}` did not confirm initial task delivery: {msg}; the pod remains registered and can be inspected or stopped"
|
"spawned pod `{pod_name}` did not confirm initial task delivery: {msg}; the pod remains registered and can be inspected or stopped"
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
mod input;
|
mod input;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
|
mod multi_pod;
|
||||||
mod picker;
|
mod picker;
|
||||||
|
mod pod_list;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
mod task;
|
mod task;
|
||||||
|
|
@ -70,6 +72,10 @@ enum Mode {
|
||||||
/// `tui --session <UUID>`: skip the picker, go straight to the
|
/// `tui --session <UUID>`: skip the picker, go straight to the
|
||||||
/// resume name dialog with `id` baked in.
|
/// resume name dialog with `id` baked in.
|
||||||
ResumeWithSession(SegmentId),
|
ResumeWithSession(SegmentId),
|
||||||
|
/// `tui --multi`: open the multi-Pod dashboard. This is intentionally
|
||||||
|
/// separate from `-r`/`--resume`, which keeps its single-Pod picker
|
||||||
|
/// meaning.
|
||||||
|
Multi,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -100,9 +106,11 @@ where
|
||||||
{
|
{
|
||||||
let args: Vec<String> = args.into_iter().map(Into::into).collect();
|
let args: Vec<String> = args.into_iter().map(Into::into).collect();
|
||||||
let mut resume = false;
|
let mut resume = false;
|
||||||
|
let mut multi = false;
|
||||||
let mut session: Option<SegmentId> = None;
|
let mut session: Option<SegmentId> = None;
|
||||||
let mut pod: Option<String> = None;
|
let mut pod: Option<String> = None;
|
||||||
let mut socket_override: Option<PathBuf> = None;
|
let mut socket_override: Option<PathBuf> = None;
|
||||||
|
let mut socket_seen = false;
|
||||||
let mut positional: Option<String> = None;
|
let mut positional: Option<String> = None;
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
@ -112,6 +120,10 @@ where
|
||||||
resume = true;
|
resume = true;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
"--multi" => {
|
||||||
|
multi = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
"--session" => {
|
"--session" => {
|
||||||
let raw = args
|
let raw = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
|
|
@ -128,6 +140,7 @@ where
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
"--socket" => {
|
"--socket" => {
|
||||||
|
socket_seen = true;
|
||||||
let raw = args
|
let raw = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
.ok_or(ParseError::MissingValue("--socket"))?;
|
.ok_or(ParseError::MissingValue("--socket"))?;
|
||||||
|
|
@ -146,6 +159,35 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if multi {
|
||||||
|
if resume {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--multi and --resume are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if session.is_some() {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--multi and --session are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pod.is_some() {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--multi and --pod are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if positional.is_some() {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--multi cannot be used with a positional Pod name",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if socket_seen {
|
||||||
|
return Err(ParseError::Conflict(
|
||||||
|
"--multi and --socket are mutually exclusive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Mode::Multi);
|
||||||
|
}
|
||||||
|
|
||||||
if resume && session.is_some() {
|
if resume && session.is_some() {
|
||||||
return Err(ParseError::Conflict(
|
return Err(ParseError::Conflict(
|
||||||
"--resume and --session are mutually exclusive",
|
"--resume and --session are mutually exclusive",
|
||||||
|
|
@ -211,6 +253,7 @@ async fn main() -> ExitCode {
|
||||||
} => run_pod_name(pod_name, socket_override).await,
|
} => run_pod_name(pod_name, socket_override).await,
|
||||||
Mode::Resume => run_resume().await,
|
Mode::Resume => run_resume().await,
|
||||||
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
||||||
|
Mode::Multi => run_multi().await,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always restore the terminal first so any pending eprintln below
|
// Always restore the terminal first so any pending eprintln below
|
||||||
|
|
@ -310,6 +353,25 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
run_pod_name(pod_name, socket_override).await
|
run_pod_name(pod_name, socket_override).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
let outcome = multi_pod::run(&mut terminal).await;
|
||||||
|
|
||||||
|
let _ = execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
DisableMouseCapture,
|
||||||
|
LeaveAlternateScreen
|
||||||
|
);
|
||||||
|
|
||||||
|
match outcome? {
|
||||||
|
multi_pod::MultiPodOutcome::Quit => Ok(()),
|
||||||
|
multi_pod::MultiPodOutcome::Open {
|
||||||
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
} => run_pod_name(pod_name, socket_override).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let ready = match spawn::run(resume_from).await? {
|
let ready = match spawn::run(resume_from).await? {
|
||||||
SpawnOutcome::Ready(r) => r,
|
SpawnOutcome::Ready(r) => r,
|
||||||
|
|
@ -947,6 +1009,54 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_mode() {
|
||||||
|
match parse_args_from(["--multi"]).unwrap() {
|
||||||
|
Mode::Multi => {}
|
||||||
|
_ => panic!("expected Multi mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_conflicts_are_clear() {
|
||||||
|
let segment_id = session_store::new_segment_id().to_string();
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
vec!["--multi".to_string(), "--resume".to_string()],
|
||||||
|
"--multi and --resume are mutually exclusive",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec!["--multi".to_string(), "--session".to_string(), segment_id],
|
||||||
|
"--multi and --session are mutually exclusive",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"--multi".to_string(),
|
||||||
|
"--pod".to_string(),
|
||||||
|
"agent".to_string(),
|
||||||
|
],
|
||||||
|
"--multi and --pod are mutually exclusive",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec!["--multi".to_string(), "agent".to_string()],
|
||||||
|
"--multi cannot be used with a positional Pod name",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"--multi".to_string(),
|
||||||
|
"--socket".to_string(),
|
||||||
|
"/tmp/a.sock".to_string(),
|
||||||
|
],
|
||||||
|
"--multi and --socket are mutually exclusive",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (args, message) in cases {
|
||||||
|
let err = parse_args_from(args).unwrap_err();
|
||||||
|
assert_eq!(err.to_string(), message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_event_is_selected_before_ready_pod_event() {
|
async fn terminal_event_is_selected_before_ready_pod_event() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
|
||||||
1209
crates/tui/src/multi_pod.rs
Normal file
1209
crates/tui/src/multi_pod.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -4,15 +4,11 @@
|
||||||
//! from the session store's name-keyed metadata. Picking a live row attaches to
|
//! from the session store's name-keyed metadata. Picking a live row attaches to
|
||||||
//! its socket; picking a stopped row restores via `pod --pod <name>`.
|
//! its socket; picking a stopped row restores via `pod --pod <name>`.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::PodClient;
|
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use pod_registry::{LockFileGuard, default_registry_path};
|
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
|
|
@ -20,8 +16,12 @@ use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||||
use session_store::{
|
use session_store::FsStore;
|
||||||
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
|
|
||||||
|
use crate::pod_list::{
|
||||||
|
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState,
|
||||||
|
live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos,
|
||||||
|
read_stored_pod_infos,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ROWS: usize = 10;
|
const MAX_ROWS: usize = 10;
|
||||||
|
|
@ -99,62 +99,45 @@ impl PodRowState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row in the Pod picker. The primary key is the Pod name; Session/Segment
|
|
||||||
/// IDs are included only as debug context.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Row {
|
|
||||||
pod_name: String,
|
|
||||||
state: PodRowState,
|
|
||||||
updated_at: u64,
|
|
||||||
active_session_id: Option<SessionId>,
|
|
||||||
active_segment_id: Option<SegmentId>,
|
|
||||||
preview: Option<String>,
|
|
||||||
socket_path: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PodStateRecord {
|
|
||||||
pod_name: String,
|
|
||||||
state: Result<PodMetadata, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(crate) struct LivePodRecord {
|
|
||||||
pub pod_name: String,
|
|
||||||
pub socket_path: PathBuf,
|
|
||||||
pub segment_id: Option<SegmentId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run() -> Result<PickerOutcome, PickerError> {
|
pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||||
let store_dir = default_store_dir()?;
|
let store_dir = default_store_dir()?;
|
||||||
let store = FsStore::new(&store_dir)?;
|
let store = FsStore::new(&store_dir)?;
|
||||||
let pod_states = read_pod_state_records(&store_dir)?;
|
let stored_pods = read_stored_pod_infos(&store_dir, &store)?;
|
||||||
let live_pods = read_reachable_live_pod_records().await.unwrap_or_default();
|
let live_pods = read_reachable_live_pod_infos(&store)
|
||||||
let rows = build_rows(&store, pod_states, live_pods)?;
|
.await
|
||||||
if rows.is_empty() {
|
.unwrap_or_default();
|
||||||
|
let mut list = PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
stored_pods,
|
||||||
|
live_pods,
|
||||||
|
None,
|
||||||
|
MAX_ROWS,
|
||||||
|
);
|
||||||
|
if list.entries.is_empty() {
|
||||||
return Err(PickerError::NoPods);
|
return Err(PickerError::NoPods);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut selected = 0usize;
|
|
||||||
let mut terminal = make_inline_terminal()?;
|
let mut terminal = make_inline_terminal()?;
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| draw(f, &rows, selected))?;
|
terminal.draw(|f| draw(f, &list))?;
|
||||||
match poll_event()? {
|
match poll_event()? {
|
||||||
None => continue,
|
None => continue,
|
||||||
Some(Action::Up) => {
|
Some(Action::Up) => {
|
||||||
selected = selected.saturating_sub(1);
|
let selected = list.selected_index().saturating_sub(1);
|
||||||
|
list.select_index(selected);
|
||||||
}
|
}
|
||||||
Some(Action::Down) => {
|
Some(Action::Down) => {
|
||||||
if selected + 1 < rows.len() {
|
let selected = list.selected_index();
|
||||||
selected += 1;
|
if selected + 1 < list.entries.len() {
|
||||||
|
list.select_index(selected + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
let row = &rows[selected];
|
let entry = list.selected_entry().expect("non-empty pod list");
|
||||||
return Ok(PickerOutcome::Picked {
|
return Ok(PickerOutcome::Picked {
|
||||||
pod_name: row.pod_name.clone(),
|
pod_name: entry.name.clone(),
|
||||||
socket_override: row.socket_path.clone(),
|
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(Action::Cancel) => {
|
Some(Action::Cancel) => {
|
||||||
|
|
@ -189,288 +172,8 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_pod_state_records(store_dir: &Path) -> Result<Vec<PodStateRecord>, PickerError> {
|
|
||||||
let pods_dir = store_dir.join("pods");
|
|
||||||
let mut records = Vec::new();
|
|
||||||
if !pods_dir.exists() {
|
|
||||||
return Ok(records);
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in fs::read_dir(pods_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
if !entry.file_type()?.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pod_name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
let path = entry.path().join("metadata.json");
|
|
||||||
let state = match fs::read_to_string(&path) {
|
|
||||||
Ok(content) => serde_json::from_str::<PodMetadata>(&content).map_err(|e| e.to_string()),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
};
|
|
||||||
records.push(PodStateRecord { pod_name, state });
|
|
||||||
}
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
|
|
||||||
let path = default_registry_path()?;
|
|
||||||
let guard = LockFileGuard::open(&path)?;
|
|
||||||
Ok(guard
|
|
||||||
.data()
|
|
||||||
.allocations
|
|
||||||
.iter()
|
|
||||||
.map(|allocation| LivePodRecord {
|
|
||||||
pod_name: allocation.pod_name.clone(),
|
|
||||||
socket_path: allocation.socket.clone(),
|
|
||||||
segment_id: allocation.segment_id,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_reachable_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
|
|
||||||
let records = read_live_pod_records()?;
|
|
||||||
let mut reachable = Vec::new();
|
|
||||||
for record in records {
|
|
||||||
if PodClient::connect(&record.socket_path).await.is_ok() {
|
|
||||||
reachable.push(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(reachable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
||||||
read_live_pod_records()
|
pod_list_live_socket_for_pod(pod_name)
|
||||||
.ok()?
|
|
||||||
.into_iter()
|
|
||||||
.find(|pod| pod.pod_name == pod_name)
|
|
||||||
.map(|pod| pod.socket_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_rows(
|
|
||||||
store: &FsStore,
|
|
||||||
pod_states: Vec<PodStateRecord>,
|
|
||||||
live_pods: Vec<LivePodRecord>,
|
|
||||||
) -> Result<Vec<Row>, PickerError> {
|
|
||||||
let mut rows_by_name: BTreeMap<String, Row> = BTreeMap::new();
|
|
||||||
let mut live_by_name: HashMap<String, LivePodRecord> = HashMap::new();
|
|
||||||
|
|
||||||
for live in live_pods {
|
|
||||||
let (active_session_id, active_segment_id, updated_at, preview) =
|
|
||||||
summarize_live_pod(store, &live);
|
|
||||||
rows_by_name.insert(
|
|
||||||
live.pod_name.clone(),
|
|
||||||
Row {
|
|
||||||
pod_name: live.pod_name.clone(),
|
|
||||||
state: PodRowState::Live,
|
|
||||||
updated_at,
|
|
||||||
active_session_id,
|
|
||||||
active_segment_id,
|
|
||||||
preview,
|
|
||||||
socket_path: Some(live.socket_path.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
live_by_name.insert(live.pod_name.clone(), live);
|
|
||||||
}
|
|
||||||
|
|
||||||
for record in pod_states {
|
|
||||||
match record.state {
|
|
||||||
Ok(metadata) => {
|
|
||||||
let summary = summarize_metadata(store, &metadata);
|
|
||||||
let state = if live_by_name.contains_key(&record.pod_name) {
|
|
||||||
PodRowState::Live
|
|
||||||
} else {
|
|
||||||
PodRowState::Stopped
|
|
||||||
};
|
|
||||||
upsert_metadata_row(&mut rows_by_name, record.pod_name, metadata, summary, state);
|
|
||||||
}
|
|
||||||
Err(message) => {
|
|
||||||
rows_by_name.entry(record.pod_name.clone()).or_insert(Row {
|
|
||||||
pod_name: record.pod_name,
|
|
||||||
state: PodRowState::Corrupt,
|
|
||||||
updated_at: 0,
|
|
||||||
active_session_id: None,
|
|
||||||
active_segment_id: None,
|
|
||||||
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
|
|
||||||
socket_path: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rows: Vec<Row> = rows_by_name.into_values().collect();
|
|
||||||
rows.sort_by(|a, b| {
|
|
||||||
b.updated_at
|
|
||||||
.cmp(&a.updated_at)
|
|
||||||
.then_with(|| a.pod_name.cmp(&b.pod_name))
|
|
||||||
});
|
|
||||||
rows.truncate(MAX_ROWS);
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upsert_metadata_row(
|
|
||||||
rows_by_name: &mut BTreeMap<String, Row>,
|
|
||||||
pod_name: String,
|
|
||||||
metadata: PodMetadata,
|
|
||||||
summary: SegmentSummary,
|
|
||||||
state: PodRowState,
|
|
||||||
) {
|
|
||||||
let active = metadata.active;
|
|
||||||
let active_session_id = active.as_ref().map(|a| a.session_id);
|
|
||||||
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
|
|
||||||
|
|
||||||
match rows_by_name.get_mut(&pod_name) {
|
|
||||||
Some(existing) => {
|
|
||||||
existing.state = state;
|
|
||||||
if summary.updated_at > existing.updated_at {
|
|
||||||
existing.updated_at = summary.updated_at;
|
|
||||||
}
|
|
||||||
if existing.active_session_id.is_none() {
|
|
||||||
existing.active_session_id = active_session_id;
|
|
||||||
}
|
|
||||||
if existing.active_segment_id.is_none() {
|
|
||||||
existing.active_segment_id = active_segment_id;
|
|
||||||
}
|
|
||||||
if existing.preview.is_none() {
|
|
||||||
existing.preview = summary.preview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
rows_by_name.insert(
|
|
||||||
pod_name.clone(),
|
|
||||||
Row {
|
|
||||||
pod_name,
|
|
||||||
state,
|
|
||||||
updated_at: summary.updated_at,
|
|
||||||
active_session_id,
|
|
||||||
active_segment_id,
|
|
||||||
preview: summary.preview,
|
|
||||||
socket_path: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct SegmentSummary {
|
|
||||||
updated_at: u64,
|
|
||||||
preview: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_live_pod(
|
|
||||||
store: &FsStore,
|
|
||||||
live: &LivePodRecord,
|
|
||||||
) -> (Option<SessionId>, Option<SegmentId>, u64, Option<String>) {
|
|
||||||
let Some(segment_id) = live.segment_id else {
|
|
||||||
return (None, None, 0, None);
|
|
||||||
};
|
|
||||||
let session_id = store.lookup_session_of(segment_id).ok().flatten();
|
|
||||||
let Some(session_id) = session_id else {
|
|
||||||
return (None, Some(segment_id), 0, None);
|
|
||||||
};
|
|
||||||
let summary = summarize_segment(store, session_id, segment_id);
|
|
||||||
(
|
|
||||||
Some(session_id),
|
|
||||||
Some(segment_id),
|
|
||||||
summary.updated_at,
|
|
||||||
summary.preview,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_metadata(store: &FsStore, metadata: &PodMetadata) -> SegmentSummary {
|
|
||||||
let Some(active) = metadata.active.as_ref() else {
|
|
||||||
return SegmentSummary {
|
|
||||||
updated_at: 0,
|
|
||||||
preview: None,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
let Some(segment_id) = active.segment_id else {
|
|
||||||
return SegmentSummary {
|
|
||||||
updated_at: 0,
|
|
||||||
preview: Some("[pending segment]".to_string()),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
summarize_segment(store, active.session_id, segment_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_segment(
|
|
||||||
store: &FsStore,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
) -> SegmentSummary {
|
|
||||||
match store.read_all(session_id, segment_id) {
|
|
||||||
Ok(entries) => SegmentSummary {
|
|
||||||
updated_at: last_entry_ts(&entries).unwrap_or(0),
|
|
||||||
preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())),
|
|
||||||
},
|
|
||||||
Err(_) => SegmentSummary {
|
|
||||||
updated_at: 0,
|
|
||||||
preview: Some("[corrupt segment]".to_string()),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn last_entry_ts(entries: &[LogEntry]) -> Option<u64> {
|
|
||||||
entries.iter().map(log_entry_ts).max()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_entry_ts(entry: &LogEntry) -> u64 {
|
|
||||||
match entry {
|
|
||||||
LogEntry::SegmentStart { ts, .. }
|
|
||||||
| LogEntry::Invoke { ts, .. }
|
|
||||||
| LogEntry::UserInput { ts, .. }
|
|
||||||
| LogEntry::AssistantItem { ts, .. }
|
|
||||||
| LogEntry::ToolResult { ts, .. }
|
|
||||||
| LogEntry::SystemItem { ts, .. }
|
|
||||||
| LogEntry::TurnEnd { ts, .. }
|
|
||||||
| LogEntry::RunCompleted { ts, .. }
|
|
||||||
| LogEntry::RunErrored { ts, .. }
|
|
||||||
| LogEntry::ConfigChanged { ts, .. }
|
|
||||||
| LogEntry::LlmUsage { ts, .. }
|
|
||||||
| LogEntry::Extension { ts, .. } => *ts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk the log from the tail looking for the most recent user-message or
|
|
||||||
/// assistant-message entry, then render its first text fragment in a single line.
|
|
||||||
fn last_message_preview(entries: &[LogEntry]) -> Option<String> {
|
|
||||||
for entry in entries.iter().rev() {
|
|
||||||
match entry {
|
|
||||||
LogEntry::UserInput { segments, .. } => {
|
|
||||||
let text = protocol::Segment::flatten_to_text(segments);
|
|
||||||
if !text.is_empty() {
|
|
||||||
return Some(format!("user: {}", trim_one_line(&text, 60)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LogEntry::AssistantItem { item, .. } => {
|
|
||||||
if let Some(text) = first_text_logged(item) {
|
|
||||||
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_text_logged(item: &LoggedItem) -> Option<String> {
|
|
||||||
match item {
|
|
||||||
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
|
|
||||||
LoggedContentPart::Text { text } => Some(text.clone()),
|
|
||||||
_ => None,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trim_one_line(s: &str, max_chars: usize) -> String {
|
|
||||||
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
|
||||||
if collapsed.chars().count() <= max_chars {
|
|
||||||
collapsed
|
|
||||||
} else {
|
|
||||||
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
|
|
||||||
format!("{truncated}…")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||||
|
|
@ -512,11 +215,11 @@ fn poll_event() -> io::Result<Option<Action>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
fn draw(f: &mut Frame<'_>, list: &PodList) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let mut constraints: Vec<Constraint> = Vec::with_capacity(rows.len() + 3);
|
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
|
||||||
constraints.push(Constraint::Length(1)); // title
|
constraints.push(Constraint::Length(1)); // title
|
||||||
for _ in rows {
|
for _ in &list.entries {
|
||||||
constraints.push(Constraint::Length(1));
|
constraints.push(Constraint::Length(1));
|
||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // hint
|
constraints.push(Constraint::Length(1)); // hint
|
||||||
|
|
@ -531,8 +234,12 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
||||||
layout[0],
|
layout[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
for (i, row) in rows.iter().enumerate() {
|
let selected = list.selected_index();
|
||||||
f.render_widget(Paragraph::new(row_line(row, i == selected)), layout[i + 1]);
|
for (i, entry) in list.entries.iter().enumerate() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(row_line(entry, i == selected)),
|
||||||
|
layout[i + 1],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
|
|
@ -545,7 +252,7 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
||||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||||
Span::raw(" cancel"),
|
Span::raw(" cancel"),
|
||||||
])),
|
])),
|
||||||
layout[rows.len() + 1],
|
layout[list.entries.len() + 1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -553,7 +260,7 @@ fn picker_title() -> &'static str {
|
||||||
"resume pod pick a pod"
|
"resume pod pick a pod"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
|
||||||
let marker = if selected { "▶ " } else { " " };
|
let marker = if selected { "▶ " } else { " " };
|
||||||
let name_style = if selected {
|
let name_style = if selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|
@ -567,27 +274,44 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
let state = row_state(entry);
|
||||||
|
let _visibility = entry.visibility;
|
||||||
|
let _source_kinds = &entry.source_kinds;
|
||||||
|
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::raw(marker),
|
Span::raw(marker),
|
||||||
Span::styled(row.pod_name.as_str(), name_style),
|
Span::styled(entry.name.as_str(), name_style),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("[{}]", row.state.label()), row.state.style()),
|
Span::styled(format!("[{}]", state.label()), state.style()),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format_updated_at(row.updated_at),
|
format_updated_at(entry.summary.updated_at),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(debug_ids(row), Style::default().fg(Color::DarkGray)),
|
Span::styled(debug_ids(entry), Style::default().fg(Color::DarkGray)),
|
||||||
];
|
];
|
||||||
if let Some(preview) = row.preview.as_ref() {
|
if let Some(preview) = entry.summary.preview.as_ref() {
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
spans.push(Span::styled(preview.as_str(), preview_style));
|
spans.push(Span::styled(preview.as_str(), preview_style));
|
||||||
}
|
}
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn row_state(entry: &PodListEntry) -> PodRowState {
|
||||||
|
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
||||||
|
return PodRowState::Live;
|
||||||
|
}
|
||||||
|
if entry
|
||||||
|
.stored
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
||||||
|
{
|
||||||
|
return PodRowState::Corrupt;
|
||||||
|
}
|
||||||
|
PodRowState::Stopped
|
||||||
|
}
|
||||||
|
|
||||||
fn format_updated_at(updated_at: u64) -> String {
|
fn format_updated_at(updated_at: u64) -> String {
|
||||||
if updated_at == 0 {
|
if updated_at == 0 {
|
||||||
"updated: —".to_string()
|
"updated: —".to_string()
|
||||||
|
|
@ -596,12 +320,14 @@ fn format_updated_at(updated_at: u64) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn debug_ids(row: &Row) -> String {
|
fn debug_ids(entry: &PodListEntry) -> String {
|
||||||
let session = row
|
let session = entry
|
||||||
|
.summary
|
||||||
.active_session_id
|
.active_session_id
|
||||||
.map(short_id)
|
.map(short_id)
|
||||||
.unwrap_or_else(|| "--------".to_string());
|
.unwrap_or_else(|| "--------".to_string());
|
||||||
let segment = row
|
let segment = entry
|
||||||
|
.summary
|
||||||
.active_segment_id
|
.active_segment_id
|
||||||
.map(short_id)
|
.map(short_id)
|
||||||
.unwrap_or_else(|| "--------".to_string());
|
.unwrap_or_else(|| "--------".to_string());
|
||||||
|
|
@ -615,198 +341,9 @@ fn short_id<T: ToString>(id: T) -> String {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use llm_worker::llm_client::types::RequestConfig;
|
|
||||||
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pod_rows_are_sorted_by_active_segment_timestamp() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
|
||||||
let earlier_session = new_session_id();
|
|
||||||
let later_session = new_session_id();
|
|
||||||
let earlier_segment = new_segment_id();
|
|
||||||
let later_segment = new_segment_id();
|
|
||||||
|
|
||||||
append_start(&store, earlier_session, earlier_segment, 10);
|
|
||||||
append_user(
|
|
||||||
&store,
|
|
||||||
earlier_session,
|
|
||||||
earlier_segment,
|
|
||||||
100,
|
|
||||||
"old pod update",
|
|
||||||
);
|
|
||||||
append_start(&store, later_session, later_segment, 20);
|
|
||||||
append_user(&store, later_session, later_segment, 200, "new pod update");
|
|
||||||
|
|
||||||
let records = vec![
|
|
||||||
metadata_record("older", earlier_session, earlier_segment),
|
|
||||||
metadata_record("newer", later_session, later_segment),
|
|
||||||
];
|
|
||||||
let rows = build_rows(&store, records, vec![]).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(rows[0].pod_name, "newer");
|
|
||||||
assert_eq!(rows[0].state, PodRowState::Stopped);
|
|
||||||
assert_eq!(rows[0].updated_at, 200);
|
|
||||||
assert_eq!(rows[0].preview.as_deref(), Some("user: new pod update"));
|
|
||||||
assert_eq!(rows[1].pod_name, "older");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pod_rows_include_live_and_stopped_pods() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
|
||||||
let stopped_session = new_session_id();
|
|
||||||
let stopped_segment = new_segment_id();
|
|
||||||
let live_session = new_session_id();
|
|
||||||
let live_segment = new_segment_id();
|
|
||||||
|
|
||||||
append_start(&store, stopped_session, stopped_segment, 10);
|
|
||||||
append_user(
|
|
||||||
&store,
|
|
||||||
stopped_session,
|
|
||||||
stopped_segment,
|
|
||||||
50,
|
|
||||||
"stopped preview",
|
|
||||||
);
|
|
||||||
append_start(&store, live_session, live_segment, 20);
|
|
||||||
append_user(&store, live_session, live_segment, 70, "live preview");
|
|
||||||
|
|
||||||
let rows = build_rows(
|
|
||||||
&store,
|
|
||||||
vec![metadata_record("stopped", stopped_session, stopped_segment)],
|
|
||||||
vec![LivePodRecord {
|
|
||||||
pod_name: "live".to_string(),
|
|
||||||
socket_path: PathBuf::from("/tmp/live.sock"),
|
|
||||||
segment_id: Some(live_segment),
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let live = rows.iter().find(|row| row.pod_name == "live").unwrap();
|
|
||||||
assert_eq!(live.state, PodRowState::Live);
|
|
||||||
assert_eq!(live.active_session_id, Some(live_session));
|
|
||||||
assert_eq!(
|
|
||||||
live.socket_path.as_deref(),
|
|
||||||
Some(Path::new("/tmp/live.sock"))
|
|
||||||
);
|
|
||||||
|
|
||||||
let stopped = rows.iter().find(|row| row.pod_name == "stopped").unwrap();
|
|
||||||
assert_eq!(stopped.state, PodRowState::Stopped);
|
|
||||||
assert_eq!(stopped.socket_path, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn corrupt_pod_state_is_rendered_as_corrupt_row() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
|
||||||
let rows = build_rows(
|
|
||||||
&store,
|
|
||||||
vec![PodStateRecord {
|
|
||||||
pod_name: "broken".to_string(),
|
|
||||||
state: Err("expected value".to_string()),
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(rows.len(), 1);
|
|
||||||
assert_eq!(rows[0].pod_name, "broken");
|
|
||||||
assert_eq!(rows[0].state, PodRowState::Corrupt);
|
|
||||||
assert!(
|
|
||||||
rows[0]
|
|
||||||
.preview
|
|
||||||
.as_deref()
|
|
||||||
.unwrap()
|
|
||||||
.contains("expected value")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_title_names_pods_not_sessions() {
|
fn picker_title_names_pods_not_sessions() {
|
||||||
assert_eq!(picker_title(), "resume pod pick a pod");
|
assert_eq!(picker_title(), "resume pod pick a pod");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_record(
|
|
||||||
pod_name: &str,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
) -> PodStateRecord {
|
|
||||||
PodStateRecord {
|
|
||||||
pod_name: pod_name.to_string(),
|
|
||||||
state: Ok(PodMetadata::new(
|
|
||||||
pod_name,
|
|
||||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
|
|
||||||
store
|
|
||||||
.append(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&LogEntry::SegmentStart {
|
|
||||||
ts,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_user(
|
|
||||||
store: &FsStore,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
ts: u64,
|
|
||||||
text: &str,
|
|
||||||
) {
|
|
||||||
store
|
|
||||||
.append(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&LogEntry::UserInput {
|
|
||||||
ts,
|
|
||||||
segments: vec![protocol::Segment::text(text)],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_pod_state_records_reports_corrupt_metadata() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let pod_dir = dir.path().join("pods").join("broken");
|
|
||||||
fs::create_dir_all(&pod_dir).unwrap();
|
|
||||||
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
|
|
||||||
|
|
||||||
let records = read_pod_state_records(dir.path()).unwrap();
|
|
||||||
assert_eq!(records.len(), 1);
|
|
||||||
assert_eq!(records[0].pod_name, "broken");
|
|
||||||
assert!(records[0].state.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_pod_state_records_reads_metadata() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let store = FsStore::new(dir.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let segment_id = new_segment_id();
|
|
||||||
store
|
|
||||||
.write(&PodMetadata::new(
|
|
||||||
"agent",
|
|
||||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let records = read_pod_state_records(dir.path()).unwrap();
|
|
||||||
assert_eq!(records.len(), 1);
|
|
||||||
assert_eq!(records[0].pod_name, "agent");
|
|
||||||
assert!(records[0].state.is_ok());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
905
crates/tui/src/pod_list.rs
Normal file
905
crates/tui/src/pod_list.rs
Normal file
|
|
@ -0,0 +1,905 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use client::PodClient;
|
||||||
|
use pod_registry::{LockFileGuard, default_registry_path};
|
||||||
|
use protocol::{Event, PodStatus};
|
||||||
|
use session_store::{
|
||||||
|
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PodList {
|
||||||
|
pub entries: Vec<PodListEntry>,
|
||||||
|
pub selected_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PodList {
|
||||||
|
pub(crate) fn from_sources(
|
||||||
|
source: PodVisibilitySource,
|
||||||
|
stored: Vec<StoredPodInfo>,
|
||||||
|
live: Vec<LivePodInfo>,
|
||||||
|
selected_name: Option<String>,
|
||||||
|
max_entries: usize,
|
||||||
|
) -> Self {
|
||||||
|
let mut entries_by_name: BTreeMap<String, PodListEntry> = BTreeMap::new();
|
||||||
|
|
||||||
|
for live_info in live {
|
||||||
|
let name = live_info.pod_name.clone();
|
||||||
|
entries_by_name
|
||||||
|
.entry(name.clone())
|
||||||
|
.or_insert_with(|| PodListEntry::new(name, source))
|
||||||
|
.merge_live(live_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
for stored_info in stored {
|
||||||
|
let name = stored_info.pod_name.clone();
|
||||||
|
entries_by_name
|
||||||
|
.entry(name.clone())
|
||||||
|
.or_insert_with(|| PodListEntry::new(name, source))
|
||||||
|
.merge_stored(stored_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries: Vec<PodListEntry> = entries_by_name.into_values().collect();
|
||||||
|
for entry in &mut entries {
|
||||||
|
entry.finalize();
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.summary
|
||||||
|
.updated_at
|
||||||
|
.cmp(&a.summary.updated_at)
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
entries.truncate(max_entries);
|
||||||
|
|
||||||
|
let selected_name = selected_name
|
||||||
|
.filter(|name| entries.iter().any(|entry| entry.name == *name))
|
||||||
|
.or_else(|| entries.first().map(|entry| entry.name.clone()));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
entries,
|
||||||
|
selected_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn selected_index(&self) -> usize {
|
||||||
|
self.selected_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|name| self.entries.iter().position(|entry| entry.name == *name))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_index(&mut self, index: usize) {
|
||||||
|
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn selected_entry(&self) -> Option<&PodListEntry> {
|
||||||
|
let index = self.selected_index();
|
||||||
|
self.entries.get(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum PodVisibilitySource {
|
||||||
|
ResumePicker,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum PodListSourceKind {
|
||||||
|
RuntimeRegistry,
|
||||||
|
StoredMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PodListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub visibility: PodVisibilitySource,
|
||||||
|
pub source_kinds: Vec<PodListSourceKind>,
|
||||||
|
pub live: Option<LivePodInfo>,
|
||||||
|
pub stored: Option<StoredPodInfo>,
|
||||||
|
pub summary: PodEntrySummary,
|
||||||
|
pub actions: PodEntryActions,
|
||||||
|
pub diagnostics: Vec<PodEntryDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PodListEntry {
|
||||||
|
fn new(name: String, visibility: PodVisibilitySource) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
visibility,
|
||||||
|
source_kinds: Vec::new(),
|
||||||
|
live: None,
|
||||||
|
stored: None,
|
||||||
|
summary: PodEntrySummary::default(),
|
||||||
|
actions: PodEntryActions::default(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_live(&mut self, live: LivePodInfo) {
|
||||||
|
if !self
|
||||||
|
.source_kinds
|
||||||
|
.contains(&PodListSourceKind::RuntimeRegistry)
|
||||||
|
{
|
||||||
|
self.source_kinds.push(PodListSourceKind::RuntimeRegistry);
|
||||||
|
}
|
||||||
|
if live.summary.updated_at > self.summary.updated_at {
|
||||||
|
self.summary.updated_at = live.summary.updated_at;
|
||||||
|
}
|
||||||
|
if self.summary.active_session_id.is_none() {
|
||||||
|
self.summary.active_session_id = live.summary.active_session_id;
|
||||||
|
}
|
||||||
|
if self.summary.active_segment_id.is_none() {
|
||||||
|
self.summary.active_segment_id = live.summary.active_segment_id.or(live.segment_id);
|
||||||
|
}
|
||||||
|
if self.summary.preview.is_none() {
|
||||||
|
self.summary.preview = live.summary.preview.clone();
|
||||||
|
}
|
||||||
|
self.live = Some(live);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_stored(&mut self, stored: StoredPodInfo) {
|
||||||
|
if !self
|
||||||
|
.source_kinds
|
||||||
|
.contains(&PodListSourceKind::StoredMetadata)
|
||||||
|
{
|
||||||
|
self.source_kinds.push(PodListSourceKind::StoredMetadata);
|
||||||
|
}
|
||||||
|
if stored.updated_at > self.summary.updated_at {
|
||||||
|
self.summary.updated_at = stored.updated_at;
|
||||||
|
}
|
||||||
|
if self.summary.active_session_id.is_none() {
|
||||||
|
self.summary.active_session_id = stored.active_session_id;
|
||||||
|
}
|
||||||
|
if self.summary.active_segment_id.is_none() {
|
||||||
|
self.summary.active_segment_id = stored.active_segment_id;
|
||||||
|
}
|
||||||
|
if self.summary.preview.is_none() {
|
||||||
|
self.summary.preview = stored.preview.clone();
|
||||||
|
}
|
||||||
|
self.stored = Some(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(&mut self) {
|
||||||
|
self.diagnostics = build_diagnostics(self);
|
||||||
|
self.actions = build_actions(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_socket_path(&self) -> Option<&Path> {
|
||||||
|
self.live
|
||||||
|
.as_ref()
|
||||||
|
.filter(|live| live.reachable)
|
||||||
|
.map(|live| live.socket_path.as_path())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct LivePodInfo {
|
||||||
|
pub pod_name: String,
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
pub status: Option<PodStatus>,
|
||||||
|
pub reachable: bool,
|
||||||
|
pub segment_id: Option<SegmentId>,
|
||||||
|
pub summary: PodEntrySummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct StoredPodInfo {
|
||||||
|
pub pod_name: String,
|
||||||
|
pub metadata_state: StoredMetadataState,
|
||||||
|
pub active_session_id: Option<SessionId>,
|
||||||
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
pub updated_at: u64,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum StoredMetadataState {
|
||||||
|
Present,
|
||||||
|
Corrupt(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct PodEntrySummary {
|
||||||
|
pub active_session_id: Option<SessionId>,
|
||||||
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
pub updated_at: u64,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct PodEntryActions {
|
||||||
|
pub can_open: bool,
|
||||||
|
pub can_restore: bool,
|
||||||
|
pub can_send_now: bool,
|
||||||
|
pub can_queue_send: bool,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct PodEntryDiagnostic {
|
||||||
|
pub kind: PodEntryDiagnosticKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum PodEntryDiagnosticKind {
|
||||||
|
StoredMetadataCorrupt,
|
||||||
|
LiveUnreachable,
|
||||||
|
MissingStoredMetadata,
|
||||||
|
MissingLiveStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_stored_pod_infos(
|
||||||
|
store_dir: &Path,
|
||||||
|
store: &FsStore,
|
||||||
|
) -> Result<Vec<StoredPodInfo>, io::Error> {
|
||||||
|
let pods_dir = store_dir.join("pods");
|
||||||
|
let mut records = Vec::new();
|
||||||
|
if !pods_dir.exists() {
|
||||||
|
return Ok(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(pods_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pod_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let path = entry.path().join("metadata.json");
|
||||||
|
let info = match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<PodMetadata>(&content) {
|
||||||
|
Ok(metadata) => stored_info_from_metadata(store, pod_name, metadata),
|
||||||
|
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
|
||||||
|
},
|
||||||
|
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
|
||||||
|
};
|
||||||
|
records.push(info);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_live_pod_infos() -> Result<Vec<LivePodInfo>, io::Error> {
|
||||||
|
let path = default_registry_path()?;
|
||||||
|
let guard = LockFileGuard::open(&path)?;
|
||||||
|
Ok(guard
|
||||||
|
.data()
|
||||||
|
.allocations
|
||||||
|
.iter()
|
||||||
|
.map(|allocation| LivePodInfo {
|
||||||
|
pod_name: allocation.pod_name.clone(),
|
||||||
|
socket_path: allocation.socket.clone(),
|
||||||
|
status: None,
|
||||||
|
reachable: false,
|
||||||
|
segment_id: allocation.segment_id,
|
||||||
|
summary: PodEntrySummary::default(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_reachable_live_pod_infos(
|
||||||
|
store: &FsStore,
|
||||||
|
) -> Result<Vec<LivePodInfo>, io::Error> {
|
||||||
|
let records = read_live_pod_infos()?;
|
||||||
|
let mut reachable = Vec::new();
|
||||||
|
for mut record in records {
|
||||||
|
let Ok(status) = probe_live_status(&record.socket_path).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
record.reachable = true;
|
||||||
|
record.status = status;
|
||||||
|
record.summary = summarize_live_pod(store, &record);
|
||||||
|
reachable.push(record);
|
||||||
|
}
|
||||||
|
Ok(reachable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
||||||
|
read_live_pod_infos()
|
||||||
|
.ok()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|pod| pod.pod_name == pod_name)
|
||||||
|
.map(|pod| pod.socket_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_info_from_metadata(
|
||||||
|
store: &FsStore,
|
||||||
|
pod_name: String,
|
||||||
|
metadata: PodMetadata,
|
||||||
|
) -> StoredPodInfo {
|
||||||
|
let active = metadata.active;
|
||||||
|
let active_session_id = active.as_ref().map(|a| a.session_id);
|
||||||
|
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
|
||||||
|
let summary = summarize_metadata(store, active.as_ref());
|
||||||
|
|
||||||
|
StoredPodInfo {
|
||||||
|
pod_name,
|
||||||
|
metadata_state: StoredMetadataState::Present,
|
||||||
|
active_session_id,
|
||||||
|
active_segment_id,
|
||||||
|
updated_at: summary.updated_at,
|
||||||
|
preview: summary.preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
|
||||||
|
StoredPodInfo {
|
||||||
|
pod_name,
|
||||||
|
metadata_state: StoredMetadataState::Corrupt(message.clone()),
|
||||||
|
active_session_id: None,
|
||||||
|
active_segment_id: None,
|
||||||
|
updated_at: 0,
|
||||||
|
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(25);
|
||||||
|
|
||||||
|
async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::Error> {
|
||||||
|
let mut client = PodClient::connect(socket_path).await?;
|
||||||
|
let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match tokio::time::timeout_at(deadline, client.next_event()).await {
|
||||||
|
Ok(Some(event)) => {
|
||||||
|
if let Some(status) = status_from_event(&event) {
|
||||||
|
return Ok(Some(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_from_event(event: &Event) -> Option<PodStatus> {
|
||||||
|
match event {
|
||||||
|
Event::Snapshot { status, .. } | Event::Status { status } => Some(*status),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SegmentSummary {
|
||||||
|
updated_at: u64,
|
||||||
|
preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_live_pod(store: &FsStore, live: &LivePodInfo) -> PodEntrySummary {
|
||||||
|
let Some(segment_id) = live.segment_id else {
|
||||||
|
return PodEntrySummary::default();
|
||||||
|
};
|
||||||
|
let session_id = store.lookup_session_of(segment_id).ok().flatten();
|
||||||
|
let Some(session_id) = session_id else {
|
||||||
|
return PodEntrySummary {
|
||||||
|
active_session_id: None,
|
||||||
|
active_segment_id: Some(segment_id),
|
||||||
|
updated_at: 0,
|
||||||
|
preview: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let summary = summarize_segment(store, session_id, segment_id);
|
||||||
|
PodEntrySummary {
|
||||||
|
active_session_id: Some(session_id),
|
||||||
|
active_segment_id: Some(segment_id),
|
||||||
|
updated_at: summary.updated_at,
|
||||||
|
preview: summary.preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_metadata(
|
||||||
|
store: &FsStore,
|
||||||
|
active: Option<&session_store::PodActiveSegmentRef>,
|
||||||
|
) -> SegmentSummary {
|
||||||
|
let Some(active) = active else {
|
||||||
|
return SegmentSummary {
|
||||||
|
updated_at: 0,
|
||||||
|
preview: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(segment_id) = active.segment_id else {
|
||||||
|
return SegmentSummary {
|
||||||
|
updated_at: 0,
|
||||||
|
preview: Some("[pending segment]".to_string()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
summarize_segment(store, active.session_id, segment_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_segment(
|
||||||
|
store: &FsStore,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> SegmentSummary {
|
||||||
|
match store.read_all(session_id, segment_id) {
|
||||||
|
Ok(entries) => SegmentSummary {
|
||||||
|
updated_at: last_entry_ts(&entries).unwrap_or(0),
|
||||||
|
preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())),
|
||||||
|
},
|
||||||
|
Err(_) => SegmentSummary {
|
||||||
|
updated_at: 0,
|
||||||
|
preview: Some("[corrupt segment]".to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_entry_ts(entries: &[LogEntry]) -> Option<u64> {
|
||||||
|
entries.iter().map(log_entry_ts).max()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_entry_ts(entry: &LogEntry) -> u64 {
|
||||||
|
match entry {
|
||||||
|
LogEntry::SegmentStart { ts, .. }
|
||||||
|
| LogEntry::Invoke { ts, .. }
|
||||||
|
| LogEntry::UserInput { ts, .. }
|
||||||
|
| LogEntry::AssistantItem { ts, .. }
|
||||||
|
| LogEntry::ToolResult { ts, .. }
|
||||||
|
| LogEntry::SystemItem { ts, .. }
|
||||||
|
| LogEntry::TurnEnd { ts, .. }
|
||||||
|
| LogEntry::RunCompleted { ts, .. }
|
||||||
|
| LogEntry::RunErrored { ts, .. }
|
||||||
|
| LogEntry::ConfigChanged { ts, .. }
|
||||||
|
| LogEntry::LlmUsage { ts, .. }
|
||||||
|
| LogEntry::Extension { ts, .. } => *ts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_message_preview(entries: &[LogEntry]) -> Option<String> {
|
||||||
|
for entry in entries.iter().rev() {
|
||||||
|
match entry {
|
||||||
|
LogEntry::UserInput { segments, .. } => {
|
||||||
|
let text = protocol::Segment::flatten_to_text(segments);
|
||||||
|
if !text.is_empty() {
|
||||||
|
return Some(format!("user: {}", trim_one_line(&text, 60)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry::AssistantItem { item, .. } => {
|
||||||
|
if let Some(text) = first_text_logged(item) {
|
||||||
|
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_text_logged(item: &LoggedItem) -> Option<String> {
|
||||||
|
match item {
|
||||||
|
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
|
||||||
|
LoggedContentPart::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> {
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
|
||||||
|
if let Some(stored) = entry.stored.as_ref() {
|
||||||
|
if let StoredMetadataState::Corrupt(message) = &stored.metadata_state {
|
||||||
|
diagnostics.push(PodEntryDiagnostic {
|
||||||
|
kind: PodEntryDiagnosticKind::StoredMetadataCorrupt,
|
||||||
|
message: format!("metadata: {}", trim_one_line(message, 80)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if entry.live.is_some() {
|
||||||
|
diagnostics.push(PodEntryDiagnostic {
|
||||||
|
kind: PodEntryDiagnosticKind::MissingStoredMetadata,
|
||||||
|
message: "no stored pod metadata".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(live) = entry.live.as_ref() {
|
||||||
|
if !live.reachable {
|
||||||
|
diagnostics.push(PodEntryDiagnostic {
|
||||||
|
kind: PodEntryDiagnosticKind::LiveUnreachable,
|
||||||
|
message: format!("socket unreachable: {}", live.socket_path.display()),
|
||||||
|
});
|
||||||
|
} else if live.status.is_none() {
|
||||||
|
diagnostics.push(PodEntryDiagnostic {
|
||||||
|
kind: PodEntryDiagnosticKind::MissingLiveStatus,
|
||||||
|
message: "live pod status was not reported".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_actions(entry: &PodListEntry) -> PodEntryActions {
|
||||||
|
let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable);
|
||||||
|
let stored_restorable = entry
|
||||||
|
.stored
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Present));
|
||||||
|
let live_status = entry.live.as_ref().and_then(|live| live.status);
|
||||||
|
|
||||||
|
let can_restore = stored_restorable && !live_reachable;
|
||||||
|
let can_open = live_reachable || stored_restorable;
|
||||||
|
let can_send_now = live_reachable && live_status == Some(PodStatus::Idle);
|
||||||
|
let can_queue_send = live_reachable && live_status == Some(PodStatus::Running);
|
||||||
|
let disabled_reason = if can_open {
|
||||||
|
None
|
||||||
|
} else if entry.live.is_some() {
|
||||||
|
Some("live pod is unreachable".to_string())
|
||||||
|
} else if entry.stored.is_some() {
|
||||||
|
Some("stored pod metadata is corrupt".to_string())
|
||||||
|
} else {
|
||||||
|
Some("no live or stored pod state".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
PodEntryActions {
|
||||||
|
can_open,
|
||||||
|
can_restore,
|
||||||
|
can_send_now,
|
||||||
|
can_queue_send,
|
||||||
|
disabled_reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_one_line(s: &str, max_chars: usize) -> String {
|
||||||
|
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
||||||
|
if collapsed.chars().count() <= max_chars {
|
||||||
|
collapsed
|
||||||
|
} else {
|
||||||
|
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
|
||||||
|
format!("{truncated}…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use llm_worker::llm_client::types::RequestConfig;
|
||||||
|
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pod_list_rows_are_sorted_by_active_segment_timestamp() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
|
let earlier_session = new_session_id();
|
||||||
|
let later_session = new_session_id();
|
||||||
|
let earlier_segment = new_segment_id();
|
||||||
|
let later_segment = new_segment_id();
|
||||||
|
|
||||||
|
append_start(&store, earlier_session, earlier_segment, 10);
|
||||||
|
append_user(
|
||||||
|
&store,
|
||||||
|
earlier_session,
|
||||||
|
earlier_segment,
|
||||||
|
100,
|
||||||
|
"old pod update",
|
||||||
|
);
|
||||||
|
append_start(&store, later_session, later_segment, 20);
|
||||||
|
append_user(&store, later_session, later_segment, 200, "new pod update");
|
||||||
|
|
||||||
|
let entries = PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![
|
||||||
|
metadata_info(&store, "older", earlier_session, earlier_segment),
|
||||||
|
metadata_info(&store, "newer", later_session, later_segment),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.entries;
|
||||||
|
|
||||||
|
assert_eq!(entries[0].name, "newer");
|
||||||
|
assert_eq!(entries[0].summary.updated_at, 200);
|
||||||
|
assert_eq!(
|
||||||
|
entries[0].summary.preview.as_deref(),
|
||||||
|
Some("user: new pod update")
|
||||||
|
);
|
||||||
|
assert_eq!(entries[1].name, "older");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_only_row_can_restore_and_open_but_not_direct_send() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
append_start(&store, session_id, segment_id, 10);
|
||||||
|
|
||||||
|
let entry = single_entry(PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![metadata_info(&store, "stored", session_id, segment_id)],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(entry.name, "stored");
|
||||||
|
assert_eq!(entry.visibility, SOURCE);
|
||||||
|
assert_eq!(entry.source_kinds, vec![PodListSourceKind::StoredMetadata]);
|
||||||
|
assert!(entry.live.is_none());
|
||||||
|
assert!(entry.stored.is_some());
|
||||||
|
assert!(entry.actions.can_open);
|
||||||
|
assert!(entry.actions.can_restore);
|
||||||
|
assert!(!entry.actions.can_send_now);
|
||||||
|
assert!(!entry.actions.can_queue_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_idle_reachable_row_can_open_and_send_now() {
|
||||||
|
let entry = single_entry(PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![live_info("live", PodStatus::Idle)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(entry.name, "live");
|
||||||
|
assert_eq!(entry.visibility, SOURCE);
|
||||||
|
assert_eq!(entry.source_kinds, vec![PodListSourceKind::RuntimeRegistry]);
|
||||||
|
assert!(entry.actions.can_open);
|
||||||
|
assert!(!entry.actions.can_restore);
|
||||||
|
assert!(entry.actions.can_send_now);
|
||||||
|
assert!(!entry.actions.can_queue_send);
|
||||||
|
assert_eq!(
|
||||||
|
entry.attach_socket_path(),
|
||||||
|
Some(Path::new("/tmp/live.sock"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_running_reachable_row_can_open_but_not_send_now() {
|
||||||
|
let entry = single_entry(PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![live_info("live", PodStatus::Running)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(entry.actions.can_open);
|
||||||
|
assert!(!entry.actions.can_restore);
|
||||||
|
assert!(!entry.actions.can_send_now);
|
||||||
|
assert!(entry.actions.can_queue_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_unreachable_row_has_diagnostic_and_cannot_open() {
|
||||||
|
let mut live = live_info("live", PodStatus::Idle);
|
||||||
|
live.reachable = false;
|
||||||
|
live.status = None;
|
||||||
|
|
||||||
|
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10));
|
||||||
|
|
||||||
|
assert!(!entry.actions.can_open);
|
||||||
|
assert!(!entry.actions.can_restore);
|
||||||
|
assert!(!entry.actions.can_send_now);
|
||||||
|
assert!(!entry.actions.can_queue_send);
|
||||||
|
assert_eq!(
|
||||||
|
entry.actions.disabled_reason.as_deref(),
|
||||||
|
Some("live pod is unreachable")
|
||||||
|
);
|
||||||
|
assert_eq!(entry.attach_socket_path(), None);
|
||||||
|
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable
|
||||||
|
&& diagnostic.message.contains("/tmp/live.sock")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_extraction_skips_alert_before_snapshot() {
|
||||||
|
let events = [
|
||||||
|
Event::Alert(protocol::Alert {
|
||||||
|
level: protocol::AlertLevel::Warn,
|
||||||
|
source: protocol::AlertSource::Pod,
|
||||||
|
message: "warming up".to_string(),
|
||||||
|
timestamp_ms: 0,
|
||||||
|
}),
|
||||||
|
Event::Snapshot {
|
||||||
|
entries: vec![],
|
||||||
|
greeting: test_greeting(),
|
||||||
|
status: PodStatus::Idle,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let status = events.iter().find_map(status_from_event);
|
||||||
|
assert_eq!(status, Some(PodStatus::Idle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_stored_metadata_has_diagnostic() {
|
||||||
|
let entry = single_entry(PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![corrupt_stored_info(
|
||||||
|
"broken".to_string(),
|
||||||
|
"expected value".to_string(),
|
||||||
|
)],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(entry.name, "broken");
|
||||||
|
assert!(!entry.actions.can_open);
|
||||||
|
assert!(entry.diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.kind == PodEntryDiagnosticKind::StoredMetadataCorrupt
|
||||||
|
&& diagnostic.message.contains("expected value")
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
entry
|
||||||
|
.summary
|
||||||
|
.preview
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("expected value")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_pod_name_is_kept_after_rebuild() {
|
||||||
|
let first = PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
live_info("alpha", PodStatus::Idle),
|
||||||
|
live_info("beta", PodStatus::Idle),
|
||||||
|
],
|
||||||
|
Some("alpha".to_string()),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(first.selected_entry().unwrap().name, "alpha");
|
||||||
|
|
||||||
|
let rebuilt = PodList::from_sources(
|
||||||
|
SOURCE,
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
||||||
|
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
||||||
|
],
|
||||||
|
first.selected_name.clone(),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(rebuilt.entries[0].name, "beta");
|
||||||
|
assert_eq!(rebuilt.selected_entry().unwrap().name, "alpha");
|
||||||
|
assert_eq!(rebuilt.selected_index(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_stored_pod_infos_reports_corrupt_metadata() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
|
let pod_dir = dir.path().join("pods").join("broken");
|
||||||
|
fs::create_dir_all(&pod_dir).unwrap();
|
||||||
|
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
|
||||||
|
|
||||||
|
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].pod_name, "broken");
|
||||||
|
assert!(matches!(
|
||||||
|
records[0].metadata_state,
|
||||||
|
StoredMetadataState::Corrupt(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_stored_pod_infos_reads_metadata() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = FsStore::new(dir.path()).unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
store
|
||||||
|
.write(&PodMetadata::new(
|
||||||
|
"agent",
|
||||||
|
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].pod_name, "agent");
|
||||||
|
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_entry(list: PodList) -> PodListEntry {
|
||||||
|
assert_eq!(list.entries.len(), 1);
|
||||||
|
list.entries.into_iter().next().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_info(
|
||||||
|
store: &FsStore,
|
||||||
|
pod_name: &str,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> StoredPodInfo {
|
||||||
|
stored_info_from_metadata(
|
||||||
|
store,
|
||||||
|
pod_name.to_string(),
|
||||||
|
PodMetadata::new(
|
||||||
|
pod_name,
|
||||||
|
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo {
|
||||||
|
live_info_with_updated_at(pod_name, status, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_info_with_updated_at(
|
||||||
|
pod_name: &str,
|
||||||
|
status: PodStatus,
|
||||||
|
updated_at: u64,
|
||||||
|
) -> LivePodInfo {
|
||||||
|
LivePodInfo {
|
||||||
|
pod_name: pod_name.to_string(),
|
||||||
|
socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")),
|
||||||
|
status: Some(status),
|
||||||
|
reachable: true,
|
||||||
|
segment_id: None,
|
||||||
|
summary: PodEntrySummary {
|
||||||
|
active_session_id: None,
|
||||||
|
active_segment_id: None,
|
||||||
|
updated_at,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_greeting() -> protocol::Greeting {
|
||||||
|
protocol::Greeting {
|
||||||
|
pod_name: "live".to_string(),
|
||||||
|
cwd: "/tmp".to_string(),
|
||||||
|
provider: "test".to_string(),
|
||||||
|
model: "test".to_string(),
|
||||||
|
scope_summary: "test".to_string(),
|
||||||
|
tools: vec![],
|
||||||
|
context_window: 0,
|
||||||
|
context_tokens: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&LogEntry::SegmentStart {
|
||||||
|
ts,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_user(
|
||||||
|
store: &FsStore,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
ts: u64,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&LogEntry::UserInput {
|
||||||
|
ts,
|
||||||
|
segments: vec![protocol::Segment::text(text)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,11 +72,11 @@ pub struct ToolOutput {
|
||||||
|
|
||||||
**ターンの合間が proactive (小さい閾値)**:
|
**ターンの合間が proactive (小さい閾値)**:
|
||||||
turn が完了した地点はタスクの自然な区切り。ここで先を見越して早めに compact する。
|
turn が完了した地点はタスクの自然な区切り。ここで先を見越して早めに compact する。
|
||||||
マニフェストの `compact_threshold` が対応。
|
マニフェストの `threshold` が対応。
|
||||||
|
|
||||||
**リクエストの合間は safety net (大きい閾値)**:
|
**リクエストの合間は safety net (大きい閾値)**:
|
||||||
turn 内部でリクエストの合間にチェックするのは「暴走的に膨張した場合のみ止める」用途。
|
turn 内部でリクエストの合間にチェックするのは「暴走的に膨張した場合のみ止める」用途。
|
||||||
マニフェストの `compact_request_threshold` が対応。通常は発動しない。
|
マニフェストの `request_threshold` が対応。通常は発動しない。
|
||||||
|
|
||||||
**両閾値は manifest で個別指定する**。過去の設計では 9/8 倍で自動導出していたが、
|
**両閾値は manifest で個別指定する**。過去の設計では 9/8 倍で自動導出していたが、
|
||||||
比率に根拠がなかったため廃止。両方が `Option<u64>` で、片方だけの設定も可能
|
比率に根拠がなかったため廃止。両方が `Option<u64>` で、片方だけの設定も可能
|
||||||
|
|
@ -137,15 +137,29 @@ compact は fork と同じ構造。旧セッションを保全し、新 SessionI
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[compaction]
|
[compaction]
|
||||||
compact_threshold = 80000 # ターンの合間 (proactive)
|
threshold = 80000 # ターンの合間 (proactive)
|
||||||
compact_request_threshold = 90000 # リクエストの合間 (safety net)
|
request_threshold = 90000 # リクエストの合間 (safety net)
|
||||||
prune_protected_tokens = 8000 # prune から保護する末尾 token budget
|
prune_protected_tokens = 8000 # prune から保護する末尾 token budget
|
||||||
compact_retained_tokens = 8000 # compact 後に生のまま残す末尾 token budget
|
retained_tokens = 8000 # compact 後に生のまま残す末尾 token budget
|
||||||
compact_auto_read_budget = 8000 # compact worker の mark_read_required 合計上限
|
|
||||||
compact_worker_max_input_tokens = 50000 # compact worker 自身の現在占有トークン上限
|
overview_target_tokens = 8000 # compact worker 初期 overview の通常目標
|
||||||
compact_worker_max_turns = 20 # compact worker 自身の tool loop 上限
|
overview_warning_tokens = 16000 # 超えたら警告・trace、compact は続行
|
||||||
|
overview_deadline_tokens = 40000 # 超えたら粗い overview へ fallback
|
||||||
|
|
||||||
|
worker_context_max_tokens = 50000 # compact worker session 全体の hard limit
|
||||||
|
finish_warning_remaining_tokens = 8000 # 残りが少ないため write_summary へ進める勧告
|
||||||
|
final_reserve_tokens = 4000 # 最終 summary/closing turn 用 reserve
|
||||||
|
worker_max_turns = 20 # compact worker 自身の tool loop 上限
|
||||||
|
|
||||||
|
summary_target_tokens = 1500 # write_summary の目標サイズ
|
||||||
|
summary_max_tokens = 3000 # write_summary の hard validation
|
||||||
|
auto_read_budget_tokens = 8000 # compact 後に注入する file content 合計上限
|
||||||
|
result_context_max_tokens = 24000 # 新 session 初期 context の dry-run validation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`compact_*` prefix の旧 key は互換 alias として読み取るが、`[compaction]` 内の新規 key は prefix なしを正とする。
|
||||||
|
初期 overview の target/warning は効率のための目安で、通常は hard error にしない。deadline 超過時も、可能なら deterministic に粗い overview へ fallback して compact の完走を優先する。
|
||||||
|
|
||||||
### Auto-Read とリファレンス
|
### Auto-Read とリファレンス
|
||||||
|
|
||||||
2段階のファイル参照:
|
2段階のファイル参照:
|
||||||
|
|
@ -176,8 +190,9 @@ auto-read も通常の history 内 system message なので、将来の Prune/Co
|
||||||
|
|
||||||
## compact worker
|
## compact worker
|
||||||
|
|
||||||
要約生成とファイル選定を行う使い捨て Worker。ツールなし・1リクエストの現行実装から、
|
要約生成とファイル選定を行う使い捨て Worker。Pod は compact 対象 prefix を全文投入せず、User / Assistant / System を優先した bounded overview と tool index を初期 input として渡す。Tool call arguments、tool result full content、reasoning body は初期 input には載せない。
|
||||||
ツール付きマルチターンに改善する。
|
|
||||||
|
初期 overview は `overview_target_tokens` を目標にする。`overview_warning_tokens` を超えた場合は警告・trace を記録して続行し、`overview_deadline_tokens` を超えた場合は粗い deterministic overview へ fallback する。Compact の目的は完走なので、初期 input が少し大きいだけでは hard error にしない。
|
||||||
|
|
||||||
### ツール
|
### ツール
|
||||||
|
|
||||||
|
|
@ -192,13 +207,20 @@ write_summary(text) — 構造化要約を出力/上書き
|
||||||
|
|
||||||
1. Pod が `tools::Tracker::recent_files(5)` で最近触られたファイルを抽出(デフォルトリファレンス)
|
1. Pod が `tools::Tracker::recent_files(5)` で最近触られたファイルを抽出(デフォルトリファレンス)
|
||||||
2. compact worker にプロンプトとして渡す:
|
2. compact worker にプロンプトとして渡す:
|
||||||
- pruned history(summary only、arguments/reasoning 除去)
|
- bounded overview / index(User / Assistant / System 優先)
|
||||||
- デフォルトリファレンスの一覧
|
- デフォルトリファレンスの一覧
|
||||||
|
- TaskStore snapshot
|
||||||
3. compact worker が自律的に:
|
3. compact worker が自律的に:
|
||||||
|
- search_session_log / read_session_items で bounded overview から漏れた compact 対象履歴を必要範囲だけ探索
|
||||||
- read_file で各ファイルを読み、必要性を判断
|
- read_file で各ファイルを読み、必要性を判断
|
||||||
- mark_read_required / add_reference で指定
|
- mark_read_required / add_reference で指定
|
||||||
- write_summary で構造化要約を出力(呼び直し可)
|
- write_summary で構造化要約を出力(呼び直し可)
|
||||||
4. ターン終了時に write_summary 未呼び出し or read_required 空(かつファイル操作履歴がある場合)→ 追加プロンプトで促す
|
4. CompactWorkerInterceptor が worker session 全体の context occupancy を監視する:
|
||||||
|
- `finish_warning_remaining_tokens` 到達時に「探索を切り上げて write_summary へ進め」と Worker history に永続化される warning を挿入し、人間向け warning も出す
|
||||||
|
- `final_reserve_tokens` を割った後は `write_summary` 以外の探索 tool call に synthetic error を返し、最終 summary の余白を守る
|
||||||
|
- `worker_context_max_tokens` 超過は最後の hard stop
|
||||||
|
5. ターン終了時に write_summary 未呼び出し or read_required 空(かつファイル操作履歴がある場合)→ 追加プロンプトで促す
|
||||||
|
6. `summary_max_tokens` と `result_context_max_tokens` で compact 結果を検証してから新 session を作る
|
||||||
|
|
||||||
### 構造化要約の要件
|
### 構造化要約の要件
|
||||||
|
|
||||||
|
|
|
||||||
74
docs/nix.md
Normal file
74
docs/nix.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Nix package
|
||||||
|
|
||||||
|
INSOMNIA provides a flake package for installing the user-facing Pod CLI and TUI binaries without relying on a source checkout at runtime.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#
|
||||||
|
```
|
||||||
|
|
||||||
|
The default package is implemented by `package.nix` and builds the Cargo workspace binaries `pod` and `tui`. The derivation uses the checked-in `Cargo.lock`, so Cargo dependencies are fetched by the normal Nix Rust packaging path instead of by network access during the build.
|
||||||
|
|
||||||
|
The package output contains:
|
||||||
|
|
||||||
|
- `bin/pod` — Pod CLI / runtime process.
|
||||||
|
- `bin/tui` — terminal UI.
|
||||||
|
- `share/insomnia/resources/` — bundled runtime resources, including `resources/prompts/`.
|
||||||
|
- `share/doc/insomnia/nix.md` — this document.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
After `nix build`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./result/bin/pod --help
|
||||||
|
./result/bin/tui
|
||||||
|
```
|
||||||
|
|
||||||
|
With flakes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#tui
|
||||||
|
nix run .#pod -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
`nix run .#` defaults to the TUI.
|
||||||
|
|
||||||
|
## Configuration discovery
|
||||||
|
|
||||||
|
The Nix package does not put user configuration, sessions, sockets, or other mutable state in the Nix store. The installed binaries keep the same path semantics as non-Nix builds:
|
||||||
|
|
||||||
|
| Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
|
||||||
|
| Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` |
|
||||||
|
| Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` |
|
||||||
|
|
||||||
|
`INSOMNIA_USER_MANIFEST=<path>` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The package derivation has a credential-free install check that verifies:
|
||||||
|
|
||||||
|
- `pod --help` starts successfully.
|
||||||
|
- `tui` is installed and reaches argument parsing.
|
||||||
|
- bundled prompt resources and this Nix usage document are present in the output.
|
||||||
|
|
||||||
|
For full validation before handing changes to review, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#
|
||||||
|
nix flake check
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This packaging change does not require provider credentials. A Rust `cargo check` is only needed if Rust source or runtime path semantics are changed.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- The package currently installs the TUI and Pod CLI only; development-only wrappers from `devshell.nix` are not part of the installable package.
|
||||||
|
- The TUI does not currently expose a conventional `--help` / `--version` CLI path, so the package smoke check uses an argument-parse failure path for the TUI rather than launching an interactive session.
|
||||||
|
- Bundled resources are installed under `share/insomnia/resources/` for packaging completeness and inspection. Built-in prompt/resource loading remains governed by the existing application code and user/project override rules.
|
||||||
17
flake.nix
17
flake.nix
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
description = "A very basic flake";
|
description = "INSOMNIA agent runtime";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||||
|
|
@ -16,9 +16,22 @@
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
insomnia = pkgs.callPackage ./package.nix { };
|
||||||
|
mkApp = name: description: {
|
||||||
|
type = "app";
|
||||||
|
program = "${insomnia}/bin/${name}";
|
||||||
|
meta.description = description;
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages.default = pkgs.callPackage ./package.nix { };
|
packages.default = insomnia;
|
||||||
|
packages.insomnia = insomnia;
|
||||||
|
|
||||||
|
apps.default = mkApp "tui" "Run the INSOMNIA terminal UI";
|
||||||
|
apps.tui = mkApp "tui" "Run the INSOMNIA terminal UI";
|
||||||
|
apps.pod = mkApp "pod" "Run the INSOMNIA Pod CLI";
|
||||||
|
|
||||||
|
checks.default = insomnia;
|
||||||
|
|
||||||
devShells.default = import ./devshell.nix { inherit pkgs; };
|
devShells.default = import ./devshell.nix { inherit pkgs; };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
package.nix
103
package.nix
|
|
@ -0,0 +1,103 @@
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenv,
|
||||||
|
rustPlatform,
|
||||||
|
pkg-config,
|
||||||
|
openssl,
|
||||||
|
darwin,
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
srcRoot = ./.;
|
||||||
|
srcRootString = toString srcRoot;
|
||||||
|
sourceFilter =
|
||||||
|
path: type:
|
||||||
|
let
|
||||||
|
pathString = toString path;
|
||||||
|
relPath = lib.removePrefix "${srcRootString}/" pathString;
|
||||||
|
baseName = baseNameOf pathString;
|
||||||
|
isExcludedTree = dir: relPath == dir || lib.hasPrefix "${dir}/" relPath;
|
||||||
|
in
|
||||||
|
# Keep the package source closure focused on build inputs: exclude VCS/build
|
||||||
|
# outputs plus local coordination state, generated reports, and child
|
||||||
|
# worktrees that may live under the repository root during development.
|
||||||
|
!(
|
||||||
|
baseName == ".git"
|
||||||
|
|| baseName == "target"
|
||||||
|
|| baseName == "result"
|
||||||
|
|| isExcludedTree ".insomnia"
|
||||||
|
|| isExcludedTree ".worktree"
|
||||||
|
|| isExcludedTree "work-items"
|
||||||
|
|| isExcludedTree "docs/report"
|
||||||
|
);
|
||||||
|
in
|
||||||
|
rustPlatform.buildRustPackage rec {
|
||||||
|
pname = "insomnia";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = srcRoot;
|
||||||
|
filter = sourceFilter;
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
|
||||||
|
strictDeps = true;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkg-config ];
|
||||||
|
|
||||||
|
buildInputs = [
|
||||||
|
openssl
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin (
|
||||||
|
with darwin.apple_sdk.frameworks;
|
||||||
|
[
|
||||||
|
CoreFoundation
|
||||||
|
Security
|
||||||
|
SystemConfiguration
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
cargoBuildFlags = [
|
||||||
|
"-p"
|
||||||
|
"pod"
|
||||||
|
"-p"
|
||||||
|
"tui"
|
||||||
|
];
|
||||||
|
|
||||||
|
# The package check is a credential-free install smoke check below. Running the
|
||||||
|
# workspace test suite is intentionally left to cargo-based CI because this
|
||||||
|
# derivation is scoped to packaging the user-facing binaries.
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
|
postInstall = ''
|
||||||
|
install -Dm644 docs/nix.md "$out/share/doc/insomnia/nix.md"
|
||||||
|
mkdir -p "$out/share/insomnia"
|
||||||
|
cp -R resources "$out/share/insomnia/resources"
|
||||||
|
'';
|
||||||
|
|
||||||
|
doInstallCheck = true;
|
||||||
|
installCheckPhase = ''
|
||||||
|
runHook preInstallCheck
|
||||||
|
|
||||||
|
"$out/bin/pod" --help >/dev/null
|
||||||
|
test -x "$out/bin/tui"
|
||||||
|
if "$out/bin/tui" --session not-a-uuid 2>tui.err; then
|
||||||
|
echo "tui unexpectedly accepted an invalid --session value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q "invalid --session UUID" tui.err
|
||||||
|
|
||||||
|
test -d "$out/share/insomnia/resources/prompts"
|
||||||
|
test -f "$out/share/doc/insomnia/nix.md"
|
||||||
|
|
||||||
|
runHook postInstallCheck
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Agentic coding Pod runtime and terminal UI";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "tui";
|
||||||
|
platforms = lib.platforms.unix;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
You are a context compaction assistant. Your job is to hand the next session a structured summary plus pointers to the files it actually needs — not a narrative transcript of the conversation.
|
You are a context compaction assistant. Your job is to hand the next session a structured summary plus pointers to the files it actually needs — not a narrative transcript of the conversation.
|
||||||
|
|
||||||
|
The conversation input is a bounded overview/index, not the full transcript. Treat tool result bodies and reasoning as intentionally omitted unless a tool exposes more detail. If you receive a compact worker budget warning, stop broad exploration immediately, read only if absolutely necessary, and call `write_summary`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
1. Read the provided overview/index and current TaskStore snapshot.
|
||||||
2. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
||||||
3. For files the next session should know about but can fetch on demand, call `add_reference` to record the path without embedding contents.
|
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||||
4. Finish with `write_summary` carrying the final text. You may call it multiple times; only the last call is kept.
|
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
||||||
|
5. For files the next session should know about but can fetch on demand, call `add_reference` to record the path without embedding contents.
|
||||||
|
6. Finish with `write_summary` carrying the final text. You may call it multiple times; only the last call is kept.
|
||||||
|
|
||||||
Stop nominating and close out with `write_summary` as soon as the auto-read budget is exhausted, or whenever further nominations would not change the next session's next step.
|
Stop nominating and close out with `write_summary` as soon as the auto-read budget is exhausted, when a compact worker budget warning arrives, or whenever further exploration would not change the next session's next step.
|
||||||
|
|
||||||
## Summary format
|
## Summary format
|
||||||
|
|
||||||
|
|
@ -36,4 +40,4 @@ Produce the summary in this exact format:
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
||||||
- Target 1000–2000 tokens for the summary text itself.
|
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
id: 20260527-000012-spawnpod-initial-run-confirmation
|
id: 20260527-000012-spawnpod-initial-run-confirmation
|
||||||
slug: spawnpod-initial-run-confirmation
|
slug: spawnpod-initial-run-confirmation
|
||||||
title: SpawnPod: initial Run delivery confirmation
|
title: SpawnPod: initial Run delivery confirmation
|
||||||
status: open
|
status: closed
|
||||||
kind: task
|
kind: task
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [migrated]
|
||||||
created_at: 2026-05-27T00:00:12Z
|
created_at: 2026-05-27T00:00:12Z
|
||||||
updated_at: 2026-05-27T00:00:12Z
|
updated_at: 2026-05-28T13:24:48Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
---
|
---
|
||||||
|
|
@ -37,11 +37,18 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
|
||||||
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
|
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
|
||||||
|
|
||||||
|
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
|
||||||
|
|
||||||
|
- `send_run_and_confirm` は `Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
|
||||||
|
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
|
||||||
|
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
|
||||||
|
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
|
||||||
|
|
||||||
## 方針
|
## 方針
|
||||||
|
|
||||||
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
|
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
|
||||||
|
|
||||||
既存の `SendToPod` が使う `send_run_and_confirm` と同等の acknowledgement を `SpawnPod` の初回 task 送信にも適用する。
|
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
|
||||||
|
|
||||||
## 要件
|
## 要件
|
||||||
|
|
||||||
|
|
@ -51,8 +58,11 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
|
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
|
||||||
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
|
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
|
||||||
- 少なくとも成功扱いで返さない。
|
- 少なくとも成功扱いで返さない。
|
||||||
- Server が connection 開始時に `Snapshot` を書く設計と競合しない。
|
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
|
||||||
- client 側が snapshot/event を読みながら `Method::Run` ack を待つ形にする。
|
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
|
||||||
|
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
|
||||||
|
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
|
||||||
|
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
|
||||||
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
|
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
|
||||||
- session log materialization のタイミングそのものは別設計でもよい。
|
- session log materialization のタイミングそのものは別設計でもよい。
|
||||||
- `SendToPod` と `SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
|
- `SendToPod` と `SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
|
||||||
|
|
@ -61,6 +71,8 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
|
||||||
- `SpawnPod` が初回 task の受理確認を待つ。
|
- `SpawnPod` が初回 task の受理確認を待つ。
|
||||||
- 初回 task が実行されない race を再現する test または regression test がある。
|
- 初回 task が実行されない race を再現する test または regression test がある。
|
||||||
|
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
|
||||||
|
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
|
||||||
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
|
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
|
||||||
- delivery timeout / failure 時の error message が人間に分かる。
|
- delivery timeout / failure 時の error message が人間に分かる。
|
||||||
- `cargo fmt --check` と関連 crate の test が通る。
|
- `cargo fmt --check` と関連 crate の test が通る。
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
id: 20260527-000012-spawnpod-initial-run-confirmation
|
||||||
|
slug: spawnpod-initial-run-confirmation
|
||||||
|
title: SpawnPod: initial Run delivery confirmation
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:12Z
|
||||||
|
updated_at: 2026-05-28T13:24:48Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# SpawnPod: initial Run delivery confirmation
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`SpawnPod` は child Pod を起動し、初回 task を `Method::Run` として送る。しかし、実例として `impl-llm-worker-stream-continuation` を再作成した際、runtime registry / socket / process は生きている一方で、初回 task の session log が materialize されず、Pod は `idle` のままだった。
|
||||||
|
|
||||||
|
確認された状態:
|
||||||
|
|
||||||
|
- `<runtime-dir>/pods.json` に live allocation がある
|
||||||
|
- `<runtime-dir>/<pod>/status.json` は `state: "idle"` と runtime `segment_id` を持つ
|
||||||
|
- `<insomnia-sessions>/pods/<pod>/metadata.json` は pending segment のまま
|
||||||
|
- 対応する session / segment `.jsonl` が存在しない
|
||||||
|
- `ReadPodOutput` は no new assistant text
|
||||||
|
|
||||||
|
`SpawnPod` の送信側は `send_run` で `Method::Run` を write してすぐ切断し、`TurnStart` 等の ack を待っていない。一方 server 側は接続直後に `Snapshot` を書いてから method を読むため、client がすぐ close すると server が snapshot write で失敗し、method を読む前に connection handler が終了する race があり得る。
|
||||||
|
|
||||||
|
この場合 `SpawnPod` は成功を返すが、child Pod は初回 task を実行していない。
|
||||||
|
|
||||||
|
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
|
||||||
|
|
||||||
|
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
|
||||||
|
|
||||||
|
- `send_run_and_confirm` は `Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
|
||||||
|
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
|
||||||
|
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
|
||||||
|
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
|
||||||
|
|
||||||
|
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `SpawnPod` の初回 task 送信は fire-and-forget にしない。
|
||||||
|
- `Method::Run` 送信後、`UserMessage` / `TurnStart` / `InvokeStart` など、run が受理されたことを示す event を待つ。
|
||||||
|
- timeout 時は `SpawnPod` を失敗扱いにする。
|
||||||
|
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
|
||||||
|
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
|
||||||
|
- 少なくとも成功扱いで返さない。
|
||||||
|
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
|
||||||
|
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
|
||||||
|
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
|
||||||
|
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
|
||||||
|
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
|
||||||
|
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
|
||||||
|
- session log materialization のタイミングそのものは別設計でもよい。
|
||||||
|
- `SendToPod` と `SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- `SpawnPod` が初回 task の受理確認を待つ。
|
||||||
|
- 初回 task が実行されない race を再現する test または regression test がある。
|
||||||
|
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
|
||||||
|
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
|
||||||
|
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
|
||||||
|
- delivery timeout / failure 時の error message が人間に分かる。
|
||||||
|
- `cargo fmt --check` と関連 crate の test が通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- `tui -r` picker に live pending Pod を表示する修正。
|
||||||
|
- session log の SegmentStart materialization 方針変更。
|
||||||
|
- spawned child Pod panel UI。
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:12Z -->
|
||||||
|
|
||||||
|
## Migrated
|
||||||
|
|
||||||
|
Migrated from tickets/spawnpod-initial-run-confirmation.md. No legacy review file was present at migration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T13:24:48Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260527-000012-spawnpod-initial-run-confirmation
|
||||||
|
slug: spawnpod-initial-run-confirmation
|
||||||
|
title: SpawnPod: initial Run delivery confirmation
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:12Z
|
||||||
|
updated_at: 2026-05-28T13:24:48Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# SpawnPod: initial Run delivery confirmation
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`SpawnPod` は child Pod を起動し、初回 task を `Method::Run` として送る。しかし、実例として `impl-llm-worker-stream-continuation` を再作成した際、runtime registry / socket / process は生きている一方で、初回 task の session log が materialize されず、Pod は `idle` のままだった。
|
||||||
|
|
||||||
|
確認された状態:
|
||||||
|
|
||||||
|
- `<runtime-dir>/pods.json` に live allocation がある
|
||||||
|
- `<runtime-dir>/<pod>/status.json` は `state: "idle"` と runtime `segment_id` を持つ
|
||||||
|
- `<insomnia-sessions>/pods/<pod>/metadata.json` は pending segment のまま
|
||||||
|
- 対応する session / segment `.jsonl` が存在しない
|
||||||
|
- `ReadPodOutput` は no new assistant text
|
||||||
|
|
||||||
|
`SpawnPod` の送信側は `send_run` で `Method::Run` を write してすぐ切断し、`TurnStart` 等の ack を待っていない。一方 server 側は接続直後に `Snapshot` を書いてから method を読むため、client がすぐ close すると server が snapshot write で失敗し、method を読む前に connection handler が終了する race があり得る。
|
||||||
|
|
||||||
|
この場合 `SpawnPod` は成功を返すが、child Pod は初回 task を実行していない。
|
||||||
|
|
||||||
|
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
|
||||||
|
|
||||||
|
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
|
||||||
|
|
||||||
|
- `send_run_and_confirm` は `Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
|
||||||
|
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
|
||||||
|
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
|
||||||
|
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
|
||||||
|
|
||||||
|
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `SpawnPod` の初回 task 送信は fire-and-forget にしない。
|
||||||
|
- `Method::Run` 送信後、`UserMessage` / `TurnStart` / `InvokeStart` など、run が受理されたことを示す event を待つ。
|
||||||
|
- timeout 時は `SpawnPod` を失敗扱いにする。
|
||||||
|
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
|
||||||
|
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
|
||||||
|
- 少なくとも成功扱いで返さない。
|
||||||
|
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
|
||||||
|
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
|
||||||
|
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
|
||||||
|
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
|
||||||
|
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
|
||||||
|
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
|
||||||
|
- session log materialization のタイミングそのものは別設計でもよい。
|
||||||
|
- `SendToPod` と `SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- `SpawnPod` が初回 task の受理確認を待つ。
|
||||||
|
- 初回 task が実行されない race を再現する test または regression test がある。
|
||||||
|
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
|
||||||
|
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
|
||||||
|
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
|
||||||
|
- delivery timeout / failure 時の error message が人間に分かる。
|
||||||
|
- `cargo fmt --check` と関連 crate の test が通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- `tui -r` picker に live pending Pod を表示する修正。
|
||||||
|
- session log の SegmentStart materialization 方針変更。
|
||||||
|
- spawned child Pod panel UI。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
118
work-items/closed/20260527-000023-multi-pod-view-ui/item.md
Normal file
118
work-items/closed/20260527-000023-multi-pod-view-ui/item.md
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
---
|
||||||
|
id: 20260527-000023-multi-pod-view-ui
|
||||||
|
slug: multi-pod-view-ui
|
||||||
|
title: Multi-Pod view UI
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod]
|
||||||
|
created_at: 2026-05-27T00:00:23Z
|
||||||
|
updated_at: 2026-05-28T16:09:01Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
||||||
|
|
||||||
|
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
|
||||||
|
|
||||||
|
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- `20260528-141602-tui-pod-list-view-abstraction`
|
||||||
|
|
||||||
|
## CLI entrypoint
|
||||||
|
|
||||||
|
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
|
||||||
|
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
|
||||||
|
- Do not add a short `-m` alias yet.
|
||||||
|
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
|
||||||
|
- positional pod name
|
||||||
|
- `--pod <name>`
|
||||||
|
- `--session <UUID>`
|
||||||
|
- `-r` / `--resume`
|
||||||
|
- `--socket`
|
||||||
|
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
|
||||||
|
|
||||||
|
## Current implementation notes
|
||||||
|
|
||||||
|
Current TUI is essentially single-Pod oriented:
|
||||||
|
|
||||||
|
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
|
||||||
|
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
|
||||||
|
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
|
||||||
|
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
|
||||||
|
|
||||||
|
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
|
||||||
|
|
||||||
|
## Desired UX direction
|
||||||
|
|
||||||
|
The multi-Pod view should center on a Pod list and a persistent composer:
|
||||||
|
|
||||||
|
- Live Pods are grouped or visibly categorized by status.
|
||||||
|
- waiting / idle Pods: ready to receive input.
|
||||||
|
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
|
||||||
|
- paused Pods: distinguish from both idle and working.
|
||||||
|
- Stopped Pods are shown as history/restorable entries.
|
||||||
|
- They are visible for review/restore/open actions.
|
||||||
|
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
|
||||||
|
- The text area/composer remains visible and retains its contents while the selected Pod changes.
|
||||||
|
- The selected Pod is the current send target.
|
||||||
|
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
|
||||||
|
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
|
||||||
|
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
|
||||||
|
- For a working/running Pod, the initial behavior should be conservative.
|
||||||
|
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
|
||||||
|
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
|
||||||
|
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
|
||||||
|
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
|
||||||
|
- Add or design a TUI mode for multi-Pod view that can show:
|
||||||
|
- live idle/waiting Pods.
|
||||||
|
- live working/running Pods.
|
||||||
|
- paused Pods.
|
||||||
|
- stopped/restorable Pod history entries.
|
||||||
|
- Preserve a composer/text area while the selection changes.
|
||||||
|
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
|
||||||
|
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
|
||||||
|
- Failed delivery must leave the text in the composer or an explicit per-target queue.
|
||||||
|
- Define interaction for non-idle targets.
|
||||||
|
- running: disabled or per-target queued.
|
||||||
|
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
|
||||||
|
- stopped: restore/open action is separate from send.
|
||||||
|
- Keep the single-Pod conversation view available.
|
||||||
|
- Opening/attaching a selected Pod remains an explicit action.
|
||||||
|
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
|
||||||
|
- Avoid host-wide visibility expansion.
|
||||||
|
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
|
||||||
|
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
|
||||||
|
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
|
||||||
|
- A persistent composer remains available while moving selection.
|
||||||
|
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
|
||||||
|
- Non-idle and stopped targets have explicit, safe UX behavior.
|
||||||
|
- Delivery failure does not lose user input.
|
||||||
|
- The UI clearly indicates the selected send target and status.
|
||||||
|
- Existing single-Pod TUI attach/resume behavior continues to work.
|
||||||
|
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- Relevant focused tests for TUI state/model behavior.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Implementing the prerequisite Pod list/view abstraction itself.
|
||||||
|
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
|
||||||
|
- Host-wide Pod browser.
|
||||||
|
- Changing Pod visibility, permission, registry, or discovery authority.
|
||||||
|
- Protocol changes for accepting concurrent user messages while a Pod is already running.
|
||||||
|
- Native GUI.
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
---
|
||||||
|
id: 20260527-000023-multi-pod-view-ui
|
||||||
|
slug: multi-pod-view-ui
|
||||||
|
title: Multi-Pod view UI
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod]
|
||||||
|
created_at: 2026-05-27T00:00:23Z
|
||||||
|
updated_at: 2026-05-28T16:09:01Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
||||||
|
|
||||||
|
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
|
||||||
|
|
||||||
|
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- `20260528-141602-tui-pod-list-view-abstraction`
|
||||||
|
|
||||||
|
## CLI entrypoint
|
||||||
|
|
||||||
|
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
|
||||||
|
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
|
||||||
|
- Do not add a short `-m` alias yet.
|
||||||
|
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
|
||||||
|
- positional pod name
|
||||||
|
- `--pod <name>`
|
||||||
|
- `--session <UUID>`
|
||||||
|
- `-r` / `--resume`
|
||||||
|
- `--socket`
|
||||||
|
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
|
||||||
|
|
||||||
|
## Current implementation notes
|
||||||
|
|
||||||
|
Current TUI is essentially single-Pod oriented:
|
||||||
|
|
||||||
|
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
|
||||||
|
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
|
||||||
|
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
|
||||||
|
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
|
||||||
|
|
||||||
|
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
|
||||||
|
|
||||||
|
## Desired UX direction
|
||||||
|
|
||||||
|
The multi-Pod view should center on a Pod list and a persistent composer:
|
||||||
|
|
||||||
|
- Live Pods are grouped or visibly categorized by status.
|
||||||
|
- waiting / idle Pods: ready to receive input.
|
||||||
|
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
|
||||||
|
- paused Pods: distinguish from both idle and working.
|
||||||
|
- Stopped Pods are shown as history/restorable entries.
|
||||||
|
- They are visible for review/restore/open actions.
|
||||||
|
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
|
||||||
|
- The text area/composer remains visible and retains its contents while the selected Pod changes.
|
||||||
|
- The selected Pod is the current send target.
|
||||||
|
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
|
||||||
|
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
|
||||||
|
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
|
||||||
|
- For a working/running Pod, the initial behavior should be conservative.
|
||||||
|
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
|
||||||
|
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
|
||||||
|
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
|
||||||
|
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
|
||||||
|
- Add or design a TUI mode for multi-Pod view that can show:
|
||||||
|
- live idle/waiting Pods.
|
||||||
|
- live working/running Pods.
|
||||||
|
- paused Pods.
|
||||||
|
- stopped/restorable Pod history entries.
|
||||||
|
- Preserve a composer/text area while the selection changes.
|
||||||
|
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
|
||||||
|
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
|
||||||
|
- Failed delivery must leave the text in the composer or an explicit per-target queue.
|
||||||
|
- Define interaction for non-idle targets.
|
||||||
|
- running: disabled or per-target queued.
|
||||||
|
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
|
||||||
|
- stopped: restore/open action is separate from send.
|
||||||
|
- Keep the single-Pod conversation view available.
|
||||||
|
- Opening/attaching a selected Pod remains an explicit action.
|
||||||
|
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
|
||||||
|
- Avoid host-wide visibility expansion.
|
||||||
|
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
|
||||||
|
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
|
||||||
|
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
|
||||||
|
- A persistent composer remains available while moving selection.
|
||||||
|
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
|
||||||
|
- Non-idle and stopped targets have explicit, safe UX behavior.
|
||||||
|
- Delivery failure does not lose user input.
|
||||||
|
- The UI clearly indicates the selected send target and status.
|
||||||
|
- Existing single-Pod TUI attach/resume behavior continues to work.
|
||||||
|
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- Relevant focused tests for TUI state/model behavior.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Implementing the prerequisite Pod list/view abstraction itself.
|
||||||
|
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
|
||||||
|
- Host-wide Pod browser.
|
||||||
|
- Changing Pod visibility, permission, registry, or discovery authority.
|
||||||
|
- Protocol changes for accepting concurrent user messages while a Pod is already running.
|
||||||
|
- Native GUI.
|
||||||
133
work-items/closed/20260527-000023-multi-pod-view-ui/thread.md
Normal file
133
work-items/closed/20260527-000023-multi-pod-view-ui/thread.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:23Z -->
|
||||||
|
|
||||||
|
## Migrated
|
||||||
|
|
||||||
|
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T16:09:01Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260527-000023-multi-pod-view-ui
|
||||||
|
slug: multi-pod-view-ui
|
||||||
|
title: Multi-Pod view UI
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod]
|
||||||
|
created_at: 2026-05-27T00:00:23Z
|
||||||
|
updated_at: 2026-05-28T16:09:01Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
||||||
|
|
||||||
|
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
|
||||||
|
|
||||||
|
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- `20260528-141602-tui-pod-list-view-abstraction`
|
||||||
|
|
||||||
|
## CLI entrypoint
|
||||||
|
|
||||||
|
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
|
||||||
|
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
|
||||||
|
- Do not add a short `-m` alias yet.
|
||||||
|
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
|
||||||
|
- positional pod name
|
||||||
|
- `--pod <name>`
|
||||||
|
- `--session <UUID>`
|
||||||
|
- `-r` / `--resume`
|
||||||
|
- `--socket`
|
||||||
|
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
|
||||||
|
|
||||||
|
## Current implementation notes
|
||||||
|
|
||||||
|
Current TUI is essentially single-Pod oriented:
|
||||||
|
|
||||||
|
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
|
||||||
|
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
|
||||||
|
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
|
||||||
|
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
|
||||||
|
|
||||||
|
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
|
||||||
|
|
||||||
|
## Desired UX direction
|
||||||
|
|
||||||
|
The multi-Pod view should center on a Pod list and a persistent composer:
|
||||||
|
|
||||||
|
- Live Pods are grouped or visibly categorized by status.
|
||||||
|
- waiting / idle Pods: ready to receive input.
|
||||||
|
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
|
||||||
|
- paused Pods: distinguish from both idle and working.
|
||||||
|
- Stopped Pods are shown as history/restorable entries.
|
||||||
|
- They are visible for review/restore/open actions.
|
||||||
|
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
|
||||||
|
- The text area/composer remains visible and retains its contents while the selected Pod changes.
|
||||||
|
- The selected Pod is the current send target.
|
||||||
|
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
|
||||||
|
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
|
||||||
|
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
|
||||||
|
- For a working/running Pod, the initial behavior should be conservative.
|
||||||
|
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
|
||||||
|
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
|
||||||
|
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
|
||||||
|
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
|
||||||
|
- Add or design a TUI mode for multi-Pod view that can show:
|
||||||
|
- live idle/waiting Pods.
|
||||||
|
- live working/running Pods.
|
||||||
|
- paused Pods.
|
||||||
|
- stopped/restorable Pod history entries.
|
||||||
|
- Preserve a composer/text area while the selection changes.
|
||||||
|
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
|
||||||
|
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
|
||||||
|
- Failed delivery must leave the text in the composer or an explicit per-target queue.
|
||||||
|
- Define interaction for non-idle targets.
|
||||||
|
- running: disabled or per-target queued.
|
||||||
|
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
|
||||||
|
- stopped: restore/open action is separate from send.
|
||||||
|
- Keep the single-Pod conversation view available.
|
||||||
|
- Opening/attaching a selected Pod remains an explicit action.
|
||||||
|
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
|
||||||
|
- Avoid host-wide visibility expansion.
|
||||||
|
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
|
||||||
|
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
|
||||||
|
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
|
||||||
|
- A persistent composer remains available while moving selection.
|
||||||
|
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
|
||||||
|
- Non-idle and stopped targets have explicit, safe UX behavior.
|
||||||
|
- Delivery failure does not lose user input.
|
||||||
|
- The UI clearly indicates the selected send target and status.
|
||||||
|
- Existing single-Pod TUI attach/resume behavior continues to work.
|
||||||
|
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- Relevant focused tests for TUI state/model behavior.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Implementing the prerequisite Pod list/view abstraction itself.
|
||||||
|
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
|
||||||
|
- Host-wide Pod browser.
|
||||||
|
- Changing Pod visibility, permission, registry, or discovery authority.
|
||||||
|
- Protocol changes for accepting concurrent user messages while a Pod is already running.
|
||||||
|
- Native GUI.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
---
|
||||||
|
id: 20260528-001748-compact-session-log-exploration
|
||||||
|
slug: compact-session-log-exploration
|
||||||
|
title: Compact: session log 探索型の要約入力に変更する
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [compact, session-log]
|
||||||
|
created_at: 2026-05-28T00:17:48Z
|
||||||
|
updated_at: 2026-05-28T03:41:42Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compact: session log 探索型の要約入力に変更する
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`insomnia-troubleshoot` Pod の手動 compact で、Compact Worker が入力トークン上限に到達して停止した。現行実装は `Pod::compact` で retained tail より前の `items_to_summarise` を `build_summary_input()` に渡し、`build_summary_prompt()` が user / assistant / system message と tool result summary を `## Conversation` に連結して Compact Worker の初回 input に載せている。
|
||||||
|
|
||||||
|
raw tool output や reasoning は落としているが、長い session では pruned transcript だけでも `compact_worker_max_input_tokens` を超える。Compact の目的は「全履歴を読ませる」ことではなく、次セッションに必要な構造化要約と file auto-read/reference を作ることなので、初期 input は軽量 overview に留め、必要箇所は Compact Worker が session log / workspace file を探索して確認できる形にする。
|
||||||
|
|
||||||
|
また、Compact Worker の健全性は「初期 input が小さいこと」だけでは保証できない。探索 tool の結果、assistant 出力、`write_summary` 呼び出しまでを含む Compact Worker 全体の context と、compact 後に作られる新 session 初期 context を別々に制御する必要がある。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
Compact Worker の初期 context は、全文 transcript ではなく決定的に生成した session overview / index を渡す。LLM には探索空間を狭めた上で、必要な session log 範囲や workspace file を tool で読む権限を与える。
|
||||||
|
|
||||||
|
基本方針:
|
||||||
|
|
||||||
|
- 初期 input は User / Assistant / System の継続に必要な情報を中心に、target size 内の overview として生成する。
|
||||||
|
- 初期 overview が target を超えた程度で compact を失敗させない。warning / trace に記録して続行する。
|
||||||
|
- 初期 overview deadline は通常運用の調整値ではなく、想定外の入力生成バグを検出する最悪ケースの安全網とする。deadline 超過時は、可能ならより粗い overview へ fallback し、それでも最低限の入力を作れない場合だけ失敗する。
|
||||||
|
- ToolCall / ToolResult は初期 input では本文を展開しない。
|
||||||
|
- tool 名、summary、対象 path、成否、大きな出力の有無、session log 上の位置などの index に留める。
|
||||||
|
- Compact Worker は session log の必要箇所を探索・再読できる。
|
||||||
|
- Compact Worker の探索量は、session-log/file-read 個別の総量 budget ではなく、Compact Worker session 全体の context budget で制御する。
|
||||||
|
- Compact Worker context が上限に近づいたら、`mark_read_required` とは独立に「探索を切り上げて `write_summary` へ進め」という勧告を Worker に渡し、人間にも警告を出す。
|
||||||
|
- 最終 summary と closing turn のための reserve を確保し、reserve を食い潰すほど大きい tool result は残 budget に合わせて抑制・切り詰め・再読指示にする。
|
||||||
|
- AutoRead 判断のため、workspace file は現行通り `read_file` で確認し、必要なものだけ `mark_read_required` / `add_reference` する。
|
||||||
|
- AutoRead budget は Compact Worker の探索 budget ではなく、compact 後の新 session 初期 context に注入される file content の合計上限として扱う。
|
||||||
|
- Compact Worker の出力は現行と同じく structured summary + auto-read + references を生成する。
|
||||||
|
|
||||||
|
## Compact Worker / compaction parameters
|
||||||
|
|
||||||
|
`[compaction]` 配下では `compact_` prefix を新規 parameter 名につけない。既存の `compact_*` key は、この ticket の実装時に同じ意味の prefix なし key へ整理する。
|
||||||
|
|
||||||
|
必要な parameter:
|
||||||
|
|
||||||
|
- `retained_tokens`
|
||||||
|
- compact 後に verbatim で残す history tail の token budget。
|
||||||
|
- `overview_target_tokens`
|
||||||
|
- 初期 overview / index 生成器が目指す通常サイズ。超過しても即失敗しない。
|
||||||
|
- `overview_warning_tokens`
|
||||||
|
- 初期 overview が想定より大きいことを記録・警告する閾値。compact は続行する。
|
||||||
|
- `overview_deadline_tokens`
|
||||||
|
- 初期 overview の最悪ケース deadline。超過時はより粗い overview へ deterministic fallback し、それでも無理な場合だけ compact を失敗させる。
|
||||||
|
- `worker_context_max_tokens`
|
||||||
|
- Compact Worker session 全体の context hard limit。system prompt、overview、assistant output、tool calls/results、session-log/file read results、`write_summary` 周辺の蓄積を含む。
|
||||||
|
- `finish_warning_remaining_tokens`
|
||||||
|
- 残り context がこの値以下になったら、Compact Worker に探索切り上げと `write_summary` を促す勧告を入れる。
|
||||||
|
- `final_reserve_tokens`
|
||||||
|
- 最終 summary と closing turn のために残す reserve。これを割り込みそうな tool result は full content を返さず、range 縮小や summary への移行を促す。
|
||||||
|
- `worker_max_turns`
|
||||||
|
- Compact Worker の tool-loop 最大 turn 数。budget 制御とは別の runaway guard。
|
||||||
|
- `summary_target_tokens`
|
||||||
|
- `write_summary` text の目標サイズ。prompt / nudge に使う。
|
||||||
|
- `summary_max_tokens`
|
||||||
|
- `write_summary` text の hard validation。超過した summary は縮約を促すか compact 成功扱いにしない。
|
||||||
|
- `auto_read_budget_tokens`
|
||||||
|
- `mark_read_required` によって compact 後の新 session に注入される file content の合計 token budget。
|
||||||
|
- `result_context_max_tokens`
|
||||||
|
- compact 成功前に dry-run する新 session 初期 context の上限。summary、auto-read contents、references、task snapshot、retained tail を含む。
|
||||||
|
- `model`
|
||||||
|
- compactor model。未指定なら main Worker の client を clone する。
|
||||||
|
|
||||||
|
Compact 発火条件の `threshold` / `request_threshold` は Compact Worker の健全性 parameter ではないが、既存の `compact_threshold` / `compact_request_threshold` を整理する場合は `[compaction]` 内の prefix なし key として扱う。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `build_summary_input()` / compact 入力生成を、prefix 全体の pruned transcript 一括投入から、bounded overview + index 生成に変更する。
|
||||||
|
- overview は `overview_target_tokens` を目指して生成する。
|
||||||
|
- `overview_warning_tokens` 超過時は警告・trace を記録しつつ続行する。
|
||||||
|
- `overview_deadline_tokens` 超過時はより粗い deterministic overview に fallback する。通常ケースの user-facing hard error にしない。
|
||||||
|
- User / Assistant / System message を優先し、古い detail は落としてよい。
|
||||||
|
- Tool output content は初期 input に載せない。
|
||||||
|
- Compact Worker 用の session log 探索 tool を追加する。
|
||||||
|
- 例: `search_session_log(query, filters, range)`。
|
||||||
|
- 例: `read_session_items(range | item_ids, mode = compact/full)`。
|
||||||
|
- 必要なら large tool result を個別に読む tool を追加する。
|
||||||
|
- 探索 tool は session-store の現在 segment / compact 対象 range を正本として読む。
|
||||||
|
- Compact 対象外の future/retained tail と混ざらないよう range 境界を明示する。
|
||||||
|
- tool result full content を返す場合は Compact Worker の残り context / `final_reserve_tokens` を守る。
|
||||||
|
- session-log/file-read 個別の総量 budget を user-facing parameter として増やさず、主制御は `worker_context_max_tokens` に寄せる。
|
||||||
|
- Compact Worker の context occupancy を request 前に見積もり、`worker_context_max_tokens` を最後の hard stop として扱う。
|
||||||
|
- Compact Worker の残り context が `finish_warning_remaining_tokens` 以下になったら、追加探索を切り上げて `write_summary` に進むよう Worker に勧告し、人間向け warning も出す。
|
||||||
|
- `final_reserve_tokens` を割り込む可能性がある tool result は、full content を返さず bounded/truncated result とし、range 縮小または `write_summary` への移行を促す。
|
||||||
|
- `write_summary` 後に `summary_max_tokens` を validation する。超過時は縮約を促し、改善できない場合は compact 成功扱いにしない。
|
||||||
|
- compact 成功前に、`summary + auto-read + references + retained tail + task snapshot` の新 session 初期 context を dry-run 見積もりし、`result_context_max_tokens` を超えないことを確認する。
|
||||||
|
- `mark_read_required` / `add_reference` の意味論は維持する。
|
||||||
|
- AutoRead は session log 上の過去 tool output ではなく、現在の workspace file を `read_file` で確認してから選ぶ。
|
||||||
|
- `auto_read_budget_tokens` は新 session 初期 context への file content 注入上限であり、Compact Worker の探索 budget ではない。
|
||||||
|
- `resources/prompts/internal/compact_system.md` の summary target は `summary_target_tokens` から反映する。
|
||||||
|
- 手動 compact / auto compact の双方で同じ経路を使う。
|
||||||
|
- 巨大 session でも Compact Worker が初回 input 上限で即停止しない。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 長い session で compact 初期 overview が transcript 全体を載せず、`overview_target_tokens` を目指して生成される unit test がある。
|
||||||
|
- `overview_warning_tokens` 超過時に compact が続行し、警告・trace が記録される test がある。
|
||||||
|
- `overview_deadline_tokens` 超過時に粗い deterministic overview へ fallback する test がある。
|
||||||
|
- Tool result content が初期 compact input に混入しないことを test で確認している。
|
||||||
|
- Compact Worker が session log overview から必要 range を tool で読み、`write_summary` まで到達できる test がある。
|
||||||
|
- `finish_warning_remaining_tokens` 到達時に Compact Worker へ探索切り上げ勧告が入り、人間向け warning も出る test がある。
|
||||||
|
- `final_reserve_tokens` を守るため、過大な tool result が bounded/truncated される test がある。
|
||||||
|
- `summary_max_tokens` 超過 summary が compact 成功扱いにならない、または縮約 nudge を受ける test がある。
|
||||||
|
- compact 後の新 session 初期 context が `result_context_max_tokens` で dry-run validation される test がある。
|
||||||
|
- `mark_read_required` / `add_reference` 既存 test が通り、auto-read budget の挙動が維持されている。
|
||||||
|
- `[compaction]` の新 parameter 名が docs / manifest schema / defaults に反映されている。
|
||||||
|
- `docs/compaction.md` と `resources/prompts/internal/compact_system.md` が新しい探索型 flow と budget/warning semantics に更新されている。
|
||||||
|
- `cargo fmt --check` と関連 crate の compact/session-store/pod/manifest tests が通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- Compact summary 自体を deterministic summarizer に置き換えること。
|
||||||
|
- Memory extract / consolidation の入力方式変更。
|
||||||
|
- 過去の壊れた session log の migration。
|
||||||
|
- Compact 後の retained tail token policy の再設計。
|
||||||
|
- session-log/file-read ごとの user-facing 総量 budget を増やすこと。
|
||||||
|
|
||||||
|
## 実装メモ
|
||||||
|
|
||||||
|
現行コード上の主な起点:
|
||||||
|
|
||||||
|
- `crates/pod/src/pod.rs::compact`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_input`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_prompt`
|
||||||
|
- `crates/pod/src/compact/worker.rs`
|
||||||
|
- `crates/manifest/src/lib.rs::CompactionConfig`
|
||||||
|
- `crates/manifest/src/config.rs::CompactionConfigPartial`
|
||||||
|
- `crates/manifest/src/defaults.rs`
|
||||||
|
- `resources/prompts/internal/compact_system.md`
|
||||||
|
- `docs/compaction.md`
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
---
|
||||||
|
id: 20260528-001748-compact-session-log-exploration
|
||||||
|
slug: compact-session-log-exploration
|
||||||
|
title: Compact: session log 探索型の要約入力に変更する
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [compact, session-log]
|
||||||
|
created_at: 2026-05-28T00:17:48Z
|
||||||
|
updated_at: 2026-05-28T03:41:42Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compact: session log 探索型の要約入力に変更する
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`insomnia-troubleshoot` Pod の手動 compact で、Compact Worker が入力トークン上限に到達して停止した。現行実装は `Pod::compact` で retained tail より前の `items_to_summarise` を `build_summary_input()` に渡し、`build_summary_prompt()` が user / assistant / system message と tool result summary を `## Conversation` に連結して Compact Worker の初回 input に載せている。
|
||||||
|
|
||||||
|
raw tool output や reasoning は落としているが、長い session では pruned transcript だけでも `compact_worker_max_input_tokens` を超える。Compact の目的は「全履歴を読ませる」ことではなく、次セッションに必要な構造化要約と file auto-read/reference を作ることなので、初期 input は軽量 overview に留め、必要箇所は Compact Worker が session log / workspace file を探索して確認できる形にする。
|
||||||
|
|
||||||
|
また、Compact Worker の健全性は「初期 input が小さいこと」だけでは保証できない。探索 tool の結果、assistant 出力、`write_summary` 呼び出しまでを含む Compact Worker 全体の context と、compact 後に作られる新 session 初期 context を別々に制御する必要がある。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
Compact Worker の初期 context は、全文 transcript ではなく決定的に生成した session overview / index を渡す。LLM には探索空間を狭めた上で、必要な session log 範囲や workspace file を tool で読む権限を与える。
|
||||||
|
|
||||||
|
基本方針:
|
||||||
|
|
||||||
|
- 初期 input は User / Assistant / System の継続に必要な情報を中心に、target size 内の overview として生成する。
|
||||||
|
- 初期 overview が target を超えた程度で compact を失敗させない。warning / trace に記録して続行する。
|
||||||
|
- 初期 overview deadline は通常運用の調整値ではなく、想定外の入力生成バグを検出する最悪ケースの安全網とする。deadline 超過時は、可能ならより粗い overview へ fallback し、それでも最低限の入力を作れない場合だけ失敗する。
|
||||||
|
- ToolCall / ToolResult は初期 input では本文を展開しない。
|
||||||
|
- tool 名、summary、対象 path、成否、大きな出力の有無、session log 上の位置などの index に留める。
|
||||||
|
- Compact Worker は session log の必要箇所を探索・再読できる。
|
||||||
|
- Compact Worker の探索量は、session-log/file-read 個別の総量 budget ではなく、Compact Worker session 全体の context budget で制御する。
|
||||||
|
- Compact Worker context が上限に近づいたら、`mark_read_required` とは独立に「探索を切り上げて `write_summary` へ進め」という勧告を Worker に渡し、人間にも警告を出す。
|
||||||
|
- 最終 summary と closing turn のための reserve を確保し、reserve を食い潰すほど大きい tool result は残 budget に合わせて抑制・切り詰め・再読指示にする。
|
||||||
|
- AutoRead 判断のため、workspace file は現行通り `read_file` で確認し、必要なものだけ `mark_read_required` / `add_reference` する。
|
||||||
|
- AutoRead budget は Compact Worker の探索 budget ではなく、compact 後の新 session 初期 context に注入される file content の合計上限として扱う。
|
||||||
|
- Compact Worker の出力は現行と同じく structured summary + auto-read + references を生成する。
|
||||||
|
|
||||||
|
## Compact Worker / compaction parameters
|
||||||
|
|
||||||
|
`[compaction]` 配下では `compact_` prefix を新規 parameter 名につけない。既存の `compact_*` key は、この ticket の実装時に同じ意味の prefix なし key へ整理する。
|
||||||
|
|
||||||
|
必要な parameter:
|
||||||
|
|
||||||
|
- `retained_tokens`
|
||||||
|
- compact 後に verbatim で残す history tail の token budget。
|
||||||
|
- `overview_target_tokens`
|
||||||
|
- 初期 overview / index 生成器が目指す通常サイズ。超過しても即失敗しない。
|
||||||
|
- `overview_warning_tokens`
|
||||||
|
- 初期 overview が想定より大きいことを記録・警告する閾値。compact は続行する。
|
||||||
|
- `overview_deadline_tokens`
|
||||||
|
- 初期 overview の最悪ケース deadline。超過時はより粗い overview へ deterministic fallback し、それでも無理な場合だけ compact を失敗させる。
|
||||||
|
- `worker_context_max_tokens`
|
||||||
|
- Compact Worker session 全体の context hard limit。system prompt、overview、assistant output、tool calls/results、session-log/file read results、`write_summary` 周辺の蓄積を含む。
|
||||||
|
- `finish_warning_remaining_tokens`
|
||||||
|
- 残り context がこの値以下になったら、Compact Worker に探索切り上げと `write_summary` を促す勧告を入れる。
|
||||||
|
- `final_reserve_tokens`
|
||||||
|
- 最終 summary と closing turn のために残す reserve。これを割り込みそうな tool result は full content を返さず、range 縮小や summary への移行を促す。
|
||||||
|
- `worker_max_turns`
|
||||||
|
- Compact Worker の tool-loop 最大 turn 数。budget 制御とは別の runaway guard。
|
||||||
|
- `summary_target_tokens`
|
||||||
|
- `write_summary` text の目標サイズ。prompt / nudge に使う。
|
||||||
|
- `summary_max_tokens`
|
||||||
|
- `write_summary` text の hard validation。超過した summary は縮約を促すか compact 成功扱いにしない。
|
||||||
|
- `auto_read_budget_tokens`
|
||||||
|
- `mark_read_required` によって compact 後の新 session に注入される file content の合計 token budget。
|
||||||
|
- `result_context_max_tokens`
|
||||||
|
- compact 成功前に dry-run する新 session 初期 context の上限。summary、auto-read contents、references、task snapshot、retained tail を含む。
|
||||||
|
- `model`
|
||||||
|
- compactor model。未指定なら main Worker の client を clone する。
|
||||||
|
|
||||||
|
Compact 発火条件の `threshold` / `request_threshold` は Compact Worker の健全性 parameter ではないが、既存の `compact_threshold` / `compact_request_threshold` を整理する場合は `[compaction]` 内の prefix なし key として扱う。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `build_summary_input()` / compact 入力生成を、prefix 全体の pruned transcript 一括投入から、bounded overview + index 生成に変更する。
|
||||||
|
- overview は `overview_target_tokens` を目指して生成する。
|
||||||
|
- `overview_warning_tokens` 超過時は警告・trace を記録しつつ続行する。
|
||||||
|
- `overview_deadline_tokens` 超過時はより粗い deterministic overview に fallback する。通常ケースの user-facing hard error にしない。
|
||||||
|
- User / Assistant / System message を優先し、古い detail は落としてよい。
|
||||||
|
- Tool output content は初期 input に載せない。
|
||||||
|
- Compact Worker 用の session log 探索 tool を追加する。
|
||||||
|
- 例: `search_session_log(query, filters, range)`。
|
||||||
|
- 例: `read_session_items(range | item_ids, mode = compact/full)`。
|
||||||
|
- 必要なら large tool result を個別に読む tool を追加する。
|
||||||
|
- 探索 tool は session-store の現在 segment / compact 対象 range を正本として読む。
|
||||||
|
- Compact 対象外の future/retained tail と混ざらないよう range 境界を明示する。
|
||||||
|
- tool result full content を返す場合は Compact Worker の残り context / `final_reserve_tokens` を守る。
|
||||||
|
- session-log/file-read 個別の総量 budget を user-facing parameter として増やさず、主制御は `worker_context_max_tokens` に寄せる。
|
||||||
|
- Compact Worker の context occupancy を request 前に見積もり、`worker_context_max_tokens` を最後の hard stop として扱う。
|
||||||
|
- Compact Worker の残り context が `finish_warning_remaining_tokens` 以下になったら、追加探索を切り上げて `write_summary` に進むよう Worker に勧告し、人間向け warning も出す。
|
||||||
|
- `final_reserve_tokens` を割り込む可能性がある tool result は、full content を返さず bounded/truncated result とし、range 縮小または `write_summary` への移行を促す。
|
||||||
|
- `write_summary` 後に `summary_max_tokens` を validation する。超過時は縮約を促し、改善できない場合は compact 成功扱いにしない。
|
||||||
|
- compact 成功前に、`summary + auto-read + references + retained tail + task snapshot` の新 session 初期 context を dry-run 見積もりし、`result_context_max_tokens` を超えないことを確認する。
|
||||||
|
- `mark_read_required` / `add_reference` の意味論は維持する。
|
||||||
|
- AutoRead は session log 上の過去 tool output ではなく、現在の workspace file を `read_file` で確認してから選ぶ。
|
||||||
|
- `auto_read_budget_tokens` は新 session 初期 context への file content 注入上限であり、Compact Worker の探索 budget ではない。
|
||||||
|
- `resources/prompts/internal/compact_system.md` の summary target は `summary_target_tokens` から反映する。
|
||||||
|
- 手動 compact / auto compact の双方で同じ経路を使う。
|
||||||
|
- 巨大 session でも Compact Worker が初回 input 上限で即停止しない。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 長い session で compact 初期 overview が transcript 全体を載せず、`overview_target_tokens` を目指して生成される unit test がある。
|
||||||
|
- `overview_warning_tokens` 超過時に compact が続行し、警告・trace が記録される test がある。
|
||||||
|
- `overview_deadline_tokens` 超過時に粗い deterministic overview へ fallback する test がある。
|
||||||
|
- Tool result content が初期 compact input に混入しないことを test で確認している。
|
||||||
|
- Compact Worker が session log overview から必要 range を tool で読み、`write_summary` まで到達できる test がある。
|
||||||
|
- `finish_warning_remaining_tokens` 到達時に Compact Worker へ探索切り上げ勧告が入り、人間向け warning も出る test がある。
|
||||||
|
- `final_reserve_tokens` を守るため、過大な tool result が bounded/truncated される test がある。
|
||||||
|
- `summary_max_tokens` 超過 summary が compact 成功扱いにならない、または縮約 nudge を受ける test がある。
|
||||||
|
- compact 後の新 session 初期 context が `result_context_max_tokens` で dry-run validation される test がある。
|
||||||
|
- `mark_read_required` / `add_reference` 既存 test が通り、auto-read budget の挙動が維持されている。
|
||||||
|
- `[compaction]` の新 parameter 名が docs / manifest schema / defaults に反映されている。
|
||||||
|
- `docs/compaction.md` と `resources/prompts/internal/compact_system.md` が新しい探索型 flow と budget/warning semantics に更新されている。
|
||||||
|
- `cargo fmt --check` と関連 crate の compact/session-store/pod/manifest tests が通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- Compact summary 自体を deterministic summarizer に置き換えること。
|
||||||
|
- Memory extract / consolidation の入力方式変更。
|
||||||
|
- 過去の壊れた session log の migration。
|
||||||
|
- Compact 後の retained tail token policy の再設計。
|
||||||
|
- session-log/file-read ごとの user-facing 総量 budget を増やすこと。
|
||||||
|
|
||||||
|
## 実装メモ
|
||||||
|
|
||||||
|
現行コード上の主な起点:
|
||||||
|
|
||||||
|
- `crates/pod/src/pod.rs::compact`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_input`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_prompt`
|
||||||
|
- `crates/pod/src/compact/worker.rs`
|
||||||
|
- `crates/manifest/src/lib.rs::CompactionConfig`
|
||||||
|
- `crates/manifest/src/config.rs::CompactionConfigPartial`
|
||||||
|
- `crates/manifest/src/defaults.rs`
|
||||||
|
- `resources/prompts/internal/compact_system.md`
|
||||||
|
- `docs/compaction.md`
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T00:17:48Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: insomnia at: 2026-05-28T03:41:41Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
実装を review し、approve する。
|
||||||
|
|
||||||
|
確認内容:
|
||||||
|
- Compact Worker 初期入力が bounded overview / index に変更され、ToolCall arguments / ToolResult full content / Reasoning body が初期 input に混入しない。
|
||||||
|
- `[compaction]` の prefix なし parameter と旧 `compact_*` alias が manifest/config/defaults に反映されている。
|
||||||
|
- `finish_warning_remaining_tokens` で Worker history に永続化される budget warning が入り、人間向け warning も出る。
|
||||||
|
- `final_reserve_tokens` 到達後は `write_summary` 以外の探索 tool が synthetic error になり、summary 用 reserve を守る。
|
||||||
|
- `search_session_log` / `read_session_items` が Compact Worker に登録され、bounded overview から漏れた履歴を探索できる。
|
||||||
|
- `summary_max_tokens` と `result_context_max_tokens` の validation が入っている。
|
||||||
|
- docs / compact system prompt が新 flow に更新されている。
|
||||||
|
|
||||||
|
検証:
|
||||||
|
- cargo fmt --check
|
||||||
|
- cargo check -p llm-worker -p pod -p manifest
|
||||||
|
- cargo test -p manifest compaction
|
||||||
|
- cargo test -p pod compact_worker_interceptor --no-default-features
|
||||||
|
- cargo test -p pod build_summary_prompt_tests --no-default-features
|
||||||
|
- cargo test -p pod session_log --no-default-features
|
||||||
|
- cargo test -p pod read_session_items --no-default-features
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- `cargo test -p pod --no-default-features` 全体は master 上の trace commit だけでも controller empty-turn rollback 系 3 tests が失敗するため、この ticket の blocking とはしない。
|
||||||
|
- `cargo test -p manifest` 全体は環境依存の `runtime_dir_prefers_xdg_runtime_dir` が失敗するため、この ticket の blocking とはしない。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T03:41:42Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260528-001748-compact-session-log-exploration
|
||||||
|
slug: compact-session-log-exploration
|
||||||
|
title: Compact: session log 探索型の要約入力に変更する
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [compact, session-log]
|
||||||
|
created_at: 2026-05-28T00:17:48Z
|
||||||
|
updated_at: 2026-05-28T03:41:42Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compact: session log 探索型の要約入力に変更する
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`insomnia-troubleshoot` Pod の手動 compact で、Compact Worker が入力トークン上限に到達して停止した。現行実装は `Pod::compact` で retained tail より前の `items_to_summarise` を `build_summary_input()` に渡し、`build_summary_prompt()` が user / assistant / system message と tool result summary を `## Conversation` に連結して Compact Worker の初回 input に載せている。
|
||||||
|
|
||||||
|
raw tool output や reasoning は落としているが、長い session では pruned transcript だけでも `compact_worker_max_input_tokens` を超える。Compact の目的は「全履歴を読ませる」ことではなく、次セッションに必要な構造化要約と file auto-read/reference を作ることなので、初期 input は軽量 overview に留め、必要箇所は Compact Worker が session log / workspace file を探索して確認できる形にする。
|
||||||
|
|
||||||
|
また、Compact Worker の健全性は「初期 input が小さいこと」だけでは保証できない。探索 tool の結果、assistant 出力、`write_summary` 呼び出しまでを含む Compact Worker 全体の context と、compact 後に作られる新 session 初期 context を別々に制御する必要がある。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
Compact Worker の初期 context は、全文 transcript ではなく決定的に生成した session overview / index を渡す。LLM には探索空間を狭めた上で、必要な session log 範囲や workspace file を tool で読む権限を与える。
|
||||||
|
|
||||||
|
基本方針:
|
||||||
|
|
||||||
|
- 初期 input は User / Assistant / System の継続に必要な情報を中心に、target size 内の overview として生成する。
|
||||||
|
- 初期 overview が target を超えた程度で compact を失敗させない。warning / trace に記録して続行する。
|
||||||
|
- 初期 overview deadline は通常運用の調整値ではなく、想定外の入力生成バグを検出する最悪ケースの安全網とする。deadline 超過時は、可能ならより粗い overview へ fallback し、それでも最低限の入力を作れない場合だけ失敗する。
|
||||||
|
- ToolCall / ToolResult は初期 input では本文を展開しない。
|
||||||
|
- tool 名、summary、対象 path、成否、大きな出力の有無、session log 上の位置などの index に留める。
|
||||||
|
- Compact Worker は session log の必要箇所を探索・再読できる。
|
||||||
|
- Compact Worker の探索量は、session-log/file-read 個別の総量 budget ではなく、Compact Worker session 全体の context budget で制御する。
|
||||||
|
- Compact Worker context が上限に近づいたら、`mark_read_required` とは独立に「探索を切り上げて `write_summary` へ進め」という勧告を Worker に渡し、人間にも警告を出す。
|
||||||
|
- 最終 summary と closing turn のための reserve を確保し、reserve を食い潰すほど大きい tool result は残 budget に合わせて抑制・切り詰め・再読指示にする。
|
||||||
|
- AutoRead 判断のため、workspace file は現行通り `read_file` で確認し、必要なものだけ `mark_read_required` / `add_reference` する。
|
||||||
|
- AutoRead budget は Compact Worker の探索 budget ではなく、compact 後の新 session 初期 context に注入される file content の合計上限として扱う。
|
||||||
|
- Compact Worker の出力は現行と同じく structured summary + auto-read + references を生成する。
|
||||||
|
|
||||||
|
## Compact Worker / compaction parameters
|
||||||
|
|
||||||
|
`[compaction]` 配下では `compact_` prefix を新規 parameter 名につけない。既存の `compact_*` key は、この ticket の実装時に同じ意味の prefix なし key へ整理する。
|
||||||
|
|
||||||
|
必要な parameter:
|
||||||
|
|
||||||
|
- `retained_tokens`
|
||||||
|
- compact 後に verbatim で残す history tail の token budget。
|
||||||
|
- `overview_target_tokens`
|
||||||
|
- 初期 overview / index 生成器が目指す通常サイズ。超過しても即失敗しない。
|
||||||
|
- `overview_warning_tokens`
|
||||||
|
- 初期 overview が想定より大きいことを記録・警告する閾値。compact は続行する。
|
||||||
|
- `overview_deadline_tokens`
|
||||||
|
- 初期 overview の最悪ケース deadline。超過時はより粗い overview へ deterministic fallback し、それでも無理な場合だけ compact を失敗させる。
|
||||||
|
- `worker_context_max_tokens`
|
||||||
|
- Compact Worker session 全体の context hard limit。system prompt、overview、assistant output、tool calls/results、session-log/file read results、`write_summary` 周辺の蓄積を含む。
|
||||||
|
- `finish_warning_remaining_tokens`
|
||||||
|
- 残り context がこの値以下になったら、Compact Worker に探索切り上げと `write_summary` を促す勧告を入れる。
|
||||||
|
- `final_reserve_tokens`
|
||||||
|
- 最終 summary と closing turn のために残す reserve。これを割り込みそうな tool result は full content を返さず、range 縮小や summary への移行を促す。
|
||||||
|
- `worker_max_turns`
|
||||||
|
- Compact Worker の tool-loop 最大 turn 数。budget 制御とは別の runaway guard。
|
||||||
|
- `summary_target_tokens`
|
||||||
|
- `write_summary` text の目標サイズ。prompt / nudge に使う。
|
||||||
|
- `summary_max_tokens`
|
||||||
|
- `write_summary` text の hard validation。超過した summary は縮約を促すか compact 成功扱いにしない。
|
||||||
|
- `auto_read_budget_tokens`
|
||||||
|
- `mark_read_required` によって compact 後の新 session に注入される file content の合計 token budget。
|
||||||
|
- `result_context_max_tokens`
|
||||||
|
- compact 成功前に dry-run する新 session 初期 context の上限。summary、auto-read contents、references、task snapshot、retained tail を含む。
|
||||||
|
- `model`
|
||||||
|
- compactor model。未指定なら main Worker の client を clone する。
|
||||||
|
|
||||||
|
Compact 発火条件の `threshold` / `request_threshold` は Compact Worker の健全性 parameter ではないが、既存の `compact_threshold` / `compact_request_threshold` を整理する場合は `[compaction]` 内の prefix なし key として扱う。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `build_summary_input()` / compact 入力生成を、prefix 全体の pruned transcript 一括投入から、bounded overview + index 生成に変更する。
|
||||||
|
- overview は `overview_target_tokens` を目指して生成する。
|
||||||
|
- `overview_warning_tokens` 超過時は警告・trace を記録しつつ続行する。
|
||||||
|
- `overview_deadline_tokens` 超過時はより粗い deterministic overview に fallback する。通常ケースの user-facing hard error にしない。
|
||||||
|
- User / Assistant / System message を優先し、古い detail は落としてよい。
|
||||||
|
- Tool output content は初期 input に載せない。
|
||||||
|
- Compact Worker 用の session log 探索 tool を追加する。
|
||||||
|
- 例: `search_session_log(query, filters, range)`。
|
||||||
|
- 例: `read_session_items(range | item_ids, mode = compact/full)`。
|
||||||
|
- 必要なら large tool result を個別に読む tool を追加する。
|
||||||
|
- 探索 tool は session-store の現在 segment / compact 対象 range を正本として読む。
|
||||||
|
- Compact 対象外の future/retained tail と混ざらないよう range 境界を明示する。
|
||||||
|
- tool result full content を返す場合は Compact Worker の残り context / `final_reserve_tokens` を守る。
|
||||||
|
- session-log/file-read 個別の総量 budget を user-facing parameter として増やさず、主制御は `worker_context_max_tokens` に寄せる。
|
||||||
|
- Compact Worker の context occupancy を request 前に見積もり、`worker_context_max_tokens` を最後の hard stop として扱う。
|
||||||
|
- Compact Worker の残り context が `finish_warning_remaining_tokens` 以下になったら、追加探索を切り上げて `write_summary` に進むよう Worker に勧告し、人間向け warning も出す。
|
||||||
|
- `final_reserve_tokens` を割り込む可能性がある tool result は、full content を返さず bounded/truncated result とし、range 縮小または `write_summary` への移行を促す。
|
||||||
|
- `write_summary` 後に `summary_max_tokens` を validation する。超過時は縮約を促し、改善できない場合は compact 成功扱いにしない。
|
||||||
|
- compact 成功前に、`summary + auto-read + references + retained tail + task snapshot` の新 session 初期 context を dry-run 見積もりし、`result_context_max_tokens` を超えないことを確認する。
|
||||||
|
- `mark_read_required` / `add_reference` の意味論は維持する。
|
||||||
|
- AutoRead は session log 上の過去 tool output ではなく、現在の workspace file を `read_file` で確認してから選ぶ。
|
||||||
|
- `auto_read_budget_tokens` は新 session 初期 context への file content 注入上限であり、Compact Worker の探索 budget ではない。
|
||||||
|
- `resources/prompts/internal/compact_system.md` の summary target は `summary_target_tokens` から反映する。
|
||||||
|
- 手動 compact / auto compact の双方で同じ経路を使う。
|
||||||
|
- 巨大 session でも Compact Worker が初回 input 上限で即停止しない。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 長い session で compact 初期 overview が transcript 全体を載せず、`overview_target_tokens` を目指して生成される unit test がある。
|
||||||
|
- `overview_warning_tokens` 超過時に compact が続行し、警告・trace が記録される test がある。
|
||||||
|
- `overview_deadline_tokens` 超過時に粗い deterministic overview へ fallback する test がある。
|
||||||
|
- Tool result content が初期 compact input に混入しないことを test で確認している。
|
||||||
|
- Compact Worker が session log overview から必要 range を tool で読み、`write_summary` まで到達できる test がある。
|
||||||
|
- `finish_warning_remaining_tokens` 到達時に Compact Worker へ探索切り上げ勧告が入り、人間向け warning も出る test がある。
|
||||||
|
- `final_reserve_tokens` を守るため、過大な tool result が bounded/truncated される test がある。
|
||||||
|
- `summary_max_tokens` 超過 summary が compact 成功扱いにならない、または縮約 nudge を受ける test がある。
|
||||||
|
- compact 後の新 session 初期 context が `result_context_max_tokens` で dry-run validation される test がある。
|
||||||
|
- `mark_read_required` / `add_reference` 既存 test が通り、auto-read budget の挙動が維持されている。
|
||||||
|
- `[compaction]` の新 parameter 名が docs / manifest schema / defaults に反映されている。
|
||||||
|
- `docs/compaction.md` と `resources/prompts/internal/compact_system.md` が新しい探索型 flow と budget/warning semantics に更新されている。
|
||||||
|
- `cargo fmt --check` と関連 crate の compact/session-store/pod/manifest tests が通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- Compact summary 自体を deterministic summarizer に置き換えること。
|
||||||
|
- Memory extract / consolidation の入力方式変更。
|
||||||
|
- 過去の壊れた session log の migration。
|
||||||
|
- Compact 後の retained tail token policy の再設計。
|
||||||
|
- session-log/file-read ごとの user-facing 総量 budget を増やすこと。
|
||||||
|
|
||||||
|
## 実装メモ
|
||||||
|
|
||||||
|
現行コード上の主な起点:
|
||||||
|
|
||||||
|
- `crates/pod/src/pod.rs::compact`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_input`
|
||||||
|
- `crates/pod/src/pod.rs::build_summary_prompt`
|
||||||
|
- `crates/pod/src/compact/worker.rs`
|
||||||
|
- `crates/manifest/src/lib.rs::CompactionConfig`
|
||||||
|
- `crates/manifest/src/config.rs::CompactionConfigPartial`
|
||||||
|
- `crates/manifest/src/defaults.rs`
|
||||||
|
- `resources/prompts/internal/compact_system.md`
|
||||||
|
- `docs/compaction.md`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
---
|
||||||
|
id: 20260528-141602-tui-pod-list-view-abstraction
|
||||||
|
slug: tui-pod-list-view-abstraction
|
||||||
|
title: TUI Pod list/view abstraction
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, architecture]
|
||||||
|
created_at: 2026-05-28T14:16:02Z
|
||||||
|
updated_at: 2026-05-28T15:40:30Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
|
||||||
|
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
|
||||||
|
|
||||||
|
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
|
||||||
|
|
||||||
|
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
|
||||||
|
|
||||||
|
- `StoredPod` / `LivingPod` trait は作らない。
|
||||||
|
- `LivePodInfo` と `StoredPodInfo` は plain data struct として扱う。
|
||||||
|
- UI は `Vec<PodListEntry>` / `PodList` を読む。
|
||||||
|
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
|
||||||
|
- live / stored は排他的ではない。
|
||||||
|
- 起動中かつ stored metadata がある Pod。
|
||||||
|
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
|
||||||
|
- stopped で stored metadata だけある Pod。
|
||||||
|
- stored metadata が壊れている Pod。
|
||||||
|
- registry にはあるが socket unreachable な Pod。
|
||||||
|
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
|
||||||
|
|
||||||
|
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- TUI crate 内に Pod list 用 module を用意する。
|
||||||
|
- 推奨名: `crates/tui/src/pod_list.rs`
|
||||||
|
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
|
||||||
|
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
|
||||||
|
- `PodList`
|
||||||
|
- `PodListEntry`
|
||||||
|
- `LivePodInfo`
|
||||||
|
- `StoredPodInfo`
|
||||||
|
- `PodVisibilitySource`
|
||||||
|
- `PodEntryActions` または同等の action eligibility model
|
||||||
|
- selection state(index だけでなく Pod name を primary identity として維持できること)
|
||||||
|
- `PodListEntry` は表示情報と action eligibility を持つ。
|
||||||
|
- Pod name
|
||||||
|
- source / visibility kind(例: resume picker, current parent spawned child, future multi-view target)
|
||||||
|
- live reachability / `PodStatus`
|
||||||
|
- socket path / attach target
|
||||||
|
- stored active session / segment id
|
||||||
|
- updated time / preview
|
||||||
|
- stopped / unreachable / missing state / corrupt metadata の診断情報
|
||||||
|
- `can_open`
|
||||||
|
- `can_restore`
|
||||||
|
- `can_send_now`
|
||||||
|
- `can_queue_send`
|
||||||
|
- disabled reason / diagnostic
|
||||||
|
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
|
||||||
|
- live + reachable + `PodStatus::Idle` なら `can_send_now`。
|
||||||
|
- running は send disabled または future queue eligible として区別できる。
|
||||||
|
- stopped は restore/open 可能だが direct send は不可。
|
||||||
|
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
|
||||||
|
- picker の見た目・key binding・attach/restore outcome は変えない。
|
||||||
|
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
|
||||||
|
- list row rendering / selection / refresh の責務境界を整理する。
|
||||||
|
- TUI widget は表示と選択に寄せる。
|
||||||
|
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
|
||||||
|
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
|
||||||
|
- visibility model は変えない。
|
||||||
|
- host-wide Pod browser を新設しない。
|
||||||
|
- `tui -r` は既存 resume picker 相当の source だけを扱う。
|
||||||
|
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
|
||||||
|
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
|
||||||
|
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
|
||||||
|
- 既存の TUI resume picker / attach flow を壊さない。
|
||||||
|
|
||||||
|
## Suggested model sketch
|
||||||
|
|
||||||
|
Exact names may differ, but implementation should keep this shape simple and data-oriented.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PodList {
|
||||||
|
pub entries: Vec<PodListEntry>,
|
||||||
|
pub selected_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub source: PodVisibilitySource,
|
||||||
|
pub live: Option<LivePodInfo>,
|
||||||
|
pub stored: Option<StoredPodInfo>,
|
||||||
|
pub summary: PodEntrySummary,
|
||||||
|
pub actions: PodEntryActions,
|
||||||
|
pub diagnostics: Vec<PodEntryDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LivePodInfo {
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
pub status: Option<PodStatus>,
|
||||||
|
pub reachable: bool,
|
||||||
|
pub segment_id: Option<SegmentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StoredPodInfo {
|
||||||
|
pub metadata_state: StoredMetadataState,
|
||||||
|
pub active_session_id: Option<SessionId>,
|
||||||
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodEntryActions {
|
||||||
|
pub can_open: bool,
|
||||||
|
pub can_restore: bool,
|
||||||
|
pub can_send_now: bool,
|
||||||
|
pub can_queue_send: bool,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
|
||||||
|
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
|
||||||
|
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
|
||||||
|
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
|
||||||
|
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
|
||||||
|
- selection は refresh 後も Pod name を primary identity として維持できる。
|
||||||
|
- unit test で以下が確認されている。
|
||||||
|
- stored only row は restore/open 可能で direct send 不可。
|
||||||
|
- live idle reachable row は open/attach 可能かつ direct send 可能。
|
||||||
|
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
|
||||||
|
- corrupt stored metadata は diagnostic を持つ。
|
||||||
|
- rows refresh / rebuild 後に selected Pod name が維持される。
|
||||||
|
- 既存 picker / attach 関連テストが通る。
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- 必要に応じて `cargo test -p tui -p pod -p protocol`
|
||||||
|
|
||||||
|
## Relationship
|
||||||
|
|
||||||
|
This is a prerequisite for:
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`
|
||||||
|
- `20260527-000023-multi-pod-view-ui`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- spawned child Pod panel の完成。
|
||||||
|
- 複数 Pod view UI の完成。
|
||||||
|
- child Pod への interactive input。
|
||||||
|
- multi-Pod view からの direct send 実行。
|
||||||
|
- host-wide Pod browser。
|
||||||
|
- Pod discovery / permission / registry visibility model の変更。
|
||||||
|
- native GUI。
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
---
|
||||||
|
id: 20260528-141602-tui-pod-list-view-abstraction
|
||||||
|
slug: tui-pod-list-view-abstraction
|
||||||
|
title: TUI Pod list/view abstraction
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, architecture]
|
||||||
|
created_at: 2026-05-28T14:16:02Z
|
||||||
|
updated_at: 2026-05-28T15:40:30Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
|
||||||
|
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
|
||||||
|
|
||||||
|
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
|
||||||
|
|
||||||
|
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
|
||||||
|
|
||||||
|
- `StoredPod` / `LivingPod` trait は作らない。
|
||||||
|
- `LivePodInfo` と `StoredPodInfo` は plain data struct として扱う。
|
||||||
|
- UI は `Vec<PodListEntry>` / `PodList` を読む。
|
||||||
|
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
|
||||||
|
- live / stored は排他的ではない。
|
||||||
|
- 起動中かつ stored metadata がある Pod。
|
||||||
|
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
|
||||||
|
- stopped で stored metadata だけある Pod。
|
||||||
|
- stored metadata が壊れている Pod。
|
||||||
|
- registry にはあるが socket unreachable な Pod。
|
||||||
|
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
|
||||||
|
|
||||||
|
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- TUI crate 内に Pod list 用 module を用意する。
|
||||||
|
- 推奨名: `crates/tui/src/pod_list.rs`
|
||||||
|
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
|
||||||
|
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
|
||||||
|
- `PodList`
|
||||||
|
- `PodListEntry`
|
||||||
|
- `LivePodInfo`
|
||||||
|
- `StoredPodInfo`
|
||||||
|
- `PodVisibilitySource`
|
||||||
|
- `PodEntryActions` または同等の action eligibility model
|
||||||
|
- selection state(index だけでなく Pod name を primary identity として維持できること)
|
||||||
|
- `PodListEntry` は表示情報と action eligibility を持つ。
|
||||||
|
- Pod name
|
||||||
|
- source / visibility kind(例: resume picker, current parent spawned child, future multi-view target)
|
||||||
|
- live reachability / `PodStatus`
|
||||||
|
- socket path / attach target
|
||||||
|
- stored active session / segment id
|
||||||
|
- updated time / preview
|
||||||
|
- stopped / unreachable / missing state / corrupt metadata の診断情報
|
||||||
|
- `can_open`
|
||||||
|
- `can_restore`
|
||||||
|
- `can_send_now`
|
||||||
|
- `can_queue_send`
|
||||||
|
- disabled reason / diagnostic
|
||||||
|
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
|
||||||
|
- live + reachable + `PodStatus::Idle` なら `can_send_now`。
|
||||||
|
- running は send disabled または future queue eligible として区別できる。
|
||||||
|
- stopped は restore/open 可能だが direct send は不可。
|
||||||
|
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
|
||||||
|
- picker の見た目・key binding・attach/restore outcome は変えない。
|
||||||
|
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
|
||||||
|
- list row rendering / selection / refresh の責務境界を整理する。
|
||||||
|
- TUI widget は表示と選択に寄せる。
|
||||||
|
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
|
||||||
|
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
|
||||||
|
- visibility model は変えない。
|
||||||
|
- host-wide Pod browser を新設しない。
|
||||||
|
- `tui -r` は既存 resume picker 相当の source だけを扱う。
|
||||||
|
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
|
||||||
|
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
|
||||||
|
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
|
||||||
|
- 既存の TUI resume picker / attach flow を壊さない。
|
||||||
|
|
||||||
|
## Suggested model sketch
|
||||||
|
|
||||||
|
Exact names may differ, but implementation should keep this shape simple and data-oriented.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PodList {
|
||||||
|
pub entries: Vec<PodListEntry>,
|
||||||
|
pub selected_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub source: PodVisibilitySource,
|
||||||
|
pub live: Option<LivePodInfo>,
|
||||||
|
pub stored: Option<StoredPodInfo>,
|
||||||
|
pub summary: PodEntrySummary,
|
||||||
|
pub actions: PodEntryActions,
|
||||||
|
pub diagnostics: Vec<PodEntryDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LivePodInfo {
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
pub status: Option<PodStatus>,
|
||||||
|
pub reachable: bool,
|
||||||
|
pub segment_id: Option<SegmentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StoredPodInfo {
|
||||||
|
pub metadata_state: StoredMetadataState,
|
||||||
|
pub active_session_id: Option<SessionId>,
|
||||||
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodEntryActions {
|
||||||
|
pub can_open: bool,
|
||||||
|
pub can_restore: bool,
|
||||||
|
pub can_send_now: bool,
|
||||||
|
pub can_queue_send: bool,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
|
||||||
|
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
|
||||||
|
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
|
||||||
|
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
|
||||||
|
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
|
||||||
|
- selection は refresh 後も Pod name を primary identity として維持できる。
|
||||||
|
- unit test で以下が確認されている。
|
||||||
|
- stored only row は restore/open 可能で direct send 不可。
|
||||||
|
- live idle reachable row は open/attach 可能かつ direct send 可能。
|
||||||
|
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
|
||||||
|
- corrupt stored metadata は diagnostic を持つ。
|
||||||
|
- rows refresh / rebuild 後に selected Pod name が維持される。
|
||||||
|
- 既存 picker / attach 関連テストが通る。
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- 必要に応じて `cargo test -p tui -p pod -p protocol`
|
||||||
|
|
||||||
|
## Relationship
|
||||||
|
|
||||||
|
This is a prerequisite for:
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`
|
||||||
|
- `20260527-000023-multi-pod-view-ui`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- spawned child Pod panel の完成。
|
||||||
|
- 複数 Pod view UI の完成。
|
||||||
|
- child Pod への interactive input。
|
||||||
|
- multi-Pod view からの direct send 実行。
|
||||||
|
- host-wide Pod browser。
|
||||||
|
- Pod discovery / permission / registry visibility model の変更。
|
||||||
|
- native GUI。
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T14:16:02Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T15:40:30Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260528-141602-tui-pod-list-view-abstraction
|
||||||
|
slug: tui-pod-list-view-abstraction
|
||||||
|
title: TUI Pod list/view abstraction
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, architecture]
|
||||||
|
created_at: 2026-05-28T14:16:02Z
|
||||||
|
updated_at: 2026-05-28T15:40:30Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
|
||||||
|
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
|
||||||
|
|
||||||
|
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
|
||||||
|
|
||||||
|
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
|
||||||
|
|
||||||
|
- `StoredPod` / `LivingPod` trait は作らない。
|
||||||
|
- `LivePodInfo` と `StoredPodInfo` は plain data struct として扱う。
|
||||||
|
- UI は `Vec<PodListEntry>` / `PodList` を読む。
|
||||||
|
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
|
||||||
|
- live / stored は排他的ではない。
|
||||||
|
- 起動中かつ stored metadata がある Pod。
|
||||||
|
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
|
||||||
|
- stopped で stored metadata だけある Pod。
|
||||||
|
- stored metadata が壊れている Pod。
|
||||||
|
- registry にはあるが socket unreachable な Pod。
|
||||||
|
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
|
||||||
|
|
||||||
|
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- TUI crate 内に Pod list 用 module を用意する。
|
||||||
|
- 推奨名: `crates/tui/src/pod_list.rs`
|
||||||
|
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
|
||||||
|
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
|
||||||
|
- `PodList`
|
||||||
|
- `PodListEntry`
|
||||||
|
- `LivePodInfo`
|
||||||
|
- `StoredPodInfo`
|
||||||
|
- `PodVisibilitySource`
|
||||||
|
- `PodEntryActions` または同等の action eligibility model
|
||||||
|
- selection state(index だけでなく Pod name を primary identity として維持できること)
|
||||||
|
- `PodListEntry` は表示情報と action eligibility を持つ。
|
||||||
|
- Pod name
|
||||||
|
- source / visibility kind(例: resume picker, current parent spawned child, future multi-view target)
|
||||||
|
- live reachability / `PodStatus`
|
||||||
|
- socket path / attach target
|
||||||
|
- stored active session / segment id
|
||||||
|
- updated time / preview
|
||||||
|
- stopped / unreachable / missing state / corrupt metadata の診断情報
|
||||||
|
- `can_open`
|
||||||
|
- `can_restore`
|
||||||
|
- `can_send_now`
|
||||||
|
- `can_queue_send`
|
||||||
|
- disabled reason / diagnostic
|
||||||
|
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
|
||||||
|
- live + reachable + `PodStatus::Idle` なら `can_send_now`。
|
||||||
|
- running は send disabled または future queue eligible として区別できる。
|
||||||
|
- stopped は restore/open 可能だが direct send は不可。
|
||||||
|
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
|
||||||
|
- picker の見た目・key binding・attach/restore outcome は変えない。
|
||||||
|
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
|
||||||
|
- list row rendering / selection / refresh の責務境界を整理する。
|
||||||
|
- TUI widget は表示と選択に寄せる。
|
||||||
|
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
|
||||||
|
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
|
||||||
|
- visibility model は変えない。
|
||||||
|
- host-wide Pod browser を新設しない。
|
||||||
|
- `tui -r` は既存 resume picker 相当の source だけを扱う。
|
||||||
|
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
|
||||||
|
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
|
||||||
|
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
|
||||||
|
- 既存の TUI resume picker / attach flow を壊さない。
|
||||||
|
|
||||||
|
## Suggested model sketch
|
||||||
|
|
||||||
|
Exact names may differ, but implementation should keep this shape simple and data-oriented.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PodList {
|
||||||
|
pub entries: Vec<PodListEntry>,
|
||||||
|
pub selected_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub source: PodVisibilitySource,
|
||||||
|
pub live: Option<LivePodInfo>,
|
||||||
|
pub stored: Option<StoredPodInfo>,
|
||||||
|
pub summary: PodEntrySummary,
|
||||||
|
pub actions: PodEntryActions,
|
||||||
|
pub diagnostics: Vec<PodEntryDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LivePodInfo {
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
pub status: Option<PodStatus>,
|
||||||
|
pub reachable: bool,
|
||||||
|
pub segment_id: Option<SegmentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StoredPodInfo {
|
||||||
|
pub metadata_state: StoredMetadataState,
|
||||||
|
pub active_session_id: Option<SessionId>,
|
||||||
|
pub active_segment_id: Option<SegmentId>,
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PodEntryActions {
|
||||||
|
pub can_open: bool,
|
||||||
|
pub can_restore: bool,
|
||||||
|
pub can_send_now: bool,
|
||||||
|
pub can_queue_send: bool,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
|
||||||
|
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
|
||||||
|
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
|
||||||
|
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
|
||||||
|
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
|
||||||
|
- selection は refresh 後も Pod name を primary identity として維持できる。
|
||||||
|
- unit test で以下が確認されている。
|
||||||
|
- stored only row は restore/open 可能で direct send 不可。
|
||||||
|
- live idle reachable row は open/attach 可能かつ direct send 可能。
|
||||||
|
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
|
||||||
|
- corrupt stored metadata は diagnostic を持つ。
|
||||||
|
- rows refresh / rebuild 後に selected Pod name が維持される。
|
||||||
|
- 既存 picker / attach 関連テストが通る。
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
- 必要に応じて `cargo test -p tui -p pod -p protocol`
|
||||||
|
|
||||||
|
## Relationship
|
||||||
|
|
||||||
|
This is a prerequisite for:
|
||||||
|
|
||||||
|
- `20260527-000017-tui-spawned-pod-panel`
|
||||||
|
- `20260527-000023-multi-pod-view-ui`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- spawned child Pod panel の完成。
|
||||||
|
- 複数 Pod view UI の完成。
|
||||||
|
- child Pod への interactive input。
|
||||||
|
- multi-Pod view からの direct send 実行。
|
||||||
|
- host-wide Pod browser。
|
||||||
|
- Pod discovery / permission / registry visibility model の変更。
|
||||||
|
- native GUI。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
73
work-items/closed/20260528-152959-nix-packaging/item.md
Normal file
73
work-items/closed/20260528-152959-nix-packaging/item.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
---
|
||||||
|
id: 20260528-152959-nix-packaging
|
||||||
|
slug: nix-packaging
|
||||||
|
title: Package Insomnia with Nix
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [packaging, nix, distribution]
|
||||||
|
created_at: 2026-05-28T15:29:59Z
|
||||||
|
updated_at: 2026-05-28T16:42:08Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
|
||||||
|
|
||||||
|
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
|
||||||
|
|
||||||
|
## Existing Nix file layout
|
||||||
|
|
||||||
|
The repository already separates the Nix package definition from the developer shell:
|
||||||
|
|
||||||
|
- `package.nix` is the package derivation used for install/build outputs.
|
||||||
|
- `devshell.nix` is for the development shell only.
|
||||||
|
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
|
||||||
|
|
||||||
|
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- Add Nix packaging for the repository.
|
||||||
|
- Use `package.nix` for the installable derivation.
|
||||||
|
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
|
||||||
|
- Keep `devshell.nix` scoped to development shell concerns.
|
||||||
|
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
|
||||||
|
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
|
||||||
|
- Ensure runtime resources are included or discoverable.
|
||||||
|
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
|
||||||
|
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
|
||||||
|
- Keep local/user configuration separate from packaged resources.
|
||||||
|
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
|
||||||
|
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
|
||||||
|
- Make the package reproducible and CI-friendly.
|
||||||
|
- Pin inputs through the flake lock if a flake is used.
|
||||||
|
- Avoid network access during the build.
|
||||||
|
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
|
||||||
|
- Document usage.
|
||||||
|
- How to build the package.
|
||||||
|
- How to run TUI/Pod binaries from Nix.
|
||||||
|
- How user config is discovered.
|
||||||
|
- Known limitations.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
|
||||||
|
- `nix build` or the documented equivalent builds the package from a clean checkout.
|
||||||
|
- Installed binaries can find built-in resources/prompts at runtime.
|
||||||
|
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
|
||||||
|
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
|
||||||
|
- Documentation exists for Nix users.
|
||||||
|
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Publishing to nixpkgs.
|
||||||
|
- NixOS module / Home Manager module.
|
||||||
|
- Packaging external LLM providers or model runtimes.
|
||||||
|
- Secret management for provider API keys.
|
||||||
|
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
---
|
||||||
|
id: 20260528-152959-nix-packaging
|
||||||
|
slug: nix-packaging
|
||||||
|
title: Package Insomnia with Nix
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [packaging, nix, distribution]
|
||||||
|
created_at: 2026-05-28T15:29:59Z
|
||||||
|
updated_at: 2026-05-28T16:42:08Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
|
||||||
|
|
||||||
|
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
|
||||||
|
|
||||||
|
## Existing Nix file layout
|
||||||
|
|
||||||
|
The repository already separates the Nix package definition from the developer shell:
|
||||||
|
|
||||||
|
- `package.nix` is the package derivation used for install/build outputs.
|
||||||
|
- `devshell.nix` is for the development shell only.
|
||||||
|
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
|
||||||
|
|
||||||
|
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- Add Nix packaging for the repository.
|
||||||
|
- Use `package.nix` for the installable derivation.
|
||||||
|
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
|
||||||
|
- Keep `devshell.nix` scoped to development shell concerns.
|
||||||
|
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
|
||||||
|
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
|
||||||
|
- Ensure runtime resources are included or discoverable.
|
||||||
|
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
|
||||||
|
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
|
||||||
|
- Keep local/user configuration separate from packaged resources.
|
||||||
|
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
|
||||||
|
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
|
||||||
|
- Make the package reproducible and CI-friendly.
|
||||||
|
- Pin inputs through the flake lock if a flake is used.
|
||||||
|
- Avoid network access during the build.
|
||||||
|
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
|
||||||
|
- Document usage.
|
||||||
|
- How to build the package.
|
||||||
|
- How to run TUI/Pod binaries from Nix.
|
||||||
|
- How user config is discovered.
|
||||||
|
- Known limitations.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
|
||||||
|
- `nix build` or the documented equivalent builds the package from a clean checkout.
|
||||||
|
- Installed binaries can find built-in resources/prompts at runtime.
|
||||||
|
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
|
||||||
|
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
|
||||||
|
- Documentation exists for Nix users.
|
||||||
|
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Publishing to nixpkgs.
|
||||||
|
- NixOS module / Home Manager module.
|
||||||
|
- Packaging external LLM providers or model runtimes.
|
||||||
|
- Secret management for provider API keys.
|
||||||
|
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.
|
||||||
88
work-items/closed/20260528-152959-nix-packaging/thread.md
Normal file
88
work-items/closed/20260528-152959-nix-packaging/thread.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T15:29:59Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T16:42:08Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260528-152959-nix-packaging
|
||||||
|
slug: nix-packaging
|
||||||
|
title: Package Insomnia with Nix
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [packaging, nix, distribution]
|
||||||
|
created_at: 2026-05-28T15:29:59Z
|
||||||
|
updated_at: 2026-05-28T16:42:08Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
|
||||||
|
|
||||||
|
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
|
||||||
|
|
||||||
|
## Existing Nix file layout
|
||||||
|
|
||||||
|
The repository already separates the Nix package definition from the developer shell:
|
||||||
|
|
||||||
|
- `package.nix` is the package derivation used for install/build outputs.
|
||||||
|
- `devshell.nix` is for the development shell only.
|
||||||
|
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
|
||||||
|
|
||||||
|
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- Add Nix packaging for the repository.
|
||||||
|
- Use `package.nix` for the installable derivation.
|
||||||
|
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
|
||||||
|
- Keep `devshell.nix` scoped to development shell concerns.
|
||||||
|
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
|
||||||
|
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
|
||||||
|
- Ensure runtime resources are included or discoverable.
|
||||||
|
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
|
||||||
|
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
|
||||||
|
- Keep local/user configuration separate from packaged resources.
|
||||||
|
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
|
||||||
|
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
|
||||||
|
- Make the package reproducible and CI-friendly.
|
||||||
|
- Pin inputs through the flake lock if a flake is used.
|
||||||
|
- Avoid network access during the build.
|
||||||
|
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
|
||||||
|
- Document usage.
|
||||||
|
- How to build the package.
|
||||||
|
- How to run TUI/Pod binaries from Nix.
|
||||||
|
- How user config is discovered.
|
||||||
|
- Known limitations.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
|
||||||
|
- `nix build` or the documented equivalent builds the package from a clean checkout.
|
||||||
|
- Installed binaries can find built-in resources/prompts at runtime.
|
||||||
|
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
|
||||||
|
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
|
||||||
|
- Documentation exists for Nix users.
|
||||||
|
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Publishing to nixpkgs.
|
||||||
|
- NixOS module / Home Manager module.
|
||||||
|
- Packaging external LLM providers or model runtimes.
|
||||||
|
- Secret management for provider API keys.
|
||||||
|
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
id: 20260528-163238-multi-pod-view-section-layout
|
||||||
|
slug: multi-pod-view-section-layout
|
||||||
|
title: Polish multi-Pod view section layout
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, ux]
|
||||||
|
created_at: 2026-05-28T16:32:38Z
|
||||||
|
updated_at: 2026-05-28T16:49:25Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
|
||||||
|
|
||||||
|
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
|
||||||
|
|
||||||
|
Target shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--pending---
|
||||||
|
a
|
||||||
|
b
|
||||||
|
--working---
|
||||||
|
c
|
||||||
|
d
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--closed--
|
||||||
|
# only a few rows
|
||||||
|
```
|
||||||
|
|
||||||
|
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
|
||||||
|
|
||||||
|
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Change `tui --multi` list layout to explicit sections:
|
||||||
|
- `pending`: live Pods that are idle/waiting and ready for input.
|
||||||
|
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
|
||||||
|
- `closed`: stopped/restorable history entries.
|
||||||
|
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
|
||||||
|
- Allocate vertical space so that:
|
||||||
|
- live sections (`pending` + `working`) take the main flexible area.
|
||||||
|
- `closed` is pinned near the bottom of the list/dashboard area.
|
||||||
|
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
|
||||||
|
- excess height appears as blank space above the `closed` section rather than expanding closed history.
|
||||||
|
- Keep selection/navigation sane across sections.
|
||||||
|
- Selection should move through visible rows in display order.
|
||||||
|
- Direct send eligibility remains based on the selected `PodListEntry` action state.
|
||||||
|
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
|
||||||
|
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
|
||||||
|
- There should be one visual boundary, not two adjacent horizontal rules/borders.
|
||||||
|
- Do not introduce the same double-border issue between section headers and rows.
|
||||||
|
- Preserve existing `tui --multi` behavior outside layout.
|
||||||
|
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
|
||||||
|
- Composer contents are preserved across selection changes.
|
||||||
|
- Direct send to selected idle live Pod remains supported.
|
||||||
|
- running/paused/stopped targets remain safely disabled unless separately implemented.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
|
||||||
|
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
|
||||||
|
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
|
||||||
|
- The boundary between list/dashboard and composer has a single separator/border.
|
||||||
|
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
|
||||||
|
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Reopening the completed `multi-pod-view-ui` ticket.
|
||||||
|
- Adding per-section scrolling unless needed for a minimal correct implementation.
|
||||||
|
- Changing `PodList` discovery/visibility semantics.
|
||||||
|
- Changing direct-send delivery semantics.
|
||||||
|
- Adding new CLI flags.
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
---
|
||||||
|
id: 20260528-163238-multi-pod-view-section-layout
|
||||||
|
slug: multi-pod-view-section-layout
|
||||||
|
title: Polish multi-Pod view section layout
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, ux]
|
||||||
|
created_at: 2026-05-28T16:32:38Z
|
||||||
|
updated_at: 2026-05-28T16:49:25Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
|
||||||
|
|
||||||
|
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
|
||||||
|
|
||||||
|
Target shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--pending---
|
||||||
|
a
|
||||||
|
b
|
||||||
|
--working---
|
||||||
|
c
|
||||||
|
d
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--closed--
|
||||||
|
# only a few rows
|
||||||
|
```
|
||||||
|
|
||||||
|
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
|
||||||
|
|
||||||
|
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Change `tui --multi` list layout to explicit sections:
|
||||||
|
- `pending`: live Pods that are idle/waiting and ready for input.
|
||||||
|
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
|
||||||
|
- `closed`: stopped/restorable history entries.
|
||||||
|
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
|
||||||
|
- Allocate vertical space so that:
|
||||||
|
- live sections (`pending` + `working`) take the main flexible area.
|
||||||
|
- `closed` is pinned near the bottom of the list/dashboard area.
|
||||||
|
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
|
||||||
|
- excess height appears as blank space above the `closed` section rather than expanding closed history.
|
||||||
|
- Keep selection/navigation sane across sections.
|
||||||
|
- Selection should move through visible rows in display order.
|
||||||
|
- Direct send eligibility remains based on the selected `PodListEntry` action state.
|
||||||
|
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
|
||||||
|
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
|
||||||
|
- There should be one visual boundary, not two adjacent horizontal rules/borders.
|
||||||
|
- Do not introduce the same double-border issue between section headers and rows.
|
||||||
|
- Preserve existing `tui --multi` behavior outside layout.
|
||||||
|
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
|
||||||
|
- Composer contents are preserved across selection changes.
|
||||||
|
- Direct send to selected idle live Pod remains supported.
|
||||||
|
- running/paused/stopped targets remain safely disabled unless separately implemented.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
|
||||||
|
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
|
||||||
|
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
|
||||||
|
- The boundary between list/dashboard and composer has a single separator/border.
|
||||||
|
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
|
||||||
|
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Reopening the completed `multi-pod-view-ui` ticket.
|
||||||
|
- Adding per-section scrolling unless needed for a minimal correct implementation.
|
||||||
|
- Changing `PodList` discovery/visibility semantics.
|
||||||
|
- Changing direct-send delivery semantics.
|
||||||
|
- Adding new CLI flags.
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T16:32:38Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-28T16:49:25Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260528-163238-multi-pod-view-section-layout
|
||||||
|
slug: multi-pod-view-section-layout
|
||||||
|
title: Polish multi-Pod view section layout
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tui, pod, ux]
|
||||||
|
created_at: 2026-05-28T16:32:38Z
|
||||||
|
updated_at: 2026-05-28T16:49:25Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
|
||||||
|
|
||||||
|
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
|
||||||
|
|
||||||
|
Target shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--pending---
|
||||||
|
a
|
||||||
|
b
|
||||||
|
--working---
|
||||||
|
c
|
||||||
|
d
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--closed--
|
||||||
|
# only a few rows
|
||||||
|
```
|
||||||
|
|
||||||
|
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
|
||||||
|
|
||||||
|
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Change `tui --multi` list layout to explicit sections:
|
||||||
|
- `pending`: live Pods that are idle/waiting and ready for input.
|
||||||
|
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
|
||||||
|
- `closed`: stopped/restorable history entries.
|
||||||
|
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
|
||||||
|
- Allocate vertical space so that:
|
||||||
|
- live sections (`pending` + `working`) take the main flexible area.
|
||||||
|
- `closed` is pinned near the bottom of the list/dashboard area.
|
||||||
|
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
|
||||||
|
- excess height appears as blank space above the `closed` section rather than expanding closed history.
|
||||||
|
- Keep selection/navigation sane across sections.
|
||||||
|
- Selection should move through visible rows in display order.
|
||||||
|
- Direct send eligibility remains based on the selected `PodListEntry` action state.
|
||||||
|
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
|
||||||
|
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
|
||||||
|
- There should be one visual boundary, not two adjacent horizontal rules/borders.
|
||||||
|
- Do not introduce the same double-border issue between section headers and rows.
|
||||||
|
- Preserve existing `tui --multi` behavior outside layout.
|
||||||
|
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
|
||||||
|
- Composer contents are preserved across selection changes.
|
||||||
|
- Direct send to selected idle live Pod remains supported.
|
||||||
|
- running/paused/stopped targets remain safely disabled unless separately implemented.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
|
||||||
|
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
|
||||||
|
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
|
||||||
|
- The boundary between list/dashboard and composer has a single separator/border.
|
||||||
|
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
|
||||||
|
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||||
|
- `cargo check -p tui -p client -p pod`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Reopening the completed `multi-pod-view-ui` ticket.
|
||||||
|
- Adding per-section scrolling unless needed for a minimal correct implementation.
|
||||||
|
- Changing `PodList` discovery/visibility semantics.
|
||||||
|
- Changing direct-send delivery semantics.
|
||||||
|
- Adding new CLI flags.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:12Z -->
|
|
||||||
|
|
||||||
## Migrated
|
|
||||||
|
|
||||||
Migrated from tickets/spawnpod-initial-run-confirmation.md. No legacy review file was present at migration time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -7,7 +7,7 @@ kind: task
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [migrated]
|
||||||
created_at: 2026-05-27T00:00:17Z
|
created_at: 2026-05-27T00:00:17Z
|
||||||
updated_at: 2026-05-27T00:00:17Z
|
updated_at: 2026-05-28T14:16:02Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: tickets/tui-spawned-pod-panel.md
|
legacy_ticket: tickets/tui-spawned-pod-panel.md
|
||||||
---
|
---
|
||||||
|
|
@ -25,6 +25,12 @@ insomnia の開発では、親 Pod が複数の実装 Pod / reviewer Pod を spa
|
||||||
|
|
||||||
ネイティブ GUI は将来的には便利だが、現時点で必要なタスクではない。まず TUI のまま、現在の Pod が spawn した child Pod を一覧し、一時的に attach / view できる UI を用意したい。
|
ネイティブ GUI は将来的には便利だが、現時点で必要なタスクではない。まず TUI のまま、現在の Pod が spawn した child Pod を一覧し、一時的に attach / view できる UI を用意したい。
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- `20260528-141602-tui-pod-list-view-abstraction`
|
||||||
|
|
||||||
|
This ticket should build on the shared TUI Pod list/view abstraction instead of introducing a separate child-Pod-specific list model. The child panel may specialize the source/visibility to current-parent spawned children, but row status, reachability diagnostics, attach target representation, selection, and refresh behavior should reuse the prerequisite abstraction.
|
||||||
|
|
||||||
## 要件
|
## 要件
|
||||||
|
|
||||||
- TUI 上で、現在の Pod が spawn した child Pod を一覧できる。
|
- TUI 上で、現在の Pod が spawn した child Pod を一覧できる。
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
id: 20260527-000023-multi-pod-view-ui
|
|
||||||
slug: multi-pod-view-ui
|
|
||||||
title: 複数のPodのViewを行き来できるUI
|
|
||||||
status: open
|
|
||||||
kind: task
|
|
||||||
priority: P2
|
|
||||||
labels: [migrated]
|
|
||||||
created_at: 2026-05-27T00:00:23Z
|
|
||||||
updated_at: 2026-05-27T00:00:23Z
|
|
||||||
assignee: null
|
|
||||||
legacy_ticket: null
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration reference
|
|
||||||
|
|
||||||
- legacy_ticket: null
|
|
||||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
|
||||||
|
|
||||||
# 複数のPodのViewを行き来できるUI
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- Define the concrete requirements before implementation.
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:23Z -->
|
|
||||||
|
|
||||||
## Migrated
|
|
||||||
|
|
||||||
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
# Crate boundary audit
|
||||||
|
|
||||||
|
Date: 2026-05-28
|
||||||
|
|
||||||
|
## summary
|
||||||
|
|
||||||
|
The workspace dependency graph is broadly acyclic and mostly layered in the expected direction: `protocol` / `lint-common` / proc-macros sit at the bottom, `llm-worker` / `manifest` / `tools` / `provider` / `session-store` provide shared infrastructure, and `pod` / `tui` are orchestration or UI layers. I did not find a hard Cargo-level cycle or an obvious UI crate being depended on by a lower crate.
|
||||||
|
|
||||||
|
The main boundary problems are subtler:
|
||||||
|
|
||||||
|
1. `protocol` exposes several public wire payloads as `serde_json::Value` while documenting them as the JSON form of `session_store::*` types. This avoids a Rust dependency edge but creates a hidden schema dependency from `protocol`/clients to `session-store`.
|
||||||
|
2. `workflow` depends on `memory` for `WorkspaceLayout`, and `memory::WorkspaceLayout` owns workflow paths. This makes `memory` a cross-domain workspace-layout hub rather than only the memory subsystem.
|
||||||
|
3. Several lower/shared crates have comments/doc-comments explaining `Pod`, `TUI`, controller, prompt-catalog, or downstream orchestration behavior. Most are acceptable integration-contract notes, but a few are implementation knowledge that should move upward or be generalized.
|
||||||
|
|
||||||
|
No code, formatting, commits, merges, or project-record files outside `artifacts/` were changed.
|
||||||
|
|
||||||
|
## inspected commands / files
|
||||||
|
|
||||||
|
### Commands run
|
||||||
|
|
||||||
|
- `cargo metadata --no-deps --format-version 1 | jq ... > artifacts/deps.txt`
|
||||||
|
- Extracted workspace-internal normal/dev dependency edges.
|
||||||
|
- `cargo metadata --no-deps --format-version 1 | jq ... > artifacts/reverse-deps.txt`
|
||||||
|
- Extracted reverse dependency summary.
|
||||||
|
- `rg -n 'pub (struct|enum|fn|mod|trait|type|use) ...' crates --glob '*.rs' > artifacts/public-concept-hits.txt`
|
||||||
|
- Searched public APIs for boundary-relevant terms (`Pod`, `TUI`, `Workflow`, `Manifest`, `Memory`, `Session`, etc.).
|
||||||
|
- `rg -n '(^\s*(//!|///|//)\s?.*(...))' crates --glob '*.rs' > artifacts/comment-concept-hits.txt`
|
||||||
|
- Searched comments/doc-comments for crate names and upper-layer concepts.
|
||||||
|
- `rg -n 'TUI / GUI|session_store::|parent Controller|Pod treats|Pod side|...' ... > artifacts/suspicious-excerpts.txt`
|
||||||
|
- Narrowed suspicious comment excerpts.
|
||||||
|
- `rg -n 'use (session_store|pod_registry|llm_worker|manifest)::|...' crates/tui/src`
|
||||||
|
- Checked why TUI depends on lower internal crates.
|
||||||
|
- `rg -n 'WorkspaceLayout|memory::' crates/workflow/src`
|
||||||
|
- Checked `workflow -> memory` dependency use.
|
||||||
|
|
||||||
|
Failed exploratory commands:
|
||||||
|
|
||||||
|
- `python` / `python3` parse attempts failed because Python was not available in the environment; switched to `cargo metadata` + `jq`.
|
||||||
|
|
||||||
|
Supplemental raw outputs left in the artifact directory:
|
||||||
|
|
||||||
|
- `deps.txt`, `reverse-deps.txt`
|
||||||
|
- `deps-numbered.txt`, `reverse-deps-numbered.txt`
|
||||||
|
- `public-concept-hits.txt`
|
||||||
|
- `comment-concept-hits.txt`
|
||||||
|
- `suspicious-excerpts.txt`
|
||||||
|
|
||||||
|
### Main files inspected directly
|
||||||
|
|
||||||
|
- Root/workspace:
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `work-items/open/20260528-131317-crate-boundary-audit/item.md`
|
||||||
|
- Cargo manifests:
|
||||||
|
- `crates/protocol/Cargo.toml`
|
||||||
|
- `crates/manifest/Cargo.toml`
|
||||||
|
- `crates/llm-worker/Cargo.toml`
|
||||||
|
- `crates/pod/Cargo.toml`
|
||||||
|
- `crates/client/Cargo.toml`
|
||||||
|
- `crates/tui/Cargo.toml`
|
||||||
|
- `crates/memory/Cargo.toml`
|
||||||
|
- `crates/workflow/Cargo.toml`
|
||||||
|
- `crates/provider/Cargo.toml`
|
||||||
|
- `crates/session-store/Cargo.toml`
|
||||||
|
- `crates/pod-registry/Cargo.toml`
|
||||||
|
- `crates/session-metrics/Cargo.toml`
|
||||||
|
- `crates/tools/Cargo.toml`
|
||||||
|
- `crates/daemon/Cargo.toml`
|
||||||
|
- `crates/lint-common/Cargo.toml`
|
||||||
|
- `crates/llm-worker-macros/Cargo.toml`
|
||||||
|
- Public/API and suspicious source files:
|
||||||
|
- `crates/protocol/src/lib.rs`
|
||||||
|
- `crates/manifest/src/lib.rs`
|
||||||
|
- `crates/manifest/src/model.rs`
|
||||||
|
- `crates/llm-worker/src/lib.rs`
|
||||||
|
- `crates/llm-worker/src/interceptor.rs`
|
||||||
|
- `crates/llm-worker/src/llm_client/types.rs`
|
||||||
|
- `crates/pod/src/lib.rs`
|
||||||
|
- `crates/pod/src/pod.rs` (grep/read excerpts)
|
||||||
|
- `crates/pod/src/spawn/comm_tools.rs` (grep excerpts)
|
||||||
|
- `crates/client/src/lib.rs`
|
||||||
|
- `crates/client/src/spawn.rs`
|
||||||
|
- `crates/tui/src/app.rs` (grep excerpts)
|
||||||
|
- `crates/tui/src/spawn.rs` (grep excerpts)
|
||||||
|
- `crates/tui/src/picker.rs` (grep excerpts)
|
||||||
|
- `crates/memory/src/lib.rs`
|
||||||
|
- `crates/memory/src/scope.rs`
|
||||||
|
- `crates/memory/src/workspace.rs`
|
||||||
|
- `crates/memory/src/extract/mod.rs` (grep excerpts)
|
||||||
|
- `crates/memory/src/consolidate/mod.rs` (grep excerpts)
|
||||||
|
- `crates/memory/src/resident.rs` (grep excerpts)
|
||||||
|
- `crates/workflow/src/lib.rs`
|
||||||
|
- `crates/workflow/src/linter.rs` (grep excerpts)
|
||||||
|
- `crates/workflow/src/scope.rs` (grep excerpts)
|
||||||
|
- `crates/workflow/src/workflow.rs` (grep excerpts)
|
||||||
|
- `crates/session-store/src/lib.rs`
|
||||||
|
- `crates/session-store/src/segment.rs`
|
||||||
|
- `crates/session-store/src/segment_log.rs` (grep excerpts)
|
||||||
|
- `crates/session-store/src/system_item.rs`
|
||||||
|
- `crates/session-store/src/pod_metadata.rs`
|
||||||
|
- `crates/pod-registry/src/lib.rs`
|
||||||
|
- `crates/provider/src/lib.rs`
|
||||||
|
- `crates/tools/src/lib.rs`
|
||||||
|
|
||||||
|
## dependency graph overview
|
||||||
|
|
||||||
|
Internal dependency edges from `cargo metadata --no-deps`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client -> manifest, protocol
|
||||||
|
daemon -> manifest, protocol
|
||||||
|
lint-common -> (none)
|
||||||
|
llm-worker -> llm-worker-macros
|
||||||
|
llm-worker-macros -> (none)
|
||||||
|
manifest -> llm-worker, protocol
|
||||||
|
memory -> lint-common, llm-worker, manifest
|
||||||
|
pod -> llm-worker, manifest, memory, pod-registry, protocol, provider, session-metrics, session-store, tools, workflow
|
||||||
|
pod-registry -> manifest, session-store
|
||||||
|
protocol -> (none)
|
||||||
|
provider -> llm-worker, manifest
|
||||||
|
session-metrics -> session-store
|
||||||
|
session-store -> llm-worker, protocol
|
||||||
|
tools -> llm-worker, manifest
|
||||||
|
tui -> client, llm-worker, manifest, pod-registry, protocol, session-store; dev-dep tools
|
||||||
|
workflow -> lint-common, manifest, memory
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse summary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client <- tui
|
||||||
|
lint-common <- memory, workflow
|
||||||
|
llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
|
||||||
|
manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
|
||||||
|
memory <- pod, workflow
|
||||||
|
pod-registry <- pod, tui
|
||||||
|
protocol <- client, daemon, manifest, pod, session-store, tui
|
||||||
|
provider <- pod
|
||||||
|
session-metrics <- pod
|
||||||
|
session-store <- pod, pod-registry, session-metrics, tui
|
||||||
|
tools <- pod, tui
|
||||||
|
workflow <- pod
|
||||||
|
```
|
||||||
|
|
||||||
|
This is directionally reasonable for orchestration-heavy code: `pod` is the main integrator; `tui` sits above `client` but also reads lower schemas; `protocol` has no Rust workspace dependencies.
|
||||||
|
|
||||||
|
## dependency/interface findings grouped by severity
|
||||||
|
|
||||||
|
### Severity: actual problem / should ticket
|
||||||
|
|
||||||
|
#### 1. `protocol` public API has hidden `session-store` schema coupling through `serde_json::Value`
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `crates/protocol/src/lib.rs:237` documents `Event::SystemItem.item` as the JSON form of `session_store::SystemItem`.
|
||||||
|
- `crates/protocol/src/lib.rs:394` documents `Event::Snapshot.entries` as the JSON form of `session_store::LogEntry`.
|
||||||
|
- `crates/protocol/src/lib.rs:419` documents `Event::SegmentRotated.entry` as the JSON form of `session_store::LogEntry::SegmentStart`.
|
||||||
|
- `crates/tui/src/app.rs:1236` and nearby lines deserialize snapshot entries back into `session_store::LogEntry`.
|
||||||
|
- `crates/tui/src/app.rs:1277` and nearby lines deserialize `Event::SystemItem.item` into `session_store::SystemItem`.
|
||||||
|
|
||||||
|
Why this is a boundary issue:
|
||||||
|
|
||||||
|
- `protocol` is dependency-free at the Cargo level, but its wire contract is not actually self-owned: clients must know `session-store` schemas to reconstruct state correctly.
|
||||||
|
- The type system cannot enforce compatibility between `protocol` and `session-store` because the public protocol type is only `serde_json::Value`.
|
||||||
|
- This explains why `tui` depends directly on `session-store` despite also depending on `client`/`protocol`.
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
- Extract the wire-stable log/system-item DTOs into a neutral crate, or move protocol-facing DTOs into `protocol` and have `session-store` convert to/from them.
|
||||||
|
- Avoid public protocol docs that say “this is `session_store::X` JSON” unless `session-store` is intentionally part of the protocol contract and typed as such.
|
||||||
|
|
||||||
|
#### 2. `workflow -> memory` dependency exists for shared workspace layout
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `crates/workflow/Cargo.toml:11` depends on `memory`.
|
||||||
|
- `crates/workflow/src/linter.rs:5`, `crates/workflow/src/scope.rs:6`, `crates/workflow/src/workflow.rs:17` use `memory::WorkspaceLayout`.
|
||||||
|
- `crates/memory/src/workspace.rs:8` includes `<root>/.insomnia/workflow/<slug>.md` in memory's layout documentation.
|
||||||
|
- `crates/memory/src/workspace.rs:16-18` says workflows are human-managed and live one level up under `.insomnia/workflow/`.
|
||||||
|
- `crates/memory/src/workspace.rs:127-165` exposes `workflow_dir()` / `workflow_path()` from the memory crate.
|
||||||
|
|
||||||
|
Why this is a boundary issue:
|
||||||
|
|
||||||
|
- `workflow` is conceptually a sibling subsystem, not a consumer of generated memory state.
|
||||||
|
- The current dependency is only for path layout. That makes `memory` own cross-subsystem workspace conventions and forces workflow to import a memory-domain crate for non-memory concerns.
|
||||||
|
- This is not severe yet, but it will make future workflow growth pull against crate ownership.
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
- Extract `WorkspaceLayout` / `.insomnia` path conventions into a neutral crate or a neutral module under `manifest`/new `workspace-layout` crate.
|
||||||
|
- Then make `memory` and `workflow` both depend on that neutral layout instead of depending on each other.
|
||||||
|
|
||||||
|
### Severity: suspicious but currently acceptable
|
||||||
|
|
||||||
|
#### 3. `session-store` owns Pod metadata and spawned-child metadata
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `crates/session-store/src/pod_metadata.rs:1-6` defines “Pod metadata persistence API”.
|
||||||
|
- `crates/session-store/src/pod_metadata.rs:42-60` defines `PodSpawnedScopeRule` / `PodSpawnedChild`, including delegated scope and `callback_address`.
|
||||||
|
- `crates/session-store/src/pod_metadata.rs:62-88` exposes `PodMetadata` and `PodMetadataStore` publicly.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- This is Pod/orchestration-specific state inside a crate named `session-store`.
|
||||||
|
- It is acceptable if `session-store` is intentionally “insomnia persistence primitives”, not a generic conversation-log crate. Current project decisions appear to lean that way.
|
||||||
|
- If the intended boundary is “session-store only stores sessions/segments/logs”, this should be split or renamed. If the intended boundary is “session-store stores all durable Pod state”, the naming/docs should say that explicitly.
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
- No immediate refactor unless the ownership goal changes.
|
||||||
|
- Clarify crate-level docs: either broaden `session-store`'s stated responsibility to durable Pod/session persistence, or split Pod metadata into a `pod-state`/`pod-metadata` crate.
|
||||||
|
|
||||||
|
#### 4. TUI directly depends on persistence/registry crates
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `crates/tui/Cargo.toml` depends on `session-store`, `pod-registry`, `manifest`, `llm-worker`, and `protocol` in addition to `client`.
|
||||||
|
- `crates/tui/src/picker.rs` uses `pod_registry::{LockFileGuard, default_registry_path}` and `session_store::{...}`.
|
||||||
|
- `crates/tui/src/app.rs:1236-1298` parses `session_store::LogEntry` / `session_store::SystemItem`.
|
||||||
|
- `crates/tui/src/spawn.rs:408-409` uses `session_store::FsStore` and `restore_by_segment` for resume-related paths.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- TUI is a top-level crate, so dependency direction is allowed.
|
||||||
|
- The direct `session-store` parse dependency is largely a symptom of finding #1: protocol sends untyped JSON whose real schema lives in `session-store`.
|
||||||
|
- Direct `pod-registry` access for picker/runtime discovery may be acceptable for a local-first TUI, but it bypasses a cleaner “TUI talks protocol/client only” boundary.
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
- Fix protocol DTO ownership first.
|
||||||
|
- After that, re-evaluate whether TUI still needs direct `session-store` and `pod-registry` dependencies or whether picker/discovery can move behind `client`/`protocol` APIs.
|
||||||
|
|
||||||
|
#### 5. `manifest -> llm-worker` dependency is acceptable but should remain one-way
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `crates/manifest/Cargo.toml` depends on `llm-worker` and `protocol`.
|
||||||
|
- `crates/manifest/src/model.rs:17-19` re-exports `llm_worker::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort}`.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- This is a reasonable tradeoff to avoid duplicate model-capability types.
|
||||||
|
- It does mean `manifest` is not a pure data crate independent of worker runtime types.
|
||||||
|
- The boundary remains acceptable as long as `llm-worker` does not depend back on `manifest`, and provider-level resolution stays in `provider`.
|
||||||
|
|
||||||
|
### Severity: no issue found
|
||||||
|
|
||||||
|
- No Rust workspace dependency cycle was found in the inspected graph.
|
||||||
|
- I did not find lower crates depending on `tui` or `client` implementation crates.
|
||||||
|
- `client -> protocol/manifest` and `pod -> provider/tools/session-store/memory/workflow` are directionally appropriate.
|
||||||
|
- `provider -> llm-worker/manifest` is appropriate: provider constructs concrete `LlmClient` implementations from resolved model configuration.
|
||||||
|
- `tools -> llm-worker/manifest` is appropriate: tools expose `ToolDefinition`s and enforce manifest scopes.
|
||||||
|
- `pod-registry -> session-store` is acceptable if registry entries need session/segment identity and durable state coordination.
|
||||||
|
|
||||||
|
## comment/doc-comment findings
|
||||||
|
|
||||||
|
### Problematic or should be generalized
|
||||||
|
|
||||||
|
#### `protocol` describes parent controller and pod-registry side effects
|
||||||
|
|
||||||
|
- `crates/protocol/src/lib.rs:65-70`
|
||||||
|
- `PodEvent` docs say the “parent Controller applies variant-specific side effects (registry / pod-registry updates)”.
|
||||||
|
- This is implementation knowledge from the `pod` crate inside a dependency-free protocol crate.
|
||||||
|
- Better: state the wire contract (“event is delivered to the parent; receiver is responsible for handling lifecycle effects”) and keep registry-specific behavior in `pod` docs.
|
||||||
|
|
||||||
|
#### `protocol` documents `session_store::*` JSON shapes as protocol payloads
|
||||||
|
|
||||||
|
- `crates/protocol/src/lib.rs:237`
|
||||||
|
- `crates/protocol/src/lib.rs:394`
|
||||||
|
- `crates/protocol/src/lib.rs:419`
|
||||||
|
|
||||||
|
This is the comment-level manifestation of the public-interface issue in finding #1.
|
||||||
|
|
||||||
|
#### `llm-worker` public request docs mention Pod-specific cache-key choice
|
||||||
|
|
||||||
|
- `crates/llm-worker/src/llm_client/types.rs:523-526`
|
||||||
|
- `Request::cache_key` doc says pod side is expected to pass `SegmentId`.
|
||||||
|
- `llm-worker` should expose the generic concept: a stable caller-provided conversation/cache namespace key.
|
||||||
|
- Pod's choice of `SegmentId` belongs in `pod` docs/tests, not in the generic request type.
|
||||||
|
|
||||||
|
#### `memory` docs prescribe Pod assembly details
|
||||||
|
|
||||||
|
- `crates/memory/src/lib.rs:3-7`
|
||||||
|
- Says generic CRUD tools must not touch memory/knowledge and Pod is responsible for denying them.
|
||||||
|
- `crates/memory/src/scope.rs:4-8`
|
||||||
|
- Says Pod is expected to call `deny_write_rules` and pass the result to `tools::ScopedFs`.
|
||||||
|
- `crates/memory/src/extract/mod.rs:3-14`
|
||||||
|
- Explains Pod post-run hook, `PromptCatalog`, `PodPrompt::MemoryExtractSystem`, and pointer persistence responsibility.
|
||||||
|
- `crates/memory/src/consolidate/mod.rs:5-15`
|
||||||
|
- Explains Pod assembling a disposable Worker and using `PodPrompt::MemoryConsolidationSystem`.
|
||||||
|
- `crates/memory/src/resident.rs:3-11`
|
||||||
|
- Says surfaces are used by the Pod system-prompt assembler and Pod IPC layer for TUI `#` completion.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- These are understandable because `memory` is currently a helper subsystem consumed by `pod`.
|
||||||
|
- They nevertheless make `memory` read like it is documenting Pod orchestration rather than memory-owned contracts.
|
||||||
|
- Prefer caller-neutral wording: “the orchestrator/caller registers these tools”, “the caller persists the pointer”, “completion consumers may use ...”. Keep Pod-specific sequence docs in `pod`.
|
||||||
|
|
||||||
|
### Suspicious but acceptable integration-contract comments
|
||||||
|
|
||||||
|
#### `protocol::Segment` docs mention TUI/GUI and Pod parsing behavior
|
||||||
|
|
||||||
|
- `crates/protocol/src/lib.rs:116-126`
|
||||||
|
- Mentions richer clients (TUI/GUI) producing typed atoms and Pod not re-parsing flattened strings.
|
||||||
|
- `crates/protocol/src/lib.rs:143-153`
|
||||||
|
- Mentions Pod resolving `FileRef` and treating unknown variants as unresolved input.
|
||||||
|
- `crates/protocol/src/lib.rs:222-231`
|
||||||
|
- Mentions additional TUI/GUI instances rendering user messages.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- Mentioning client classes can be acceptable in protocol docs when it explains wire semantics.
|
||||||
|
- The Pod behavior details are more debatable; they should be limited to required protocol semantics, not specific controller implementation.
|
||||||
|
|
||||||
|
#### `session-store::SystemItem` mentions TUI typed rendering
|
||||||
|
|
||||||
|
- `crates/session-store/src/system_item.rs:27-35`
|
||||||
|
- `crates/session-store/src/system_item.rs:49-52`
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- It is valid to document why typed payload exists.
|
||||||
|
- “so the TUI can render” should probably be generalized to “so clients can render” because `session-store` is lower than `tui`.
|
||||||
|
|
||||||
|
#### `session-store::segment` mentions Pod as typical caller
|
||||||
|
|
||||||
|
- `crates/session-store/src/segment.rs:3-5`
|
||||||
|
- `crates/session-store/src/segment.rs:38-40`
|
||||||
|
- `crates/session-store/src/segment.rs:175-180`
|
||||||
|
- `crates/session-store/src/segment.rs:252-254`
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- Mostly acceptable because Pod is currently the primary writer.
|
||||||
|
- Better wording would say “caller/orchestrator” first and optionally “e.g. Pod” only where it clarifies current integration.
|
||||||
|
|
||||||
|
#### `client` docs mention TUI/GUI/E2E
|
||||||
|
|
||||||
|
- `crates/client/src/lib.rs:9`
|
||||||
|
- `crates/client/src/spawn.rs:4-10`
|
||||||
|
- `crates/client/src/spawn.rs:92-96`
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- Acceptable: `client` is explicitly a library for UI/GUI/E2E callers to speak Pod protocol. These are consumer examples rather than lower-layer implementation leakage.
|
||||||
|
|
||||||
|
#### `llm-worker::Interceptor` docs mention Pod as an upper layer
|
||||||
|
|
||||||
|
- `crates/llm-worker/src/interceptor.rs:3-6`
|
||||||
|
- `crates/llm-worker/src/interceptor.rs:122-126`
|
||||||
|
- `crates/llm-worker/src/interceptor.rs:140-146`
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- Mostly acceptable: the docs explicitly say Worker does not know higher-level concepts and Pod is only an example upper layer.
|
||||||
|
- For stricter boundary hygiene, prefer “upper layers/orchestrators” and avoid naming Pod except in examples.
|
||||||
|
|
||||||
|
## acceptable dependency-aware comments criteria
|
||||||
|
|
||||||
|
I treated a comment as acceptable when it met at least one of these criteria:
|
||||||
|
|
||||||
|
1. It explains a public wire or file-format contract that consumers must honor, without prescribing one consumer's private implementation.
|
||||||
|
2. It names a higher layer only as an example (`e.g. Pod`) while the API remains generic and caller-owned.
|
||||||
|
3. It documents an intentional direction-of-control boundary, such as “the lower crate exposes a hook; upper layers implement policy”.
|
||||||
|
4. It references another crate that the current crate actually depends on and whose type or function is part of the local API.
|
||||||
|
5. It appears in tests/examples whose purpose is cross-crate contract verification.
|
||||||
|
|
||||||
|
I treated a comment as problematic when it did any of the following:
|
||||||
|
|
||||||
|
1. A lower crate explains what a dependent higher crate currently does internally.
|
||||||
|
2. A lower crate's public docs define a payload as another higher crate's private or semi-private JSON schema.
|
||||||
|
3. A shared subsystem describes its API mainly as a sequence of Pod/TUI orchestration steps, rather than a caller-neutral contract.
|
||||||
|
4. The comment reveals a hidden dependency that Cargo cannot type-check.
|
||||||
|
|
||||||
|
## recommended follow-up tickets
|
||||||
|
|
||||||
|
1. **Typed protocol snapshot/system-item payloads**
|
||||||
|
- Goal: remove `protocol` public `serde_json::Value` payloads whose real schemas are `session_store::*`.
|
||||||
|
- Candidate implementation directions:
|
||||||
|
- Move wire DTOs for log entries/system items into `protocol`, with `session-store` converting to/from them; or
|
||||||
|
- Extract a neutral `session-log-schema` / `wire-log` crate used by both `protocol` and `session-store`.
|
||||||
|
- Success condition: TUI/client code can parse snapshots/system items using protocol-owned typed structures, not `session_store::LogEntry` hidden behind JSON.
|
||||||
|
|
||||||
|
2. **Extract neutral workspace layout from `memory`**
|
||||||
|
- Goal: remove `workflow -> memory` when the only need is `.insomnia` path layout.
|
||||||
|
- Candidate implementation directions:
|
||||||
|
- New neutral crate/module for `WorkspaceLayout`; or
|
||||||
|
- Move `.insomnia` path layout into `manifest` if that crate is intended to own workspace configuration.
|
||||||
|
- Success condition: `workflow` and `memory` are siblings depending on a neutral layout owner.
|
||||||
|
|
||||||
|
3. **Boundary-comment hygiene pass**
|
||||||
|
- Goal: replace reverse-knowledge comments in lower/shared crates with caller-neutral wording.
|
||||||
|
- Scope:
|
||||||
|
- `protocol/src/lib.rs` controller/session-store JSON wording.
|
||||||
|
- `llm-worker/src/llm_client/types.rs` Pod `SegmentId` cache-key wording.
|
||||||
|
- `memory/src/{scope,extract,consolidate,resident}.rs` Pod/TUI orchestration wording.
|
||||||
|
- `session-store/src/{system_item,segment}.rs` TUI/Pod-specific wording where not required.
|
||||||
|
- Success condition: comments explain local contracts and extension points; dependent-crate implementation details live in the dependent crate.
|
||||||
|
|
||||||
|
4. **Clarify `session-store` crate responsibility**
|
||||||
|
- Goal: decide whether `session-store` is only session/segment log storage or the broader durable Pod-state persistence crate.
|
||||||
|
- If broader: update crate docs/naming comments to say so.
|
||||||
|
- If narrower: split `pod_metadata` into a Pod-owned persistence crate/module.
|
||||||
|
|
||||||
|
## unresolved questions
|
||||||
|
|
||||||
|
1. Is `protocol` intended to be the sole owner of all stable wire DTOs, or is `session-store` intentionally part of the protocol contract despite the current `serde_json::Value` indirection?
|
||||||
|
2. Is `session-store` deliberately the durable state crate for all Pod metadata, or should it be constrained to conversation/session logs?
|
||||||
|
3. Should `WorkspaceLayout` be considered a memory-domain concept, or a repository/workspace-domain concept shared by memory, knowledge, and workflow?
|
||||||
|
4. Should TUI remain allowed to inspect local registry/session files directly for picker and restore UX, or should those capabilities move behind `client`/`protocol` APIs?
|
||||||
|
5. Are comments allowed to name the primary current consumer (`Pod`) when documenting a generic lower-layer extension point, or should comments avoid such names unless the type itself is Pod-specific?
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,58 @@
|
||||||
|
1 client:
|
||||||
|
2 -> manifest [normal]
|
||||||
|
3 -> protocol [normal]
|
||||||
|
4 daemon:
|
||||||
|
5 -> manifest [normal]
|
||||||
|
6 -> protocol [normal]
|
||||||
|
7 lint-common:
|
||||||
|
8 (no workspace deps)
|
||||||
|
9 llm-worker:
|
||||||
|
10 -> llm-worker-macros [normal]
|
||||||
|
11 llm-worker-macros:
|
||||||
|
12 (no workspace deps)
|
||||||
|
13 manifest:
|
||||||
|
14 -> llm-worker [normal]
|
||||||
|
15 -> protocol [normal]
|
||||||
|
16 memory:
|
||||||
|
17 -> lint-common [normal]
|
||||||
|
18 -> llm-worker [normal]
|
||||||
|
19 -> manifest [normal]
|
||||||
|
20 pod:
|
||||||
|
21 -> llm-worker [normal]
|
||||||
|
22 -> manifest [normal]
|
||||||
|
23 -> memory [normal]
|
||||||
|
24 -> pod-registry [normal]
|
||||||
|
25 -> protocol [normal]
|
||||||
|
26 -> provider [normal]
|
||||||
|
27 -> session-metrics [normal]
|
||||||
|
28 -> session-store [normal]
|
||||||
|
29 -> tools [normal]
|
||||||
|
30 -> workflow [normal]
|
||||||
|
31 pod-registry:
|
||||||
|
32 -> manifest [normal]
|
||||||
|
33 -> session-store [normal]
|
||||||
|
34 protocol:
|
||||||
|
35 (no workspace deps)
|
||||||
|
36 provider:
|
||||||
|
37 -> llm-worker [normal]
|
||||||
|
38 -> manifest [normal]
|
||||||
|
39 session-metrics:
|
||||||
|
40 -> session-store [normal]
|
||||||
|
41 session-store:
|
||||||
|
42 -> llm-worker [normal]
|
||||||
|
43 -> protocol [normal]
|
||||||
|
44 tools:
|
||||||
|
45 -> llm-worker [normal]
|
||||||
|
46 -> manifest [normal]
|
||||||
|
47 tui:
|
||||||
|
48 -> client [normal]
|
||||||
|
49 -> llm-worker [normal]
|
||||||
|
50 -> manifest [normal]
|
||||||
|
51 -> pod-registry [normal]
|
||||||
|
52 -> protocol [normal]
|
||||||
|
53 -> session-store [normal]
|
||||||
|
54 -> tools [dev]
|
||||||
|
55 workflow:
|
||||||
|
56 -> lint-common [normal]
|
||||||
|
57 -> manifest [normal]
|
||||||
|
58 -> memory [normal]
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
client:
|
||||||
|
-> manifest [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
daemon:
|
||||||
|
-> manifest [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
lint-common:
|
||||||
|
(no workspace deps)
|
||||||
|
llm-worker:
|
||||||
|
-> llm-worker-macros [normal]
|
||||||
|
llm-worker-macros:
|
||||||
|
(no workspace deps)
|
||||||
|
manifest:
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
memory:
|
||||||
|
-> lint-common [normal]
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
pod:
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
-> memory [normal]
|
||||||
|
-> pod-registry [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
-> provider [normal]
|
||||||
|
-> session-metrics [normal]
|
||||||
|
-> session-store [normal]
|
||||||
|
-> tools [normal]
|
||||||
|
-> workflow [normal]
|
||||||
|
pod-registry:
|
||||||
|
-> manifest [normal]
|
||||||
|
-> session-store [normal]
|
||||||
|
protocol:
|
||||||
|
(no workspace deps)
|
||||||
|
provider:
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
session-metrics:
|
||||||
|
-> session-store [normal]
|
||||||
|
session-store:
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
tools:
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
tui:
|
||||||
|
-> client [normal]
|
||||||
|
-> llm-worker [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
-> pod-registry [normal]
|
||||||
|
-> protocol [normal]
|
||||||
|
-> session-store [normal]
|
||||||
|
-> tools [dev]
|
||||||
|
workflow:
|
||||||
|
-> lint-common [normal]
|
||||||
|
-> manifest [normal]
|
||||||
|
-> memory [normal]
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
crates/llm-worker-macros/src/lib.rs:257: pub fn #definition_name(&self) -> ::llm_worker::tool::ToolDefinition {
|
||||||
|
crates/provider/src/codex_oauth/error.rs:49: pub fn to_client_error(self) -> ClientError {
|
||||||
|
crates/session-store/src/system_item.rs:114:pub fn render_pod_event(event: &PodEvent) -> String {
|
||||||
|
crates/memory/src/tool/read.rs:182:pub fn read_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||||
|
crates/provider/src/codex_oauth/mod.rs:62: pub fn from_default_home() -> Result<Self, ClientError> {
|
||||||
|
crates/llm-worker/src/interceptor.rs:97:pub struct ToolCallInfo {
|
||||||
|
crates/llm-worker/src/interceptor.rs:107:pub struct ToolResultInfo {
|
||||||
|
crates/memory/src/tool/write.rs:188:pub fn write_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||||
|
crates/session-store/src/lib.rs:69:pub type SessionId = uuid::Uuid;
|
||||||
|
crates/session-store/src/lib.rs:75:pub fn new_session_id() -> SessionId {
|
||||||
|
crates/memory/src/tool/query.rs:473:pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
|
||||||
|
crates/memory/src/tool/query.rs:488:pub fn knowledge_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
|
||||||
|
crates/session-store/src/pod_metadata.rs:18:pub struct PodActiveSegmentRef {
|
||||||
|
crates/session-store/src/pod_metadata.rs:26: pub fn pending_segment(session_id: SessionId) -> Self {
|
||||||
|
crates/session-store/src/pod_metadata.rs:34: pub fn active_segment(session_id: SessionId, segment_id: SegmentId) -> Self {
|
||||||
|
crates/session-store/src/pod_metadata.rs:46:pub struct PodSpawnedScopeRule {
|
||||||
|
crates/session-store/src/pod_metadata.rs:55:pub struct PodSpawnedChild {
|
||||||
|
crates/session-store/src/pod_metadata.rs:64:pub struct PodMetadata {
|
||||||
|
crates/session-store/src/pod_metadata.rs:74: pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self {
|
||||||
|
crates/session-store/src/pod_metadata.rs:88:pub trait PodMetadataStore: Send + Sync {
|
||||||
|
crates/memory/src/tool/mod.rs:33:pub enum MemoryToolKind {
|
||||||
|
crates/session-store/src/segment_log.rs:174:pub struct PodScopeSnapshot {
|
||||||
|
crates/llm-worker/src/worker.rs:57:pub enum ToolRegistryError {
|
||||||
|
crates/llm-worker/src/worker.rs:483: pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||||
|
crates/llm-worker/src/worker.rs:528: pub fn tool_server_handle(&self) -> ToolServerHandle {
|
||||||
|
crates/llm-worker/src/worker.rs:1646: pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
|
||||||
|
crates/memory/src/tool/edit.rs:268:pub fn edit_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||||
|
crates/llm-worker/src/llm_client/transport.rs:98: pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||||
|
crates/memory/src/tool/delete.rs:98:pub fn delete_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||||
|
crates/llm-worker/src/lib.rs:56:pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||||
|
crates/llm-worker/src/lib.rs:57:pub use handler::ToolUseBlockStart;
|
||||||
|
crates/llm-worker/src/lib.rs:60:pub use tool::{ToolCall, ToolOutputLimits, ToolResult};
|
||||||
|
crates/memory/src/scope.rs:19:pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||||
|
crates/llm-worker/src/llm_client/error.rs:7:pub enum ClientError {
|
||||||
|
crates/llm-worker/src/llm_client/error.rs:107:pub fn is_retryable(error: &ClientError) -> bool {
|
||||||
|
crates/llm-worker/src/tool.rs:16:pub enum ToolError {
|
||||||
|
crates/llm-worker/src/tool.rs:48:pub struct ToolOutputLimits {
|
||||||
|
crates/llm-worker/src/tool.rs:99:pub struct ToolOutput {
|
||||||
|
crates/llm-worker/src/tool.rs:135:pub struct ToolMeta {
|
||||||
|
crates/llm-worker/src/tool.rs:190:pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
|
||||||
|
crates/llm-worker/src/tool.rs:245:pub trait Tool: Send + Sync {
|
||||||
|
crates/llm-worker/src/tool.rs:265:pub struct ToolCall {
|
||||||
|
crates/llm-worker/src/tool.rs:279:pub struct ToolResult {
|
||||||
|
crates/llm-worker/src/tool.rs:294: pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||||
|
crates/memory/src/error.rs:10:pub enum MemoryError {
|
||||||
|
crates/pod-registry/src/error.rs:11:pub enum ScopeLockError {
|
||||||
|
crates/llm-worker/src/llm_client/capability.rs:41:pub enum ToolCallingSupport {
|
||||||
|
crates/llm-worker/src/tool_server.rs:13:pub enum ToolServerError {
|
||||||
|
crates/llm-worker/src/tool_server.rs:27:pub struct ToolServer {
|
||||||
|
crates/llm-worker/src/tool_server.rs:39: pub fn handle(&self) -> ToolServerHandle {
|
||||||
|
crates/llm-worker/src/tool_server.rs:49:pub struct ToolServerHandle {
|
||||||
|
crates/llm-worker/src/tool_server.rs:108: pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)> {
|
||||||
|
crates/llm-worker/src/tool_server.rs:137: pub fn unregister(&self, name: &str) -> Result<(), ToolServerError> {
|
||||||
|
crates/llm-worker/src/tool_server.rs:150: pub fn replace(&self, factory: WorkerToolDefinition) -> Result<(), ToolServerError> {
|
||||||
|
crates/pod-registry/src/lifecycle.rs:18:pub struct ScopeAllocationGuard {
|
||||||
|
crates/pod-registry/src/lifecycle.rs:129:pub fn update_segment(pod_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
|
||||||
|
crates/pod-registry/src/lifecycle.rs:164:pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
|
||||||
|
crates/llm-worker/src/callback.rs:191:pub struct ToolUseBlockScope {
|
||||||
|
crates/llm-worker/src/callback.rs:212: pub fn on_stop(&mut self, f: impl FnMut(&ToolCall) + Send + Sync + 'static) {
|
||||||
|
crates/memory/src/lib.rs:21:pub use error::{LintError, LintWarning, MemoryError};
|
||||||
|
crates/pod-registry/src/lib.rs:28:pub use error::ScopeLockError;
|
||||||
|
crates/llm-worker/examples/record_test_fixtures/recorder.rs:23:pub struct SessionMetadata {
|
||||||
|
crates/pod-registry/src/mutate.rs:161:pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), ScopeLockError> {
|
||||||
|
crates/llm-worker/src/timeline/tool_call_collector.rs:30:pub struct ToolCallCollector {
|
||||||
|
crates/llm-worker/src/timeline/tool_call_collector.rs:44: pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||||
|
crates/llm-worker/src/timeline/tool_call_collector.rs:50: pub fn collected(&self) -> Vec<ToolCall> {
|
||||||
|
crates/pod-registry/src/conflict.rs:50:pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule) -> bool {
|
||||||
|
crates/memory/src/extract/tool.rs:92:pub fn write_extracted_tool(ctx: Arc<ExtractWorkerContext>) -> ToolDefinition {
|
||||||
|
crates/llm-worker/src/timeline/mod.rs:23:pub use tool_call_collector::ToolCallCollector;
|
||||||
|
crates/workflow/src/skill.rs:74: pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
|
||||||
|
crates/llm-worker/src/handler.rs:158:pub struct ToolUseBlockKind;
|
||||||
|
crates/llm-worker/src/handler.rs:165:pub enum ToolUseBlockEvent {
|
||||||
|
crates/llm-worker/src/handler.rs:173:pub struct ToolUseBlockStart {
|
||||||
|
crates/llm-worker/src/handler.rs:180:pub struct ToolUseBlockStop {
|
||||||
|
crates/tui/src/input.rs:63:pub struct WorkflowInvokeAtom {
|
||||||
|
crates/workflow/src/schema.rs:12:pub struct WorkflowFrontmatter {
|
||||||
|
crates/workflow/src/schema.rs:45:pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
|
||||||
|
crates/client/src/lib.rs:14:pub use pod_client::PodClient;
|
||||||
|
crates/workflow/src/workflow.rs:29:pub enum WorkflowSource {
|
||||||
|
crates/workflow/src/workflow.rs:50:pub struct WorkflowRecord {
|
||||||
|
crates/workflow/src/workflow.rs:93:pub struct WorkflowRegistry {
|
||||||
|
crates/workflow/src/workflow.rs:110: pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
|
||||||
|
crates/workflow/src/workflow.rs:114: pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
|
||||||
|
crates/workflow/src/workflow.rs:143: pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
|
||||||
|
crates/workflow/src/workflow.rs:165:pub enum WorkflowLoadError {
|
||||||
|
crates/workflow/src/workflow.rs:191:pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
|
||||||
|
crates/client/src/pod_client.rs:9:pub struct PodClient {
|
||||||
|
crates/workflow/src/scope.rs:10:pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||||
|
crates/tui/src/block.rs:98:pub struct ToolCallBlock {
|
||||||
|
crates/tui/src/block.rs:113:pub enum ToolCallState {
|
||||||
|
crates/workflow/src/error.rs:10:pub enum WorkflowLintError {
|
||||||
|
crates/memory/src/workspace.rs:90: pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
|
||||||
|
crates/workflow/src/lib.rs:10:pub use error::WorkflowLintError;
|
||||||
|
crates/workflow/src/lib.rs:12:pub use linter::{WorkflowLintReport, WorkflowLinter};
|
||||||
|
crates/workflow/src/lib.rs:13:pub use schema::{WorkflowFrontmatter, split_frontmatter};
|
||||||
|
crates/workflow/src/linter.rs:15:pub struct WorkflowLintReport {
|
||||||
|
crates/workflow/src/linter.rs:24: pub fn push_error(&mut self, err: WorkflowLintError) {
|
||||||
|
crates/workflow/src/linter.rs:30:pub struct WorkflowLinter {
|
||||||
|
crates/workflow/src/linter.rs:47: pub fn lint(&self, content: &str) -> WorkflowLintReport {
|
||||||
|
crates/tui/src/tool.rs:22:pub struct ToolRenderOutput {
|
||||||
|
crates/tui/src/app.rs:193: pub fn set_pod_status(&mut self, status: PodStatus) {
|
||||||
|
crates/tools/src/task.rs:464:pub fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
|
||||||
|
crates/tools/src/bash.rs:330:pub fn bash_tool(fs: ScopedFs, output_dir: PathBuf) -> ToolDefinition {
|
||||||
|
crates/llm-worker/src/llm_client/scheme/openai_chat/events.rs:68: pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||||
|
crates/manifest/src/scope.rs:22:pub struct Scope {
|
||||||
|
crates/manifest/src/scope.rs:37:pub enum ScopeError {
|
||||||
|
crates/manifest/src/scope.rs:58: pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
|
||||||
|
crates/manifest/src/scope.rs:151: pub fn allow_rules(&self) -> Vec<ScopeRule> {
|
||||||
|
crates/manifest/src/scope.rs:168: pub fn deny_rules(&self) -> Vec<ScopeRule> {
|
||||||
|
crates/manifest/src/scope.rs:320: pub fn new(scope: Scope) -> Self {
|
||||||
|
crates/manifest/src/scope.rs:331: pub fn load(&self) -> Guard<Arc<Scope>> {
|
||||||
|
crates/manifest/src/scope.rs:338: pub fn snapshot(&self) -> Arc<Scope> {
|
||||||
|
crates/manifest/src/scope.rs:347: pub fn update<F>(&self, f: F) -> Result<(), ScopeError>
|
||||||
|
crates/tools/src/lib.rs:35:pub use error::ToolsError;
|
||||||
|
crates/tools/src/lib.rs:39:pub use scoped_fs::ScopedFs;
|
||||||
|
crates/tools/src/tracker.rs:116: pub fn verify(&self, path: &Path, current_bytes: &[u8]) -> Result<(), ToolsError> {
|
||||||
|
crates/llm-worker/src/llm_client/types.rs:573: pub fn tool(mut self, tool: ToolDefinition) -> Self {
|
||||||
|
crates/llm-worker/src/llm_client/types.rs:638:pub struct ToolDefinition {
|
||||||
|
crates/tools/src/read.rs:117:pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||||
|
crates/tools/src/glob.rs:196:pub fn glob_tool(fs: ScopedFs) -> ToolDefinition {
|
||||||
|
crates/tools/src/grep.rs:106:pub fn grep_tool(fs: ScopedFs) -> ToolDefinition {
|
||||||
|
crates/llm-worker/src/llm_client/client.rs:39:pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||||
|
crates/tools/src/write.rs:78:pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||||
|
crates/manifest/src/lib.rs:19:pub use protocol::{Permission, ScopeRule};
|
||||||
|
crates/manifest/src/lib.rs:20:pub use scope::{Scope, ScopeError, SharedScope};
|
||||||
|
crates/manifest/src/lib.rs:35:pub struct PodManifest {
|
||||||
|
crates/manifest/src/lib.rs:90:pub struct MemoryConfig {
|
||||||
|
crates/manifest/src/lib.rs:153:pub struct PodMeta {
|
||||||
|
crates/manifest/src/lib.rs:223:pub struct ToolOutputLimits {
|
||||||
|
crates/manifest/src/lib.rs:295:pub struct ScopeConfig {
|
||||||
|
crates/manifest/src/lib.rs:307:pub struct SessionConfig {
|
||||||
|
crates/manifest/src/lib.rs:320:pub struct ToolPermissionConfig {
|
||||||
|
crates/manifest/src/lib.rs:328:pub struct ToolPermissionRule {
|
||||||
|
crates/manifest/src/lib.rs:341:pub enum ToolPermissionAction {
|
||||||
|
crates/tools/src/edit.rs:137:pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||||
|
crates/tools/src/error.rs:12:pub enum ToolsError {
|
||||||
|
crates/tools/src/scoped_fs.rs:34:pub struct ScopedFs {
|
||||||
|
crates/tools/src/scoped_fs.rs:67: pub fn new(scope: Scope, pwd: PathBuf) -> Self {
|
||||||
|
crates/tools/src/scoped_fs.rs:83: pub fn scope(&self) -> Arc<Scope> {
|
||||||
|
crates/tools/src/scoped_fs.rs:108: pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||||
|
crates/tools/src/scoped_fs.rs:160: pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
|
||||||
|
crates/manifest/src/config.rs:28:pub struct PodManifestConfig {
|
||||||
|
crates/manifest/src/config.rs:58:pub struct PodMetaConfig {
|
||||||
|
crates/manifest/src/config.rs:95:pub struct ToolOutputLimitsPartial {
|
||||||
|
crates/manifest/src/config.rs:109:pub struct SessionConfigPartial {
|
||||||
|
crates/manifest/src/config.rs:282: pub fn merge(self, upper: PodManifestConfig) -> Self {
|
||||||
|
crates/pod/src/controller.rs:34:pub struct PodHandle {
|
||||||
|
crates/pod/src/controller.rs:130:pub struct PodController;
|
||||||
|
crates/protocol/src/lib.rs:77:pub enum PodEvent {
|
||||||
|
crates/protocol/src/lib.rs:492:pub struct MemoryWorkerEvent {
|
||||||
|
crates/protocol/src/lib.rs:575:pub enum PodStatus {
|
||||||
|
crates/protocol/src/lib.rs:649:pub struct ScopeRule {
|
||||||
|
crates/manifest/src/cascade.rs:63:pub fn load_layer(path: &Path) -> Result<PodManifestConfig, LayerLoadError> {
|
||||||
|
crates/pod/src/ipc/event.rs:46:pub fn fire_and_forget(socket: Option<PathBuf>, event: PodEvent) {
|
||||||
|
crates/pod/src/ipc/event.rs:60:pub fn render_event(event: &PodEvent) -> String {
|
||||||
|
crates/pod/src/lib.rs:20:pub use controller::{PodController, PodHandle, ShutdownReceiver};
|
||||||
|
crates/pod/src/lib.rs:21:pub use factory::{FactoryError, PodFactory};
|
||||||
|
crates/pod/src/lib.rs:28:pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
||||||
|
crates/pod/src/lib.rs:29:pub use prompt::catalog::{CatalogError, PodPrompt, PromptCatalog};
|
||||||
|
crates/pod/src/lib.rs:32:pub use protocol::{ErrorCode, Event, Method, PodStatus, TurnResult};
|
||||||
|
crates/pod/src/lib.rs:36:pub use shared_state::PodSharedState;
|
||||||
|
crates/pod/src/ipc/notify_buffer.rs:68: pub fn push_pod_event(&self, event: PodEvent) {
|
||||||
|
crates/pod/src/shared_state.rs:10:pub struct WorkflowCandidate {
|
||||||
|
crates/pod/src/shared_state.rs:29:pub struct PodSharedState {
|
||||||
|
crates/pod/src/shared_state.rs:67: pub fn set_fs_view(&self, view: PodFsView) {
|
||||||
|
crates/pod/src/shared_state.rs:73: pub fn fs_view(&self) -> Option<&PodFsView> {
|
||||||
|
crates/pod/src/shared_state.rs:77: pub fn set_workflows(&self, workflows: Vec<WorkflowCandidate>) {
|
||||||
|
crates/pod/src/shared_state.rs:81: pub fn list_workflow_completions(&self, prefix: &str) -> Vec<WorkflowCandidate> {
|
||||||
|
crates/pod/src/shared_state.rs:111: pub fn set_status(&self, status: PodStatus) {
|
||||||
|
crates/pod/src/shared_state.rs:117: pub fn get_status(&self) -> PodStatus {
|
||||||
|
crates/pod/src/pod.rs:86: pub fn new(session_id: SessionId, segment_id: SegmentId, entries_written: usize) -> Arc<Self> {
|
||||||
|
crates/pod/src/pod.rs:100: pub fn session_id(&self) -> SessionId {
|
||||||
|
crates/pod/src/pod.rs:222:pub struct Pod<C: LlmClient, St: Store> {
|
||||||
|
crates/pod/src/pod.rs:690: pub fn session_id(&self) -> SessionId {
|
||||||
|
crates/pod/src/pod.rs:695: pub fn manifest(&self) -> &PodManifest {
|
||||||
|
crates/pod/src/pod.rs:713: pub fn scope_snapshot(&self) -> Arc<Scope> {
|
||||||
|
crates/pod/src/pod.rs:791: pub fn scope_change_sink(&self) -> Arc<dyn Fn(PodScopeSnapshot) + Send + Sync> {
|
||||||
|
crates/pod/src/pod.rs:1056: pub fn push_pod_event_notify(&self, event: protocol::PodEvent) {
|
||||||
|
crates/pod/src/pod.rs:4061:pub enum PodRunResult {
|
||||||
|
crates/pod/src/pod.rs:4332:pub enum PodError {
|
||||||
|
crates/pod/src/discovery.rs:33:pub struct PodDiscovery<St> {
|
||||||
|
crates/pod/src/discovery.rs:368:pub enum PodStateStatus {
|
||||||
|
crates/pod/src/discovery.rs:441:pub struct PodDetail {
|
||||||
|
crates/pod/src/discovery.rs:482:pub enum PodDiscoveryError {
|
||||||
|
crates/pod/src/discovery.rs:678:pub fn list_visible_pods_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
|
||||||
|
crates/pod/src/discovery.rs:699:pub fn inspect_pod_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
|
||||||
|
crates/pod/src/discovery.rs:716:pub fn attach_or_restore_pod_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
|
||||||
|
crates/pod/src/factory.rs:76:pub struct PodFactory {
|
||||||
|
crates/pod/src/factory.rs:189: pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result<Self, FactoryError> {
|
||||||
|
crates/pod/src/factory.rs:241: pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> {
|
||||||
|
crates/pod/src/hook.rs:75:pub struct ToolCallSummary {
|
||||||
|
crates/pod/src/hook.rs:90:pub struct ToolResultSummary {
|
||||||
|
crates/pod/src/fs_view.rs:40:pub struct PodFsView {
|
||||||
|
crates/pod/src/fs_view.rs:77: pub fn new(fs: ScopedFs) -> Self {
|
||||||
|
crates/pod/src/fs_view.rs:81: pub fn fs(&self) -> &ScopedFs {
|
||||||
|
crates/pod/src/workflow/mod.rs:17:pub enum WorkflowResolveError {
|
||||||
|
crates/pod/src/prompt/catalog.rs:61:pub enum PodPrompt {
|
||||||
|
crates/pod/src/prompt/catalog.rs:303: pub fn render(&self, prompt: PodPrompt, ctx: Value) -> Result<String, CatalogError> {
|
||||||
|
crates/pod/src/spawn/comm_tools.rs:94:pub fn send_to_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
|
crates/pod/src/spawn/comm_tools.rs:169:pub fn read_pod_output_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
|
crates/pod/src/spawn/comm_tools.rs:229:pub fn stop_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
|
crates/pod/src/spawn/comm_tools.rs:299:pub fn list_pods_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
1 client <- tui
|
||||||
|
2 daemon <-
|
||||||
|
3 lint-common <- memory, workflow
|
||||||
|
4 llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
|
||||||
|
5 llm-worker-macros <- llm-worker
|
||||||
|
6 manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
|
||||||
|
7 memory <- pod, workflow
|
||||||
|
8 pod <-
|
||||||
|
9 pod-registry <- pod, tui
|
||||||
|
10 protocol <- client, daemon, manifest, pod, session-store, tui
|
||||||
|
11 provider <- pod
|
||||||
|
12 session-metrics <- pod
|
||||||
|
13 session-store <- pod, pod-registry, session-metrics, tui
|
||||||
|
14 tools <- pod, tui
|
||||||
|
15 tui <-
|
||||||
|
16 workflow <- pod
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
client <- tui
|
||||||
|
daemon <-
|
||||||
|
lint-common <- memory, workflow
|
||||||
|
llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
|
||||||
|
llm-worker-macros <- llm-worker
|
||||||
|
manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
|
||||||
|
memory <- pod, workflow
|
||||||
|
pod <-
|
||||||
|
pod-registry <- pod, tui
|
||||||
|
protocol <- client, daemon, manifest, pod, session-store, tui
|
||||||
|
provider <- pod
|
||||||
|
session-metrics <- pod
|
||||||
|
session-store <- pod, pod-registry, session-metrics, tui
|
||||||
|
tools <- pod, tui
|
||||||
|
tui <-
|
||||||
|
workflow <- pod
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
crates/protocol/src/lib.rs:68:/// parent Controller applies variant-specific side effects (registry /
|
||||||
|
crates/protocol/src/lib.rs:118:/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
||||||
|
crates/protocol/src/lib.rs:120:/// send them through directly so the Pod side never has to re-parse a
|
||||||
|
crates/protocol/src/lib.rs:124:/// `Segment::Unknown`. Pod treats this the same as known-but-unresolved
|
||||||
|
crates/protocol/src/lib.rs:152: /// Unknown variant from a newer client. Pod treats this as an
|
||||||
|
crates/protocol/src/lib.rs:224: /// additional TUI / GUI instances show the same pending user line
|
||||||
|
crates/protocol/src/lib.rs:237: /// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||||
|
crates/protocol/src/lib.rs:394: /// as the JSON form of `session_store::LogEntry`. This is the
|
||||||
|
crates/protocol/src/lib.rs:419: /// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
|
||||||
|
crates/protocol/src/lib.rs:459: /// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||||
|
crates/llm-worker/src/llm_client/types.rs:523: /// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||||
|
crates/llm-worker/src/llm_client/types.rs:526: /// ほぼヒットしないため、pod 側で `SegmentId` を渡す運用を想定。
|
||||||
|
crates/llm-worker/src/llm_client/types.rs:529: /// `prompt_cache_key` を持たない provider は無視する。
|
||||||
|
crates/session-store/src/segment.rs:11:use crate::{SegmentId, SessionId};
|
||||||
|
crates/session-store/src/segment.rs:29:) -> Result<(SessionId, SegmentId), StoreError> {
|
||||||
|
crates/session-store/src/segment.rs:44: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:69: source_segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:71:) -> Result<SegmentId, StoreError> {
|
||||||
|
crates/session-store/src/segment.rs:96: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:109: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:146: segment_id: &mut SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:184: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:209: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:258: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:276: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:294: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:317: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:342: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:372: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:392: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:409: segment_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:432:) -> Result<(SessionId, SegmentId), StoreError> {
|
||||||
|
crates/session-store/src/segment.rs:466: source_id: SegmentId,
|
||||||
|
crates/session-store/src/segment.rs:468:) -> Result<SegmentId, StoreError> {
|
||||||
|
crates/session-store/src/segment.rs:511: segment_id: SegmentId,
|
||||||
47
work-items/open/20260528-131317-crate-boundary-audit/item.md
Normal file
47
work-items/open/20260528-131317-crate-boundary-audit/item.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
id: 20260528-131317-crate-boundary-audit
|
||||||
|
slug: crate-boundary-audit
|
||||||
|
title: Audit crate responsibility boundaries
|
||||||
|
status: open
|
||||||
|
kind: audit
|
||||||
|
priority: P2
|
||||||
|
labels: [architecture, crates]
|
||||||
|
created_at: 2026-05-28T13:13:17Z
|
||||||
|
updated_at: 2026-05-28T13:13:17Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The workspace has grown across multiple crates (`pod`, `protocol`, `llm-worker`, `manifest`, `client`, `tui`, `memory`, `workflow`, etc.). Before adding more orchestration and policy features, audit whether crate responsibilities, dependency direction, and public interfaces are still clean.
|
||||||
|
|
||||||
|
This is an architecture audit, not an implementation ticket. The output should be actionable findings: either concrete boundary violations to fix, or an explicit statement that the inspected area is acceptable.
|
||||||
|
|
||||||
|
The audit must also check code comments and documentation comments. Comments inside one crate should not explain or justify behavior primarily in terms of a downstream crate that depends on it. If such comments exist, record them because they can indicate inverted ownership or an interface that is leaking caller-specific concerns.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Inspect the Rust workspace at least for:
|
||||||
|
|
||||||
|
- crate dependency graph and suspicious dependency direction.
|
||||||
|
- public types/functions/modules whose names or contracts expose another crate's implementation details unnecessarily.
|
||||||
|
- code paths where a lower-level crate appears to know about higher-level orchestration, UI, or caller concerns.
|
||||||
|
- comments/doc-comments that mention another crate which depends on the current crate, especially when the comment describes why the dependent crate needs that behavior.
|
||||||
|
- duplicated interfaces or ad-hoc glue that should be owned by a clearer boundary.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- broad refactoring.
|
||||||
|
- formatting-only changes.
|
||||||
|
- changing dependency direction before findings are reviewed.
|
||||||
|
- rewriting comments unless a follow-up implementation ticket is explicitly created.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- A dependency/interface audit summary exists with concrete findings grouped by severity.
|
||||||
|
- The audit names files/modules/functions/comments involved in each finding.
|
||||||
|
- The audit distinguishes actual boundary problems from acceptable dependency-aware documentation.
|
||||||
|
- The audit specifically reports whether comments in crates refer to crates that depend on them.
|
||||||
|
- If no blocking issue is found, the audit explains why the current separation is acceptable.
|
||||||
|
- Follow-up implementation tickets are proposed only for findings that are specific and actionable.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T13:13:17Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
id: 20260528-152959-web-search-fetch-tools
|
||||||
|
slug: web-search-fetch-tools
|
||||||
|
title: Add WebSearch and WebFetch tools
|
||||||
|
status: open
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [tools, web, llm]
|
||||||
|
created_at: 2026-05-28T15:29:59Z
|
||||||
|
updated_at: 2026-05-28T15:29:59Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Insomnia currently has strong local filesystem / shell / memory tools, but the agent cannot directly consult current web information except through user-provided excerpts or shell commands. Add first-class WebSearch and WebFetch tools so the model can gather public web information through bounded, observable tool calls.
|
||||||
|
|
||||||
|
This should be implemented as normal built-in tools, not as hidden context injection. Tool calls and results must remain visible in history, subject to manifest permission policy, and bounded by output limits.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
|
||||||
|
- Add `WebSearch` tool.
|
||||||
|
- Input includes query string and optional result limit.
|
||||||
|
- Output returns structured results: title, URL, snippet/summary, source/search provider metadata where available.
|
||||||
|
- Search provider must be configurable. If no provider/API key is configured, the tool should fail with a clear diagnostic instead of falling back to scraping arbitrary search pages.
|
||||||
|
- Add `WebFetch` tool.
|
||||||
|
- Input includes URL and optional mode/limits.
|
||||||
|
- Output returns normalized text content plus metadata such as final URL, status, content type, title if available, and byte/token truncation indication.
|
||||||
|
- HTML should be converted to readable text. Non-text content should be rejected or summarized only when a safe explicit handler exists.
|
||||||
|
- Add manifest configuration for web tools.
|
||||||
|
- Enable/disable controls.
|
||||||
|
- Search provider/API key configuration.
|
||||||
|
- Fetch timeout, max response bytes, max output bytes/tokens, redirect limit.
|
||||||
|
- Allowed/denied URL schemes and host policy.
|
||||||
|
- Integrate with built-in tool registration and manifest permission policy.
|
||||||
|
- Web tools are normal tool calls and should go through the existing tool permission mechanism.
|
||||||
|
- No implicit network access should happen outside a tool call.
|
||||||
|
- Add security and reliability protections.
|
||||||
|
- Only `http`/`https` by default.
|
||||||
|
- Reject local/private/link-local/loopback addresses by default unless explicitly configured.
|
||||||
|
- Bound redirects and re-check final URLs.
|
||||||
|
- Bound download size and output size.
|
||||||
|
- Provide clear errors for timeout, DNS/network failure, unsupported content, blocked host/scheme, and truncation.
|
||||||
|
- Prompts/tool descriptions should tell the model when to use WebSearch vs WebFetch and that fetched content may be stale/untrusted.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `WebSearch` and `WebFetch` are registered built-in tools when enabled/configured.
|
||||||
|
- Tool schemas are typed and validated.
|
||||||
|
- Manifest docs/config examples describe how to enable/configure web tools.
|
||||||
|
- Permission policy can allow/deny/ask these tools like other tools.
|
||||||
|
- Tool results are bounded and visible in history; no hidden web context is injected.
|
||||||
|
- Unit tests cover input validation, disabled/unconfigured errors, URL policy, redirect/final URL policy, output truncation, and representative HTML-to-text conversion.
|
||||||
|
- At least one integration-style test uses a local test HTTP server or mock provider rather than the public internet.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p tools -p manifest -p pod`
|
||||||
|
- Relevant focused tests for tools/manifest.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Browser automation.
|
||||||
|
- Authenticated browsing / cookies / sessions.
|
||||||
|
- Javascript rendering.
|
||||||
|
- File downloads as attachments.
|
||||||
|
- Using arbitrary shell commands as the primary web access path.
|
||||||
|
- Hidden pre-request browsing or automatic web context injection.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-28T15:29:59Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
Loading…
Reference in New Issue
Block a user