feat: expose compaction as an internal worker
This commit is contained in:
+84
-27
@@ -281,11 +281,44 @@ impl Method {
|
|||||||
/// Presentation category for an Internal Worker exposed through its parent's
|
/// Presentation category for an Internal Worker exposed through its parent's
|
||||||
/// protocol stream. Internal Workers never become independently addressable
|
/// protocol stream. Internal Workers never become independently addressable
|
||||||
/// protocol subjects.
|
/// protocol subjects.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InternalWorkerKind {
|
pub enum InternalWorkerKind {
|
||||||
SubWorker,
|
SubWorker,
|
||||||
|
Service { kind: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable parent-owned lifecycle for one compaction run.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct CompactionLifecycle {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub compaction_id: String,
|
||||||
|
pub revision: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub internal_worker: Option<InternalWorkerRef>,
|
||||||
|
pub state: CompactionLifecycleState,
|
||||||
|
/// Milliseconds since the Unix epoch.
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ended_at_ms: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub new_segment_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompactionLifecycleState {
|
||||||
|
Running,
|
||||||
|
Done,
|
||||||
|
Failed,
|
||||||
|
Interrupted,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable presentation identity for one parent-owned Internal Worker session.
|
/// Stable presentation identity for one parent-owned Internal Worker session.
|
||||||
@@ -607,23 +640,18 @@ pub enum Event {
|
|||||||
/// This is not part of LLM history or prompt context; clients may display it
|
/// This is not part of LLM history or prompt context; clients may display it
|
||||||
/// briefly as operational status.
|
/// briefly as operational status.
|
||||||
MemoryWorker(MemoryWorkerEvent),
|
MemoryWorker(MemoryWorkerEvent),
|
||||||
/// Worker has started compacting the current session.
|
/// Worker has started compacting the current session, or bound the run to its
|
||||||
///
|
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||||
/// Fired immediately before a compaction run. Success is signalled by
|
CompactStart {
|
||||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
lifecycle: CompactionLifecycle,
|
||||||
/// Broadcast to all clients; not replayed to late subscribers.
|
|
||||||
CompactStart,
|
|
||||||
/// Compaction completed and the session was rotated.
|
|
||||||
///
|
|
||||||
/// `new_segment_id` is the UUID of the freshly created session that
|
|
||||||
/// replaced the old history.
|
|
||||||
CompactDone {
|
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
|
||||||
new_segment_id: uuid::Uuid,
|
|
||||||
},
|
},
|
||||||
/// Compaction failed. The session is unchanged.
|
/// Compaction completed and the session was rotated.
|
||||||
|
CompactDone {
|
||||||
|
lifecycle: CompactionLifecycle,
|
||||||
|
},
|
||||||
|
/// Compaction failed or was cancelled. The session is unchanged.
|
||||||
CompactFailed {
|
CompactFailed {
|
||||||
error: String,
|
lifecycle: CompactionLifecycle,
|
||||||
},
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
@@ -1732,45 +1760,74 @@ mod tests {
|
|||||||
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
|
||||||
|
CompactionLifecycle {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||||
|
revision: 1,
|
||||||
|
internal_worker: None,
|
||||||
|
state,
|
||||||
|
started_at_ms: 1_700_000_000_000,
|
||||||
|
ended_at_ms: None,
|
||||||
|
summary: None,
|
||||||
|
error: None,
|
||||||
|
new_segment_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_compact_start_roundtrip() {
|
fn event_compact_start_roundtrip() {
|
||||||
let event = Event::CompactStart;
|
let event = Event::CompactStart {
|
||||||
|
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
|
||||||
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
assert_eq!(json, r#"{"event":"compact_start"}"#);
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["event"], "compact_start");
|
||||||
|
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
assert!(matches!(decoded, Event::CompactStart));
|
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_compact_done_roundtrip() {
|
fn event_compact_done_roundtrip() {
|
||||||
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
||||||
let event = Event::CompactDone { new_segment_id: id };
|
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
|
||||||
|
lifecycle.new_segment_id = Some(id.to_string());
|
||||||
|
lifecycle.summary = Some("accepted summary".into());
|
||||||
|
let event = Event::CompactDone { lifecycle };
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "compact_done");
|
assert_eq!(parsed["event"], "compact_done");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["data"]["new_segment_id"],
|
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||||
);
|
);
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id),
|
Event::CompactDone { lifecycle } => {
|
||||||
|
assert_eq!(
|
||||||
|
lifecycle.new_segment_id.as_deref(),
|
||||||
|
Some(id.to_string().as_str())
|
||||||
|
)
|
||||||
|
}
|
||||||
other => panic!("expected CompactDone, got {other:?}"),
|
other => panic!("expected CompactDone, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_compact_failed_roundtrip() {
|
fn event_compact_failed_roundtrip() {
|
||||||
let event = Event::CompactFailed {
|
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||||
error: "provider 429".into(),
|
lifecycle.error = Some("provider 429".into());
|
||||||
};
|
let event = Event::CompactFailed { lifecycle };
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "compact_failed");
|
assert_eq!(parsed["event"], "compact_failed");
|
||||||
assert_eq!(parsed["data"]["error"], "provider 429");
|
assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::CompactFailed { error } => assert_eq!(error, "provider 429"),
|
Event::CompactFailed { lifecycle } => {
|
||||||
|
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
|
||||||
|
}
|
||||||
other => panic!("expected CompactFailed, got {other:?}"),
|
other => panic!("expected CompactFailed, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||||
WorkerStatus,
|
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
@@ -53,6 +53,8 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||||
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||||
|
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||||
|
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||||
|
|||||||
+48
-13
@@ -1342,13 +1342,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactStart => {
|
Event::CompactStart { .. } => {
|
||||||
|
if self.last_streaming_compact_mut().is_none() {
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Event::CompactDone { new_segment_id } => {
|
}
|
||||||
|
Event::CompactDone { lifecycle } => {
|
||||||
self.session_context_tokens = 0;
|
self.session_context_tokens = 0;
|
||||||
|
let new_segment_id = lifecycle
|
||||||
|
.new_segment_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||||
let elapsed_secs = match evt {
|
let elapsed_secs = match evt {
|
||||||
CompactEvent::Streaming { started_at } => {
|
CompactEvent::Streaming { started_at } => {
|
||||||
@@ -1367,7 +1374,10 @@ impl App {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactFailed { error } => {
|
Event::CompactFailed { lifecycle } => {
|
||||||
|
let error = lifecycle
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "compaction failed".to_string());
|
||||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||||
let elapsed_secs = match evt {
|
let elapsed_secs = match evt {
|
||||||
CompactEvent::Streaming { started_at } => {
|
CompactEvent::Streaming { started_at } => {
|
||||||
@@ -2486,7 +2496,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
|
|||||||
event,
|
event,
|
||||||
Event::Alert(_)
|
Event::Alert(_)
|
||||||
| Event::MemoryWorker(_)
|
| Event::MemoryWorker(_)
|
||||||
| Event::CompactStart
|
| Event::CompactStart { .. }
|
||||||
| Event::CompactDone { .. }
|
| Event::CompactDone { .. }
|
||||||
| Event::CompactFailed { .. }
|
| Event::CompactFailed { .. }
|
||||||
| Event::SegmentRotated { .. }
|
| Event::SegmentRotated { .. }
|
||||||
@@ -4076,13 +4086,34 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_compaction_lifecycle(
|
||||||
|
state: protocol::CompactionLifecycleState,
|
||||||
|
) -> protocol::CompactionLifecycle {
|
||||||
|
protocol::CompactionLifecycle {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "compaction-test".into(),
|
||||||
|
revision: 1,
|
||||||
|
internal_worker: None,
|
||||||
|
state,
|
||||||
|
started_at_ms: 1,
|
||||||
|
ended_at_ms: None,
|
||||||
|
summary: None,
|
||||||
|
error: None,
|
||||||
|
new_segment_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_done_replaces_live_block() {
|
fn compact_done_replaces_live_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart {
|
||||||
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
|
});
|
||||||
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||||
|
lifecycle.new_segment_id = Some(id.to_string());
|
||||||
|
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||||
|
|
||||||
assert_eq!(compact_block_count(&app), 1);
|
assert_eq!(compact_block_count(&app), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -4098,10 +4129,12 @@ mod completion_flow_tests {
|
|||||||
fn compact_failed_replaces_live_block() {
|
fn compact_failed_replaces_live_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart {
|
||||||
app.handle_worker_event(Event::CompactFailed {
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
error: "provider 429".into(),
|
|
||||||
});
|
});
|
||||||
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||||
|
lifecycle.error = Some("provider 429".into());
|
||||||
|
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||||
|
|
||||||
assert_eq!(compact_block_count(&app), 1);
|
assert_eq!(compact_block_count(&app), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -4117,7 +4150,9 @@ mod completion_flow_tests {
|
|||||||
fn shutdown_marks_live_compact_incomplete() {
|
fn shutdown_marks_live_compact_incomplete() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart {
|
||||||
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
|
});
|
||||||
app.handle_worker_event(Event::Shutdown);
|
app.handle_worker_event(Event::Shutdown);
|
||||||
|
|
||||||
assert!(app.quit);
|
assert!(app.quit);
|
||||||
@@ -4208,9 +4243,9 @@ mod completion_flow_tests {
|
|||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.session_context_tokens = 42_000;
|
app.session_context_tokens = 42_000;
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactDone {
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||||
new_segment_id: uuid::Uuid::nil(),
|
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||||
});
|
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||||
|
|
||||||
assert_eq!(app.session_context_tokens, 0);
|
assert_eq!(app.session_context_tokens, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
|
|||||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||||
|
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::fs_view::ReadRequirement;
|
use crate::feature::{
|
||||||
#[cfg(test)]
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||||
use crate::fs_view::slice_lines;
|
ToolDeclaration,
|
||||||
use crate::session_capture::{
|
|
||||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
|
|
||||||
};
|
};
|
||||||
|
use crate::fs_view::ReadRequirement;
|
||||||
|
|
||||||
/// Aggregated output of a compact worker run.
|
/// Aggregated output of a compact worker run.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
@@ -91,248 +90,19 @@ 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. \
|
||||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \
|
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
|
||||||
Paths must be absolute.";
|
|
||||||
|
|
||||||
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \
|
const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
|
||||||
context without injecting its contents. Use for files that are contextually relevant but \
|
the compacted context without injecting its contents. Use for files that are contextually \
|
||||||
whose current content the next session can fetch on demand.";
|
relevant but whose current content the next session can fetch on demand.";
|
||||||
|
|
||||||
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
|
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
|
||||||
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>>,
|
|
||||||
view: SessionCapture,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SearchSessionLogTool {
|
|
||||||
state: Arc<SessionLogToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for SearchSessionLogTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
|
||||||
) -> 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);
|
|
||||||
let limit = params
|
|
||||||
.limit
|
|
||||||
.unwrap_or(20)
|
|
||||||
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
|
||||||
let hits = self.state.view.search(&SearchOptions {
|
|
||||||
query: params.query.clone(),
|
|
||||||
kind: None,
|
|
||||||
tool_part: None,
|
|
||||||
tool_name: None,
|
|
||||||
limit: Some(limit),
|
|
||||||
min_entry_index: Some(offset as u64),
|
|
||||||
from: None,
|
|
||||||
through: None,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
let blocks = hits
|
|
||||||
.iter()
|
|
||||||
.map(|hit| {
|
|
||||||
let part = hit
|
|
||||||
.tool_part
|
|
||||||
.map(|part| format!(" {part:?}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tool = hit
|
|
||||||
.tool_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|name| format!(" {name}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!(
|
|
||||||
"[{} {}{}{} {:?}] {}\n{}",
|
|
||||||
hit.id,
|
|
||||||
hit.kind.as_str(),
|
|
||||||
part,
|
|
||||||
tool,
|
|
||||||
hit.entry_range,
|
|
||||||
hit.label,
|
|
||||||
hit.summary
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut content = blocks.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),
|
|
||||||
|
|
||||||
attachments: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReadSessionItemsTool {
|
|
||||||
state: Arc<SessionLogToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for ReadSessionItemsTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
|
||||||
) -> 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 detail = match mode {
|
|
||||||
SessionReadMode::Compact => ReadDetail::Compact,
|
|
||||||
SessionReadMode::Full => ReadDetail::Full,
|
|
||||||
};
|
|
||||||
let read = if offset >= end {
|
|
||||||
crate::session_capture::ReadResult {
|
|
||||||
entries: Vec::new(),
|
|
||||||
truncated: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.state.view.read(
|
|
||||||
ReadSelector::EntryRange([offset as u64, end.saturating_sub(1) as u64]),
|
|
||||||
ReadOptions {
|
|
||||||
include_tools: true,
|
|
||||||
tool_part: ToolPart::Both,
|
|
||||||
detail,
|
|
||||||
max_items: limit,
|
|
||||||
max_bytes: 48 * 1024,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut content = read
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.text.clone())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
let token_truncated =
|
|
||||||
truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
|
||||||
let truncated = read.truncated || token_truncated;
|
|
||||||
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),
|
|
||||||
|
|
||||||
attachments: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 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 {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
ctx: Arc<Mutex<CompactWorkerContext>>,
|
ctx: Arc<Mutex<CompactWorkerContext>>,
|
||||||
@@ -508,36 +278,63 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
#[derive(Clone)]
|
||||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
pub(crate) struct CompactionOutputFeature {
|
||||||
let state = Arc::new(SessionLogToolState { items, view });
|
session: Option<WorkdirSessionHandle>,
|
||||||
Arc::new(move || {
|
tracker: tools::Tracker,
|
||||||
let schema = schemars::schema_for!(SearchSessionParams);
|
context: Arc<Mutex<CompactWorkerContext>>,
|
||||||
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 {
|
impl CompactionOutputFeature {
|
||||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
pub(crate) fn new(
|
||||||
let state = Arc::new(SessionLogToolState { items, view });
|
session: Option<WorkdirSessionHandle>,
|
||||||
Arc::new(move || {
|
tracker: tools::Tracker,
|
||||||
let schema = schemars::schema_for!(ReadSessionParams);
|
context: Arc<Mutex<CompactWorkerContext>>,
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
) -> Self {
|
||||||
let meta = ToolMeta::new("read_session_items")
|
Self {
|
||||||
.description(READ_SESSION_DESCRIPTION)
|
session,
|
||||||
.input_schema(schema_value);
|
tracker,
|
||||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
context,
|
||||||
state: state.clone(),
|
}
|
||||||
});
|
}
|
||||||
(meta, tool)
|
}
|
||||||
})
|
|
||||||
|
impl FeatureModule for CompactionOutputFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
let descriptor = FeatureDescriptor::builtin("compaction-output", "Compaction Output")
|
||||||
|
.with_description("Read-only Workdir access and bounded compaction output decisions.")
|
||||||
|
.with_tool(ToolDeclaration::new("add_reference", REFERENCE_DESCRIPTION))
|
||||||
|
.with_tool(ToolDeclaration::new("write_summary", SUMMARY_DESCRIPTION));
|
||||||
|
if self.session.is_some() {
|
||||||
|
descriptor
|
||||||
|
.with_tool(ToolDeclaration::new("Read", "Read a Workdir file."))
|
||||||
|
.with_tool(ToolDeclaration::new("mark_read_required", MARK_DESCRIPTION))
|
||||||
|
} else {
|
||||||
|
descriptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
if let Some(session) = &self.session {
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"Read",
|
||||||
|
tools::read_tool(session.clone(), self.tracker.clone()),
|
||||||
|
))?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"mark_read_required",
|
||||||
|
mark_read_required_tool(session.clone(), self.context.clone()),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"add_reference",
|
||||||
|
add_reference_tool(self.context.clone()),
|
||||||
|
))?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"write_summary",
|
||||||
|
write_summary_tool(self.context.clone()),
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interceptor that monitors compact-worker context occupancy.
|
/// Interceptor that monitors compact-worker context occupancy.
|
||||||
@@ -810,53 +607,4 @@ mod tests {
|
|||||||
assert_eq!(guard.references.len(), 1);
|
assert_eq!(guard.references.len(), 1);
|
||||||
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 view = SessionCapture::new("test", (*items).clone());
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
|
||||||
state: Arc::new(SessionLogToolState { items, view }),
|
|
||||||
});
|
|
||||||
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
|
||||||
let out = tool.execute(&input, Default::default()).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 view = SessionCapture::new("test", (*items).clone());
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
|
||||||
state: Arc::new(SessionLogToolState { items, view }),
|
|
||||||
});
|
|
||||||
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
|
||||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
|
||||||
let content = out.content.unwrap();
|
|
||||||
|
|
||||||
assert!(content.contains("raw trace detail"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slice_lines_handles_offset_and_limit() {
|
|
||||||
let text = "a\nb\nc\nd";
|
|
||||||
assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd");
|
|
||||||
assert_eq!(slice_lines(text, 1, Some(2)), "b\nc");
|
|
||||||
assert_eq!(slice_lines(text, 10, None), "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ impl WorkerController {
|
|||||||
worker.attach_alerter(alerter.clone());
|
worker.attach_alerter(alerter.clone());
|
||||||
// Also hand the raw broadcast sender so Worker-internal operations
|
// Also hand the raw broadcast sender so Worker-internal operations
|
||||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||||
|
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||||
worker.attach_event_tx(event_tx.clone());
|
worker.attach_event_tx(event_tx.clone());
|
||||||
|
|
||||||
// Bash spills long outputs to a per-worker subdir under the runtime
|
// Bash spills long outputs to a per-worker subdir under the runtime
|
||||||
@@ -421,7 +422,6 @@ impl WorkerController {
|
|||||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||||
|
|
||||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||||
spawned_registry.attach_parent_protocol(event_tx.clone(), worker.session_id().to_string());
|
|
||||||
let fs_for_view = register_worker_tools(
|
let fs_for_view = register_worker_tools(
|
||||||
&mut worker,
|
&mut worker,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ where
|
|||||||
attempt.instance_id, attempt.checked_state_revision
|
attempt.instance_id, attempt.checked_state_revision
|
||||||
)),
|
)),
|
||||||
max_turns: Some(12),
|
max_turns: Some(12),
|
||||||
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &[
|
required_tools: &[
|
||||||
"ShowOverview",
|
"ShowOverview",
|
||||||
@@ -571,6 +572,7 @@ where
|
|||||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||||
filesystem: WorkerFilesystemAuthority::None,
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
scope: Scope::empty(),
|
scope: Scope::empty(),
|
||||||
|
workdir_session: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
match run_internal_worker(spec).await {
|
match run_internal_worker(spec).await {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
|
|||||||
pub workspace: WorkerWorkspaceContext,
|
pub workspace: WorkerWorkspaceContext,
|
||||||
pub filesystem: WorkerFilesystemAuthority,
|
pub filesystem: WorkerFilesystemAuthority,
|
||||||
pub scope: Scope,
|
pub scope: Scope,
|
||||||
|
/// Provider-bound session inherited in an attenuated form from the owner.
|
||||||
|
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct InternalWorkerSpec {
|
pub(crate) struct InternalWorkerSpec {
|
||||||
@@ -53,6 +55,7 @@ pub(crate) struct InternalWorkerSpec {
|
|||||||
pub input: String,
|
pub input: String,
|
||||||
pub cache_key: Option<String>,
|
pub cache_key: Option<String>,
|
||||||
pub max_turns: Option<u32>,
|
pub max_turns: Option<u32>,
|
||||||
|
pub engine_configurator: Option<Box<dyn FnOnce(&mut Engine<Box<dyn LlmClient>>) + Send>>,
|
||||||
pub features: FeatureRegistryBuilder,
|
pub features: FeatureRegistryBuilder,
|
||||||
pub required_tools: &'static [&'static str],
|
pub required_tools: &'static [&'static str],
|
||||||
pub authority: InternalWorkerAuthority,
|
pub authority: InternalWorkerAuthority,
|
||||||
@@ -102,6 +105,7 @@ where
|
|||||||
input,
|
input,
|
||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -128,7 +132,11 @@ where
|
|||||||
});
|
});
|
||||||
engine.set_cache_key(cache_key);
|
engine.set_cache_key(cache_key);
|
||||||
engine.set_max_turns(max_turns);
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
engine,
|
engine,
|
||||||
@@ -144,6 +152,9 @@ where
|
|||||||
identity: identity.clone(),
|
identity: identity.clone(),
|
||||||
history_entries: 0,
|
history_entries: 0,
|
||||||
})?;
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
|
|
||||||
let install_report = worker.install_features(features);
|
let install_report = worker.install_features(features);
|
||||||
let installed_tools = install_report.installed_tool_names();
|
let installed_tools = install_report.installed_tool_names();
|
||||||
@@ -250,7 +261,6 @@ impl InternalWorkerSessionStatus {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub(crate) enum InternalWorkerSessionError {
|
pub(crate) enum InternalWorkerSessionError {
|
||||||
#[cfg(test)]
|
|
||||||
#[error("failed to build internal Worker session: {message}")]
|
#[error("failed to build internal Worker session: {message}")]
|
||||||
Build { message: String },
|
Build { message: String },
|
||||||
#[error("internal Worker session is busy")]
|
#[error("internal Worker session is busy")]
|
||||||
@@ -410,7 +420,6 @@ impl InternalWorkerSessionHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
||||||
loop {
|
loop {
|
||||||
let notified = self.state_changed.notified();
|
let notified = self.state_changed.notified();
|
||||||
@@ -475,6 +484,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
input,
|
input,
|
||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -492,7 +502,11 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
});
|
});
|
||||||
engine.set_cache_key(cache_key);
|
engine.set_cache_key(cache_key);
|
||||||
engine.set_max_turns(max_turns);
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
engine,
|
engine,
|
||||||
@@ -505,6 +519,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
let install_report = worker.install_features(features);
|
let install_report = worker.install_features(features);
|
||||||
let installed_tools = install_report.installed_tool_names();
|
let installed_tools = install_report.installed_tool_names();
|
||||||
let install_failed = install_report
|
let install_failed = install_report
|
||||||
@@ -539,6 +556,102 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
spawn_prepared_internal_worker_session(worker, store, input, None).await
|
spawn_prepared_internal_worker_session(worker, store, input, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepare an observable Internal Worker from the same bounded spec as one-shot
|
||||||
|
/// helpers, but do not start its first turn. The owner must register the returned
|
||||||
|
/// handle before calling `send`, preserving the snapshot/live boundary.
|
||||||
|
pub(crate) fn prepare_internal_worker_from_spec(
|
||||||
|
spec: InternalWorkerSpec,
|
||||||
|
visibility: InternalWorkerVisibility,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<
|
||||||
|
dyn std::future::Future<
|
||||||
|
Output = Result<InternalWorkerSessionHandle, InternalWorkerSessionError>,
|
||||||
|
> + Send,
|
||||||
|
>,
|
||||||
|
> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let InternalWorkerSpec {
|
||||||
|
identity,
|
||||||
|
mut manifest,
|
||||||
|
client,
|
||||||
|
system_prompt,
|
||||||
|
input: _,
|
||||||
|
cache_key,
|
||||||
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
|
features,
|
||||||
|
required_tools,
|
||||||
|
authority,
|
||||||
|
} = spec;
|
||||||
|
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||||
|
manifest.feature = Default::default();
|
||||||
|
manifest.plugins = Default::default();
|
||||||
|
manifest.mcp = Default::default();
|
||||||
|
manifest.skills = None;
|
||||||
|
manifest.compaction = None;
|
||||||
|
manifest.memory = None;
|
||||||
|
|
||||||
|
let mut engine = Engine::new(client).system_prompt(system_prompt);
|
||||||
|
engine.set_cache_key(cache_key);
|
||||||
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
engine,
|
||||||
|
store.clone(),
|
||||||
|
authority.workspace,
|
||||||
|
authority.filesystem,
|
||||||
|
authority.scope,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
|
message: source.to_string(),
|
||||||
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
|
let install_report = worker.install_features(features);
|
||||||
|
let installed_tools = install_report.installed_tool_names();
|
||||||
|
let install_failed = install_report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.any(|report| !report.installed);
|
||||||
|
let missing = required_tools
|
||||||
|
.iter()
|
||||||
|
.filter(|required| {
|
||||||
|
!installed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|installed| installed == **required)
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if install_failed || !missing.is_empty() {
|
||||||
|
let diagnostics = install_report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.flat_map(|report| report.diagnostics.iter())
|
||||||
|
.map(|diagnostic| diagnostic.message.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
return Err(InternalWorkerSessionError::Build {
|
||||||
|
message: format!(
|
||||||
|
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::pin(prepare_internal_worker_session(
|
||||||
|
worker, store, visibility, None, None,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
|
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
|
||||||
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1076,12 +1189,14 @@ permission = "write"
|
|||||||
input: "input".to_string(),
|
input: "input".to_string(),
|
||||||
cache_key: Some("internal-test".to_string()),
|
cache_key: Some("internal-test".to_string()),
|
||||||
max_turns: Some(1),
|
max_turns: Some(1),
|
||||||
|
engine_configurator: None,
|
||||||
features: FeatureRegistryBuilder::new(),
|
features: FeatureRegistryBuilder::new(),
|
||||||
required_tools,
|
required_tools,
|
||||||
authority: InternalWorkerAuthority {
|
authority: InternalWorkerAuthority {
|
||||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||||
filesystem: WorkerFilesystemAuthority::None,
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
scope: Scope::empty(),
|
scope: Scope::empty(),
|
||||||
|
workdir_session: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ pub(crate) struct SearchHit {
|
|||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub(crate) enum ReadSelector<'a> {
|
pub(crate) enum ReadSelector<'a> {
|
||||||
Id(&'a str),
|
Id(&'a str),
|
||||||
|
#[cfg(test)]
|
||||||
EntryRange([u64; 2]),
|
EntryRange([u64; 2]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +419,7 @@ impl SessionCapture {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| entry.id.as_str() == id)
|
.filter(|entry| entry.id.as_str() == id)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
#[cfg(test)]
|
||||||
ReadSelector::EntryRange([start, end]) => self
|
ReadSelector::EntryRange([start, end]) => self
|
||||||
.index
|
.index
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Parent-owned registry of direct Internal SubWorker sessions.
|
//! Parent-owned registry of direct Internal Worker sessions.
|
||||||
//!
|
//!
|
||||||
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
|
//! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such as
|
||||||
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
|
//! compaction insert parent-visible service handles without joining the model-facing
|
||||||
|
//! List/Send/Stop surface. Internal children are not persisted, restored, discovered as
|
||||||
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
|
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
|
||||||
//! child records only to reclaim their delegated scope and clear obsolete metadata.
|
//! child records only to reclaim their delegated scope and clear obsolete metadata.
|
||||||
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
||||||
@@ -177,6 +178,52 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parent-visible service Internal Worker. Unlike a SubWorker this record has no
|
||||||
|
/// delegated scope, model-facing control name, or stop-summary authority.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct InternalServiceWorkerRecord {
|
||||||
|
pub service_kind: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub session: InternalWorkerSessionHandle,
|
||||||
|
protocol_revision: Arc<AtomicU64>,
|
||||||
|
protocol_emit_lock: Arc<Mutex<()>>,
|
||||||
|
protocol_terminal: Arc<AtomicBool>,
|
||||||
|
forwarding_started: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalServiceWorkerRecord {
|
||||||
|
pub(crate) fn new(
|
||||||
|
service_kind: impl Into<String>,
|
||||||
|
display_name: impl Into<String>,
|
||||||
|
session: InternalWorkerSessionHandle,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
service_kind: service_kind.into(),
|
||||||
|
display_name: display_name.into(),
|
||||||
|
session,
|
||||||
|
protocol_revision: Arc::new(AtomicU64::new(0)),
|
||||||
|
protocol_emit_lock: Arc::new(Mutex::new(())),
|
||||||
|
protocol_terminal: Arc::new(AtomicBool::new(false)),
|
||||||
|
forwarding_started: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_ref(&self, parent_session_id: Option<String>) -> InternalWorkerRef {
|
||||||
|
InternalWorkerRef {
|
||||||
|
session_id: self.session.session_id_string(),
|
||||||
|
name: self.display_name.clone(),
|
||||||
|
parent_session_id,
|
||||||
|
kind: InternalWorkerKind::Service {
|
||||||
|
kind: self.service_kind.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_revision(&self) -> u64 {
|
||||||
|
self.protocol_revision.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct InternalSpawnReservation {
|
pub(crate) struct InternalSpawnReservation {
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
|
|||||||
|
|
||||||
pub struct SpawnedWorkerRegistry {
|
pub struct SpawnedWorkerRegistry {
|
||||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||||
|
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||||
parent_scope: Option<SharedScope>,
|
parent_scope: Option<SharedScope>,
|
||||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||||
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnedWorkerRegistry {
|
impl SpawnedWorkerRegistry {
|
||||||
|
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
parent_scope: None,
|
||||||
|
parent_protocol: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Empty registry used by tests and non-spawning projections.
|
/// Empty registry used by tests and non-spawning projections.
|
||||||
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
|
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
|
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope: Some(parent_scope),
|
parent_scope: Some(parent_scope),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
Ok(SpawnedWorkerRegistryLoad {
|
Ok(SpawnedWorkerRegistryLoad {
|
||||||
registry: Arc::new(Self {
|
registry: Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope,
|
parent_scope,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
|
|||||||
for record in self.internal_records.lock().unwrap().clone() {
|
for record in self.internal_records.lock().unwrap().clone() {
|
||||||
self.start_protocol_forwarding(record);
|
self.start_protocol_forwarding(record);
|
||||||
}
|
}
|
||||||
|
for record in self.service_records.lock().unwrap().clone() {
|
||||||
|
self.start_service_protocol_forwarding(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a parent-visible service Internal Worker before its first turn.
|
||||||
|
pub(crate) fn attach_service(
|
||||||
|
&self,
|
||||||
|
record: InternalServiceWorkerRecord,
|
||||||
|
) -> io::Result<InternalWorkerRef> {
|
||||||
|
let parent_session_id = self
|
||||||
|
.parent_protocol
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, id)| id.clone());
|
||||||
|
let worker_ref = record.protocol_ref(parent_session_id);
|
||||||
|
let session_id = record.session.session_id_string();
|
||||||
|
let mut records = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
|
||||||
|
if records
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.session.session_id_string() == session_id)
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"internal service Worker is already registered",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
records.push(record.clone());
|
||||||
|
drop(records);
|
||||||
|
self.start_service_protocol_forwarding(record);
|
||||||
|
Ok(worker_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and remove one parent-owned service Worker. This is host-only and is
|
||||||
|
/// intentionally separate from the SubWorker control surface.
|
||||||
|
pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result<bool> {
|
||||||
|
let record = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.session.session_id_string() == session_id)
|
||||||
|
.cloned();
|
||||||
|
let Some(record) = record else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.stop()
|
||||||
|
.await
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
self.remove_service(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one service Worker and emit the terminal projection fence.
|
||||||
|
pub(crate) fn remove_service(&self, session_id: &str) -> io::Result<bool> {
|
||||||
|
let removed = {
|
||||||
|
let mut records = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.position(|record| record.session.session_id_string() == session_id)
|
||||||
|
.map(|index| records.remove(index))
|
||||||
|
};
|
||||||
|
if let Some(record) = removed {
|
||||||
|
self.publish_service_removal(&record);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_service_protocol_forwarding(&self, record: InternalServiceWorkerRecord) {
|
||||||
|
if record.session.visibility() != InternalWorkerVisibility::ParentClient
|
||||||
|
|| record.forwarding_started.swap(true, Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
|
||||||
|
else {
|
||||||
|
record.forwarding_started.store(false, Ordering::Release);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let worker = record.protocol_ref(Some(parent_session_id));
|
||||||
|
let protocol_revision = record.protocol_revision.clone();
|
||||||
|
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||||
|
let protocol_terminal = record.protocol_terminal.clone();
|
||||||
|
let mut child_rx = record.session.subscribe_events();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match child_rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
let shutdown = matches!(event, Event::Shutdown);
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision,
|
||||||
|
event: Box::new(event),
|
||||||
|
});
|
||||||
|
if shutdown {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision,
|
||||||
|
event: Box::new(Event::Error {
|
||||||
|
code: protocol::ErrorCode::Internal,
|
||||||
|
message: format!(
|
||||||
|
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
|
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
|
||||||
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(_, id)| id.clone());
|
.map(|(_, id)| id.clone());
|
||||||
self.internal_records
|
let mut snapshots = self
|
||||||
|
.internal_records
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
||||||
.map(|record| {
|
.map(|record| {
|
||||||
let snapshot = record.session.protocol_snapshot();
|
internal_worker_snapshot(
|
||||||
InternalWorkerSnapshot {
|
record.protocol_ref(parent_session_id.clone()),
|
||||||
worker: record.protocol_ref(parent_session_id.clone()),
|
record.protocol_revision(),
|
||||||
revision: record.protocol_revision(),
|
&record.session,
|
||||||
entries: snapshot
|
)
|
||||||
.entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|entry| serde_json::to_value(entry).ok())
|
|
||||||
.collect(),
|
|
||||||
status: snapshot.status,
|
|
||||||
error: snapshot.error,
|
|
||||||
in_flight: snapshot.in_flight,
|
|
||||||
internal_workers: snapshot.internal_workers,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
|
snapshots.extend(
|
||||||
|
self.service_records
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|record| {
|
||||||
|
record.session.visibility() == InternalWorkerVisibility::ParentClient
|
||||||
|
})
|
||||||
|
.map(|record| {
|
||||||
|
internal_worker_snapshot(
|
||||||
|
record.protocol_ref(parent_session_id.clone()),
|
||||||
|
record.protocol_revision(),
|
||||||
|
&record.session,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
|
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
|
||||||
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
|
|||||||
revision,
|
revision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) {
|
||||||
|
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _emit_guard = record
|
||||||
|
.protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
record.protocol_terminal.store(true, Ordering::Release);
|
||||||
|
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorkerRemoved {
|
||||||
|
worker: record.protocol_ref(Some(parent_session_id)),
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_worker_snapshot(
|
||||||
|
worker: InternalWorkerRef,
|
||||||
|
revision: u64,
|
||||||
|
session: &InternalWorkerSessionHandle,
|
||||||
|
) -> InternalWorkerSnapshot {
|
||||||
|
let snapshot = session.protocol_snapshot();
|
||||||
|
InternalWorkerSnapshot {
|
||||||
|
worker,
|
||||||
|
revision,
|
||||||
|
entries: snapshot
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| serde_json::to_value(entry).ok())
|
||||||
|
.collect(),
|
||||||
|
status: snapshot.status,
|
||||||
|
error: snapshot.error,
|
||||||
|
in_flight: snapshot.in_flight,
|
||||||
|
internal_workers: snapshot.internal_workers,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SpawnedWorkerRegistry {
|
impl Drop for SpawnedWorkerRegistry {
|
||||||
@@ -813,6 +1062,63 @@ mod tests {
|
|||||||
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
|
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn service_worker_is_parent_visible_but_not_subworker_controllable() {
|
||||||
|
let registry = registry();
|
||||||
|
let (parent_tx, mut parent_rx) = broadcast::channel(16);
|
||||||
|
registry.attach_parent_protocol(parent_tx, "parent-session".into());
|
||||||
|
let (session, child_tx) =
|
||||||
|
test_internal_worker_session(InternalWorkerVisibility::ParentClient);
|
||||||
|
let session_id = session.session_id_string();
|
||||||
|
|
||||||
|
let worker_ref = registry
|
||||||
|
.attach_service(InternalServiceWorkerRecord::new(
|
||||||
|
"compaction",
|
||||||
|
"Compaction",
|
||||||
|
session,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
worker_ref.kind,
|
||||||
|
InternalWorkerKind::Service { ref kind } if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(registry.list_internal().is_empty());
|
||||||
|
|
||||||
|
child_tx
|
||||||
|
.send(Event::TextDone {
|
||||||
|
text: "summary candidate".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
event,
|
||||||
|
Event::InternalWorker { worker, revision: 1, event }
|
||||||
|
if worker.session_id == session_id
|
||||||
|
&& matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction")
|
||||||
|
&& matches!(*event, Event::TextDone { ref text } if text == "summary candidate")
|
||||||
|
));
|
||||||
|
let snapshots = registry.internal_worker_snapshots();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
snapshots[0].worker.kind,
|
||||||
|
InternalWorkerKind::Service { ref kind } if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(registry.remove_service(&session_id).unwrap());
|
||||||
|
let removed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
removed,
|
||||||
|
Event::InternalWorkerRemoved { worker, revision: 2 }
|
||||||
|
if worker.session_id == session_id
|
||||||
|
));
|
||||||
|
assert!(registry.internal_worker_snapshots().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn service_private_internal_output_is_never_disclosed() {
|
async fn service_private_internal_output_is_never_disclosed() {
|
||||||
let registry = registry();
|
let registry = registry();
|
||||||
|
|||||||
+275
-123
@@ -46,12 +46,11 @@ use crate::hook::{
|
|||||||
};
|
};
|
||||||
use crate::in_flight::InFlightEvents;
|
use crate::in_flight::InFlightEvents;
|
||||||
use crate::internal_worker::{
|
use crate::internal_worker::{
|
||||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, run_internal_worker,
|
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, InternalWorkerVisibility,
|
||||||
run_internal_worker_with_cancel_sender,
|
prepare_internal_worker_from_spec, run_internal_worker, run_internal_worker_with_cancel_sender,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||||
const COMPACTION_BLOCK_ID: &str = "compact";
|
|
||||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
||||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
||||||
|
|
||||||
@@ -76,7 +75,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, Event, RewindSummary,
|
||||||
|
RewindTarget, RewindTargetId, Segment,
|
||||||
};
|
};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -971,6 +971,9 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
/// notifications, events sent here are NOT replayed to clients that
|
/// notifications, events sent here are NOT replayed to clients that
|
||||||
/// connect after the fact — they are fire-and-forget broadcasts.
|
/// connect after the fact — they are fire-and-forget broadcasts.
|
||||||
event_tx: Option<broadcast::Sender<Event>>,
|
event_tx: Option<broadcast::Sender<Event>>,
|
||||||
|
/// Parent-owned projection/control boundary for observable Internal service Workers.
|
||||||
|
/// Service Workers are never exposed through the model-facing SubWorker control surface.
|
||||||
|
internal_worker_registry: Option<Arc<crate::spawn::registry::SpawnedWorkerRegistry>>,
|
||||||
in_flight: Option<InFlightEvents>,
|
in_flight: Option<InFlightEvents>,
|
||||||
/// Monotonic counter incremented by worker event bridges when an
|
/// Monotonic counter incremented by worker event bridges when an
|
||||||
/// assistant-side execution artifact becomes visible to clients before
|
/// assistant-side execution artifact becomes visible to clients before
|
||||||
@@ -1115,6 +1118,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
|||||||
feature_instructions: self.feature_instructions.clone(),
|
feature_instructions: self.feature_instructions.clone(),
|
||||||
alerter: self.alerter.clone(),
|
alerter: self.alerter.clone(),
|
||||||
event_tx: self.event_tx.clone(),
|
event_tx: self.event_tx.clone(),
|
||||||
|
internal_worker_registry: self.internal_worker_registry.clone(),
|
||||||
in_flight: self.in_flight.clone(),
|
in_flight: self.in_flight.clone(),
|
||||||
ai_activity_counter: self.ai_activity_counter.clone(),
|
ai_activity_counter: self.ai_activity_counter.clone(),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -1315,6 +1319,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
feature_instructions: Vec::new(),
|
feature_instructions: Vec::new(),
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -1957,9 +1962,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
/// Worker-internal operations (currently: compaction) can surface
|
/// Worker-internal operations (currently: compaction) can surface
|
||||||
/// progress to connected clients.
|
/// progress to connected clients.
|
||||||
pub fn attach_event_tx(&mut self, event_tx: broadcast::Sender<Event>) {
|
pub fn attach_event_tx(&mut self, event_tx: broadcast::Sender<Event>) {
|
||||||
|
let session_id = self.session_id().to_string();
|
||||||
|
let registry = self.internal_worker_registry.get_or_insert_with(
|
||||||
|
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
||||||
|
);
|
||||||
|
registry.attach_parent_protocol(event_tx.clone(), session_id);
|
||||||
self.event_tx = Some(event_tx);
|
self.event_tx = Some(event_tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attach_internal_worker_registry(
|
||||||
|
&mut self,
|
||||||
|
registry: Arc<crate::spawn::registry::SpawnedWorkerRegistry>,
|
||||||
|
) {
|
||||||
|
self.internal_worker_registry = Some(registry);
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared activity counter incremented by worker event bridges when any
|
/// Shared activity counter incremented by worker event bridges when any
|
||||||
/// assistant-side output is surfaced before history persistence.
|
/// assistant-side output is surfaced before history persistence.
|
||||||
pub fn ai_activity_counter(&self) -> Arc<AtomicUsize> {
|
pub fn ai_activity_counter(&self) -> Arc<AtomicUsize> {
|
||||||
@@ -2895,52 +2912,46 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.map_err(WorkerError::Engine)
|
.map_err(WorkerError::Engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_compaction_block(
|
fn persist_compaction_lifecycle(
|
||||||
&mut self,
|
&mut self,
|
||||||
state: &str,
|
lifecycle: &CompactionLifecycle,
|
||||||
message: &str,
|
|
||||||
error: Option<&str>,
|
|
||||||
new_segment_id: Option<SegmentId>,
|
|
||||||
) -> Result<(), WorkerError> {
|
) -> Result<(), WorkerError> {
|
||||||
let payload = serde_json::json!({
|
|
||||||
"kind": "compaction_block",
|
|
||||||
"schema_version": 1,
|
|
||||||
"block_id": COMPACTION_BLOCK_ID,
|
|
||||||
"state": state,
|
|
||||||
"message": message,
|
|
||||||
"error": error,
|
|
||||||
"new_segment_id": new_segment_id.map(|id| id.to_string()),
|
|
||||||
});
|
|
||||||
Ok(self.commit_entry(LogEntry::Extension {
|
Ok(self.commit_entry(LogEntry::Extension {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
domain: COMPACTION_EXTENSION_DOMAIN.into(),
|
domain: COMPACTION_EXTENSION_DOMAIN.into(),
|
||||||
payload,
|
payload: serde_json::to_value(lifecycle).map_err(|error| {
|
||||||
|
WorkerError::InvalidState(format!(
|
||||||
|
"serialize compaction lifecycle {}: {error}",
|
||||||
|
lifecycle.compaction_id
|
||||||
|
))
|
||||||
|
})?,
|
||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_and_send_compact_start(&mut self) -> Result<(), WorkerError> {
|
fn persist_and_send_compact_start(
|
||||||
self.persist_compaction_block("running", "Compacting…", None, None)?;
|
&mut self,
|
||||||
self.send_event(Event::CompactStart);
|
lifecycle: CompactionLifecycle,
|
||||||
|
) -> Result<(), WorkerError> {
|
||||||
|
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||||
|
self.send_event(Event::CompactStart { lifecycle });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_and_send_compact_done(
|
fn persist_and_send_compact_done(
|
||||||
&mut self,
|
&mut self,
|
||||||
new_segment_id: SegmentId,
|
lifecycle: CompactionLifecycle,
|
||||||
) -> Result<(), WorkerError> {
|
) -> Result<(), WorkerError> {
|
||||||
self.persist_compaction_block("done", "Compacted.", None, Some(new_segment_id))?;
|
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||||
self.send_event(Event::CompactDone { new_segment_id });
|
self.send_event(Event::CompactDone { lifecycle });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_and_send_compact_failed(&mut self, error: String) -> Result<(), WorkerError> {
|
fn persist_and_send_compact_failed(
|
||||||
self.persist_compaction_block(
|
&mut self,
|
||||||
"failed",
|
lifecycle: CompactionLifecycle,
|
||||||
&format!("Compact failed: {error}"),
|
) -> Result<(), WorkerError> {
|
||||||
Some(error.as_str()),
|
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||||
None,
|
self.send_event(Event::CompactFailed { lifecycle });
|
||||||
)?;
|
|
||||||
self.send_event(Event::CompactFailed { error });
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2969,14 +2980,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.map(|s| s.retained_tokens())
|
.map(|s| s.retained_tokens())
|
||||||
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
||||||
|
|
||||||
self.persist_and_send_compact_start()?;
|
|
||||||
match self.compact(retained).await {
|
match self.compact(retained).await {
|
||||||
Ok(new_segment_id) => {
|
Ok(new_segment_id) => {
|
||||||
info!(
|
info!(
|
||||||
new_segment_id = %new_segment_id,
|
new_segment_id = %new_segment_id,
|
||||||
"Compaction succeeded, resuming execution"
|
"Compaction succeeded, resuming execution"
|
||||||
);
|
);
|
||||||
self.persist_and_send_compact_done(new_segment_id)?;
|
|
||||||
if let Some(ref state) = self.compact_state {
|
if let Some(ref state) = self.compact_state {
|
||||||
state.record_compact_success();
|
state.record_compact_success();
|
||||||
}
|
}
|
||||||
@@ -2984,7 +2993,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Compaction failed during run");
|
warn!(error = %e, "Compaction failed during run");
|
||||||
self.persist_and_send_compact_failed(e.to_string())?;
|
|
||||||
self.alert(
|
self.alert(
|
||||||
AlertLevel::Error,
|
AlertLevel::Error,
|
||||||
AlertSource::Compactor,
|
AlertSource::Compactor,
|
||||||
@@ -3017,45 +3025,16 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let retained = state.retained_tokens();
|
let retained = state.retained_tokens();
|
||||||
if let Err(err) = self.persist_and_send_compact_start() {
|
|
||||||
warn!(error = %err, "failed to persist proactive compact start");
|
|
||||||
self.alert(
|
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::Compactor,
|
|
||||||
format!("pre-run compaction not started: failed to persist status block: {err}"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match self.compact(retained).await {
|
match self.compact(retained).await {
|
||||||
Ok(new_segment_id) => {
|
Ok(new_segment_id) => {
|
||||||
info!(
|
info!(
|
||||||
new_segment_id = %new_segment_id,
|
new_segment_id = %new_segment_id,
|
||||||
"Proactive pre-run compaction succeeded"
|
"Proactive pre-run compaction succeeded"
|
||||||
);
|
);
|
||||||
if let Err(err) = self.persist_and_send_compact_done(new_segment_id) {
|
|
||||||
warn!(error = %err, "failed to persist proactive compact completion");
|
|
||||||
self.alert(
|
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::Compactor,
|
|
||||||
format!(
|
|
||||||
"pre-run compaction completed but status block was not persisted: {err}"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
state.record_compact_success();
|
state.record_compact_success();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Proactive pre-run compaction failed");
|
warn!(error = %e, "Proactive pre-run compaction failed");
|
||||||
if let Err(err) = self.persist_and_send_compact_failed(e.to_string()) {
|
|
||||||
warn!(error = %err, "failed to persist proactive compact failure");
|
|
||||||
self.alert(
|
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::Compactor,
|
|
||||||
format!(
|
|
||||||
"pre-run compaction failed and status block was not persisted: {err}"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.alert(
|
self.alert(
|
||||||
AlertLevel::Warn,
|
AlertLevel::Warn,
|
||||||
AlertSource::Compactor,
|
AlertSource::Compactor,
|
||||||
@@ -3113,11 +3092,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.join_memory_task().await;
|
self.join_memory_task().await;
|
||||||
self.persist_and_send_compact_start()?;
|
|
||||||
match self.compact(retained).await {
|
match self.compact(retained).await {
|
||||||
Ok(new_segment_id) => {
|
Ok(new_segment_id) => {
|
||||||
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
|
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
|
||||||
self.persist_and_send_compact_done(new_segment_id)?;
|
|
||||||
if let Some(ref state) = state {
|
if let Some(ref state) = state {
|
||||||
state.record_compact_success();
|
state.record_compact_success();
|
||||||
}
|
}
|
||||||
@@ -3125,7 +3102,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Manual compaction failed");
|
warn!(error = %e, "Manual compaction failed");
|
||||||
self.persist_and_send_compact_failed(e.to_string())?;
|
|
||||||
self.alert(
|
self.alert(
|
||||||
AlertLevel::Error,
|
AlertLevel::Error,
|
||||||
AlertSource::Compactor,
|
AlertSource::Compactor,
|
||||||
@@ -3262,20 +3238,79 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact the current session by summarising history via a
|
/// Runs one parent-owned observable compaction service and returns the new
|
||||||
/// disposable Engine, then replacing history with
|
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
|
||||||
/// `[summary, ...recent_turns]` in a new Segment of the same Session.
|
|
||||||
///
|
|
||||||
/// The summary Engine uses:
|
|
||||||
/// - `compaction.model` from the manifest if configured, or
|
|
||||||
/// - a clone of the main LlmClient via `clone_boxed()`.
|
|
||||||
///
|
|
||||||
/// Returns the new Segment ID. The Worker keeps its Session ID.
|
|
||||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
||||||
|
let mut lifecycle = CompactionLifecycle {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
revision: 1,
|
||||||
|
internal_worker: None,
|
||||||
|
state: CompactionLifecycleState::Running,
|
||||||
|
started_at_ms: segment_log::now_millis(),
|
||||||
|
ended_at_ms: None,
|
||||||
|
summary: None,
|
||||||
|
error: None,
|
||||||
|
new_segment_id: None,
|
||||||
|
};
|
||||||
|
self.persist_and_send_compact_start(lifecycle.clone())?;
|
||||||
|
match self.compact_impl(retained_tokens, &mut lifecycle).await {
|
||||||
|
Ok((new_segment_id, summary)) => {
|
||||||
|
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||||
|
lifecycle.state = CompactionLifecycleState::Done;
|
||||||
|
lifecycle.ended_at_ms = Some(segment_log::now_millis());
|
||||||
|
lifecycle.summary = Some(summary);
|
||||||
|
lifecycle.new_segment_id = Some(new_segment_id.to_string());
|
||||||
|
let terminal = self.persist_and_send_compact_done(lifecycle.clone());
|
||||||
|
self.release_compaction_service(&lifecycle).await;
|
||||||
|
terminal?;
|
||||||
|
Ok(new_segment_id)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||||
|
lifecycle.state = if matches!(error, WorkerError::CompactCancelled) {
|
||||||
|
CompactionLifecycleState::Interrupted
|
||||||
|
} else {
|
||||||
|
CompactionLifecycleState::Failed
|
||||||
|
};
|
||||||
|
lifecycle.ended_at_ms = Some(segment_log::now_millis());
|
||||||
|
lifecycle.error = Some(error.to_string().chars().take(2_000).collect());
|
||||||
|
let terminal = self.persist_and_send_compact_failed(lifecycle.clone());
|
||||||
|
self.release_compaction_service(&lifecycle).await;
|
||||||
|
terminal?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_compaction_service(&self, lifecycle: &CompactionLifecycle) {
|
||||||
|
let Some(session_id) = lifecycle
|
||||||
|
.internal_worker
|
||||||
|
.as_ref()
|
||||||
|
.map(|worker| worker.session_id.as_str())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(registry) = &self.internal_worker_registry else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = registry.stop_service(session_id).await {
|
||||||
|
warn!(
|
||||||
|
compaction_id = %lifecycle.compaction_id,
|
||||||
|
internal_worker_session_id = %session_id,
|
||||||
|
error = %error,
|
||||||
|
"failed to release terminal compaction Internal Worker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compact_impl(
|
||||||
|
&mut self,
|
||||||
|
retained_tokens: u64,
|
||||||
|
lifecycle: &mut CompactionLifecycle,
|
||||||
|
) -> Result<(SegmentId, String), WorkerError> {
|
||||||
use crate::compact::worker::{
|
use crate::compact::worker::{
|
||||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
CompactWorkerContext, CompactWorkerInterceptor, CompactionOutputFeature,
|
||||||
mark_read_required_tool, read_session_items_tool, search_session_log_tool,
|
|
||||||
write_summary_tool,
|
|
||||||
};
|
};
|
||||||
use crate::fs_view::WorkerFsView;
|
use crate::fs_view::WorkerFsView;
|
||||||
|
|
||||||
@@ -3385,10 +3420,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
auto_read_budget,
|
auto_read_budget,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Build an independent compact worker. It clones the main Worker's
|
// Build a normal parent-owned Internal Worker over a pinned immutable
|
||||||
// provider handle, so compact-time reads use the same WorkdirSession instance.
|
// capture. Only SessionExplore and compaction output tools are installed.
|
||||||
// No-workdir Workers deliberately omit compact-time filesystem tools.
|
|
||||||
let workdir = self.workdir_session.clone();
|
let workdir = self.workdir_session.clone();
|
||||||
|
let read_only_workdir = workdir.clone().map(|session| {
|
||||||
|
Arc::new(ReadOnlyWorkdirSession::new(session)) as workdir::WorkdirSessionHandle
|
||||||
|
});
|
||||||
let summary_tracker = tools::Tracker::new();
|
let summary_tracker = tools::Tracker::new();
|
||||||
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||||
let summary_system_prompt = self
|
let summary_system_prompt = self
|
||||||
@@ -3396,51 +3433,129 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.load_full()
|
.load_full()
|
||||||
.compact_system()
|
.compact_system()
|
||||||
.map_err(WorkerError::PromptCatalog)?;
|
.map_err(WorkerError::PromptCatalog)?;
|
||||||
let mut summary_worker = Engine::new(summary_client).system_prompt(summary_system_prompt);
|
|
||||||
summary_worker.set_cache_key(Some(self.segment_id().to_string()));
|
|
||||||
|
|
||||||
// Occupancy-based input-token meter + interceptor. The tracker pairs
|
|
||||||
// each pre-request history length with the following UsageEvent, then
|
|
||||||
// the interceptor projects current prompt occupancy with the same
|
|
||||||
// UsageRecord counter used by the main Worker thresholds.
|
|
||||||
let summary_usage_tracker = Arc::new(UsageTracker::new());
|
let summary_usage_tracker = Arc::new(UsageTracker::new());
|
||||||
{
|
|
||||||
let tracker = summary_usage_tracker.clone();
|
|
||||||
summary_worker.on_usage(move |event| {
|
|
||||||
tracker.record_usage(event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let compactor_warning_cb = self.alerter.clone().map(|alerter| {
|
let compactor_warning_cb = self.alerter.clone().map(|alerter| {
|
||||||
Arc::new(move |message: String| {
|
Arc::new(move |message: String| {
|
||||||
alerter.alert(AlertLevel::Warn, AlertSource::Compactor, message);
|
alerter.alert(AlertLevel::Warn, AlertSource::Compactor, message);
|
||||||
}) as Arc<dyn Fn(String) + Send + Sync>
|
}) as Arc<dyn Fn(String) + Send + Sync>
|
||||||
});
|
});
|
||||||
summary_worker.set_interceptor(CompactWorkerInterceptor::new(
|
let interceptor = CompactWorkerInterceptor::new(
|
||||||
summary_usage_tracker,
|
summary_usage_tracker.clone(),
|
||||||
worker_context_max_tokens,
|
worker_context_max_tokens,
|
||||||
finish_warning_remaining_tokens,
|
finish_warning_remaining_tokens,
|
||||||
final_reserve_tokens,
|
final_reserve_tokens,
|
||||||
compactor_warning_cb,
|
compactor_warning_cb,
|
||||||
|
);
|
||||||
|
let tracker_for_engine = summary_usage_tracker.clone();
|
||||||
|
let features = crate::feature::FeatureRegistryBuilder::new()
|
||||||
|
.with_module(
|
||||||
|
crate::feature::builtin::session_explore::SessionExploreFeature::new(
|
||||||
|
crate::feature::builtin::session_explore::SessionExploreState::new(
|
||||||
|
crate::session_capture::SessionCapture::new(
|
||||||
|
self.segment_id().to_string(),
|
||||||
|
items_to_summarise.clone(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_module(CompactionOutputFeature::new(
|
||||||
|
read_only_workdir.clone(),
|
||||||
|
summary_tracker,
|
||||||
|
ctx.clone(),
|
||||||
));
|
));
|
||||||
summary_worker.set_max_turns(worker_max_turns);
|
let required_tools: &'static [&'static str] = if read_only_workdir.is_some() {
|
||||||
|
&[
|
||||||
// Tools: read_file (shared scope, fresh tracker), bounded session
|
"ShowOverview",
|
||||||
// history exploration, and compact-specific tools that populate `ctx`.
|
"SearchEntries",
|
||||||
let compact_target_items = Arc::new(items_to_summarise.clone());
|
"ReadEntry",
|
||||||
if let Some(workdir) = workdir.clone() {
|
"Read",
|
||||||
summary_worker.register_tool(tools::read_tool(workdir.clone(), summary_tracker));
|
"mark_read_required",
|
||||||
summary_worker.register_tool(mark_read_required_tool(workdir, ctx.clone()));
|
"add_reference",
|
||||||
}
|
"write_summary",
|
||||||
summary_worker.register_tool(search_session_log_tool(compact_target_items.clone()));
|
]
|
||||||
summary_worker.register_tool(read_session_items_tool(compact_target_items));
|
} else {
|
||||||
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
&[
|
||||||
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
"ShowOverview",
|
||||||
|
"SearchEntries",
|
||||||
let out = summary_worker
|
"ReadEntry",
|
||||||
.run(summary_input.text)
|
"add_reference",
|
||||||
|
"write_summary",
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let handle = prepare_internal_worker_from_spec(
|
||||||
|
InternalWorkerSpec {
|
||||||
|
identity: InternalWorkerIdentity {
|
||||||
|
kind: "compaction",
|
||||||
|
run_id: uuid::Uuid::parse_str(&lifecycle.compaction_id).map_err(|error| {
|
||||||
|
WorkerError::InvalidState(format!("invalid compaction id: {error}"))
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
manifest: self.manifest.clone(),
|
||||||
|
client: summary_client,
|
||||||
|
system_prompt: summary_system_prompt,
|
||||||
|
input: summary_input.text.clone(),
|
||||||
|
cache_key: Some(self.segment_id().to_string()),
|
||||||
|
max_turns: worker_max_turns,
|
||||||
|
engine_configurator: Some(Box::new(move |engine| {
|
||||||
|
let tracker = tracker_for_engine;
|
||||||
|
engine.on_usage(move |event| {
|
||||||
|
tracker.record_usage(event);
|
||||||
|
});
|
||||||
|
engine.set_interceptor(interceptor);
|
||||||
|
})),
|
||||||
|
features,
|
||||||
|
required_tools,
|
||||||
|
authority: InternalWorkerAuthority {
|
||||||
|
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||||
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
|
scope: Scope::empty(),
|
||||||
|
workdir_session: read_only_workdir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InternalWorkerVisibility::ParentClient,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(WorkerError::Engine)?;
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
let mut locked_engine = out.engine;
|
let registry = self
|
||||||
|
.internal_worker_registry
|
||||||
|
.get_or_insert_with(
|
||||||
|
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
||||||
|
)
|
||||||
|
.clone();
|
||||||
|
let internal_ref = registry
|
||||||
|
.attach_service(crate::spawn::registry::InternalServiceWorkerRecord::new(
|
||||||
|
"compaction",
|
||||||
|
"Compaction",
|
||||||
|
handle.clone(),
|
||||||
|
))
|
||||||
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
|
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||||
|
lifecycle.internal_worker = Some(internal_ref);
|
||||||
|
self.persist_and_send_compact_start(lifecycle.clone())?;
|
||||||
|
|
||||||
|
if let Err(error) = handle.send(summary_input.text).await {
|
||||||
|
let _ = registry.remove_service(&handle.session_id_string());
|
||||||
|
return Err(WorkerError::InvalidState(error.to_string()));
|
||||||
|
}
|
||||||
|
match handle.wait_until_idle().await {
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||||
|
return Err(WorkerError::CompactCancelled);
|
||||||
|
}
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Failed => {
|
||||||
|
return Err(WorkerError::InvalidState(
|
||||||
|
handle
|
||||||
|
.protocol_snapshot()
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
return Err(WorkerError::InvalidState(format!(
|
||||||
|
"compactor Internal Worker ended in {status:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: nudge the worker once more if the expected outputs
|
// Guard: nudge the worker once more if the expected outputs
|
||||||
// (summary, and any auto-read nominations when default refs
|
// (summary, and any auto-read nominations when default refs
|
||||||
@@ -3469,10 +3584,24 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(prompt) = nudge {
|
if let Some(prompt) = nudge {
|
||||||
let _ = locked_engine
|
handle
|
||||||
.run(prompt)
|
.send(prompt)
|
||||||
.await
|
.await
|
||||||
.map_err(WorkerError::Engine)?;
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
|
match handle.wait_until_idle().await {
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||||
|
return Err(WorkerError::CompactCancelled);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(WorkerError::InvalidState(
|
||||||
|
handle
|
||||||
|
.protocol_snapshot()
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
@@ -3487,10 +3616,24 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||||
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
||||||
);
|
);
|
||||||
let _ = locked_engine
|
handle
|
||||||
.run(prompt)
|
.send(prompt)
|
||||||
.await
|
.await
|
||||||
.map_err(WorkerError::Engine)?;
|
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||||
|
match handle.wait_until_idle().await {
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||||
|
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||||
|
return Err(WorkerError::CompactCancelled);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(WorkerError::InvalidState(
|
||||||
|
handle
|
||||||
|
.protocol_snapshot()
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
summary_text = final_ctx
|
summary_text = final_ctx
|
||||||
.summary
|
.summary
|
||||||
@@ -3694,7 +3837,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("extract_pointer poisoned") = None;
|
.expect("extract_pointer poisoned") = None;
|
||||||
|
|
||||||
Ok(new_segment_id)
|
Ok((new_segment_id, summary_text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the LlmClient for the compactor Engine.
|
/// Build the LlmClient for the compactor Engine.
|
||||||
@@ -4080,6 +4223,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
input: input_text,
|
input: input_text,
|
||||||
cache_key: Some(self.segment_id().to_string()),
|
cache_key: Some(self.segment_id().to_string()),
|
||||||
max_turns: extract_worker_max_turns,
|
max_turns: extract_worker_max_turns,
|
||||||
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &[
|
required_tools: &[
|
||||||
"ShowOverview",
|
"ShowOverview",
|
||||||
@@ -4092,6 +4236,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
workspace: self.workspace_context.clone(),
|
workspace: self.workspace_context.clone(),
|
||||||
filesystem: WorkerFilesystemAuthority::None,
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
scope: Scope::empty(),
|
scope: Scope::empty(),
|
||||||
|
workdir_session: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let internal_result = match cancel_observer {
|
let internal_result = match cancel_observer {
|
||||||
@@ -4566,6 +4711,7 @@ where
|
|||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -4643,6 +4789,7 @@ where
|
|||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -4755,6 +4902,7 @@ where
|
|||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -5071,6 +5219,7 @@ where
|
|||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
@@ -5725,6 +5874,9 @@ pub enum WorkerError {
|
|||||||
#[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("compaction was cancelled")]
|
||||||
|
CompactCancelled,
|
||||||
|
|
||||||
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
||||||
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
||||||
|
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -445,10 +445,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
!kinds.contains(&"failed"),
|
!kinds.contains(&"failed"),
|
||||||
"unexpected CompactFailed in {kinds:?}"
|
"unexpected CompactFailed in {kinds:?}"
|
||||||
);
|
);
|
||||||
|
let starts = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|event| match event {
|
||||||
|
Event::CompactStart { lifecycle } => Some(lifecycle),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
starts.len(),
|
||||||
|
2,
|
||||||
|
"start and Internal Worker binding revisions"
|
||||||
|
);
|
||||||
|
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
|
||||||
|
assert_eq!(starts[0].revision, 1);
|
||||||
|
assert!(starts[0].internal_worker.is_none());
|
||||||
|
assert_eq!(starts[1].revision, 2);
|
||||||
|
assert!(matches!(
|
||||||
|
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
|
||||||
|
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(events.iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
Event::InternalWorker { worker, .. }
|
||||||
|
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
|
||||||
|
)), "compactor activity must be projected through the parent stream");
|
||||||
|
let completed = events
|
||||||
|
.iter()
|
||||||
|
.find_map(|event| match event {
|
||||||
|
Event::CompactDone { lifecycle } => Some(lifecycle),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("completed lifecycle");
|
||||||
|
assert_eq!(completed.compaction_id, starts[0].compaction_id);
|
||||||
|
assert_eq!(completed.revision, 3);
|
||||||
|
assert_eq!(completed.summary.as_deref(), Some("summary"));
|
||||||
|
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
|
||||||
|
let done_index = events
|
||||||
|
.iter()
|
||||||
|
.position(|event| matches!(event, Event::CompactDone { .. }))
|
||||||
|
.expect("done event");
|
||||||
|
let removed_index = events
|
||||||
|
.iter()
|
||||||
|
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
|
||||||
|
.expect("terminal compactor session must be released");
|
||||||
|
assert!(
|
||||||
|
done_index < removed_index,
|
||||||
|
"terminal lifecycle precedes release fence"
|
||||||
|
);
|
||||||
|
|
||||||
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||||
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
Event::CompactDone { lifecycle } => lifecycle
|
||||||
|
.new_segment_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
||||||
@@ -488,7 +539,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -504,7 +555,10 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||||
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
Event::CompactDone { lifecycle } => lifecycle
|
||||||
|
.new_segment_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
||||||
@@ -659,7 +713,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -817,11 +871,14 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
.expect("timeout waiting for compact events")
|
.expect("timeout waiting for compact events")
|
||||||
.expect("event")
|
.expect("event")
|
||||||
{
|
{
|
||||||
Event::CompactStart => saw_start = true,
|
Event::CompactStart { .. } => saw_start = true,
|
||||||
Event::CompactDone { .. } => {
|
Event::CompactDone { .. } => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
|
Event::CompactFailed { lifecycle } => panic!(
|
||||||
|
"manual compact failed: {}",
|
||||||
|
lifecycle.error.as_deref().unwrap_or("unknown error")
|
||||||
|
),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ The conversation input is a bounded overview/index, not the full transcript. Tre
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read the provided overview/index and current TaskStore snapshot.
|
1. Read the provided overview/index and current TaskStore snapshot.
|
||||||
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.
|
2. If the provided index is not enough, call `ShowOverview` on the pinned capture. Use `SearchEntries` to locate relevant entries, then `ReadEntry` with the returned stable entry reference to inspect only what is needed.
|
||||||
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
3. Use `Read` to inspect referenced Workdir files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||||
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.
|
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.
|
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.
|
6. Finish with `write_summary` carrying the final text. You may call it multiple times; only the last call is kept.
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export type CommandSnapshot = { command_id: string, tool_call_id: string | null,
|
|||||||
|
|
||||||
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
||||||
|
|
||||||
|
export type CompactionLifecycleState = "running" | "done" | "failed" | "interrupted";
|
||||||
|
|
||||||
|
export type CompactionLifecycle = { schema_version: number, compaction_id: string, revision: number, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState,
|
||||||
|
/**
|
||||||
|
* Milliseconds since the Unix epoch.
|
||||||
|
*/
|
||||||
|
started_at_ms: number, ended_at_ms?: number | null, summary?: string | null, error?: string | null, new_segment_id?: string | null, };
|
||||||
|
|
||||||
export type ScopeRule = {
|
export type ScopeRule = {
|
||||||
/**
|
/**
|
||||||
* Target path. Must be absolute by the time a `Scope` is built from
|
* Target path. Must be absolute by the time a `Scope` is built from
|
||||||
@@ -63,7 +71,7 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
|
|||||||
|
|
||||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
|
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
|
||||||
|
|
||||||
export type InternalWorkerKind = "sub_worker";
|
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
|
||||||
|
|
||||||
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
||||||
|
|
||||||
@@ -193,4 +201,4 @@ in_flight?: InFlightSnapshot,
|
|||||||
* Parent-owned Internal Worker sessions visible to this client.
|
* Parent-owned Internal Worker sessions visible to this client.
|
||||||
* Service-private Internal Workers are deliberately excluded.
|
* Service-private Internal Workers are deliberately excluded.
|
||||||
*/
|
*/
|
||||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
|
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
|
||||||
|
|||||||
@@ -8,6 +8,33 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let { item }: Props = $props();
|
let { item }: Props = $props();
|
||||||
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (item.compaction?.state !== 'running') return;
|
||||||
|
nowMs = Date.now();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
nowMs = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function compactionElapsedMs(line: ConsoleLine): number {
|
||||||
|
const compaction = line.compaction;
|
||||||
|
if (!compaction) return 0;
|
||||||
|
return Math.max(0, (compaction.endedAtMs ?? nowMs) - compaction.startedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactionState(line: ConsoleLine): string {
|
||||||
|
return line.compaction?.state.replace('_', ' ') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
function lineClass(line: ConsoleLine): string {
|
function lineClass(line: ConsoleLine): string {
|
||||||
return line.error ? 'error' : line.kind;
|
return line.error ? 'error' : line.kind;
|
||||||
@@ -52,7 +79,29 @@
|
|||||||
class:error-line={item.error}
|
class:error-line={item.error}
|
||||||
data-console-line-id={item.id}
|
data-console-line-id={item.id}
|
||||||
>
|
>
|
||||||
{#if shouldRenderHeading(item)}
|
{#if item.compaction}
|
||||||
|
<div class="compaction-heading">
|
||||||
|
<span>Compaction · {compactionState(item)}</span>
|
||||||
|
<span>{formatElapsed(compactionElapsedMs(item))}</span>
|
||||||
|
</div>
|
||||||
|
{#if item.compaction.activity.length > 0}
|
||||||
|
<ul class="compaction-activity">
|
||||||
|
{#each item.compaction.activity as activity}
|
||||||
|
<li>{activity}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if item.compaction.summary}
|
||||||
|
<div class="compaction-summary">{item.compaction.summary}</div>
|
||||||
|
{:else if item.compaction.candidate}
|
||||||
|
<div class="compaction-candidate">
|
||||||
|
<span class="compaction-candidate-label">candidate</span>
|
||||||
|
{item.compaction.candidate}
|
||||||
|
</div>
|
||||||
|
{:else if item.compaction.error}
|
||||||
|
<div class="compaction-error">{item.compaction.error}</div>
|
||||||
|
{/if}
|
||||||
|
{:else if shouldRenderHeading(item)}
|
||||||
<div class="message-heading">
|
<div class="message-heading">
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +112,9 @@
|
|||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.kind === 'tool'}
|
{#if item.compaction}
|
||||||
|
<!-- rendered as one lifecycle item above -->
|
||||||
|
{:else if item.kind === 'tool'}
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
{#if bodyTextAfterToolSummary(item)}
|
||||||
<p class="console-plain-text">
|
<p class="console-plain-text">
|
||||||
{#if isBashTool(item)}
|
{#if isBashTool(item)}
|
||||||
@@ -141,6 +192,43 @@
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compaction-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-activity {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
color: var(--tui-dark-gray);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-summary,
|
||||||
|
.compaction-candidate,
|
||||||
|
.compaction-error {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-candidate-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compaction-error {
|
||||||
|
color: var(--tui-error);
|
||||||
|
}
|
||||||
|
|
||||||
.activity-summary {
|
.activity-summary {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -647,42 +647,173 @@ Deno.test("projectConsole renders alert events", () => {
|
|||||||
assertEquals(projection.lines[1].error, true);
|
assertEquals(projection.lines[1].error, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole shows compact progress as a status block", () => {
|
Deno.test("projectConsole upserts compaction lifecycle by stable id", () => {
|
||||||
|
const running = {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "compaction-1",
|
||||||
|
revision: 1,
|
||||||
|
internal_worker: null,
|
||||||
|
state: "running",
|
||||||
|
started_at_ms: 1_000,
|
||||||
|
ended_at_ms: null,
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
new_segment_id: null,
|
||||||
|
} as const;
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "compact-1",
|
eventId: "compact-1",
|
||||||
event: { event: "compact_start" } satisfies Event,
|
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assertEquals(projection.lines.length, 1);
|
assertEquals(projection.lines.length, 1);
|
||||||
assertEquals(projection.lines[0].id, "status-compact");
|
assertEquals(projection.lines[0].id, "compaction-compaction-1");
|
||||||
assertEquals(projection.lines[0].kind, "status");
|
assertEquals(projection.lines[0].compaction?.state, "running");
|
||||||
assertEquals(projection.lines[0].body, "Compacting…");
|
|
||||||
assertEquals(projection.lines[0].streaming, true);
|
assertEquals(projection.lines[0].streaming, true);
|
||||||
|
|
||||||
const completed = projectConsole([
|
const completed = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "compact-1",
|
eventId: "compact-1",
|
||||||
event: { event: "compact_start" } satisfies Event,
|
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eventId: "compact-2",
|
eventId: "compact-2",
|
||||||
event: {
|
event: {
|
||||||
event: "compact_done",
|
event: "compact_done",
|
||||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
data: {
|
||||||
|
lifecycle: {
|
||||||
|
...running,
|
||||||
|
revision: 2,
|
||||||
|
state: "done",
|
||||||
|
ended_at_ms: 4_000,
|
||||||
|
summary: "accepted summary",
|
||||||
|
new_segment_id: "00000000-0000-0000-0000-000000000001",
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies Event,
|
} satisfies Event,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assertEquals(completed.lines.length, 1);
|
assertEquals(completed.lines.length, 1);
|
||||||
assertEquals(completed.lines[0].id, "status-compact");
|
assertEquals(completed.lines[0].id, "compaction-compaction-1");
|
||||||
assertEquals(completed.lines[0].body, "Compacted.");
|
assertEquals(completed.lines[0].compaction?.state, "done");
|
||||||
|
assertEquals(completed.lines[0].compaction?.summary, "accepted summary");
|
||||||
assertEquals(completed.lines[0].streaming, false);
|
assertEquals(completed.lines[0].streaming, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("createConsoleProjector updates only compact status block", () => {
|
Deno.test("compaction service activity stays nested in one lifecycle item", () => {
|
||||||
|
const worker = {
|
||||||
|
session_id: "compactor-session",
|
||||||
|
name: "Compaction",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: { service: { kind: "compaction" } },
|
||||||
|
} as const;
|
||||||
|
const lifecycle = {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "compaction-nested",
|
||||||
|
revision: 2,
|
||||||
|
internal_worker: worker,
|
||||||
|
state: "running",
|
||||||
|
started_at_ms: 1_000,
|
||||||
|
ended_at_ms: null,
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
new_segment_id: null,
|
||||||
|
} as const;
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "compaction-start",
|
||||||
|
event: { event: "compact_start", data: { lifecycle } } satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "compaction-tool",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 1,
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "call-1", name: "write_summary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "compaction-tool-done",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 2,
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "call-1",
|
||||||
|
name: "write_summary",
|
||||||
|
arguments: JSON.stringify({ text: "draft candidate" }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(projection.lines.length, 1);
|
||||||
|
assertEquals(projection.lines[0].id, "compaction-compaction-nested");
|
||||||
|
assertEquals(projection.lines[0].compaction?.activity, ["write_summary — running"]);
|
||||||
|
assertEquals(projection.lines[0].compaction?.candidate, "draft candidate");
|
||||||
|
assert(
|
||||||
|
consoleWorkerViews(projection).length === 1,
|
||||||
|
"service is not a selectable SubWorker pane",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot normalizes orphaned running compaction to interrupted", () => {
|
||||||
|
const projection = projectConsole([{
|
||||||
|
eventId: "snapshot",
|
||||||
|
observedAtMs: 9_000,
|
||||||
|
event: snapshotEvent("/repo", [{
|
||||||
|
kind: "extension",
|
||||||
|
ts: 1,
|
||||||
|
domain: "yoi.compaction",
|
||||||
|
payload: {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "compaction-orphaned",
|
||||||
|
revision: 2,
|
||||||
|
internal_worker: {
|
||||||
|
session_id: "missing-service",
|
||||||
|
name: "Compaction",
|
||||||
|
parent_session_id: "parent",
|
||||||
|
kind: { service: { kind: "compaction" } },
|
||||||
|
},
|
||||||
|
state: "running",
|
||||||
|
started_at_ms: 1_000,
|
||||||
|
},
|
||||||
|
}]),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(projection.lines.length, 1);
|
||||||
|
assertEquals(projection.lines[0].compaction?.state, "interrupted");
|
||||||
|
assertEquals(projection.lines[0].compaction?.endedAtMs, 9_000);
|
||||||
|
assertEquals(projection.lines[0].streaming, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
|
||||||
const projector = createConsoleProjector();
|
const projector = createConsoleProjector();
|
||||||
|
const base = {
|
||||||
|
schema_version: 2,
|
||||||
|
compaction_id: "compaction-identity",
|
||||||
|
revision: 1,
|
||||||
|
internal_worker: null,
|
||||||
|
state: "running",
|
||||||
|
started_at_ms: 1_000,
|
||||||
|
ended_at_ms: null,
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
new_segment_id: null,
|
||||||
|
} as const;
|
||||||
let projection = projector.append([
|
let projection = projector.append([
|
||||||
{
|
{
|
||||||
eventId: "compact-identity-1",
|
eventId: "compact-identity-1",
|
||||||
@@ -693,31 +824,37 @@ Deno.test("createConsoleProjector updates only compact status block", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
eventId: "compact-identity-2",
|
eventId: "compact-identity-2",
|
||||||
event: { event: "compact_start" } satisfies Event,
|
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const userLine = projection.lines[0];
|
const userLine = projection.lines[0];
|
||||||
const compactLine = projection.lines[1];
|
|
||||||
|
|
||||||
projection = projector.append([
|
projection = projector.append([
|
||||||
{
|
{
|
||||||
eventId: "compact-identity-3",
|
eventId: "compact-identity-3",
|
||||||
event: {
|
event: {
|
||||||
event: "compact_done",
|
event: "compact_done",
|
||||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
data: {
|
||||||
|
lifecycle: {
|
||||||
|
...base,
|
||||||
|
revision: 2,
|
||||||
|
state: "done",
|
||||||
|
ended_at_ms: 2_000,
|
||||||
|
summary: "accepted",
|
||||||
|
new_segment_id: "00000000-0000-0000-0000-000000000001",
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies Event,
|
} satisfies Event,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
eventId: "compact-identity-stale",
|
||||||
|
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert(
|
assert(projection.lines[0] === userLine, "unrelated line retains identity");
|
||||||
projection.lines[0] === userLine,
|
assertEquals(projection.lines[1].compaction?.state, "done");
|
||||||
"unrelated message line should keep object identity",
|
assertEquals(projection.lines[1].compaction?.summary, "accepted");
|
||||||
);
|
|
||||||
assert(
|
|
||||||
projection.lines[1] !== compactLine,
|
|
||||||
"compact status line should update object identity",
|
|
||||||
);
|
|
||||||
assertEquals(projection.lines[1].body, "Compacted.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CommandEvent,
|
CommandEvent,
|
||||||
CommandSnapshot,
|
CommandSnapshot,
|
||||||
CommandStreamSlice,
|
CommandStreamSlice,
|
||||||
|
CompactionLifecycle,
|
||||||
Event as ProtocolEvent,
|
Event as ProtocolEvent,
|
||||||
InFlightBlock,
|
InFlightBlock,
|
||||||
InFlightToolCallState,
|
InFlightToolCallState,
|
||||||
@@ -67,12 +68,26 @@ export type ConsoleDiffLine = {
|
|||||||
|
|
||||||
export type ConsoleViewMode = "overview" | "normal";
|
export type ConsoleViewMode = "overview" | "normal";
|
||||||
|
|
||||||
|
export type ConsoleCompaction = {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
state: "running" | "done" | "failed" | "interrupted";
|
||||||
|
startedAtMs: number;
|
||||||
|
endedAtMs?: number;
|
||||||
|
summary?: string;
|
||||||
|
candidate?: string;
|
||||||
|
error?: string;
|
||||||
|
internalWorkerSessionId?: string;
|
||||||
|
activity: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ConsoleLine = {
|
export type ConsoleLine = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
compaction?: ConsoleCompaction;
|
||||||
diff?: ConsoleDiffLine[];
|
diff?: ConsoleDiffLine[];
|
||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
source: "event";
|
source: "event";
|
||||||
@@ -114,16 +129,17 @@ export type ConsoleWorkerView = {
|
|||||||
export function consoleWorkerViews(
|
export function consoleWorkerViews(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
): ConsoleWorkerView[] {
|
): ConsoleWorkerView[] {
|
||||||
const labels = projection.internalWorkers.map((worker) =>
|
const children = projection.internalWorkers.filter((worker) =>
|
||||||
worker.worker.name || "subworker"
|
worker.worker.kind === "sub_worker"
|
||||||
);
|
);
|
||||||
|
const labels = children.map((worker) => worker.worker.name || "subworker");
|
||||||
const labelCounts = new Map<string, number>();
|
const labelCounts = new Map<string, number>();
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ sessionId: null, label: "main", console: projection },
|
{ sessionId: null, label: "main", console: projection },
|
||||||
...projection.internalWorkers.map((worker, index) => {
|
...children.map((worker, index) => {
|
||||||
const label = labels[index] ?? "subworker";
|
const label = labels[index] ?? "subworker";
|
||||||
return {
|
return {
|
||||||
sessionId: worker.worker.session_id,
|
sessionId: worker.worker.session_id,
|
||||||
@@ -664,6 +680,105 @@ function projectInternalWorkerSnapshot(
|
|||||||
return { worker: snapshot.worker, revision: snapshot.revision, console };
|
return { worker: snapshot.worker, revision: snapshot.revision, console };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactionCandidate(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!sessionId) return undefined;
|
||||||
|
const worker = projection.internalWorkers.find(
|
||||||
|
(candidate) => candidate.worker.session_id === sessionId,
|
||||||
|
);
|
||||||
|
const calls = worker?.console.lines
|
||||||
|
.map((line) => line.toolCall)
|
||||||
|
.filter((call): call is ToolCallView => call?.name === "write_summary") ?? [];
|
||||||
|
const latest = calls.at(-1);
|
||||||
|
const raw = latest?.arguments ?? latest?.argsStream;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { text?: unknown };
|
||||||
|
return typeof parsed.text === "string" ? parsed.text : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactionActivity(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
sessionId: string | undefined
|
||||||
|
): string[] {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
const worker = projection.internalWorkers.find(
|
||||||
|
(candidate) => candidate.worker.session_id === sessionId
|
||||||
|
);
|
||||||
|
if (!worker) return [];
|
||||||
|
return worker.console.lines
|
||||||
|
.filter((line) => line.kind === "tool" || line.kind === "status" || line.kind === "error")
|
||||||
|
.slice(-12)
|
||||||
|
.map((line) =>
|
||||||
|
line.toolCall?.name === "write_summary"
|
||||||
|
? `write_summary — ${line.toolCall.state}`
|
||||||
|
: line.body || line.title
|
||||||
|
)
|
||||||
|
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompactionLifecycle(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
lifecycle: CompactionLifecycle
|
||||||
|
): ConsoleProjection {
|
||||||
|
const lineId = `compaction-${lifecycle.compaction_id}`;
|
||||||
|
const existing = projection.lines.find((line) => line.id === lineId)?.compaction;
|
||||||
|
if (existing && existing.revision >= lifecycle.revision) return projection;
|
||||||
|
const internalWorkerSessionId = lifecycle.internal_worker?.session_id;
|
||||||
|
const compaction: ConsoleCompaction = {
|
||||||
|
id: lifecycle.compaction_id,
|
||||||
|
revision: lifecycle.revision,
|
||||||
|
state: lifecycle.state,
|
||||||
|
startedAtMs: lifecycle.started_at_ms,
|
||||||
|
endedAtMs: lifecycle.ended_at_ms ?? undefined,
|
||||||
|
summary: lifecycle.summary ?? undefined,
|
||||||
|
candidate: compactionCandidate(projection, internalWorkerSessionId),
|
||||||
|
error: lifecycle.error ?? undefined,
|
||||||
|
internalWorkerSessionId,
|
||||||
|
activity: compactionActivity(projection, internalWorkerSessionId)
|
||||||
|
};
|
||||||
|
const line: ConsoleLine = {
|
||||||
|
id: lineId,
|
||||||
|
source: "event",
|
||||||
|
kind: compaction.state === "failed" ? "error" : "status",
|
||||||
|
title: "Compaction",
|
||||||
|
body: compaction.summary ?? compaction.error ?? "",
|
||||||
|
streaming: compaction.state === "running",
|
||||||
|
error: compaction.state === "failed",
|
||||||
|
compaction
|
||||||
|
};
|
||||||
|
const index = projection.lines.findIndex((candidate) => candidate.id === lineId);
|
||||||
|
const lines = [...projection.lines];
|
||||||
|
if (index >= 0) lines[index] = line;
|
||||||
|
else lines.push(line);
|
||||||
|
return { ...projection, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCompactionActivity(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
sessionId: string
|
||||||
|
): ConsoleProjection {
|
||||||
|
let changed = false;
|
||||||
|
const lines = projection.lines.map((line) => {
|
||||||
|
if (line.compaction?.internalWorkerSessionId !== sessionId) return line;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
compaction: {
|
||||||
|
...line.compaction,
|
||||||
|
candidate: compactionCandidate(projection, sessionId),
|
||||||
|
activity: compactionActivity(projection, sessionId)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? { ...projection, lines } : projection;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyProtocolEvent(
|
export function applyProtocolEvent(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
envelope: ConsoleEventInput,
|
envelope: ConsoleEventInput,
|
||||||
@@ -809,6 +924,31 @@ export function applyProtocolEvent(
|
|||||||
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
||||||
);
|
);
|
||||||
next.removedInternalWorkers = {};
|
next.removedInternalWorkers = {};
|
||||||
|
for (const line of next.lines) {
|
||||||
|
const compaction = line.compaction;
|
||||||
|
if (!compaction) continue;
|
||||||
|
const sessionId = compaction.internalWorkerSessionId;
|
||||||
|
if (sessionId) {
|
||||||
|
line.compaction = {
|
||||||
|
...compaction,
|
||||||
|
candidate: compactionCandidate(next, sessionId),
|
||||||
|
activity: compactionActivity(next, sessionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
compaction.state === "running" &&
|
||||||
|
(!sessionId || !next.internalWorkers.some((worker) =>
|
||||||
|
worker.worker.session_id === sessionId
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
line.streaming = false;
|
||||||
|
line.compaction = {
|
||||||
|
...line.compaction!,
|
||||||
|
state: "interrupted",
|
||||||
|
endedAtMs: envelope.observedAtMs ?? Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "internal_worker": {
|
case "internal_worker": {
|
||||||
@@ -841,7 +981,7 @@ export function applyProtocolEvent(
|
|||||||
};
|
};
|
||||||
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
|
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
|
||||||
else next.internalWorkers.push(updated);
|
else next.internalWorkers.push(updated);
|
||||||
break;
|
return refreshCompactionActivity(next, event.data.worker.session_id);
|
||||||
}
|
}
|
||||||
case "internal_worker_removed": {
|
case "internal_worker_removed": {
|
||||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||||
@@ -905,21 +1045,9 @@ export function applyProtocolEvent(
|
|||||||
// them to the conversation surface; browser Console should not either.
|
// them to the conversation surface; browser Console should not either.
|
||||||
break;
|
break;
|
||||||
case "compact_start":
|
case "compact_start":
|
||||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacting…", true);
|
|
||||||
break;
|
|
||||||
case "compact_done":
|
case "compact_done":
|
||||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacted.", false);
|
|
||||||
break;
|
|
||||||
case "compact_failed":
|
case "compact_failed":
|
||||||
upsertStatusLine(
|
return applyCompactionLifecycle(next, event.data.lifecycle);
|
||||||
next,
|
|
||||||
"compact",
|
|
||||||
envelope.eventId,
|
|
||||||
`Compact failed: ${event.data.error}`,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "shutdown":
|
case "shutdown":
|
||||||
next.status = "shutdown";
|
next.status = "shutdown";
|
||||||
break;
|
break;
|
||||||
@@ -1880,7 +2008,22 @@ function applyExtensionEntry(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = entry["payload"];
|
const payload = entry["payload"];
|
||||||
if (!isRecord(payload) || payload["kind"] !== "compaction_block") {
|
if (!isRecord(payload)) return;
|
||||||
|
if (
|
||||||
|
typeof payload["compaction_id"] === "string" &&
|
||||||
|
typeof payload["revision"] === "number" &&
|
||||||
|
typeof payload["state"] === "string" &&
|
||||||
|
typeof payload["started_at_ms"] === "number"
|
||||||
|
) {
|
||||||
|
const updated = applyCompactionLifecycle(
|
||||||
|
projection,
|
||||||
|
payload as unknown as CompactionLifecycle,
|
||||||
|
);
|
||||||
|
projection.lines = updated.lines;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Schema v1 remains readable historical evidence.
|
||||||
|
if (payload["kind"] !== "compaction_block") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blockId = stringField(payload, "block_id") || "compact";
|
const blockId = stringField(payload, "block_id") || "compact";
|
||||||
|
|||||||
@@ -926,8 +926,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
consoleModel.includes("consoleWorkerViews") &&
|
consoleModel.includes("consoleWorkerViews") &&
|
||||||
consoleModel.includes("projection.internalWorkers.map") &&
|
consoleModel.includes('worker.worker.kind === "sub_worker"') &&
|
||||||
|
consoleModel.includes("children.map") &&
|
||||||
consoleModel.includes("resolveConsoleWorkerView"),
|
consoleModel.includes("resolveConsoleWorkerView"),
|
||||||
"Worker view selection should use direct Internal Worker session identities with main fallback",
|
"Worker view selection should expose only direct SubWorker session identities with main fallback",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user