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
|
||||
/// protocol stream. Internal Workers never become independently addressable
|
||||
/// 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))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InternalWorkerKind {
|
||||
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.
|
||||
@@ -607,23 +640,18 @@ pub enum Event {
|
||||
/// This is not part of LLM history or prompt context; clients may display it
|
||||
/// briefly as operational status.
|
||||
MemoryWorker(MemoryWorkerEvent),
|
||||
/// Worker has started compacting the current session.
|
||||
///
|
||||
/// Fired immediately before a compaction run. Success is signalled by
|
||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||
/// 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,
|
||||
/// Worker has started compacting the current session, or bound the run to its
|
||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||
CompactStart {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// 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 {
|
||||
error: String,
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
@@ -1732,45 +1760,74 @@ mod tests {
|
||||
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]
|
||||
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();
|
||||
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();
|
||||
assert!(matches!(decoded, Event::CompactStart));
|
||||
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_done_roundtrip() {
|
||||
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 parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_done");
|
||||
assert_eq!(
|
||||
parsed["data"]["new_segment_id"],
|
||||
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||
);
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_failed_roundtrip() {
|
||||
let event = Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
};
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
let event = Event::CompactFailed { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
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();
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
|
||||
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
||||
WorkerStatus,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -53,6 +53,8 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||
push_decl::<CommandSnapshot>(&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::<CompletionEntry>(&cfg, &mut output);
|
||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||
|
||||
+51
-16
@@ -1342,13 +1342,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::CompactStart => {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
Event::CompactStart { .. } => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Event::CompactDone { new_segment_id } => {
|
||||
Event::CompactDone { lifecycle } => {
|
||||
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() {
|
||||
let elapsed_secs = match evt {
|
||||
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() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -2486,7 +2496,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
|
||||
event,
|
||||
Event::Alert(_)
|
||||
| Event::MemoryWorker(_)
|
||||
| Event::CompactStart
|
||||
| Event::CompactStart { .. }
|
||||
| Event::CompactDone { .. }
|
||||
| Event::CompactFailed { .. }
|
||||
| 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]
|
||||
fn compact_done_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
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!(matches!(
|
||||
@@ -4098,10 +4129,12 @@ mod completion_flow_tests {
|
||||
fn compact_failed_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
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!(matches!(
|
||||
@@ -4117,7 +4150,9 @@ mod completion_flow_tests {
|
||||
fn shutdown_marks_live_compact_incomplete() {
|
||||
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);
|
||||
|
||||
assert!(app.quit);
|
||||
@@ -4208,9 +4243,9 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.session_context_tokens = 42_000;
|
||||
|
||||
app.handle_worker_event(Event::CompactDone {
|
||||
new_segment_id: uuid::Uuid::nil(),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
}
|
||||
|
||||
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::fs_view::ReadRequirement;
|
||||
#[cfg(test)]
|
||||
use crate::fs_view::slice_lines;
|
||||
use crate::session_capture::{
|
||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::fs_view::ReadRequirement;
|
||||
|
||||
/// Aggregated output of a compact worker run.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
@@ -91,248 +90,19 @@ struct SummaryParams {
|
||||
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 \
|
||||
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. \
|
||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \
|
||||
Paths must be absolute.";
|
||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
|
||||
|
||||
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \
|
||||
context without injecting its contents. Use for files that are contextually relevant but \
|
||||
whose current content the next session can fetch on demand.";
|
||||
const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
|
||||
the compacted context without injecting its contents. Use for files that are contextually \
|
||||
relevant but whose current content the next session can fetch on demand.";
|
||||
|
||||
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 \
|
||||
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 {
|
||||
session: WorkdirSessionHandle,
|
||||
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 {
|
||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SearchSessionParams);
|
||||
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)
|
||||
})
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CompactionOutputFeature {
|
||||
session: Option<WorkdirSessionHandle>,
|
||||
tracker: tools::Tracker,
|
||||
context: Arc<Mutex<CompactWorkerContext>>,
|
||||
}
|
||||
|
||||
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadSessionParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("read_session_items")
|
||||
.description(READ_SESSION_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
impl CompactionOutputFeature {
|
||||
pub(crate) fn new(
|
||||
session: Option<WorkdirSessionHandle>,
|
||||
tracker: tools::Tracker,
|
||||
context: Arc<Mutex<CompactWorkerContext>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
tracker,
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -810,53 +607,4 @@ mod tests {
|
||||
assert_eq!(guard.references.len(), 1);
|
||||
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());
|
||||
// Also hand the raw broadcast sender so Worker-internal operations
|
||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||
worker.attach_event_tx(event_tx.clone());
|
||||
|
||||
// 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);
|
||||
|
||||
// === 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(
|
||||
&mut worker,
|
||||
bash_output_dir,
|
||||
|
||||
@@ -560,6 +560,7 @@ where
|
||||
attempt.instance_id, attempt.checked_state_revision
|
||||
)),
|
||||
max_turns: Some(12),
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
"ShowOverview",
|
||||
@@ -571,6 +572,7 @@ where
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
};
|
||||
match run_internal_worker(spec).await {
|
||||
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
|
||||
pub workspace: WorkerWorkspaceContext,
|
||||
pub filesystem: WorkerFilesystemAuthority,
|
||||
pub scope: Scope,
|
||||
/// Provider-bound session inherited in an attenuated form from the owner.
|
||||
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
|
||||
}
|
||||
|
||||
pub(crate) struct InternalWorkerSpec {
|
||||
@@ -53,6 +55,7 @@ pub(crate) struct InternalWorkerSpec {
|
||||
pub input: String,
|
||||
pub cache_key: Option<String>,
|
||||
pub max_turns: Option<u32>,
|
||||
pub engine_configurator: Option<Box<dyn FnOnce(&mut Engine<Box<dyn LlmClient>>) + Send>>,
|
||||
pub features: FeatureRegistryBuilder,
|
||||
pub required_tools: &'static [&'static str],
|
||||
pub authority: InternalWorkerAuthority,
|
||||
@@ -102,6 +105,7 @@ where
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -128,7 +132,11 @@ where
|
||||
});
|
||||
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,
|
||||
@@ -144,6 +152,9 @@ where
|
||||
identity: identity.clone(),
|
||||
history_entries: 0,
|
||||
})?;
|
||||
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();
|
||||
@@ -250,7 +261,6 @@ impl InternalWorkerSessionStatus {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum InternalWorkerSessionError {
|
||||
#[cfg(test)]
|
||||
#[error("failed to build internal Worker session: {message}")]
|
||||
Build { message: String },
|
||||
#[error("internal Worker session is busy")]
|
||||
@@ -410,7 +420,6 @@ impl InternalWorkerSessionHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
||||
loop {
|
||||
let notified = self.state_changed.notified();
|
||||
@@ -475,6 +484,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -492,7 +502,11 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
});
|
||||
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,
|
||||
@@ -505,6 +519,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
.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
|
||||
@@ -539,6 +556,102 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
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>) {
|
||||
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
||||
tokio::spawn(async move {
|
||||
@@ -1076,12 +1189,14 @@ permission = "write"
|
||||
input: "input".to_string(),
|
||||
cache_key: Some("internal-test".to_string()),
|
||||
max_turns: Some(1),
|
||||
engine_configurator: None,
|
||||
features: FeatureRegistryBuilder::new(),
|
||||
required_tools,
|
||||
authority: InternalWorkerAuthority {
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ pub(crate) struct SearchHit {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ReadSelector<'a> {
|
||||
Id(&'a str),
|
||||
#[cfg(test)]
|
||||
EntryRange([u64; 2]),
|
||||
}
|
||||
|
||||
@@ -418,6 +419,7 @@ impl SessionCapture {
|
||||
.iter()
|
||||
.filter(|entry| entry.id.as_str() == id)
|
||||
.collect(),
|
||||
#[cfg(test)]
|
||||
ReadSelector::EntryRange([start, end]) => self
|
||||
.index
|
||||
.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
|
||||
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
|
||||
//! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such 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
|
||||
//! 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
|
||||
@@ -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 {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
worker_name: String,
|
||||
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
|
||||
|
||||
pub struct SpawnedWorkerRegistry {
|
||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||
parent_scope: Option<SharedScope>,
|
||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> 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),
|
||||
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
|
||||
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> 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: Some(parent_scope),
|
||||
parent_protocol: Mutex::new(None),
|
||||
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
|
||||
Ok(SpawnedWorkerRegistryLoad {
|
||||
registry: 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,
|
||||
parent_protocol: Mutex::new(None),
|
||||
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
|
||||
for record in self.internal_records.lock().unwrap().clone() {
|
||||
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) {
|
||||
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|(_, id)| id.clone());
|
||||
self.internal_records
|
||||
let mut snapshots = self
|
||||
.internal_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
||||
.map(|record| {
|
||||
let snapshot = record.session.protocol_snapshot();
|
||||
InternalWorkerSnapshot {
|
||||
worker: record.protocol_ref(parent_session_id.clone()),
|
||||
revision: record.protocol_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,
|
||||
}
|
||||
internal_worker_snapshot(
|
||||
record.protocol_ref(parent_session_id.clone()),
|
||||
record.protocol_revision(),
|
||||
&record.session,
|
||||
)
|
||||
})
|
||||
.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> {
|
||||
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
|
||||
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 {
|
||||
@@ -813,6 +1062,63 @@ mod tests {
|
||||
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]
|
||||
async fn service_private_internal_output_is_never_disclosed() {
|
||||
let registry = registry();
|
||||
|
||||
+275
-123
@@ -46,12 +46,11 @@ use crate::hook::{
|
||||
};
|
||||
use crate::in_flight::InFlightEvents;
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, run_internal_worker,
|
||||
run_internal_worker_with_cancel_sender,
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, InternalWorkerVisibility,
|
||||
prepare_internal_worker_from_spec, run_internal_worker, run_internal_worker_with_cancel_sender,
|
||||
};
|
||||
|
||||
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||
const COMPACTION_BLOCK_ID: &str = "compact";
|
||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
||||
|
||||
@@ -76,7 +75,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||
#[cfg(test)]
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, Event, RewindSummary,
|
||||
RewindTarget, RewindTargetId, Segment,
|
||||
};
|
||||
use tokio::net::UnixStream;
|
||||
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
|
||||
/// connect after the fact — they are fire-and-forget broadcasts.
|
||||
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>,
|
||||
/// Monotonic counter incremented by worker event bridges when an
|
||||
/// 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(),
|
||||
alerter: self.alerter.clone(),
|
||||
event_tx: self.event_tx.clone(),
|
||||
internal_worker_registry: self.internal_worker_registry.clone(),
|
||||
in_flight: self.in_flight.clone(),
|
||||
ai_activity_counter: self.ai_activity_counter.clone(),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -1315,6 +1319,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
feature_instructions: Vec::new(),
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -1957,9 +1962,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
/// Worker-internal operations (currently: compaction) can surface
|
||||
/// progress to connected clients.
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
/// assistant-side output is surfaced before history persistence.
|
||||
pub fn ai_activity_counter(&self) -> Arc<AtomicUsize> {
|
||||
@@ -2895,52 +2912,46 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.map_err(WorkerError::Engine)
|
||||
}
|
||||
|
||||
fn persist_compaction_block(
|
||||
fn persist_compaction_lifecycle(
|
||||
&mut self,
|
||||
state: &str,
|
||||
message: &str,
|
||||
error: Option<&str>,
|
||||
new_segment_id: Option<SegmentId>,
|
||||
lifecycle: &CompactionLifecycle,
|
||||
) -> 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 {
|
||||
ts: segment_log::now_millis(),
|
||||
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> {
|
||||
self.persist_compaction_block("running", "Compacting…", None, None)?;
|
||||
self.send_event(Event::CompactStart);
|
||||
fn persist_and_send_compact_start(
|
||||
&mut self,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactStart { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_and_send_compact_done(
|
||||
&mut self,
|
||||
new_segment_id: SegmentId,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_block("done", "Compacted.", None, Some(new_segment_id))?;
|
||||
self.send_event(Event::CompactDone { new_segment_id });
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactDone { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_and_send_compact_failed(&mut self, error: String) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_block(
|
||||
"failed",
|
||||
&format!("Compact failed: {error}"),
|
||||
Some(error.as_str()),
|
||||
None,
|
||||
)?;
|
||||
self.send_event(Event::CompactFailed { error });
|
||||
fn persist_and_send_compact_failed(
|
||||
&mut self,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactFailed { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2969,14 +2980,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.map(|s| s.retained_tokens())
|
||||
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
||||
|
||||
self.persist_and_send_compact_start()?;
|
||||
match self.compact(retained).await {
|
||||
Ok(new_segment_id) => {
|
||||
info!(
|
||||
new_segment_id = %new_segment_id,
|
||||
"Compaction succeeded, resuming execution"
|
||||
);
|
||||
self.persist_and_send_compact_done(new_segment_id)?;
|
||||
if let Some(ref state) = self.compact_state {
|
||||
state.record_compact_success();
|
||||
}
|
||||
@@ -2984,7 +2993,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Compaction failed during run");
|
||||
self.persist_and_send_compact_failed(e.to_string())?;
|
||||
self.alert(
|
||||
AlertLevel::Error,
|
||||
AlertSource::Compactor,
|
||||
@@ -3017,45 +3025,16 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
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 {
|
||||
Ok(new_segment_id) => {
|
||||
info!(
|
||||
new_segment_id = %new_segment_id,
|
||||
"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();
|
||||
}
|
||||
Err(e) => {
|
||||
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(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Compactor,
|
||||
@@ -3113,11 +3092,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
self.join_memory_task().await;
|
||||
self.persist_and_send_compact_start()?;
|
||||
match self.compact(retained).await {
|
||||
Ok(new_segment_id) => {
|
||||
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 {
|
||||
state.record_compact_success();
|
||||
}
|
||||
@@ -3125,7 +3102,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Manual compaction failed");
|
||||
self.persist_and_send_compact_failed(e.to_string())?;
|
||||
self.alert(
|
||||
AlertLevel::Error,
|
||||
AlertSource::Compactor,
|
||||
@@ -3262,20 +3238,79 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact the current session by summarising history via a
|
||||
/// disposable Engine, then replacing history with
|
||||
/// `[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.
|
||||
/// Runs one parent-owned observable compaction service and returns the new
|
||||
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
|
||||
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::{
|
||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
||||
mark_read_required_tool, read_session_items_tool, search_session_log_tool,
|
||||
write_summary_tool,
|
||||
CompactWorkerContext, CompactWorkerInterceptor, CompactionOutputFeature,
|
||||
};
|
||||
use crate::fs_view::WorkerFsView;
|
||||
|
||||
@@ -3385,10 +3420,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
auto_read_budget,
|
||||
)));
|
||||
|
||||
// Build an independent compact worker. It clones the main Worker's
|
||||
// provider handle, so compact-time reads use the same WorkdirSession instance.
|
||||
// No-workdir Workers deliberately omit compact-time filesystem tools.
|
||||
// Build a normal parent-owned Internal Worker over a pinned immutable
|
||||
// capture. Only SessionExplore and compaction output tools are installed.
|
||||
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_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||
let summary_system_prompt = self
|
||||
@@ -3396,51 +3433,129 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.load_full()
|
||||
.compact_system()
|
||||
.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 tracker = summary_usage_tracker.clone();
|
||||
summary_worker.on_usage(move |event| {
|
||||
tracker.record_usage(event);
|
||||
});
|
||||
}
|
||||
let compactor_warning_cb = self.alerter.clone().map(|alerter| {
|
||||
Arc::new(move |message: String| {
|
||||
alerter.alert(AlertLevel::Warn, AlertSource::Compactor, message);
|
||||
}) as Arc<dyn Fn(String) + Send + Sync>
|
||||
});
|
||||
summary_worker.set_interceptor(CompactWorkerInterceptor::new(
|
||||
summary_usage_tracker,
|
||||
let interceptor = CompactWorkerInterceptor::new(
|
||||
summary_usage_tracker.clone(),
|
||||
worker_context_max_tokens,
|
||||
finish_warning_remaining_tokens,
|
||||
final_reserve_tokens,
|
||||
compactor_warning_cb,
|
||||
));
|
||||
summary_worker.set_max_turns(worker_max_turns);
|
||||
);
|
||||
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(),
|
||||
));
|
||||
let required_tools: &'static [&'static str] = if read_only_workdir.is_some() {
|
||||
&[
|
||||
"ShowOverview",
|
||||
"SearchEntries",
|
||||
"ReadEntry",
|
||||
"Read",
|
||||
"mark_read_required",
|
||||
"add_reference",
|
||||
"write_summary",
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
"ShowOverview",
|
||||
"SearchEntries",
|
||||
"ReadEntry",
|
||||
"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
|
||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||
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())?;
|
||||
|
||||
// Tools: read_file (shared scope, fresh tracker), bounded session
|
||||
// history exploration, and compact-specific tools that populate `ctx`.
|
||||
let compact_target_items = Arc::new(items_to_summarise.clone());
|
||||
if let Some(workdir) = workdir.clone() {
|
||||
summary_worker.register_tool(tools::read_tool(workdir.clone(), summary_tracker));
|
||||
summary_worker.register_tool(mark_read_required_tool(workdir, ctx.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:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
summary_worker.register_tool(search_session_log_tool(compact_target_items.clone()));
|
||||
summary_worker.register_tool(read_session_items_tool(compact_target_items));
|
||||
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
||||
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
||||
|
||||
let out = summary_worker
|
||||
.run(summary_input.text)
|
||||
.await
|
||||
.map_err(WorkerError::Engine)?;
|
||||
let mut locked_engine = out.engine;
|
||||
|
||||
// Guard: nudge the worker once more if the expected outputs
|
||||
// (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 {
|
||||
let _ = locked_engine
|
||||
.run(prompt)
|
||||
handle
|
||||
.send(prompt)
|
||||
.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();
|
||||
@@ -3487,10 +3616,24 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
||||
);
|
||||
let _ = locked_engine
|
||||
.run(prompt)
|
||||
handle
|
||||
.send(prompt)
|
||||
.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();
|
||||
summary_text = final_ctx
|
||||
.summary
|
||||
@@ -3694,7 +3837,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.lock()
|
||||
.expect("extract_pointer poisoned") = None;
|
||||
|
||||
Ok(new_segment_id)
|
||||
Ok((new_segment_id, summary_text))
|
||||
}
|
||||
|
||||
/// Build the LlmClient for the compactor Engine.
|
||||
@@ -4080,6 +4223,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
input: input_text,
|
||||
cache_key: Some(self.segment_id().to_string()),
|
||||
max_turns: extract_worker_max_turns,
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
"ShowOverview",
|
||||
@@ -4092,6 +4236,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
workspace: self.workspace_context.clone(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
};
|
||||
let internal_result = match cancel_observer {
|
||||
@@ -4566,6 +4711,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -4643,6 +4789,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -4755,6 +4902,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -5071,6 +5219,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -5725,6 +5874,9 @@ pub enum WorkerError {
|
||||
#[error("compact worker did not produce a summary (write_summary was never called)")]
|
||||
CompactSummaryMissing,
|
||||
|
||||
#[error("compaction was cancelled")]
|
||||
CompactCancelled,
|
||||
|
||||
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
||||
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
@@ -445,10 +445,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
||||
!kinds.contains(&"failed"),
|
||||
"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.
|
||||
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,
|
||||
});
|
||||
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
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "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 {
|
||||
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,
|
||||
});
|
||||
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
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
@@ -817,11 +871,14 @@ async fn controller_compact_method_emits_start_and_done() {
|
||||
.expect("timeout waiting for compact events")
|
||||
.expect("event")
|
||||
{
|
||||
Event::CompactStart => saw_start = true,
|
||||
Event::CompactStart { .. } => saw_start = true,
|
||||
Event::CompactDone { .. } => {
|
||||
break;
|
||||
}
|
||||
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
|
||||
Event::CompactFailed { lifecycle } => panic!(
|
||||
"manual compact failed: {}",
|
||||
lifecycle.error.as_deref().unwrap_or("unknown error")
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user