13 Commits
46 changed files with 3375 additions and 588 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 **禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>`はこの原則で扱う。 新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。 また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
--- ---
+13 -1
View File
@@ -24,7 +24,6 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_PATH_BYTES: usize = 512; pub const MAX_PATH_BYTES: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
#[serde(transparent)]
pub struct VirtualPath(String); pub struct VirtualPath(String);
impl VirtualPath { impl VirtualPath {
@@ -1791,6 +1790,19 @@ mod tests {
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl"); assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
} }
#[test]
fn virtual_path_serde_shape_is_a_string() {
let path = path("profiles/main.dcdl");
assert_eq!(
serde_json::to_value(&path).unwrap(),
serde_json::json!(path.as_str())
);
assert_eq!(
serde_json::from_value::<VirtualPath>(serde_json::json!(path.as_str())).unwrap(),
path
);
}
#[test] #[test]
fn candidate_changes_are_atomic_ordered_and_conflict_checked() { fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
let base = ConfigTreeSnapshot::from_entries( let base = ConfigTreeSnapshot::from_entries(
+2 -2
View File
@@ -358,7 +358,7 @@ pub enum Event {
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`), /// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other /// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output, /// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
/// `<system-reminder>` injection that doesn't break IDLE) do not /// typed system reminder insertion that doesn't break IDLE) do not
/// emit `InvokeStart` — they appear as `SystemItem` only. /// emit `InvokeStart` — they appear as `SystemItem` only.
/// ///
/// Carries `kind` only; the payload (user text / notify message / /// Carries `kind` only; the payload (user text / notify message /
@@ -921,7 +921,7 @@ pub enum InvokeKind {
Notify, Notify,
/// `Method::WorkerEvent` — typed lifecycle report from a child Worker. /// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
WorkerEvent, WorkerEvent,
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run /// A typed system reminder that crosses an IDLE boundary (mid-run
/// reminders that don't break IDLE are SystemItem-only and do not /// reminders that don't break IDLE are SystemItem-only and do not
/// open a new Invoke). /// open a new Invoke).
SystemReminder, SystemReminder,
+3
View File
@@ -554,6 +554,8 @@ pub struct SubscriptionWorker {
/// Producer-owned monotonic revision for this Worker subject. /// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64, pub subject_revision: u64,
pub state: SubscriptionWorkerState, pub state: SubscriptionWorkerState,
#[serde(default)]
pub has_running_internal_workers: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -796,6 +798,7 @@ mod tests {
runtime_id: None, runtime_id: None,
subject_revision: 0, subject_revision: 0,
state: SubscriptionWorkerState::Idle, state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
workspace_id: Some("workspace-1".to_string()), workspace_id: Some("workspace-1".to_string()),
display_name: Some(format!("Worker {value}")), display_name: Some(format!("Worker {value}")),
profile: Some("builtin:coder".to_string()), profile: Some("builtin:coder".to_string()),
+16 -63
View File
@@ -2,8 +2,8 @@
//! //!
//! Items in worker history with `role:system` are never produced by the //! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Worker itself (notifications, //! LLM — they are always inserted by the Worker itself (notifications,
//! file ref resolutions, child-worker lifecycle events, //! file ref resolutions, child-worker lifecycle events, reminders, …).
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the //! [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on //! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or //! `kind` instead of parsing text prefixes like `[Notification] …` or
//! `[File: …]`. //! `[File: …]`.
@@ -22,10 +22,7 @@ use llm_engine::llm_client::types::Item;
use protocol::WorkerEvent; use protocol::WorkerEvent;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>"; /// Source policy that produced a durable system reminder input.
const SYSTEM_REMINDER_CLOSE: &str = "</system-reminder>";
/// Source policy that produced a durable `<system-reminder>` input.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SystemReminderSource { pub enum SystemReminderSource {
@@ -52,57 +49,30 @@ pub struct SystemReminder {
} }
impl SystemReminder { impl SystemReminder {
/// Build a task-inactivity reminder from an unwrapped body. /// Build a task-inactivity reminder from its plain system-message body.
pub fn task_inactivity(body: impl Into<String>) -> Self { pub fn task_inactivity(body: impl Into<String>) -> Self {
Self::new(SystemReminderSource::TaskInactivity, body) Self::new(SystemReminderSource::TaskInactivity, body)
} }
/// Build a reminder from an unwrapped body. If a caller passes a body that /// Build a reminder whose body is committed verbatim as a system message.
/// is already exactly wrapped in `<system-reminder>` tags, normalize it back
/// to the inner body so rendering still wraps exactly once.
pub fn new(source: SystemReminderSource, body: impl Into<String>) -> Self { pub fn new(source: SystemReminderSource, body: impl Into<String>) -> Self {
let body = normalize_unwrapped_system_reminder_body(body.into()); Self {
Self { source, body } source,
body: body.into(),
} }
pub fn source(&self) -> SystemReminderSource {
self.source
}
pub fn body(&self) -> &str {
&self.body
}
pub fn rendered_body(&self) -> String {
render_system_reminder(&self.body)
} }
pub fn into_system_item(self) -> SystemItem { pub fn into_system_item(self) -> SystemItem {
match self.source { match self.source {
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder { SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
source: self.source, source: self.source,
body: self.rendered_body(), body: self.body,
prompt_provenance: None, prompt_provenance: None,
}, },
} }
} }
} }
fn normalize_unwrapped_system_reminder_body(body: String) -> String {
let trimmed = body.trim();
if let Some(inner) = trimmed
.strip_prefix(SYSTEM_REMINDER_OPEN)
.and_then(|rest| rest.strip_suffix(SYSTEM_REMINDER_CLOSE))
{
return inner.trim_matches('\n').to_string();
}
body
}
fn render_system_reminder(body: &str) -> String {
format!("{SYSTEM_REMINDER_OPEN}\n{body}\n{SYSTEM_REMINDER_CLOSE}")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PromptRenderProvenance { pub struct PromptRenderProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -178,7 +148,7 @@ pub enum SystemItem {
/// Task-management inactivity reminder inserted before an LLM request. /// Task-management inactivity reminder inserted before an LLM request.
/// `source` is the policy that produced this durable reminder; `body` is /// `source` is the policy that produced this durable reminder; `body` is
/// the exact LLM-context text wrapped in a `<system-reminder>` block. /// the exact plain system-message text committed to LLM context.
TaskReminder { TaskReminder {
#[serde(default = "default_task_reminder_source")] #[serde(default = "default_task_reminder_source")]
source: SystemReminderSource, source: SystemReminderSource,
@@ -324,21 +294,9 @@ mod tests {
} }
#[test] #[test]
fn system_reminder_renders_body_once() { fn system_reminder_preserves_plain_body() {
let reminder = SystemReminder::task_inactivity("remember tasks"); let item = SystemReminder::task_inactivity("remember tasks").into_system_item();
assert_eq!( assert_eq!(item.history_text(), "remember tasks");
reminder.rendered_body(),
"<system-reminder>\nremember tasks\n</system-reminder>"
);
let already_wrapped = SystemReminder::task_inactivity(
"<system-reminder>\nremember tasks\n</system-reminder>",
);
assert_eq!(already_wrapped.body(), "remember tasks");
assert_eq!(
already_wrapped.rendered_body(),
"<system-reminder>\nremember tasks\n</system-reminder>"
);
} }
#[test] #[test]
@@ -347,10 +305,7 @@ mod tests {
match item { match item {
SystemItem::TaskReminder { source, body, .. } => { SystemItem::TaskReminder { source, body, .. } => {
assert_eq!(source, SystemReminderSource::TaskInactivity); assert_eq!(source, SystemReminderSource::TaskInactivity);
assert_eq!( assert_eq!(body, "remember tasks");
body,
"<system-reminder>\nremember tasks\n</system-reminder>"
);
} }
other => panic!("unexpected: {other:?}"), other => panic!("unexpected: {other:?}"),
} }
@@ -358,10 +313,8 @@ mod tests {
#[test] #[test]
fn task_reminder_deserialization_defaults_legacy_source() { fn task_reminder_deserialization_defaults_legacy_source() {
let parsed: SystemItem = serde_json::from_str( let parsed: SystemItem =
r#"{"kind":"task_reminder","body":"<system-reminder>\nbody\n</system-reminder>"}"#, serde_json::from_str(r#"{"kind":"task_reminder","body":"legacy body"}"#).unwrap();
)
.unwrap();
match parsed { match parsed {
SystemItem::TaskReminder { source, .. } => { SystemItem::TaskReminder { source, .. } => {
assert_eq!(source, SystemReminderSource::TaskInactivity); assert_eq!(source, SystemReminderSource::TaskInactivity);
+359 -12
View File
@@ -233,6 +233,12 @@ pub struct InternalWorkerView {
pub app: Box<App>, pub app: Box<App>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerViewTab {
pub label: String,
pub selected: bool,
}
pub struct App { pub struct App {
pub worker_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
@@ -281,8 +287,11 @@ pub struct App {
/// replayable conversation rows during segment rotation. /// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>, run_error_messages: Vec<String>,
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate sub-panes and never mixed into `blocks`. /// They are rendered in separate selectable views and never mixed into `blocks`.
pub internal_workers: Vec<InternalWorkerView>, pub internal_workers: Vec<InternalWorkerView>,
/// Selected Internal Worker transcript/task view. `None` is the parent (`main`)
/// view; the stable session identity survives projection reordering.
selected_internal_worker_session_id: Option<String>,
/// Terminal child-session fences, reset only by an authoritative snapshot. /// Terminal child-session fences, reset only by an authoritative snapshot.
removed_internal_workers: HashMap<String, u64>, removed_internal_workers: HashMap<String, u64>,
pub scroll: Scroll, pub scroll: Scroll,
@@ -363,6 +372,7 @@ impl App {
blocks: Vec::new(), blocks: Vec::new(),
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
selected_internal_worker_session_id: None,
removed_internal_workers: HashMap::new(), removed_internal_workers: HashMap::new(),
scroll: Scroll::default(), scroll: Scroll::default(),
mode: Mode::Normal, mode: Mode::Normal,
@@ -448,16 +458,98 @@ impl App {
pub fn toggle_task_pane(&mut self) { pub fn toggle_task_pane(&mut self) {
self.task_pane_open = !self.task_pane_open; self.task_pane_open = !self.task_pane_open;
if !self.task_pane_open { if !self.task_pane_open {
self.task_pane_scroll = 0; self.selected_worker_view_mut().task_pane_scroll = 0;
}
}
pub fn worker_view_tabs(&self) -> Vec<WorkerViewTab> {
let selected = self.selected_internal_worker_session_id.as_deref();
let mut tabs = Vec::with_capacity(self.internal_workers.len().saturating_add(1));
tabs.push(WorkerViewTab {
label: "main".to_owned(),
selected: selected.is_none(),
});
tabs.extend(self.internal_workers.iter().map(|view| {
WorkerViewTab {
label: view
.worker
.name
.lines()
.next()
.filter(|name| !name.is_empty())
.unwrap_or("subworker")
.to_owned(),
selected: selected == Some(view.worker.session_id.as_str()),
}
}));
tabs
}
pub fn selected_internal_worker_index(&self) -> Option<usize> {
let selected = self.selected_internal_worker_session_id.as_deref()?;
self.internal_workers
.iter()
.position(|view| view.worker.session_id == selected)
}
pub fn selected_worker_view(&self) -> &App {
self.selected_internal_worker_index()
.map(|index| self.internal_workers[index].app.as_ref())
.unwrap_or(self)
}
pub fn selected_worker_view_mut(&mut self) -> &mut App {
if let Some(index) = self.selected_internal_worker_index() {
self.internal_workers[index].app.as_mut()
} else {
self
}
}
/// Cycle the presentation-only transcript/task view. Input and control
/// methods continue to target the parent Worker regardless of selection.
pub fn cycle_worker_view(&mut self) -> bool {
if self.internal_workers.is_empty() {
self.selected_internal_worker_session_id = None;
return false;
}
self.selected_internal_worker_session_id = self
.selected_internal_worker_index()
.and_then(|index| self.internal_workers.get(index.saturating_add(1)))
.map(|view| view.worker.session_id.clone())
.or_else(|| {
if self.selected_internal_worker_session_id.is_none() {
self.internal_workers
.first()
.map(|view| view.worker.session_id.clone())
} else {
None
}
});
true
}
pub fn cycle_mode(&mut self) {
let mode = self.mode.cycle();
self.set_mode_recursively(mode);
}
fn set_mode_recursively(&mut self, mode: Mode) {
self.mode = mode;
for view in &mut self.internal_workers {
view.app.set_mode_recursively(mode);
} }
} }
pub fn scroll_task_pane_up(&mut self, n: usize) { pub fn scroll_task_pane_up(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n); let view = self.selected_worker_view_mut();
view.task_pane_scroll = view.task_pane_scroll.saturating_sub(n);
} }
pub fn scroll_task_pane_down(&mut self, n: usize) { pub fn scroll_task_pane_down(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n); let view = self.selected_worker_view_mut();
view.task_pane_scroll = view.task_pane_scroll.saturating_add(n);
} }
pub fn set_worker_status(&mut self, status: WorkerStatus) { pub fn set_worker_status(&mut self, status: WorkerStatus) {
@@ -1747,6 +1839,9 @@ impl App {
} }
pub fn request_rewind_picker(&mut self) -> Option<Method> { pub fn request_rewind_picker(&mut self) -> Option<Method> {
// Rewind is a parent Worker control surface. Bring the parent transcript
// back into view before presenting diagnostics or the picker.
self.selected_internal_worker_session_id = None;
if self.rewind_submit_pending() { if self.rewind_submit_pending() {
self.push_command_diagnostic( self.push_command_diagnostic(
"rewind is already applying; wait for the Worker response", "rewind is already applying; wait for the Worker response",
@@ -2007,15 +2102,66 @@ impl App {
/// produced. Followed by `Event::Entry` updates for anything /// produced. Followed by `Event::Entry` updates for anything
/// committed after the snapshot. /// committed after the snapshot.
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) { fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
let mode = self.mode;
let mut previous = std::mem::take(&mut self.internal_workers);
self.internal_workers = snapshots self.internal_workers = snapshots
.into_iter() .into_iter()
.map(Self::internal_worker_view_from_snapshot) .map(|snapshot| {
if let Some(index) = previous
.iter()
.position(|view| view.worker.session_id == snapshot.worker.session_id)
{
let view = previous.remove(index);
Self::update_internal_worker_view_from_snapshot(view, snapshot, mode)
} else {
Self::internal_worker_view_from_snapshot(snapshot, mode)
}
})
.collect(); .collect();
if self.selected_internal_worker_index().is_none() {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers.clear(); self.removed_internal_workers.clear();
} }
fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView { fn update_internal_worker_view_from_snapshot(
mut previous: InternalWorkerView,
snapshot: InternalWorkerSnapshot,
mode: Mode,
) -> InternalWorkerView {
let mut refreshed = Self::internal_worker_view_from_snapshot(snapshot, mode);
Self::transfer_worker_view_state(&mut previous.app, &mut refreshed.app);
refreshed
}
fn transfer_worker_view_state(previous: &mut App, refreshed: &mut App) {
refreshed.scroll = std::mem::take(&mut previous.scroll);
refreshed.text_selection = std::mem::take(&mut previous.text_selection);
refreshed.task_pane_scroll = previous.task_pane_scroll;
refreshed.selected_internal_worker_session_id =
previous.selected_internal_worker_session_id.take();
let mut previous_children = std::mem::take(&mut previous.internal_workers);
for child in &mut refreshed.internal_workers {
if let Some(index) = previous_children
.iter()
.position(|old| old.worker.session_id == child.worker.session_id)
{
let mut old = previous_children.remove(index);
Self::transfer_worker_view_state(&mut old.app, &mut child.app);
}
}
if refreshed.selected_internal_worker_index().is_none() {
refreshed.selected_internal_worker_session_id = None;
}
}
fn internal_worker_view_from_snapshot(
snapshot: InternalWorkerSnapshot,
mode: Mode,
) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone()); let mut app = App::new(snapshot.worker.name.clone());
app.mode = mode;
app.restore_entries(&snapshot.entries, None); app.restore_entries(&snapshot.entries, None);
app.apply_in_flight_snapshot(snapshot.in_flight); app.apply_in_flight_snapshot(snapshot.in_flight);
app.set_worker_status(snapshot.status); app.set_worker_status(snapshot.status);
@@ -2052,10 +2198,12 @@ impl App {
let target = if let Some(index) = index { let target = if let Some(index) = index {
&mut self.internal_workers[index] &mut self.internal_workers[index]
} else { } else {
let mut app = App::new(worker.name.clone());
app.mode = self.mode;
self.internal_workers.push(InternalWorkerView { self.internal_workers.push(InternalWorkerView {
worker: worker.clone(), worker: worker.clone(),
revision: 0, revision: 0,
app: Box::new(App::new(worker.name.clone())), app: Box::new(app),
}); });
self.internal_workers.last_mut().unwrap() self.internal_workers.last_mut().unwrap()
}; };
@@ -2068,13 +2216,17 @@ impl App {
} }
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
let session_id = worker.session_id;
let Some(index) = self let Some(index) = self
.internal_workers .internal_workers
.iter() .iter()
.position(|candidate| candidate.worker.session_id == worker.session_id) .position(|candidate| candidate.worker.session_id == session_id)
else { else {
if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers self.removed_internal_workers
.entry(worker.session_id) .entry(session_id)
.and_modify(|current| *current = (*current).max(revision)) .and_modify(|current| *current = (*current).max(revision))
.or_insert(revision); .or_insert(revision);
return; return;
@@ -2083,8 +2235,10 @@ impl App {
return; return;
} }
self.internal_workers.remove(index); self.internal_workers.remove(index);
self.removed_internal_workers if self.selected_internal_worker_session_id.as_deref() == Some(session_id.as_str()) {
.insert(worker.session_id, revision); self.selected_internal_worker_session_id = None;
}
self.removed_internal_workers.insert(session_id, revision);
} }
fn restore_snapshot( fn restore_snapshot(
@@ -2296,11 +2450,14 @@ impl App {
} }
session_store::SystemItem::FileAttachment { body, .. } session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::TaskReminder { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => { | session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body); self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body }); self.blocks.push(Block::SystemMessage { text: body });
} }
session_store::SystemItem::TaskReminder { body, .. } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::TaskReminder { text: body });
}
session_store::SystemItem::LegacyIgnored { .. } => {} session_store::SystemItem::LegacyIgnored { .. } => {}
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {} session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
} }
@@ -2645,6 +2802,7 @@ mod rewind_refresh_tests {
app.blocks.iter().any(|block| match block { app.blocks.iter().any(|block| match block {
Block::AssistantText { text } Block::AssistantText { text }
| Block::SystemMessage { text } | Block::SystemMessage { text }
| Block::TaskReminder { text }
| Block::Alert { message: text, .. } => text.contains(needle), | Block::Alert { message: text, .. } => text.contains(needle),
Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle), Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle),
_ => false, _ => false,
@@ -3579,6 +3737,188 @@ mod completion_flow_tests {
); );
} }
fn test_internal_worker_snapshot(
session_id: &str,
name: &str,
revision: u64,
) -> InternalWorkerSnapshot {
InternalWorkerSnapshot {
worker: InternalWorkerRef {
session_id: session_id.into(),
name: name.into(),
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision,
status: WorkerStatus::Idle,
entries: Vec::new(),
in_flight: protocol::InFlightSnapshot::default(),
error: None,
internal_workers: Vec::new(),
}
}
#[test]
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
let mut app = App::new("parent".into());
for (session_id, name) in [("child-a", "alpha"), ("child-b", "beta")] {
app.internal_workers.push(InternalWorkerView {
worker: InternalWorkerRef {
session_id: session_id.into(),
name: name.into(),
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new(name.into())),
});
}
assert_eq!(app.selected_worker_view().worker_name, "parent");
assert!(app.cycle_worker_view());
assert_eq!(app.selected_worker_view().worker_name, "alpha");
app.internal_workers.swap(0, 1);
assert_eq!(app.selected_worker_view().worker_name, "alpha");
assert!(app.cycle_worker_view());
assert_eq!(app.selected_worker_view().worker_name, "parent");
assert!(app.cycle_worker_view());
assert_eq!(app.selected_worker_view().worker_name, "beta");
}
#[test]
fn worker_view_cycle_preserves_each_views_text_selection() {
use crate::text_selection::{HistoryViewport, SelectionRow};
fn select_first_row(app: &mut App, text: &str) {
app.text_selection.set_history_snapshot(
HistoryViewport {
x: 0,
y: 0,
width: 20,
height: 1,
top_offset: 0,
total_lines: 1,
},
vec![SelectionRow::new(text.into(), true)],
);
assert!(app.text_selection.begin_drag(0, 0));
}
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1,
)]);
select_first_row(&mut app, "parent selection");
select_first_row(app.internal_workers[0].app.as_mut(), "child selection");
app.cycle_worker_view();
assert!(app.selected_worker_view().text_selection.has_selection());
app.cycle_worker_view();
assert!(app.text_selection.has_selection());
assert!(app.internal_workers[0].app.text_selection.has_selection());
}
#[test]
fn snapshot_removal_falls_selected_worker_view_back_to_main() {
let mut app = App::new("parent".into());
app.internal_workers.push(InternalWorkerView {
worker: InternalWorkerRef {
session_id: "old".into(),
name: "old".into(),
parent_session_id: Some("parent".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new("old".into())),
});
app.cycle_worker_view();
assert_eq!(app.selected_worker_view().worker_name, "old");
app.replace_internal_worker_snapshots(Vec::new());
assert_eq!(app.selected_worker_view().worker_name, "parent");
assert_eq!(
app.worker_view_tabs(),
vec![WorkerViewTab {
label: "main".into(),
selected: true,
}]
);
}
#[test]
fn same_session_snapshot_preserves_subworker_view_local_state() {
use crate::text_selection::{HistoryViewport, SelectionRow};
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child", "child", 1,
)]);
let child = app.internal_workers[0].app.as_mut();
child.scroll.follow_tail = false;
child.scroll.top_offset = 7;
child.task_pane_scroll = 4;
child.text_selection.set_history_snapshot(
HistoryViewport {
x: 0,
y: 0,
width: 20,
height: 1,
top_offset: 0,
total_lines: 1,
},
vec![SelectionRow::new("selected".into(), true)],
);
assert!(child.text_selection.begin_drag(0, 0));
app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot(
"child",
"renamed-child",
2,
)]);
let view = &app.internal_workers[0];
assert_eq!(view.revision, 2);
assert_eq!(view.app.worker_name, "renamed-child");
assert!(!view.app.scroll.follow_tail);
assert_eq!(view.app.scroll.top_offset, 7);
assert_eq!(view.app.task_pane_scroll, 4);
assert!(view.app.text_selection.has_selection());
}
#[test]
fn task_pane_scroll_is_local_to_selected_worker_view() {
let mut app = App::new("parent".into());
app.replace_internal_worker_snapshots(vec![
test_internal_worker_snapshot("child-a", "alpha", 1),
test_internal_worker_snapshot("child-b", "beta", 1),
]);
app.task_pane_scroll = 3;
app.cycle_worker_view();
app.scroll_task_pane_down(5);
assert_eq!(app.selected_worker_view().task_pane_scroll, 5);
app.cycle_worker_view();
app.scroll_task_pane_down(7);
assert_eq!(app.selected_worker_view().task_pane_scroll, 7);
app.cycle_worker_view();
assert_eq!(app.selected_worker_view().worker_name, "parent");
assert_eq!(app.task_pane_scroll, 3);
assert_eq!(app.internal_workers[0].app.task_pane_scroll, 5);
assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7);
app.cycle_worker_view();
app.toggle_task_pane();
app.toggle_task_pane();
assert_eq!(app.internal_workers[0].app.task_pane_scroll, 0);
assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7);
assert_eq!(app.task_pane_scroll, 3);
}
#[test] #[test]
fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() { fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
@@ -3607,6 +3947,8 @@ mod completion_flow_tests {
}); });
assert_eq!(app.internal_workers.len(), 1); assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1); assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1);
app.cycle_worker_view();
assert_eq!(app.selected_worker_view().worker_name, "child");
app.handle_worker_event(Event::InternalWorkerRemoved { app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(), worker: worker.clone(),
@@ -3621,6 +3963,7 @@ mod completion_flow_tests {
}); });
assert!(app.internal_workers.is_empty()); assert!(app.internal_workers.is_empty());
assert_eq!(app.selected_worker_view().worker_name, "parent");
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: Vec::new(), entries: Vec::new(),
@@ -3962,6 +4305,10 @@ mod completion_flow_tests {
assert_eq!(tasks.len(), 1); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 4); assert_eq!(tasks[0].taskid, 4);
assert_eq!(tasks[0].subject, "from snapshot"); assert_eq!(tasks[0].subject, "from snapshot");
assert!(matches!(
app.blocks.last(),
Some(Block::TaskReminder { text }) if text == snapshot
));
} }
#[test] #[test]
+5
View File
@@ -25,6 +25,11 @@ pub enum Block {
SystemMessage { SystemMessage {
text: String, text: String,
}, },
/// Typed task reminder. Its presentation depends on the selected history
/// mode rather than exposing the full reminder in compact views.
TaskReminder {
text: String,
},
/// Echo of `Method::Notify` received by this Worker, surfaced as a log /// Echo of `Method::Notify` received by this Worker, surfaced as a log
/// element so subscribers see the external input that drove any /// element so subscribers see the external input that drove any
/// following auto-kicked turn. /// following auto-kicked turn.
+128 -25
View File
@@ -96,12 +96,12 @@ fn copy_to_terminal_clipboard<W: io::Write>(out: &mut W, text: &str) -> io::Resu
} }
fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool { fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool {
let Some(text) = app.text_selection.copy_text() else { let Some(text) = app.selected_worker_view_mut().text_selection.copy_text() else {
return false; return false;
}; };
let result = copy_to_terminal_clipboard(out, &text); let result = copy_to_terminal_clipboard(out, &text);
app.text_selection.clear(); app.selected_worker_view_mut().text_selection.clear();
match result { match result {
Ok(()) => { Ok(()) => {
app.flash_actionbar_notice( app.flash_actionbar_notice(
@@ -890,25 +890,27 @@ const WHEEL_LINES: usize = 3;
const PANE_SCROLL_LINES: usize = 5; const PANE_SCROLL_LINES: usize = 5;
fn handle_mouse(app: &mut App, mouse: MouseEvent) { fn handle_mouse(app: &mut App, mouse: MouseEvent) {
let rewind_picker_open = app.rewind_picker.is_some();
let view = app.selected_worker_view_mut();
match mouse.kind { match mouse.kind {
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
app.text_selection.clear(); view.text_selection.clear();
app.scroll.scroll_up(WHEEL_LINES); view.scroll.scroll_up(WHEEL_LINES);
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
app.text_selection.clear(); view.text_selection.clear();
app.scroll.scroll_down(WHEEL_LINES); view.scroll.scroll_down(WHEEL_LINES);
} }
MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => { MouseEventKind::Down(MouseButton::Left) if !rewind_picker_open => {
if !app.text_selection.begin_drag(mouse.column, mouse.row) { if !view.text_selection.begin_drag(mouse.column, mouse.row) {
app.text_selection.clear(); view.text_selection.clear();
} }
} }
MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => { MouseEventKind::Drag(MouseButton::Left) if !rewind_picker_open => {
app.text_selection.update_drag(mouse.column, mouse.row); view.text_selection.update_drag(mouse.column, mouse.row);
} }
MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => { MouseEventKind::Up(MouseButton::Left) if !rewind_picker_open => {
app.text_selection.finish_drag(mouse.column, mouse.row); view.text_selection.finish_drag(mouse.column, mouse.row);
} }
_ => {} _ => {}
} }
@@ -942,31 +944,31 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
// Modifier-key bindings. // Modifier-key bindings.
if let Some(method) = match key.code { if let Some(method) = match key.code {
KeyCode::Up if shift => { KeyCode::Up if shift => {
app.scroll.scroll_up(1); app.selected_worker_view_mut().scroll.scroll_up(1);
Some(None) Some(None)
} }
KeyCode::Down if shift => { KeyCode::Down if shift => {
app.scroll.scroll_down(1); app.selected_worker_view_mut().scroll.scroll_down(1);
Some(None) Some(None)
} }
KeyCode::Home if ctrl => { KeyCode::Home if ctrl => {
app.scroll.to_top(); app.selected_worker_view_mut().scroll.to_top();
Some(None) Some(None)
} }
KeyCode::End if ctrl => { KeyCode::End if ctrl => {
app.scroll.to_bottom(); app.selected_worker_view_mut().scroll.to_bottom();
Some(None) Some(None)
} }
KeyCode::Char('[') if ctrl => { KeyCode::Char('[') if ctrl => {
app.scroll.jump_prev_turn(); app.selected_worker_view_mut().scroll.jump_prev_turn();
Some(None) Some(None)
} }
KeyCode::Char(']') if ctrl => { KeyCode::Char(']') if ctrl => {
app.scroll.jump_next_turn(); app.selected_worker_view_mut().scroll.jump_next_turn();
Some(None) Some(None)
} }
KeyCode::Char('o') if ctrl => { KeyCode::Char('o') if ctrl => {
app.mode = app.mode.cycle(); app.cycle_mode();
Some(None) Some(None)
} }
KeyCode::Char('t') if ctrl => { KeyCode::Char('t') if ctrl => {
@@ -1047,7 +1049,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
if app.task_pane_open { if app.task_pane_open {
app.scroll_task_pane_up(PANE_SCROLL_LINES); app.scroll_task_pane_up(PANE_SCROLL_LINES);
} else { } else {
app.scroll.page_up(); app.selected_worker_view_mut().scroll.page_up();
} }
return None; return None;
} }
@@ -1055,7 +1057,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
if app.task_pane_open { if app.task_pane_open {
app.scroll_task_pane_down(PANE_SCROLL_LINES); app.scroll_task_pane_down(PANE_SCROLL_LINES);
} else { } else {
app.scroll.page_down(); app.selected_worker_view_mut().scroll.page_down();
} }
return None; return None;
} }
@@ -1130,12 +1132,17 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
} }
} }
if key.code == KeyCode::Tab && key.modifiers.is_empty() && app.completion.is_none() {
app.cycle_worker_view();
return None;
}
if key.modifiers.is_empty() { if key.modifiers.is_empty() {
match key.code { match key.code {
KeyCode::Esc if app.text_selection.clear() => return None, KeyCode::Esc if app.selected_worker_view_mut().text_selection.clear() => return None,
KeyCode::Char('y') if app.text_selection.has_selection() => { KeyCode::Char('y') if app.selected_worker_view().text_selection.has_selection() => {
if !copy_selection_to_terminal(app) { if !copy_selection_to_terminal(app) {
app.text_selection.clear(); app.selected_worker_view_mut().text_selection.clear();
app.flash_actionbar_notice( app.flash_actionbar_notice(
"Selection contains no copyable text.", "Selection contains no copyable text.",
ActionbarNoticeLevel::Warn, ActionbarNoticeLevel::Warn,
@@ -2170,6 +2177,18 @@ mod tests {
#[test] #[test]
fn command_completion_tab_applies_unambiguous_candidate() { fn command_completion_tab_applies_unambiguous_candidate() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::InternalWorker {
worker: protocol::InternalWorkerRef {
session_id: "child-session".into(),
name: "subworker-hoge".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
}),
});
enter_command_mode(&mut app); enter_command_mode(&mut app);
type_keys(&mut app, "no"); type_keys(&mut app, "no");
@@ -2177,6 +2196,7 @@ mod tests {
assert!(app.is_command_mode()); assert!(app.is_command_mode());
assert_eq!(app.command_text(), "noop "); assert_eq!(app.command_text(), "noop ");
assert_eq!(app.selected_worker_view().worker_name, "agent");
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
@@ -2269,6 +2289,89 @@ mod tests {
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
#[test]
fn tab_cycles_main_and_subworker_view_without_changing_composer() {
let mut app = App::new("agent".to_string());
type_keys(&mut app, "hello");
app.handle_worker_event(Event::InternalWorker {
worker: protocol::InternalWorkerRef {
session_id: "child-session".into(),
name: "subworker-hoge".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
}),
});
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
assert_eq!(input_text(&app), "hello");
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
assert_eq!(app.selected_worker_view().worker_name, "agent");
assert_eq!(input_text(&app), "hello");
}
#[test]
fn subworker_view_does_not_redirect_parent_worker_controls() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Idle);
app.handle_worker_event(Event::InternalWorker {
worker: protocol::InternalWorkerRef {
session_id: "child-session".into(),
name: "subworker-hoge".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
}),
});
handle_key(&mut app, key(KeyCode::Tab));
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
let method = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(matches!(method, Some(Method::Shutdown)));
assert_eq!(app.worker_status, WorkerStatus::Idle);
}
#[test]
fn active_composer_completion_takes_tab_priority_over_worker_view_cycle() {
let mut app = App::new("agent".to_string());
app.insert_char('@');
app.insert_char('s');
let _ = app.refresh_completion();
app.completion.as_mut().unwrap().entries = vec![protocol::CompletionEntry {
value: "src/main.rs".into(),
is_dir: false,
}];
app.handle_worker_event(Event::InternalWorker {
worker: protocol::InternalWorkerRef {
session_id: "child-session".into(),
name: "subworker-hoge".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
}),
});
let _ = handle_key(&mut app, key(KeyCode::Tab));
assert_eq!(app.selected_worker_view().worker_name, "agent");
assert_eq!(input_text(&app), "@src/main.rs");
}
#[test] #[test]
fn command_completion_does_not_affect_normal_composer_without_popup() { fn command_completion_does_not_affect_normal_composer_without_popup() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
+6
View File
@@ -2980,6 +2980,12 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
.expect("idle orchestrator should receive queued-work attention"); .expect("idle orchestrator should receive queued-work attention");
assert_eq!(request.worker_name, "test-orchestrator"); assert_eq!(request.worker_name, "test-orchestrator");
assert!(
request
.notice
.message
.starts_with("Workspace Dashboard observed")
);
assert!(request.notice.message.contains("00001QUEUE")); assert!(request.notice.message.contains("00001QUEUE"));
assert!(request.notice.message.contains("new_queued")); assert!(request.notice.message.contains("new_queued"));
assert!(request.notice.message.contains("queued -> inprogress")); assert!(request.notice.message.contains("queued -> inprogress"));
+153
View File
@@ -6,6 +6,8 @@
//! `Read`) consume multiple consecutive blocks to produce a single //! `Read`) consume multiple consecutive blocks to produce a single
//! aggregate display. //! aggregate display.
use std::collections::BTreeMap;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
@@ -38,6 +40,9 @@ pub fn render_tool(
consumed: 1, consumed: 1,
}; };
}; };
if mode == Mode::Overview {
return render_overview_activity(blocks, start);
}
match tc.name.as_str() { match tc.name.as_str() {
"Read" => render_read_aggregate(blocks, start, mode), "Read" => render_read_aggregate(blocks, start, mode),
@@ -49,6 +54,154 @@ pub fn render_tool(
} }
} }
#[derive(Debug, Default, Clone, Copy)]
struct ActivityCount {
total: usize,
active: bool,
}
impl ActivityCount {
fn add(&mut self, state: &ToolCallState) {
self.total += 1;
self.active |= matches!(
state,
ToolCallState::Pending | ToolCallState::Streaming | ToolCallState::Executing
);
}
}
fn render_overview_activity(blocks: &[Block], start: usize) -> ToolRenderOutput {
let mut end = start;
let mut tools = Vec::new();
while let Some(block) = blocks.get(end) {
match block {
Block::ToolCall(tool) => tools.push(tool),
Block::Thinking(_) | Block::TaskReminder { .. } => {}
_ => break,
}
end += 1;
}
let mut reads = ActivityCount::default();
let mut searches = ActivityCount::default();
let mut commands = ActivityCount::default();
let mut edits = ActivityCount::default();
let mut writes = ActivityCount::default();
let mut additions = 0;
let mut deletions = 0;
let mut failed = 0;
let mut incomplete = 0;
let mut others = BTreeMap::<String, ActivityCount>::new();
for tool in tools {
if matches!(tool.state, ToolCallState::Error { .. }) {
failed += 1;
}
if matches!(tool.state, ToolCallState::Incomplete) {
incomplete += 1;
}
match tool.name.as_str() {
"Read" => reads.add(&tool.state),
"Glob" | "Grep" | "WebSearch" | "SearchSessionEntries" => searches.add(&tool.state),
"Bash" => commands.add(&tool.state),
"Edit" => {
edits.add(&tool.state);
if matches!(tool.state, ToolCallState::Done { .. })
&& let Some(arguments) = tool.arguments.as_deref()
&& let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments)
{
if let Some(old) = args.get("old_string").and_then(|value| value.as_str()) {
deletions += old.lines().count().max(1);
}
if let Some(new) = args.get("new_string").and_then(|value| value.as_str()) {
additions += new.lines().count().max(1);
}
}
}
"Write" => writes.add(&tool.state),
name => others.entry(name.to_owned()).or_default().add(&tool.state),
}
}
let mut primary = Vec::new();
if reads.total > 0 {
primary.push(if reads.active {
format!("reading {} file{}", reads.total, plural(reads.total))
} else {
format!("{} file{} read", reads.total, plural(reads.total))
});
}
if searches.total > 0 {
primary.push(if searches.active {
format!(
"searching {} time{}",
searches.total,
plural(searches.total)
)
} else {
format!("searched {} time{}", searches.total, plural(searches.total))
});
}
if commands.total > 0 {
primary.push(if commands.active {
format!(
"running {} command{}",
commands.total,
plural(commands.total)
)
} else {
format!("ran {} command{}", commands.total, plural(commands.total))
});
}
for (name, count) in others {
primary.push(if count.total == 1 {
name
} else {
format!("{} {name}", count.total)
});
}
let mut summary = Vec::new();
if !primary.is_empty() {
summary.push(primary.join(""));
}
if edits.total > 0 {
summary.push(if edits.active {
format!("editing {} file{}", edits.total, plural(edits.total))
} else if additions > 0 || deletions > 0 {
format!("edited +{additions}/-{deletions}")
} else {
format!("edited {} file{}", edits.total, plural(edits.total))
});
}
if writes.total > 0 {
summary.push(if writes.active {
format!("writing {} file{}", writes.total, plural(writes.total))
} else {
format!("wrote {} file{}", writes.total, plural(writes.total))
});
}
if failed > 0 {
summary.push(format!("{failed} failed"));
}
if incomplete > 0 {
summary.push(format!("{incomplete} incomplete"));
}
let color = if failed > 0 {
Color::Red
} else {
Color::DarkGray
};
ToolRenderOutput {
lines: summary
.into_iter()
.map(|text| Line::from(Span::styled(text, Style::default().fg(color))))
.collect(),
consumed: end.saturating_sub(start).max(1),
}
}
fn single(lines: Vec<Line<'static>>) -> ToolRenderOutput { fn single(lines: Vec<Line<'static>>) -> ToolRenderOutput {
ToolRenderOutput { lines, consumed: 1 } ToolRenderOutput { lines, consumed: 1 }
} }
+317 -38
View File
@@ -27,7 +27,9 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent}; use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens}; use crate::app::{
ActionbarNoticeLevel, App, CompletionState, WorkerViewTab, alert_source_label, fmt_tokens,
};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
use crate::command::CommandCandidate; use crate::command::CommandCandidate;
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
@@ -52,7 +54,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
app.input app.input
.apply_cursor_viewport(&mut input_render, input_height); .apply_cursor_viewport(&mut input_render, input_height);
} }
let mini_view_h = task_mini_view_height(&app.task_store); let tabs = app.worker_view_tabs();
let show_tabs = tabs.len() > 1;
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
// One blank row separates the history tail from the mini-view so // One blank row separates the history tail from the mini-view so
// the latest message doesn't visually crash into the task summary. // the latest message doesn't visually crash into the task summary.
// Folds away with the mini-view when there are no tasks. // Folds away with the mini-view when there are no tasks.
@@ -69,11 +73,26 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
]) ])
.split(area); .split(area);
let selected_index = app.selected_internal_worker_index();
if let Some(index) = selected_index {
let task_pane_open = app.task_pane_open;
let view = app.internal_workers[index].app.as_mut();
view.task_pane_open = task_pane_open;
draw_history(frame, view, chunks[0]);
} else {
draw_history(frame, app, chunks[0]); draw_history(frame, app, chunks[0]);
}
if mini_view_h > 0 { if mini_view_h > 0 {
draw_task_mini_view(frame, &app.task_store, chunks[2]); draw_task_mini_view(
frame,
&app.selected_worker_view().task_store,
&tabs,
chunks[2],
);
} }
draw_separator(frame, chunks[3]); draw_separator(frame, chunks[3]);
// Status/composer/control surfaces remain parent-owned. View selection changes
// only transcript/task presentation and never implies SubWorker control.
draw_status(frame, app, chunks[4]); draw_status(frame, app, chunks[4]);
draw_input(frame, app, &input_render, chunks[5]); draw_input(frame, app, &input_render, chunks[5]);
draw_actionbar(frame, app, chunks[6]); draw_actionbar(frame, app, chunks[6]);
@@ -89,19 +108,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
/// the summary. /// the summary.
const MINI_VIEW_MAX_ACTIVE: usize = 3; const MINI_VIEW_MAX_ACTIVE: usize = 3;
/// Height the mini-view section occupies. Returns 0 when there are no /// Height the mini-view section occupies. Returns 0 only when there are
/// tasks at all, so the section collapses cleanly into surrounding /// neither tasks nor Worker-view tabs, so SubWorker selection remains
/// layout — there's no point reserving rows for an empty store. /// available even when the selected task store is empty.
fn task_mini_view_height(store: &TaskStore) -> u16 { fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
if store.is_empty() { if store.is_empty() && !show_tabs {
return 0; return 0;
} }
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE); let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
// active rows + 1 summary line // active rows + 1 summary/tab line
(active_shown as u16).saturating_add(1) (active_shown as u16).saturating_add(1)
} }
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) { fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
if area.height == 0 || area.width == 0 { if area.height == 0 || area.width == 0 {
return; return;
} }
@@ -123,7 +142,7 @@ fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) {
lines.push(mini_view_active_line(entry, inner.width)); lines.push(mini_view_active_line(entry, inner.width));
shown += 1; shown += 1;
} }
lines.push(mini_view_summary_line(store.counts(), inner.width)); lines.push(mini_view_summary_line(store.counts(), tabs, inner.width));
Paragraph::new(lines) Paragraph::new(lines)
.block(outer_block) .block(outer_block)
@@ -146,8 +165,8 @@ fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> {
]) ])
} }
fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> { fn mini_view_summary_line(counts: TaskCounts, tabs: &[WorkerViewTab], width: u16) -> Line<'static> {
let text = format!( let summary = format!(
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}", "{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
counts.total(), counts.total(),
counts.pending, counts.pending,
@@ -155,8 +174,79 @@ fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
counts.completed, counts.completed,
counts.deleted, counts.deleted,
); );
let shown = truncate_with_ellipsis(&text, width as usize); if tabs.len() <= 1 {
Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray))) let shown = truncate_with_ellipsis(&summary, width as usize);
return Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray)));
}
let tabs_width = worker_view_tabs_width(tabs);
let width = width as usize;
if tabs_width >= width {
let selected = tabs.iter().find(|tab| tab.selected).unwrap_or(&tabs[0]);
if width <= 4 {
return Line::from(Span::styled(
truncate_with_ellipsis(&selected.label, width),
worker_view_selected_tab_style(),
));
}
let shown = truncate_with_ellipsis(&selected.label, width.saturating_sub(4));
let selected_width = UnicodeWidthStr::width(shown.as_str());
return Line::from(vec![
Span::raw(" ".repeat(width.saturating_sub(selected_width.saturating_add(4)))),
Span::styled("[ ", Style::default().fg(Color::DarkGray)),
Span::styled(shown, worker_view_selected_tab_style()),
Span::styled(" ]", Style::default().fg(Color::DarkGray)),
]);
}
let summary_budget = width.saturating_sub(tabs_width + 1);
let shown = truncate_with_ellipsis(&summary, summary_budget);
let shown_width = UnicodeWidthStr::width(shown.as_str());
let padding = width.saturating_sub(shown_width + tabs_width);
let mut spans = vec![
Span::styled(shown, Style::default().fg(Color::DarkGray)),
Span::raw(" ".repeat(padding)),
];
spans.extend(worker_view_tab_spans(tabs));
Line::from(spans)
}
fn worker_view_tabs_text(tabs: &[WorkerViewTab]) -> String {
format!(
"[ {} ]",
tabs.iter()
.map(|tab| tab.label.as_str())
.collect::<Vec<_>>()
.join(" | ")
)
}
fn worker_view_tabs_width(tabs: &[WorkerViewTab]) -> usize {
UnicodeWidthStr::width(worker_view_tabs_text(tabs).as_str())
}
fn worker_view_selected_tab_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
fn worker_view_tab_spans(tabs: &[WorkerViewTab]) -> Vec<Span<'static>> {
let dim = Style::default().fg(Color::DarkGray);
let selected = worker_view_selected_tab_style();
let mut spans = Vec::with_capacity(tabs.len().saturating_mul(2).saturating_add(1));
spans.push(Span::styled("[ ", dim));
for (index, tab) in tabs.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" | ", dim));
}
spans.push(Span::styled(
tab.label.clone(),
if tab.selected { selected } else { dim },
));
}
spans.push(Span::styled(" ]", dim));
spans
} }
/// Two-character status marker + the style to render it with. Mirrors /// Two-character status marker + the style to render it with. Mirrors
@@ -344,6 +434,12 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
let mut i = 0; let mut i = 0;
while i < app.blocks.len() { while i < app.blocks.len() {
let block = &app.blocks[i]; let block = &app.blocks[i];
if app.mode == Mode::Overview
&& matches!(block, Block::TaskReminder { .. } | Block::Thinking(_))
{
i += 1;
continue;
}
let current_selectable = block_is_selectable_text(block); let current_selectable = block_is_selectable_text(block);
if !first { if !first {
// Preserve a deterministic blank-line separator when copying // Preserve a deterministic blank-line separator when copying
@@ -381,28 +477,6 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
i += 1; i += 1;
} }
for internal in &app.internal_workers {
logical.push((Line::from(""), false));
logical.push((
Line::from(vec![
Span::styled("SubWorker ", Style::default().bold()),
Span::raw(internal.worker.name.clone()),
Span::styled(
format!(" {:?}", internal.app.worker_status),
Style::default().fg(Color::DarkGray),
),
]),
false,
));
let child_width = width.saturating_sub(2).max(1);
let child_history = compute_history(&internal.app, child_width);
logical.extend(child_history.rows.into_iter().map(|row| {
let mut spans = vec![Span::raw(" ")];
spans.extend(row.line.spans);
(Line::from(spans), row.selectable)
}));
}
// Step 2: pre-wrap every logical line to char-based terminal rows so // Step 2: pre-wrap every logical line to char-based terminal rows so
// scroll math is exact. Track the logical → wrapped mapping so // scroll math is exact. Track the logical → wrapped mapping so
// turn-start indices get translated into wrapped-row coordinates. // turn-start indices get translated into wrapped-row coordinates.
@@ -885,7 +959,10 @@ fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> L
fn block_is_selectable_text(block: &Block) -> bool { fn block_is_selectable_text(block: &Block) -> bool {
matches!( matches!(
block, block,
Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. } Block::UserMessage { .. }
| Block::SystemMessage { .. }
| Block::TaskReminder { .. }
| Block::AssistantText { .. }
) )
} }
@@ -909,6 +986,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
} }
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode), Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
Block::SystemMessage { text } => render_system_message(lines, text, width, mode), Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
Block::TaskReminder { text } => render_task_reminder(lines, text, width, mode),
Block::Notify { message } => { Block::Notify { message } => {
let text = format!("[notify] {message}"); let text = format!("[notify] {message}");
match mode { match mode {
@@ -1078,6 +1156,33 @@ fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16,
} }
} }
fn render_task_reminder(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
match mode {
Mode::Overview => {}
Mode::Normal => {
let first = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
let summary = format!("task reminder: {first}");
push_overview_line(lines, &summary, width, MessageKind::System, "");
}
Mode::Detail => {
lines.push(Line::from(Span::styled(
"task reminder",
kind_style(MessageKind::System),
)));
let body_style = Style::default().fg(Color::DarkGray);
for raw in text.lines() {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
}
}
}
fn split_system_message(text: &str) -> (&str, &str) { fn split_system_message(text: &str) -> (&str, &str) {
match text.split_once('\n') { match text.split_once('\n') {
Some((header, body)) => (header, body.trim_start_matches('\n')), Some((header, body)) => (header, body.trim_start_matches('\n')),
@@ -1944,9 +2049,97 @@ fn format_worker_event(event: &WorkerEvent) -> String {
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::block::{ToolCallBlock, ToolCallState};
use protocol::WorkerStatus; use protocol::WorkerStatus;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[test]
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
let tabs = vec![
WorkerViewTab {
label: "main".into(),
selected: false,
},
WorkerViewTab {
label: "subworker-hoge".into(),
selected: true,
},
WorkerViewTab {
label: "subworker-fuga".into(),
selected: false,
},
];
let line = mini_view_summary_line(TaskCounts::default(), &tabs, 96);
let text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(UnicodeWidthStr::width(text.as_str()), 96);
assert!(text.ends_with("[ main | subworker-hoge | subworker-fuga ]"));
let selected = line
.spans
.iter()
.find(|span| span.content == "subworker-hoge")
.expect("selected tab span");
assert_eq!(selected.style.fg, Some(Color::Cyan));
assert!(selected.style.add_modifier.contains(Modifier::BOLD));
let narrow = mini_view_summary_line(TaskCounts::default(), &tabs, 20);
let narrow_text = narrow
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(UnicodeWidthStr::width(narrow_text.as_str()), 20);
assert!(narrow_text.ends_with("[ subworker-hoge ]"));
}
#[test]
fn selected_worker_view_history_is_not_appended_to_main_history() {
let mut app = App::new("main".into());
app.handle_worker_event(protocol::Event::TextDelta {
text: "main transcript".into(),
});
app.handle_worker_event(protocol::Event::InternalWorker {
worker: protocol::InternalWorkerRef {
session_id: "child-session".into(),
name: "subworker-hoge".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(protocol::Event::TextDelta {
text: "child transcript".into(),
}),
});
let main = compute_history(&app, 80)
.rows
.into_iter()
.map(|row| row.line.to_string())
.collect::<String>();
assert!(main.contains("main transcript"));
assert!(!main.contains("child transcript"));
app.cycle_worker_view();
let child = compute_history(app.selected_worker_view(), 80)
.rows
.into_iter()
.map(|row| row.line.to_string())
.collect::<String>();
assert!(!child.contains("main transcript"));
assert!(child.contains("child transcript"));
}
#[test]
fn worker_tabs_keep_mini_view_visible_without_tasks() {
assert_eq!(task_mini_view_height(&TaskStore::new(), false), 0);
assert_eq!(task_mini_view_height(&TaskStore::new(), true), 1);
}
#[test] #[test]
fn queue_status_text_includes_count_and_preview() { fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -2032,6 +2225,92 @@ mod tests {
.collect() .collect()
} }
#[test]
fn overview_omits_task_reminders_without_leaving_a_gap() {
let mut app = App::new("worker".to_string());
app.mode = Mode::Overview;
app.blocks = vec![
Block::AssistantText {
text: "before".to_string(),
},
Block::TaskReminder {
text: "Current session steps are listed below.\nsecond line".to_string(),
},
Block::AssistantText {
text: "after".to_string(),
},
];
assert_eq!(row_texts(&app), ["before", "", "after"]);
}
#[test]
fn normal_renders_task_reminder_as_one_summary_line() {
let mut app = App::new("worker".to_string());
app.mode = Mode::Normal;
app.blocks = vec![Block::TaskReminder {
text: "Current session steps are listed below.\nsecond line".to_string(),
}];
assert_eq!(
row_texts(&app),
["task reminder: Current session steps are listed below."]
);
}
fn done_tool(id: &str, name: &str, arguments: Option<&str>) -> Block {
Block::ToolCall(ToolCallBlock {
id: id.to_string(),
name: name.to_string(),
args_stream: String::new(),
arguments: arguments.map(str::to_string),
state: ToolCallState::Done {
summary: "done".to_string(),
output: None,
},
edit_snapshot: None,
})
}
#[test]
fn overview_aggregates_tools_across_hidden_thinking() {
let mut app = App::new("worker".to_string());
app.mode = Mode::Overview;
app.blocks = vec![
done_tool("read", "Read", Some(r#"{"file_path":"a.rs"}"#)),
finished_thinking("private reasoning"),
done_tool("bash", "Bash", Some(r#"{"command":"cargo check"}"#)),
done_tool(
"edit",
"Edit",
Some(r#"{"old_string":"old","new_string":"new\nnext"}"#),
),
];
assert_eq!(
row_texts(&app),
["1 file read・ran 1 command", "edited +2/-1"]
);
}
#[test]
fn overview_starts_a_new_activity_after_visible_output() {
let mut app = App::new("worker".to_string());
app.mode = Mode::Overview;
app.blocks = vec![
done_tool("read", "Read", None),
Block::AssistantText {
text: "finding".to_string(),
},
done_tool("bash", "Bash", None),
];
assert_eq!(
row_texts(&app),
["1 file read", "", "finding", "", "ran 1 command"]
);
}
fn finished_thinking(text: &str) -> Block { fn finished_thinking(text: &str) -> Block {
Block::Thinking(ThinkingBlock { Block::Thinking(ThinkingBlock {
text: text.to_string(), text: text.to_string(),
+328 -5
View File
@@ -335,6 +335,7 @@ impl Runtime {
for (worker_id, worker) in &mut state.workers { for (worker_id, worker) in &mut state.workers {
if worker.status.is_active() { if worker.status.is_active() {
worker.status = WorkerStatus::Stopped; worker.status = WorkerStatus::Stopped;
worker.internal_workers.clear();
stopped.push(*worker_id); stopped.push(*worker_id);
} }
} }
@@ -574,6 +575,7 @@ impl Runtime {
run_generation: 1, run_generation: 1,
working_directory: None, working_directory: None,
execution_handle: None, execution_handle: None,
internal_workers: BTreeMap::new(),
}; };
state.workers.insert(worker_id, record); state.workers.insert(worker_id, record);
state.persist_runtime_snapshot()?; state.persist_runtime_snapshot()?;
@@ -1474,7 +1476,8 @@ impl Runtime {
let mut state = self.lock()?; let mut state = self.lock()?;
state.ensure_worker_ref(worker_ref)?; state.ensure_worker_ref(worker_ref)?;
let status_changed = state.project_protocol_event_to_status(worker_ref, &payload); let status_changed = state.project_protocol_event_to_status(worker_ref, &payload);
if status_changed { let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
if status_changed || activity_changed {
state.publish_worker_upsert(worker_ref.worker_id)?; state.publish_worker_upsert(worker_ref.worker_id)?;
} }
let event = state.push_worker_observation_event(worker_ref.clone(), payload); let event = state.push_worker_observation_event(worker_ref.clone(), payload);
@@ -1531,6 +1534,7 @@ impl Runtime {
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
worker.status = status; worker.status = status;
worker.execution_handle = None; worker.execution_handle = None;
worker.internal_workers.clear();
let status = worker.status; let status = worker.status;
state.publish_worker_upsert(worker_ref.worker_id)?; state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_runtime_snapshot()?; state.persist_runtime_snapshot()?;
@@ -1943,6 +1947,7 @@ impl RuntimeState {
run_generation: worker.run_generation, run_generation: worker.run_generation,
working_directory: worker.working_directory, working_directory: worker.working_directory,
execution_handle: None, execution_handle: None,
internal_workers: BTreeMap::new(),
}, },
); );
} }
@@ -2263,6 +2268,10 @@ impl RuntimeState {
.copied() .copied()
.unwrap_or(0), .unwrap_or(0),
state: subscription_worker_state(worker.status), state: subscription_worker_state(worker.status),
has_running_internal_workers: worker
.internal_workers
.values()
.any(|worker| worker.status == protocol::WorkerStatus::Running),
workspace_id: worker.workspace_id.clone(), workspace_id: worker.workspace_id.clone(),
display_name: worker.request.display_name.clone(), display_name: worker.request.display_name.clone(),
profile, profile,
@@ -2405,6 +2414,7 @@ impl RuntimeState {
let worker = self.worker_mut(worker_ref)?; let worker = self.worker_mut(worker_ref)?;
worker.execution_handle = None; worker.execution_handle = None;
worker.status = WorkerStatus::Stopped; worker.status = WorkerStatus::Stopped;
worker.internal_workers.clear();
self.publish_worker_upsert(worker_ref.worker_id)?; self.publish_worker_upsert(worker_ref.worker_id)?;
self.persist_runtime_snapshot()?; self.persist_runtime_snapshot()?;
Ok(()) Ok(())
@@ -2458,7 +2468,134 @@ impl RuntimeState {
event event
} }
#[cfg(feature = "ws-server")] fn internal_worker_snapshot_statuses(
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
snapshot: &protocol::InternalWorkerSnapshot,
) {
statuses.insert(
snapshot.worker.session_id.clone(),
InternalWorkerActivity {
status: snapshot.status,
parent_session_id: snapshot.worker.parent_session_id.clone(),
},
);
for child in &snapshot.internal_workers {
Self::internal_worker_snapshot_statuses(statuses, child);
}
}
fn remove_internal_worker_subtree(
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
root_session_id: &str,
) {
let mut removed = vec![root_session_id.to_string()];
while let Some(parent_session_id) = removed.pop() {
let children = statuses
.iter()
.filter_map(|(session_id, worker)| {
(worker.parent_session_id.as_deref() == Some(parent_session_id.as_str()))
.then(|| session_id.clone())
})
.collect::<Vec<_>>();
statuses.remove(&parent_session_id);
removed.extend(children);
}
}
fn project_internal_worker_event(
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
worker: &protocol::InternalWorkerRef,
event: &protocol::Event,
) {
match event {
protocol::Event::Snapshot {
status,
internal_workers,
..
} => {
Self::remove_internal_worker_subtree(statuses, &worker.session_id);
statuses.insert(
worker.session_id.clone(),
InternalWorkerActivity {
status: *status,
parent_session_id: worker.parent_session_id.clone(),
},
);
for child in internal_workers {
Self::internal_worker_snapshot_statuses(statuses, child);
}
}
protocol::Event::InternalWorker {
worker: nested_worker,
event,
..
} => Self::project_internal_worker_event(statuses, nested_worker, event),
protocol::Event::Status { status } => {
statuses.insert(
worker.session_id.clone(),
InternalWorkerActivity {
status: *status,
parent_session_id: worker.parent_session_id.clone(),
},
);
}
protocol::Event::RunEnd { result } => {
let status = match result {
protocol::RunResult::Paused => protocol::WorkerStatus::Paused,
protocol::RunResult::Finished
| protocol::RunResult::LimitReached
| protocol::RunResult::RolledBack => protocol::WorkerStatus::Idle,
};
statuses.insert(
worker.session_id.clone(),
InternalWorkerActivity {
status,
parent_session_id: worker.parent_session_id.clone(),
},
);
}
_ => {}
}
}
fn update_internal_worker_activity(
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
event: &protocol::Event,
) -> bool {
let was_running = statuses
.values()
.any(|worker| worker.status == protocol::WorkerStatus::Running);
match event {
protocol::Event::Snapshot {
internal_workers, ..
} => {
statuses.clear();
for child in internal_workers {
Self::internal_worker_snapshot_statuses(statuses, child);
}
}
protocol::Event::InternalWorker { worker, event, .. } => {
Self::project_internal_worker_event(statuses, worker, event);
}
_ => {}
}
let is_running = statuses
.values()
.any(|worker| worker.status == protocol::WorkerStatus::Running);
was_running != is_running
}
fn project_internal_worker_activity(
&mut self,
worker_ref: &WorkerRef,
event: &protocol::Event,
) -> bool {
let Some(worker) = self.workers.get_mut(&worker_ref.worker_id) else {
return false;
};
Self::update_internal_worker_activity(&mut worker.internal_workers, event)
}
fn project_protocol_event_to_status( fn project_protocol_event_to_status(
&mut self, &mut self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
@@ -2501,6 +2638,12 @@ impl RuntimeState {
} }
} }
#[derive(Debug, Clone)]
struct InternalWorkerActivity {
status: protocol::WorkerStatus,
parent_session_id: Option<String>,
}
#[derive(Debug)] #[derive(Debug)]
struct WorkerRecord { struct WorkerRecord {
worker_ref: WorkerRef, worker_ref: WorkerRef,
@@ -2511,6 +2654,7 @@ struct WorkerRecord {
run_generation: u64, run_generation: u64,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
execution_handle: Option<WorkerExecutionHandle>, execution_handle: Option<WorkerExecutionHandle>,
internal_workers: BTreeMap<String, InternalWorkerActivity>,
} }
impl WorkerRecord { impl WorkerRecord {
@@ -2733,6 +2877,126 @@ mod tests {
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn internal_worker_ref(
session_id: &str,
parent_session_id: Option<&str>,
) -> protocol::InternalWorkerRef {
protocol::InternalWorkerRef {
session_id: session_id.to_string(),
parent_session_id: parent_session_id.map(str::to_string),
name: session_id.to_string(),
kind: protocol::InternalWorkerKind::SubWorker,
}
}
fn internal_worker_status_event(
worker: protocol::InternalWorkerRef,
status: protocol::WorkerStatus,
) -> protocol::Event {
protocol::Event::InternalWorker {
worker,
revision: 1,
event: Box::new(protocol::Event::Status { status }),
}
}
#[test]
fn internal_worker_activity_tracks_running_children_independently() {
let mut activity = BTreeMap::new();
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
protocol::WorkerStatus::Running,
),
));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-b", None),
protocol::WorkerStatus::Running,
),
));
assert!(!RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
protocol::WorkerStatus::Idle,
),
));
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-b", None),
protocol::WorkerStatus::Idle,
),
));
}
#[test]
fn nested_internal_worker_activity_reaches_the_parent_projection() {
let mut activity = BTreeMap::new();
let direct_child = internal_worker_ref("child", None);
let nested_running = protocol::Event::InternalWorker {
worker: direct_child.clone(),
revision: 1,
event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")),
protocol::WorkerStatus::Running,
)),
};
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&nested_running,
));
let nested_idle = protocol::Event::InternalWorker {
worker: direct_child,
revision: 2,
event: Box::new(internal_worker_status_event(
internal_worker_ref("grandchild", Some("child")),
protocol::WorkerStatus::Idle,
)),
};
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&nested_idle,
));
}
#[test]
fn parent_snapshot_replaces_stale_internal_worker_activity() {
let mut activity = BTreeMap::new();
RuntimeState::update_internal_worker_activity(
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-a", None),
protocol::WorkerStatus::Running,
),
);
let snapshot = protocol::Event::Snapshot {
entries: Vec::new(),
greeting: protocol::Greeting {
worker_name: "parent".to_string(),
cwd: "/tmp".to_string(),
provider: "test".to_string(),
model: "test".to_string(),
scope_summary: String::new(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: protocol::WorkerStatus::Idle,
in_flight: protocol::InFlightSnapshot::default(),
internal_workers: Vec::new(),
};
assert!(RuntimeState::update_internal_worker_activity(
&mut activity,
&snapshot,
));
assert!(activity.is_empty());
}
#[test] #[test]
fn runtime_identity_binding_is_immutable_and_host_owned() { fn runtime_identity_binding_is_immutable_and_host_owned() {
let runtime = Runtime::new_memory(); let runtime = Runtime::new_memory();
@@ -3086,16 +3350,75 @@ mod tests {
payload => panic!("unexpected subscription payload: {payload:?}"), payload => panic!("unexpected subscription payload: {payload:?}"),
} }
runtime.stop_runtime().unwrap(); runtime
.observe_worker_event(
&created.worker_ref,
internal_worker_status_event(
internal_worker_ref("child-live", None),
protocol::WorkerStatus::Running,
),
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap(); let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 2); assert_eq!(update.subject_revision, 2);
match update.payload { match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => { SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string()); assert_eq!(worker.state, SubscriptionWorkerState::Idle);
assert_eq!(worker.state, SubscriptionWorkerState::Stopped); assert!(worker.has_running_internal_workers);
} }
payload => panic!("unexpected subscription payload: {payload:?}"), payload => panic!("unexpected subscription payload: {payload:?}"),
} }
runtime
.observe_worker_event(
&created.worker_ref,
internal_worker_status_event(
internal_worker_ref("child-live", None),
protocol::WorkerStatus::Idle,
),
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 3);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
assert!(!worker.has_running_internal_workers);
}
payload => panic!("unexpected subscription payload: {payload:?}"),
}
runtime
.observe_worker_event(
&created.worker_ref,
internal_worker_status_event(
internal_worker_ref("child-live", None),
protocol::WorkerStatus::Running,
),
)
.unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 4);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
assert!(worker.has_running_internal_workers);
}
payload => panic!("unexpected subscription payload: {payload:?}"),
}
runtime.stop_worker(&created.worker_ref, None).unwrap();
let update = receive_subscription_update(&mut subscription).unwrap();
assert_eq!(update.subject_revision, 5);
match update.payload {
SubscriptionEventPayload::WorkerUpserted { worker } => {
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
assert_eq!(worker.state, SubscriptionWorkerState::Stopped);
assert!(!worker.has_running_internal_workers);
}
payload => panic!("unexpected subscription payload: {payload:?}"),
}
runtime.stop_runtime().unwrap();
} }
#[test] #[test]
+6 -31
View File
@@ -125,7 +125,6 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
proof: InProcessWorkerMutationProof, proof: InProcessWorkerMutationProof,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>; ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError>;
} }
@@ -187,7 +186,6 @@ impl RuntimeWorkerMutationForwarder {
&self, &self,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let proof = self.authority.issue_worker_remove( let proof = self.authority.issue_worker_remove(
@@ -206,7 +204,6 @@ impl RuntimeWorkerMutationForwarder {
token, token,
target_runtime_id: target_runtime_id.to_string(), target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(), target_worker_id: target_worker_id.to_string(),
expected_worker_revision: expected_worker_revision.to_string(),
reason: reason.to_string(), reason: reason.to_string(),
}), }),
( (
@@ -216,7 +213,6 @@ impl RuntimeWorkerMutationForwarder {
claims, claims,
target_runtime_id, target_runtime_id,
target_worker_id, target_worker_id,
expected_worker_revision,
reason, reason,
), ),
_ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch), _ => Err(RuntimeWorkerMutationForwardError::AuthorityTransportMismatch),
@@ -230,7 +226,6 @@ struct RemoteWorkerRemoveHttpRequest {
token: String, token: String,
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
expected_worker_revision: String,
reason: String, reason: String,
} }
@@ -267,7 +262,6 @@ fn execute_remote_worker_remove_http_blocking(
let body = serde_json::json!({ let body = serde_json::json!({
"target_runtime_id": request.target_runtime_id, "target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id, "target_worker_id": request.target_worker_id,
"expected_worker_revision": request.expected_worker_revision,
"reason": request.reason, "reason": request.reason,
}); });
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
@@ -474,7 +468,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
&self, &self,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.worker_remove self.worker_remove
@@ -484,12 +477,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
"Runtime-owned WorkerRemove forwarding is unavailable".to_string(), "Runtime-owned WorkerRemove forwarding is unavailable".to_string(),
) )
})? })?
.execute_worker_remove( .execute_worker_remove(target_runtime_id, target_worker_id, reason)
target_runtime_id,
target_worker_id,
expected_worker_revision,
reason,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string())) .map_err(|error| WorkspaceClientError::Request(error.to_string()))
} }
} }
@@ -915,12 +903,7 @@ mod tests {
format!("http://{address}"), format!("http://{address}"),
); );
let response = forwarder let response = forwarder
.execute_worker_remove( .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
"runtime-target",
"worker-target",
"revision-7",
"retire obsolete Worker",
)
.unwrap(); .unwrap();
assert_eq!(response.status, 204); assert_eq!(response.status, 204);
server.join().unwrap(); server.join().unwrap();
@@ -929,7 +912,7 @@ mod tests {
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1")); assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
assert!(request.contains("\"target_runtime_id\":\"runtime-target\"")); assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
assert!(request.contains("\"target_worker_id\":\"worker-target\"")); assert!(request.contains("\"target_worker_id\":\"worker-target\""));
assert!(request.contains("\"expected_worker_revision\":\"revision-7\"")); assert!(!request.contains("expected_worker_revision"));
assert!(request.contains("\"reason\":\"retire obsolete Worker\"")); assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
let token = request let token = request
.lines() .lines()
@@ -962,7 +945,7 @@ mod tests {
#[derive(Default)] #[derive(Default)]
struct RecordingDispatcher { struct RecordingDispatcher {
seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String, String)>>, seen: Mutex<Option<(WorkerMutationSourceClaims, String, String, String)>>,
} }
impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher { impl EmbeddedWorkerMutationDispatcher for RecordingDispatcher {
fn execute_worker_remove( fn execute_worker_remove(
@@ -970,14 +953,12 @@ mod tests {
proof: InProcessWorkerMutationProof, proof: InProcessWorkerMutationProof,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
*self.seen.lock().unwrap() = Some(( *self.seen.lock().unwrap() = Some((
proof.into_claims(), proof.into_claims(),
target_runtime_id.to_string(), target_runtime_id.to_string(),
target_worker_id.to_string(), target_worker_id.to_string(),
expected_worker_revision.to_string(),
reason.to_string(), reason.to_string(),
)); ));
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
@@ -996,15 +977,10 @@ mod tests {
dispatcher.clone(), dispatcher.clone(),
); );
let response = forwarder let response = forwarder
.execute_worker_remove( .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
"runtime-target",
"worker-target",
"revision-7",
"retire obsolete Worker",
)
.unwrap(); .unwrap();
assert_eq!(response.status, 202); assert_eq!(response.status, 202);
let (claims, target_runtime_id, target_worker_id, expected_revision, reason) = let (claims, target_runtime_id, target_worker_id, reason) =
dispatcher.seen.lock().unwrap().take().unwrap(); dispatcher.seen.lock().unwrap().take().unwrap();
assert_eq!(claims.iss, "runtime-embedded"); assert_eq!(claims.iss, "runtime-embedded");
assert_eq!(claims.worker_id, "worker-source"); assert_eq!(claims.worker_id, "worker-source");
@@ -1012,7 +988,6 @@ mod tests {
assert_eq!(claims.target_worker_id, "worker-target"); assert_eq!(claims.target_worker_id, "worker-target");
assert_eq!(target_runtime_id, "runtime-target"); assert_eq!(target_runtime_id, "runtime-target");
assert_eq!(target_worker_id, "worker-target"); assert_eq!(target_worker_id, "worker-target");
assert_eq!(expected_revision, "revision-7");
assert_eq!(reason, "retire obsolete Worker"); assert_eq!(reason, "retire obsolete Worker");
} }
@@ -48,7 +48,6 @@ pub trait WorkerControlService: Send + Sync {
&self, &self,
runtime_id: &str, runtime_id: &str,
worker_id: &str, worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError>; ) -> Result<WorkspaceResponse, WorkspaceClientError>;
async fn execute_runtime( async fn execute_runtime(
@@ -170,11 +169,10 @@ impl WorkerControlService for WorkspaceWorkerControlService {
&self, &self,
runtime_id: &str, runtime_id: &str,
worker_id: &str, worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.client self.client
.execute_worker_remove(runtime_id, worker_id, expected_worker_revision, reason) .execute_worker_remove(runtime_id, worker_id, reason)
} }
async fn execute_runtime( async fn execute_runtime(
@@ -535,7 +533,6 @@ struct WorkerStopInput {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct WorkerRemoveInput { struct WorkerRemoveInput {
subject: WorkerSubjectInput, subject: WorkerSubjectInput,
expected_worker_revision: String,
reason: String, reason: String,
} }
@@ -597,7 +594,7 @@ impl WorkerOperation {
"Restore a stopped Backend/Runtime Worker session in the current Workspace." "Restore a stopped Backend/Runtime Worker session in the current Workspace."
} }
Self::Remove => { Self::Remove => {
"Remove an eligible stopped, unassigned, non-internal Worker. Supply the current Worker revision and a bounded reason; Backend validation and retention are authoritative." "Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
} }
} }
} }
@@ -747,8 +744,6 @@ impl Tool for WorkspaceWorkerTool {
WorkerOperation::Remove => { WorkerOperation::Remove => {
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?; let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?; let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
let expected_worker_revision =
non_empty(input.expected_worker_revision, "expected_worker_revision")?;
let reason = non_empty(input.reason, "reason")?; let reason = non_empty(input.reason, "reason")?;
if reason.len() > 512 { if reason.len() > 512 {
return Err(ToolError::ExecutionFailed( return Err(ToolError::ExecutionFailed(
@@ -756,12 +751,7 @@ impl Tool for WorkspaceWorkerTool {
)); ));
} }
self.control self.control
.remove_runtime_worker( .remove_runtime_worker(&runtime_id, &worker_id, &reason)
&runtime_id,
&worker_id,
&expected_worker_revision,
&reason,
)
.map_err(control_tool_error)? .map_err(control_tool_error)?
} }
}; };
@@ -957,7 +947,7 @@ mod tests {
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct RecordingWorkspaceClient { struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>, requests: Mutex<Vec<WorkspaceRequest>>,
removals: Mutex<Vec<(String, String, String, String)>>, removals: Mutex<Vec<(String, String, String)>>,
} }
impl WorkspaceClient for RecordingWorkspaceClient { impl WorkspaceClient for RecordingWorkspaceClient {
@@ -988,13 +978,11 @@ mod tests {
&self, &self,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.removals.lock().unwrap().push(( self.removals.lock().unwrap().push((
target_runtime_id.to_string(), target_runtime_id.to_string(),
target_worker_id.to_string(), target_worker_id.to_string(),
expected_worker_revision.to_string(),
reason.to_string(), reason.to_string(),
)); ));
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
@@ -1205,7 +1193,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn worker_remove_forwards_only_target_revision_and_bounded_reason() { async fn worker_remove_forwards_only_target_and_bounded_reason() {
let client = Arc::new(RecordingWorkspaceClient::default()); let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool { let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Remove, operation: WorkerOperation::Remove,
@@ -1218,7 +1206,6 @@ mod tests {
"runtime_id": "runtime-1", "runtime_id": "runtime-1",
"worker_id": "worker-7", "worker_id": "worker-7",
}, },
"expected_worker_revision": "2026-08-11T20:00:00Z",
"reason": " retire completed Worker " "reason": " retire completed Worker "
}) })
.to_string(), .to_string(),
@@ -1231,7 +1218,6 @@ mod tests {
[( [(
"runtime-1".to_string(), "runtime-1".to_string(),
"worker-7".to_string(), "worker-7".to_string(),
"2026-08-11T20:00:00Z".to_string(),
"retire completed Worker".to_string(), "retire completed Worker".to_string(),
)] )]
); );
@@ -1239,15 +1225,18 @@ mod tests {
let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput)) let schema = serde_json::to_value(schemars::schema_for!(WorkerRemoveInput))
.unwrap() .unwrap()
.to_string(); .to_string();
for field in [ for field in ["runtime_id", "worker_id", "reason"] {
"runtime_id",
"worker_id",
"expected_worker_revision",
"reason",
] {
assert!(schema.contains(field)); assert!(schema.contains(field));
} }
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] { for forbidden in [
"expected_worker_revision",
"proof",
"actor",
"workspace_id",
"policy",
"plan",
"stage",
] {
assert!(!schema.contains(forbidden), "schema leaked {forbidden}"); assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
} }
} }
@@ -1268,7 +1257,6 @@ mod tests {
"runtime_id": "runtime-1", "runtime_id": "runtime-1",
"worker_id": "worker-7", "worker_id": "worker-7",
}, },
"expected_worker_revision": "revision-1",
"reason": reason, "reason": reason,
}) })
.to_string(), .to_string(),
@@ -312,8 +312,7 @@ mod tests {
let SystemItem::TaskReminder { body, .. } = &queued[0] else { let SystemItem::TaskReminder { body, .. } = &queued[0] else {
panic!("unexpected system item: {:?}", queued[0]); panic!("unexpected system item: {:?}", queued[0]);
}; };
assert_eq!(body.matches("<system-reminder>").count(), 1); assert!(body.starts_with("Current session steps are listed below."));
assert_eq!(body.matches("</system-reminder>").count(), 1);
assert!(body.contains("taskid 1")); assert!(body.contains("taskid 1"));
assert!(body.contains("pending")); assert!(body.contains("pending"));
assert!(body.contains("keep going")); assert!(body.contains("keep going"));
@@ -338,19 +337,17 @@ mod tests {
panic!("unexpected system item: {:?}", queued[0]); panic!("unexpected system item: {:?}", queued[0]);
}; };
assert_eq!(*source, SystemReminderSource::TaskInactivity); assert_eq!(*source, SystemReminderSource::TaskInactivity);
assert_eq!(body.matches("<system-reminder>").count(), 1); assert!(body.starts_with("Current session steps are listed below."));
assert_eq!(body.matches("</system-reminder>").count(), 1);
assert!(body.contains("typed")); assert!(body.contains("typed"));
} }
#[test] #[test]
fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() { fn render_task_reminder_body_is_plain_system_text() {
let feature = TaskFeature::new(); let feature = TaskFeature::new();
let task = feature.task_store().create("body".into(), String::new()); let task = feature.task_store().create("body".into(), String::new());
let body = render_task_reminder_body(&[task]); let body = render_task_reminder_body(&[task]);
assert!(!body.contains("<system-reminder>")); assert!(body.starts_with("Current session steps are listed below."));
assert!(!body.contains("</system-reminder>"));
assert!(body.contains("TaskUpdate")); assert!(body.contains("TaskUpdate"));
assert!(body.contains("taskid 1")); assert!(body.contains("taskid 1"));
} }
+2 -5
View File
@@ -176,9 +176,7 @@ impl SystemItemAppendHandle {
/// Queue a task-inactivity reminder for durable model-visible append. /// Queue a task-inactivity reminder for durable model-visible append.
/// ///
/// The body should be the unwrapped reminder text; the host-side /// The body is committed verbatim as the typed item's system-message text.
/// `SystemReminder` renderer wraps it exactly once in `<system-reminder>`
/// tags before commit.
pub fn append_task_reminder(&self, body: impl Into<String>) { pub fn append_task_reminder(&self, body: impl Into<String>) {
let item = SystemReminder::task_inactivity(body).into_system_item(); let item = SystemReminder::task_inactivity(body).into_system_item();
self.pending self.pending
@@ -452,8 +450,7 @@ mod tests {
assert_eq!(queued.len(), 1); assert_eq!(queued.len(), 1);
match &queued[0] { match &queued[0] {
SystemItem::TaskReminder { body, .. } => { SystemItem::TaskReminder { body, .. } => {
assert_eq!(body.matches("<system-reminder>").count(), 1); assert_eq!(body, "remember tasks");
assert!(body.contains("remember tasks"));
} }
other => panic!("unexpected system item: {other:?}"), other => panic!("unexpected system item: {other:?}"),
} }
+2 -2
View File
@@ -12,8 +12,8 @@
//! //!
//! This is the **single lane** for "system messages produced by Worker //! This is the **single lane** for "system messages produced by Worker
//! state that should land in the next LLM request": Notify, //! state that should land in the next LLM request": Notify,
//! agent-visible WorkerEvent variants, and any future `<system-reminder>` //! agent-visible WorkerEvent variants, and any future typed system reminder
//! injection all ride this queue. //! insertion all ride this queue.
//! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM //! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM
//! context の加工原則), there is **no** "transient, history-skipping" //! context の加工原則), there is **no** "transient, history-skipping"
//! lane — everything injected into a request is also committed to //! lane — everything injected into a request is also committed to
-1
View File
@@ -272,7 +272,6 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
&self, &self,
_target_runtime_id: &str, _target_runtime_id: &str,
_target_worker_id: &str, _target_worker_id: &str,
_expected_worker_revision: &str,
_reason: &str, _reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable( Err(WorkspaceClientError::Unavailable(
+88 -45
View File
@@ -59,7 +59,6 @@ pub struct WorkerRetentionPolicyUpdate {
pub struct WorkerRemovalPlanRequest { pub struct WorkerRemovalPlanRequest {
pub workspace_id: String, pub workspace_id: String,
pub worker: RuntimeWorkerRef, pub worker: RuntimeWorkerRef,
pub expected_worker_revision: String,
pub reason: String, pub reason: String,
} }
@@ -154,8 +153,6 @@ pub enum WorkerRetentionError {
WorkerNotFound, WorkerNotFound,
#[error("Worker belongs to a different Workspace")] #[error("Worker belongs to a different Workspace")]
CrossWorkspace, CrossWorkspace,
#[error("Worker revision changed: expected {expected}, current {actual}")]
WorkerRevisionConflict { expected: String, actual: String },
#[error("Worker removal is blocked: {0:?}")] #[error("Worker removal is blocked: {0:?}")]
Blocked(Vec<WorkerRemovalBlocker>), Blocked(Vec<WorkerRemovalBlocker>),
#[error("Worker removal plan {plan_id} is stale: {reason}")] #[error("Worker removal plan {plan_id} is stale: {reason}")]
@@ -308,17 +305,16 @@ impl SqliteWorkspaceStore {
return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()})); return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()}));
} }
}; };
if worker.updated_at!=req.expected_worker_revision { return Err(StoreError::InvalidInput(format!("worker-conflict:{}:{}",req.expected_worker_revision,worker.updated_at))); }
let mut blockers=Vec::new(); let mut blockers=Vec::new();
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); } if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? { if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}); blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id});
} }
let fp=fingerprint(req,inv,&policy,&blockers)?; let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp); let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp)); let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked}; let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,run_generation,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?21)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,req.expected_worker_revision,inv.run_generation,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?; tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,run_generation,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?21)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,inv.run_generation,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?; let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));} if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
tx.commit()?; Ok(plan) tx.commit()?; Ok(plan)
@@ -422,27 +418,22 @@ impl SqliteWorkspaceStore {
&self, &self,
workspace_id: &str, workspace_id: &str,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
expected_worker_revision: &str,
reason: &str,
) -> Result<Option<PreparedWorkerRemoval>, WorkerRetentionError> { ) -> Result<Option<PreparedWorkerRemoval>, WorkerRetentionError> {
bounded("workspace", workspace_id, 160)?; bounded("workspace", workspace_id, 160)?;
bounded("revision", expected_worker_revision, 256)?;
bounded("reason", reason, 512)?;
let plan = self.with_conn(|conn| { let plan = self.with_conn(|conn| {
conn.query_row( conn.query_row(
"SELECT plan_id FROM worker_removal_operations "SELECT plan_id FROM worker_removal_operations
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
AND worker_revision=?4 AND reason=?5 AND state IN ('planned','executing','failed','succeeded')
AND state IN ('executing','failed','succeeded') AND (
state='succeeded' OR worker_revision=(
SELECT updated_at FROM worker_registry
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3
)
)
ORDER BY CASE state WHEN 'succeeded' THEN 0 ELSE 1 END, ORDER BY CASE state WHEN 'succeeded' THEN 0 ELSE 1 END,
created_at DESC LIMIT 1", created_at DESC LIMIT 1",
params![ params![workspace_id, worker.runtime_id, worker.worker_id],
workspace_id,
worker.runtime_id,
worker.worker_id,
expected_worker_revision,
reason,
],
|row| row.get::<_, String>(0), |row| row.get::<_, String>(0),
) )
.optional() .optional()
@@ -848,6 +839,7 @@ fn stale_error(plan: &WorkerRemovalPlan, reason: &str) -> StoreError {
} }
fn fingerprint( fn fingerprint(
r: &WorkerRemovalPlanRequest, r: &WorkerRemovalPlanRequest,
worker_revision: &str,
i: &WorkerRetentionInventory, i: &WorkerRetentionInventory,
p: &WorkerRetentionPolicy, p: &WorkerRetentionPolicy,
b: &[WorkerRemovalBlocker], b: &[WorkerRemovalBlocker],
@@ -856,7 +848,7 @@ fn fingerprint(
r.workspace_id, r.workspace_id,
r.worker.runtime_id, r.worker.runtime_id,
r.worker.worker_id, r.worker.worker_id,
r.expected_worker_revision, worker_revision,
i.run_generation, i.run_generation,
i.session_id, i.session_id,
i.segment_ids, i.segment_ids,
@@ -887,7 +879,6 @@ fn validate_plan(
i: &WorkerRetentionInventory, i: &WorkerRetentionInventory,
) -> Result<(), WorkerRetentionError> { ) -> Result<(), WorkerRetentionError> {
bounded("workspace", &r.workspace_id, 160)?; bounded("workspace", &r.workspace_id, 160)?;
bounded("revision", &r.expected_worker_revision, 256)?;
bounded("reason", &r.reason, 2000)?; bounded("reason", &r.reason, 2000)?;
if i.workspace_id != r.workspace_id if i.workspace_id != r.workspace_id
|| i.runtime_id != r.worker.runtime_id || i.runtime_id != r.worker.runtime_id
@@ -947,13 +938,6 @@ fn map_error(e: StoreError) -> WorkerRetentionError {
if m == "worker-missing" { if m == "worker-missing" {
return WorkerRetentionError::WorkerNotFound; return WorkerRetentionError::WorkerNotFound;
} }
if let Some(x) = m.strip_prefix("worker-conflict:") {
let mut s = x.splitn(2, ':');
return WorkerRetentionError::WorkerRevisionConflict {
expected: s.next().unwrap_or_default().into(),
actual: s.next().unwrap_or_default().into(),
};
}
if let Some(x) = m.strip_prefix("fingerprint:") { if let Some(x) = m.strip_prefix("fingerprint:") {
return WorkerRetentionError::OperationFingerprintConflict { return WorkerRetentionError::OperationFingerprintConflict {
operation_id: x.into(), operation_id: x.into(),
@@ -1111,7 +1095,6 @@ mod tests {
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: worker_id().to_string(), worker_id: worker_id().to_string(),
}, },
expected_worker_revision: "rev1".into(),
reason: "cleanup".into(), reason: "cleanup".into(),
} }
} }
@@ -1146,6 +1129,7 @@ mod tests {
let a = s.plan_worker_removal(&req(), &inv()).unwrap(); let a = s.plan_worker_removal(&req(), &inv()).unwrap();
let b = s.plan_worker_removal(&req(), &inv()).unwrap(); let b = s.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(a.plan_id, b.plan_id); assert_eq!(a.plan_id, b.plan_id);
assert_eq!(a.worker_revision, "rev1");
s.with_conn(|c| { s.with_conn(|c| {
c.execute( c.execute(
"UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'", "UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w'",
@@ -1154,9 +1138,7 @@ mod tests {
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
let mut q = req(); let p = s.plan_worker_removal(&req(), &inv()).unwrap();
q.expected_worker_revision = "rev1".into();
let p = s.plan_worker_removal(&q, &inv()).unwrap();
assert_eq!(p.blockers, vec![WorkerRemovalBlocker::Hold]); assert_eq!(p.blockers, vec![WorkerRemovalBlocker::Hold]);
assert!(matches!( assert!(matches!(
s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint), s.begin_worker_removal("w", &p.plan_id, &p.input_fingerprint),
@@ -1580,6 +1562,77 @@ mod tests {
); );
} }
#[test]
fn worker_removal_recovery_is_target_keyed_and_preserves_original_reason() {
let store = setup();
let request = req();
let plan = store.plan_worker_removal(&request, &inv()).unwrap();
let recovered_planned = store
.recover_worker_removal_execution("w", &request.worker)
.unwrap()
.unwrap();
assert_eq!(
recovered_planned.plan.state,
WorkerRemovalPlanState::Planned
);
assert_eq!(recovered_planned.plan.plan_id, plan.plan_id);
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
store
.fail_worker_removal(
"w",
&plan.operation_id,
&plan.input_fingerprint,
"runtime_remove_failed",
)
.unwrap();
let recovered = store
.recover_worker_removal_execution("w", &request.worker)
.unwrap()
.unwrap();
assert_eq!(recovered.plan.plan_id, plan.plan_id);
assert_eq!(recovered.plan.reason, request.reason);
}
#[test]
fn stale_failed_removal_is_not_recovered_after_worker_authority_changes() {
let store = setup();
let request = req();
let plan = store.plan_worker_removal(&request, &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
store
.fail_worker_removal(
"w",
&plan.operation_id,
&plan.input_fingerprint,
"runtime_remove_failed",
)
.unwrap();
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_registry SET updated_at='rev2' WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[worker_id().to_string()],
)?;
Ok(())
})
.unwrap();
assert!(
store
.recover_worker_removal_execution("w", &request.worker)
.unwrap()
.is_none()
);
let replacement = store.plan_worker_removal(&request, &inv()).unwrap();
assert_eq!(replacement.worker_revision, "rev2");
assert_ne!(replacement.plan_id, plan.plan_id);
}
#[test] #[test]
fn succeeded_worker_removal_recovers_after_registry_purge() { fn succeeded_worker_removal_recovers_after_registry_purge() {
let s = setup(); let s = setup();
@@ -1632,18 +1685,13 @@ mod tests {
.is_none() .is_none()
); );
let recovered = s let recovered = s
.recover_worker_removal_execution( .recover_worker_removal_execution("w", &request.worker)
"w",
&request.worker,
&request.expected_worker_revision,
&request.reason,
)
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded); assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Succeeded);
assert_eq!( assert_eq!(
recovered.runtime_request.expected_worker_revision, recovered.runtime_request.expected_worker_revision,
request.expected_worker_revision plan.worker_revision
); );
} }
@@ -1662,12 +1710,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let recovered = s let recovered = s
.recover_worker_removal_execution( .recover_worker_removal_execution("w", &request.worker)
"w",
&request.worker,
&request.expected_worker_revision,
&request.reason,
)
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
+14 -65
View File
@@ -411,7 +411,6 @@ impl WorkspaceWorkerRemoveExecutor {
source: crate::worker_source::VerifiedWorkerMutationSource, source: crate::worker_source::VerifiedWorkerMutationSource,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> std::result::Result<worker::WorkspaceResponse, String> { ) -> std::result::Result<worker::WorkspaceResponse, String> {
let reason = reason.trim(); let reason = reason.trim();
@@ -509,24 +508,17 @@ impl WorkspaceWorkerRemoveExecutor {
let prepared = self let prepared = self
.store .store
.recover_worker_removal_execution( .recover_worker_removal_execution(&self.workspace_id, &target)
&self.workspace_id,
&target,
expected_worker_revision,
reason,
)
.map_err(|_| "Worker removal recovery authority is unavailable".to_string())?; .map_err(|_| "Worker removal recovery authority is unavailable".to_string())?;
if let Some(prepared) = prepared { if let Some(prepared) = prepared {
if prepared.plan.state == crate::retention::WorkerRemovalPlanState::Succeeded { if prepared.plan.state == crate::retention::WorkerRemovalPlanState::Succeeded {
return Ok(worker_remove_success_response(&target)); return Ok(worker_remove_success_response(&target));
} }
let must_close_session = let prepared = if matches!(
prepared.prior_failure_category.as_deref() == Some("workdir_session_close_failed"); prepared.plan.state,
let must_release_attachment = must_close_session crate::retention::WorkerRemovalPlanState::Planned
|| prepared.prior_failure_category.as_deref() | crate::retention::WorkerRemovalPlanState::Failed
== Some("workdir_attachment_release_failed"); ) {
let prepared =
if prepared.plan.state == crate::retention::WorkerRemovalPlanState::Failed {
match self.store.prepare_worker_removal_execution( match self.store.prepare_worker_removal_execution(
&self.workspace_id, &self.workspace_id,
&prepared.plan.plan_id, &prepared.plan.plan_id,
@@ -538,7 +530,6 @@ impl WorkspaceWorkerRemoveExecutor {
} else { } else {
prepared prepared
}; };
if must_close_session {
let session = { let session = {
self.workdir_sessions self.workdir_sessions
.lock() .lock()
@@ -565,9 +556,7 @@ impl WorkspaceWorkerRemoveExecutor {
.map_err(|_| "Workdir session registry was poisoned".to_string())? .map_err(|_| "Workdir session registry was poisoned".to_string())?
.remove(&target); .remove(&target);
} }
} if self
if must_release_attachment
&& self
.store .store
.detach_worker_workdir( .detach_worker_workdir(
&self.workspace_id, &self.workspace_id,
@@ -632,7 +621,6 @@ impl WorkspaceWorkerRemoveExecutor {
let request = crate::retention::WorkerRemovalPlanRequest { let request = crate::retention::WorkerRemovalPlanRequest {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
worker: target.clone(), worker: target.clone(),
expected_worker_revision: expected_worker_revision.to_string(),
reason: reason.to_string(), reason: reason.to_string(),
}; };
let plan = match self.store.plan_worker_removal(&request, &inventory) { let plan = match self.store.plan_worker_removal(&request, &inventory) {
@@ -737,13 +725,11 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov
source: crate::worker_source::VerifiedWorkerMutationSource, source: crate::worker_source::VerifiedWorkerMutationSource,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> std::result::Result<worker::WorkspaceResponse, String> { ) -> std::result::Result<worker::WorkspaceResponse, String> {
let executor = self.clone(); let executor = self.clone();
let target_runtime_id = target_runtime_id.to_string(); let target_runtime_id = target_runtime_id.to_string();
let target_worker_id = target_worker_id.to_string(); let target_worker_id = target_worker_id.to_string();
let expected_worker_revision = expected_worker_revision.to_string();
let reason = reason.to_string(); let reason = reason.to_string();
std::thread::spawn(move || { std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
@@ -754,7 +740,6 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov
source, source,
&target_runtime_id, &target_runtime_id,
&target_worker_id, &target_worker_id,
&expected_worker_revision,
&reason, &reason,
)) ))
}) })
@@ -6476,14 +6461,13 @@ fn worker_retention_error_response(
"worker_not_found", "worker_not_found",
"Worker was not found in this Workspace", "Worker was not found in this Workspace",
), ),
crate::retention::WorkerRetentionError::WorkerRevisionConflict { .. } crate::retention::WorkerRetentionError::PolicyRevisionConflict { .. }
| crate::retention::WorkerRetentionError::PolicyRevisionConflict { .. }
| crate::retention::WorkerRetentionError::StalePlan { .. } | crate::retention::WorkerRetentionError::StalePlan { .. }
| crate::retention::WorkerRetentionError::OperationFingerprintConflict { .. } => { | crate::retention::WorkerRetentionError::OperationFingerprintConflict { .. } => {
worker_remove_error_response( worker_remove_error_response(
StatusCode::CONFLICT, StatusCode::CONFLICT,
"worker_revision_conflict", "worker_removal_conflict",
"Worker removal state changed; reread the Worker and retry", "Worker removal state changed; retry the operation",
) )
} }
crate::retention::WorkerRetentionError::Blocked(_) => worker_remove_error_response( crate::retention::WorkerRetentionError::Blocked(_) => worker_remove_error_response(
@@ -6510,7 +6494,6 @@ fn worker_retention_error_response(
struct WorkerRemoveBoundaryRequest { struct WorkerRemoveBoundaryRequest {
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
expected_worker_revision: String,
reason: String, reason: String,
} }
@@ -6548,7 +6531,6 @@ async fn scoped_worker_remove_source_boundary(
source, source,
&request.target_runtime_id, &request.target_runtime_id,
&request.target_worker_id, &request.target_worker_id,
&request.expected_worker_revision,
&request.reason, &request.reason,
) )
.await .await
@@ -8291,11 +8273,6 @@ struct PasskeyLoginCompleteRequest {
credential: PublicKeyCredential, credential: PublicKeyCredential,
} }
#[derive(Debug, Serialize, Deserialize)]
struct PasskeyLoginCompleteResponse {
user: AuthenticatedUser,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct DeviceLoginStartRequest { struct DeviceLoginStartRequest {
@@ -16362,7 +16339,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let app = build_router(test_api(temp.path()).await); let app = build_router(test_api(temp.path()).await);
let body = r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","expected_worker_revision":"revision-1","reason":"retire target Worker"}"#; let body = r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker"}"#;
let browser = app let browser = app
.clone() .clone()
.oneshot( .oneshot(
@@ -16479,7 +16456,6 @@ mod tests {
fresh_proof, fresh_proof,
"runtime-target", "runtime-target",
"target-worker", "target-worker",
"revision-1",
"retire target Worker", "retire target Worker",
) )
.unwrap_err(); .unwrap_err();
@@ -16487,7 +16463,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn worker_remove_rejects_self_running_and_stale_revision_at_caller_boundary() { async fn worker_remove_rejects_self_and_running_at_caller_boundary() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await; let api = test_api(temp.path()).await;
let Json(orchestrator) = scoped_start_workspace_orchestrator( let Json(orchestrator) = scoped_start_workspace_orchestrator(
@@ -16512,7 +16488,6 @@ mod tests {
verified_source(), verified_source(),
&source.runtime_id, &source.runtime_id,
&source.worker_id, &source.worker_id,
"irrelevant",
"must reject self", "must reject self",
) )
.await .await
@@ -16554,37 +16529,12 @@ mod tests {
verified_source(), verified_source(),
&target.runtime_id, &target.runtime_id,
&target.worker_id, &target.worker_id,
"irrelevant",
"must reject a live Worker", "must reject a live Worker",
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(running_response.status, StatusCode::CONFLICT.as_u16()); assert_eq!(running_response.status, StatusCode::CONFLICT.as_u16());
assert!(running_response.body.contains("worker_not_stopped")); assert!(running_response.body.contains("worker_not_stopped"));
api.runtime
.stop_worker(
&target,
WorkerLifecycleRequest {
reason: Some("prepare stale revision guard".to_string()),
ticket_assignment: None,
},
)
.unwrap();
let summary = api.runtime.worker(&target).unwrap();
let record = sync_worker_observation(&api, &summary).unwrap();
let stale_response = executor
.execute_async(
verified_source(),
&target.runtime_id,
&target.worker_id,
&format!("{}-stale", record.updated_at),
"must reject stale revision",
)
.await
.unwrap();
assert_eq!(stale_response.status, StatusCode::CONFLICT.as_u16());
assert!(stale_response.body.contains("worker_revision_conflict"));
} }
#[tokio::test] #[tokio::test]
@@ -16658,7 +16608,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let summary = api.runtime.worker(&target).unwrap(); let summary = api.runtime.worker(&target).unwrap();
let record = sync_worker_observation(&api, &summary).unwrap(); sync_worker_observation(&api, &summary).unwrap();
seed_worker_control_grant(&api, &source, &target, "embedded-valid-proof"); seed_worker_control_grant(&api, &source, &target, "embedded-valid-proof");
let response = WorkspaceWorkerRemoveExecutor::new(&api) let response = WorkspaceWorkerRemoveExecutor::new(&api)
@@ -16672,7 +16622,6 @@ mod tests {
}, },
&target.runtime_id, &target.runtime_id,
&target.worker_id, &target.worker_id,
&record.updated_at,
"retire completed Worker", "retire completed Worker",
) )
.await .await
@@ -16883,7 +16832,7 @@ mod tests {
route_token, route_token,
) )
.body(Body::from( .body(Body::from(
r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","expected_worker_revision":"revision-1","reason":"retire target Worker"}"#, r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker"}"#,
)) ))
.unwrap(), .unwrap(),
) )
+256 -37
View File
@@ -653,13 +653,11 @@ pub trait ControlPlaneStore: Send + Sync {
&self, &self,
workspace_id: &str, workspace_id: &str,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
expected_worker_revision: &str,
reason: &str,
) -> std::result::Result< ) -> std::result::Result<
Option<crate::retention::PreparedWorkerRemoval>, Option<crate::retention::PreparedWorkerRemoval>,
crate::retention::WorkerRetentionError, crate::retention::WorkerRetentionError,
> { > {
let _ = (workspace_id, worker, expected_worker_revision, reason); let _ = (workspace_id, worker);
Ok(None) Ok(None)
} }
fn fail_worker_removal( fn fail_worker_removal(
@@ -1061,6 +1059,14 @@ impl SqliteWorkspaceStore {
} else { } else {
Vec::new() Vec::new()
}; };
apply_migrations_through(&candidate, 38)?;
let assignment_worker_tombstone_repairs =
legacy_assignment_worker_tombstone_repairs(&candidate)?.len();
if assignment_worker_tombstone_repairs > 0 {
repairs.push(format!(
"materialize {assignment_worker_tombstone_repairs} legacy Ticket assignment Worker tombstone(s)"
));
}
apply_migrations_through(&candidate, i64::MAX)?; apply_migrations_through(&candidate, i64::MAX)?;
ticket::migrate_sqlite_ticket_schema(&candidate)?; ticket::migrate_sqlite_ticket_schema(&candidate)?;
merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?; merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?;
@@ -1642,19 +1648,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
&self, &self,
workspace_id: &str, workspace_id: &str,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
expected_worker_revision: &str,
reason: &str,
) -> std::result::Result< ) -> std::result::Result<
Option<crate::retention::PreparedWorkerRemoval>, Option<crate::retention::PreparedWorkerRemoval>,
crate::retention::WorkerRetentionError, crate::retention::WorkerRetentionError,
> { > {
SqliteWorkspaceStore::recover_worker_removal_execution( SqliteWorkspaceStore::recover_worker_removal_execution(self, workspace_id, worker)
self,
workspace_id,
worker,
expected_worker_revision,
reason,
)
} }
fn fail_worker_removal( fn fail_worker_removal(
@@ -5537,8 +5535,11 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
} }
// Assignment and operation rows are historical soft references. Schema v39 records an // Assignment and operation rows are historical soft references. Schema v39 records an
// explicit tombstone before a live Ticket or Worker parent is deleted/moved; older schemas // explicit tombstone before a live Ticket or Worker parent is deleted/moved. A pre-v39
// have no tombstone authority, so every missing live parent remains migration-blocking drift. // assignment with a valid Worker UUID and no contradictory Worker authority in another
// Workspace is repairable legacy evidence; the migration materializes its tombstone.
// Missing Ticket parents remain migration-blocking because no equivalent legacy repair is
// currently defined.
if table_exists(conn, "ticket_worker_assignments")? && table_exists(conn, "typed_tickets")? { if table_exists(conn, "ticket_worker_assignments")? && table_exists(conn, "typed_tickets")? {
let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? { let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? {
"AND NOT EXISTS (SELECT 1 FROM ticket_assignment_ticket_tombstones AS tombstone \ "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_ticket_tombstones AS tombstone \
@@ -5566,28 +5567,7 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
&& column_exists(conn, "ticket_worker_assignments", "worker_id")? && column_exists(conn, "ticket_worker_assignments", "worker_id")?
&& column_exists(conn, "worker_registry", "worker_id")? && column_exists(conn, "worker_registry", "worker_id")?
{ {
let tombstone_filter = if table_exists(conn, "ticket_assignment_worker_tombstones")? { collect_assignment_worker_reference_diagnostics(conn, &mut diagnostics)?;
"AND NOT EXISTS (SELECT 1 FROM ticket_assignment_worker_tombstones AS tombstone \
WHERE tombstone.workspace_id = assignment.workspace_id \
AND tombstone.runtime_id = assignment.runtime_id \
AND tombstone.worker_id = assignment.worker_id)"
} else {
""
};
collect_reference_diagnostics(
conn,
&format!(
"SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \
FROM ticket_worker_assignments AS assignment \
WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
WHERE worker.workspace_id = assignment.workspace_id \
AND worker.runtime_id = assignment.runtime_id \
AND worker.worker_id = assignment.worker_id) \
{tombstone_filter} LIMIT 100"
),
"ticket_worker_assignments.worker_id",
&mut diagnostics,
)?;
} }
if table_exists(conn, "ticket_assignment_operations")? && table_exists(conn, "typed_tickets")? { if table_exists(conn, "ticket_assignment_operations")? && table_exists(conn, "typed_tickets")? {
let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? { let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? {
@@ -5614,6 +5594,114 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
Ok(diagnostics) Ok(diagnostics)
} }
fn collect_assignment_worker_reference_diagnostics(
conn: &Connection,
diagnostics: &mut Vec<String>,
) -> Result<()> {
let has_assignment_tombstones = table_exists(conn, "ticket_assignment_worker_tombstones")?;
let legacy_tombstone_repairs = legacy_assignment_worker_tombstone_repairs(conn)?;
let tombstone_filter = if has_assignment_tombstones {
"AND NOT EXISTS (SELECT 1 FROM ticket_assignment_worker_tombstones AS tombstone \
WHERE tombstone.workspace_id = assignment.workspace_id \
AND tombstone.runtime_id = assignment.runtime_id \
AND tombstone.worker_id = assignment.worker_id)"
} else {
""
};
let sql = format!(
"SELECT assignment.workspace_id, assignment.assignment_id, \
assignment.runtime_id, assignment.worker_id \
FROM ticket_worker_assignments AS assignment \
WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
WHERE worker.workspace_id = assignment.workspace_id \
AND worker.runtime_id = assignment.runtime_id \
AND worker.worker_id = assignment.worker_id) \
{tombstone_filter}"
);
let mut statement = conn.prepare(&sql)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut worker_diagnostic_count = 0;
for row in rows {
let (workspace_id, assignment_id, runtime_id, worker_id) = row?;
if legacy_tombstone_repairs.contains(&(
workspace_id.clone(),
runtime_id.clone(),
worker_id.clone(),
)) {
continue;
}
diagnostics.push(format!(
"ticket_worker_assignments.worker_id: \
{workspace_id}/{assignment_id} -> {runtime_id}/{worker_id}"
));
worker_diagnostic_count += 1;
if worker_diagnostic_count == 100 {
break;
}
}
Ok(())
}
fn legacy_assignment_worker_tombstone_repairs(
conn: &Connection,
) -> Result<std::collections::BTreeSet<(String, String, String)>> {
if current_schema_version(conn)? >= 39
|| table_exists(conn, "ticket_assignment_worker_tombstones")?
|| !table_exists(conn, "ticket_worker_assignments")?
|| !table_exists(conn, "worker_registry")?
|| !column_exists(conn, "ticket_worker_assignments", "worker_id")?
|| !column_exists(conn, "worker_registry", "worker_id")?
{
return Ok(std::collections::BTreeSet::new());
}
let mut repairs = std::collections::BTreeSet::new();
let mut statement = conn.prepare(
"SELECT DISTINCT assignment.workspace_id, assignment.runtime_id, assignment.worker_id \
FROM ticket_worker_assignments AS assignment \
WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
WHERE worker.workspace_id = assignment.workspace_id \
AND worker.runtime_id = assignment.runtime_id \
AND worker.worker_id = assignment.worker_id)",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (workspace_id, runtime_id, worker_id) = row?;
if WorkerId::parse(&worker_id).is_none() {
continue;
}
let exists_only_outside_workspace: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM worker_registry \
WHERE worker_id = ?1 AND workspace_id != ?2) \
AND NOT EXISTS(SELECT 1 FROM worker_registry \
WHERE worker_id = ?1 AND workspace_id = ?2)",
params![worker_id, workspace_id],
|row| row.get(0),
)?;
if !exists_only_outside_workspace {
// Before v39, supported cleanup and Runtime-placement changes could remove or move a
// Worker without recording an assignment-specific tombstone. A valid,
// non-cross-Workspace Worker identity is sufficient legacy evidence; v39
// materializes the missing tombstone in the migration transaction.
repairs.insert((workspace_id, runtime_id, worker_id));
}
}
Ok(repairs)
}
fn collect_reference_diagnostics( fn collect_reference_diagnostics(
conn: &Connection, conn: &Connection,
sql: &str, sql: &str,
@@ -6540,6 +6628,21 @@ CREATE TABLE ticket_worker_assignments_v39 (
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
); );
INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments; INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments;
INSERT OR IGNORE INTO ticket_assignment_worker_tombstones (
workspace_id, runtime_id, worker_id, deleted_at
)
SELECT DISTINCT
assignment.workspace_id,
assignment.runtime_id,
assignment.worker_id,
CURRENT_TIMESTAMP
FROM ticket_worker_assignments_v39 AS assignment
WHERE NOT EXISTS (
SELECT 1 FROM worker_registry AS worker
WHERE worker.workspace_id = assignment.workspace_id
AND worker.runtime_id = assignment.runtime_id
AND worker.worker_id = assignment.worker_id
);
CREATE TABLE ticket_worker_assignment_events_v39 ( CREATE TABLE ticket_worker_assignment_events_v39 (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
@@ -8947,7 +9050,6 @@ INSERT INTO ticket_worker_assignment_events (
"{error}" "{error}"
); );
assert!(error.contains("assignment-cross-worker"), "{error}"); assert!(error.contains("assignment-cross-worker"), "{error}");
assert!(error.contains("assignment-runtime-mismatch"), "{error}");
assert!(error.contains("assignment-missing-parents"), "{error}"); assert!(error.contains("assignment-missing-parents"), "{error}");
assert!( assert!(
error.contains("ticket_worker_assignment_events.assignment_id"), error.contains("ticket_worker_assignment_events.assignment_id"),
@@ -9127,6 +9229,123 @@ INSERT INTO ticket_worker_assignment_events (
assert_eq!(integrity, "ok"); assert_eq!(integrity, "ok");
} }
#[test]
fn workspace_resource_fk_migration_preserves_assignments_for_legacy_absent_workers() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations_through(&conn, 38).unwrap();
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
merge_request::migrate(&conn).unwrap();
conn.execute_batch(
r#"
INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at
) VALUES ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01');
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES (
'workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '',
'planning', 1
);
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES
(
'workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001',
'Worker A', 'normal', '2026-01-01', '2026-01-01'
),
(
'workspace-a', 'runtime-old', '00000000-0000-7000-8000-000000000002',
'Worker B', 'normal', '2026-01-01', '2026-01-01'
);
INSERT INTO ticket_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at
) VALUES
(
'workspace-a', 'ticket-a', 'assignment-a', 'runtime-a',
'00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01'
),
(
'workspace-a', 'ticket-a', 'assignment-b', 'runtime-old',
'00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01'
);
DELETE FROM worker_registry
WHERE workspace_id = 'workspace-a'
AND runtime_id = 'runtime-a'
AND worker_id = '00000000-0000-7000-8000-000000000001';
UPDATE worker_registry
SET runtime_id = 'runtime-new'
WHERE workspace_id = 'workspace-a'
AND runtime_id = 'runtime-old'
AND worker_id = '00000000-0000-7000-8000-000000000002';
"#,
)
.unwrap();
assert_eq!(
legacy_assignment_worker_tombstone_repairs(&conn)
.unwrap()
.len(),
2
);
drop(conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert!(
plan.repairs.iter().any(
|repair| repair == "materialize 2 legacy Ticket assignment Worker tombstone(s)"
),
"{:?}",
plan.repairs
);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 38);
assert!(!table_exists(&conn, "ticket_assignment_worker_tombstones").unwrap());
apply_migrations_through(&conn, 39).unwrap();
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments \
WHERE workspace_id = 'workspace-a' AND assignment_id = 'assignment-a'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_assignment_worker_tombstones \
WHERE workspace_id = 'workspace-a' \
AND runtime_id = 'runtime-a' \
AND worker_id = '00000000-0000-7000-8000-000000000001'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_assignment_worker_tombstones \
WHERE workspace_id = 'workspace-a' \
AND runtime_id = 'runtime-old' \
AND worker_id = '00000000-0000-7000-8000-000000000002'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1
);
validate_workspace_resource_references(&conn).unwrap();
}
#[test] #[test]
fn fresh_schema_matches_workspace_db_v0_boundaries() { fn fresh_schema_matches_workspace_db_v0_boundaries() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
+1 -9
View File
@@ -175,7 +175,6 @@ pub(crate) trait VerifiedWorkerRemoveExecutor: Send + Sync {
source: VerifiedWorkerMutationSource, source: VerifiedWorkerMutationSource,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result<worker::WorkspaceResponse, String>; ) -> Result<worker::WorkspaceResponse, String>;
} }
@@ -217,7 +216,6 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
proof: InProcessWorkerMutationProof, proof: InProcessWorkerMutationProof,
target_runtime_id: &str, target_runtime_id: &str,
target_worker_id: &str, target_worker_id: &str,
expected_worker_revision: &str,
reason: &str, reason: &str,
) -> Result< ) -> Result<
worker::WorkspaceResponse, worker::WorkspaceResponse,
@@ -241,13 +239,7 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
) )
})?; })?;
executor executor
.execute( .execute(source, target_runtime_id, target_worker_id, reason)
source,
target_runtime_id,
target_worker_id,
expected_worker_revision,
reason,
)
.map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded) .map_err(worker_runtime::worker_source::RuntimeWorkerMutationForwardError::Embedded)
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ Do not insert turn-crossing information directly into context without first appe
Forbidden examples: Forbidden examples:
- Delivering a `Notify` or `WorkerEvent` only as a temporary context note. - Delivering a `Notify` or `WorkerEvent` only as a temporary context note.
- Adding a `<system-reminder>` that explains behavior but is not persisted. - Adding a system reminder that explains behavior but is not persisted.
- Rewriting old messages to include new facts. - Rewriting old messages to include new facts.
- Letting UI/controller-only state become model-visible without a committed record. - Letting UI/controller-only state become model-visible without a committed record.
@@ -21,6 +21,7 @@ Start exactly one instance of the new Server binary against the database. Startu
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; - rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; - adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist; - validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and - checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and
- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.
@@ -1,4 +1,3 @@
<system-reminder>
Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present. Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present.
This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted. This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted.
@@ -21,4 +20,3 @@ Additional queued Tickets omitted from this bounded notice: {{ omitted_ticket_co
{% endif -%} {% endif -%}
Preserve the existing human gate, dependency/conflict/capacity/dirty-workspace checks, and duplicate-start checks using actual Ticket state, role/session claims, visible Workers, and worktrees. Preserve the existing human gate, dependency/conflict/capacity/dirty-workspace checks, and duplicate-start checks using actual Ticket state, role/session claims, visible Workers, and worktrees.
</system-reminder>
+1 -1
View File
@@ -22,4 +22,4 @@ Do not create or delegate an implementation worktree/branch until the Ticket rec
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker with `WorkerShow`; pass the exact current `updated_at` value as `expected_worker_revision` with a concise reason. After removal, reread the Worker catalog and attachment state. Treat revision, assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures: do not guess policy or retry with stale input. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
+1 -1
View File
@@ -138,7 +138,7 @@ runtime_id?: string | null,
/** /**
* Producer-owned monotonic revision for this Worker subject. * Producer-owned monotonic revision for this Worker subject.
*/ */
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, }; subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, }; export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
@@ -19,7 +19,8 @@
} }
function shouldRenderHeading(line: ConsoleLine): boolean { function shouldRenderHeading(line: ConsoleLine): boolean {
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool'; return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
} }
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } { function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
@@ -49,24 +50,29 @@
{#if shouldRenderHeading(item)} {#if shouldRenderHeading(item)}
<div class="message-heading"> <div class="message-heading">
<span>{item.title}</span> <span>{item.title}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div> </div>
{:else if item.kind === 'tool'} {:else if item.kind === 'tool'}
<div class="tool-summary"> <div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span> <span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span> <span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span> <span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
{#if item.streaming}<small>streaming</small>{/if}
</div>
{:else if item.streaming}
<div class="message-heading streaming-heading">
<small>streaming</small>
</div> </div>
{/if} {/if}
{#if item.kind === 'tool'} {#if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)} {#if bodyTextAfterToolSummary(item)}
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p> <p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
{/if} {/if}
{:else if item.kind === 'user'}
<div class="user-message">
<span class="user-prompt" aria-hidden="true">&gt;</span>
<div><RichMarkdown text={item.body || '—'} /></div>
</div>
{:else if item.kind === 'activity'}
<p class="activity-summary">{item.body || '—'}</p>
{:else if item.kind === 'task_reminder'}
<p class="task-reminder-summary">{item.body || 'task reminder'}</p>
{:else if item.kind === 'run_stats'}
<p class="run-stats">{item.body}</p>
{:else if shouldRenderMarkdown(item)} {:else if shouldRenderMarkdown(item)}
<RichMarkdown text={item.body || '—'} /> <RichMarkdown text={item.body || '—'} />
{:else} {:else}
@@ -102,6 +108,48 @@
color: var(--tui-green); color: var(--tui-green);
} }
.user-message {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.55rem;
align-items: start;
}
.user-prompt {
color: var(--tui-green);
font-weight: 700;
line-height: 1.55;
}
.activity-summary,
.task-reminder-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.55;
white-space: pre-line;
}
.task-reminder-summary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.console-line.error .activity-summary {
color: var(--tui-error);
}
.run-stats {
margin: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.console-line.assistant { .console-line.assistant {
color: var(--text-strong); color: var(--text-strong);
} }
@@ -154,14 +202,6 @@
font-weight: 750; font-weight: 750;
} }
.tool-summary small {
margin-left: var(--space-2);
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.tool-label { .tool-label {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--tui-cyan); color: var(--tui-cyan);
@@ -208,18 +248,6 @@
font-weight: 750; font-weight: 750;
} }
.message-heading.streaming-heading {
justify-content: flex-start;
}
.message-heading small {
margin: 0;
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.console-diff { .console-diff {
background: color-mix(in oklch, var(--bg-raised) 85%, black); background: color-mix(in oklch, var(--bg-raised) 85%, black);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -1,12 +1,26 @@
<script lang="ts"> <script lang="ts">
import { taskCounts, type ConsoleTask } from "./tasks.ts"; import { taskCounts, type ConsoleTask } from "./tasks.ts";
type WorkerViewTab = {
sessionId: string | null;
label: string;
};
type Props = { type Props = {
tasks: ConsoleTask[]; tasks: ConsoleTask[];
mode: "mini" | "pane"; mode: "mini" | "pane";
workerViews?: WorkerViewTab[];
selectedWorkerViewSessionId?: string | null;
onSelectWorkerView?: (sessionId: string | null) => void;
}; };
let { tasks, mode }: Props = $props(); let {
tasks,
mode,
workerViews = [],
selectedWorkerViewSessionId = null,
onSelectWorkerView = () => {},
}: Props = $props();
const counts = $derived(taskCounts(tasks)); const counts = $derived(taskCounts(tasks));
const activeTasks = $derived( const activeTasks = $derived(
tasks tasks
@@ -28,7 +42,7 @@
} }
</script> </script>
{#if mode === "mini" && tasks.length > 0} {#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)}
<section class="task-mini" aria-label="Worker task summary"> <section class="task-mini" aria-label="Worker task summary">
{#each activeTasks as task (task.taskid)} {#each activeTasks as task (task.taskid)}
<div class="task-mini-row"> <div class="task-mini-row">
@@ -38,8 +52,25 @@
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span> <span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
</div> </div>
{/each} {/each}
<div class="task-summary"> <div class="task-summary-row">
<span class="task-summary">
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted} {counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
</span>
{#if workerViews.length > 1}
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
<span aria-hidden="true">[ </span>
{#each workerViews as view, index (view.sessionId ?? "main")}
{#if index > 0}<span aria-hidden="true"> | </span>{/if}
<button
type="button"
aria-pressed={view.sessionId === selectedWorkerViewSessionId}
class:active={view.sessionId === selectedWorkerViewSessionId}
onclick={() => onSelectWorkerView(view.sessionId)}
>{view.label}</button>
{/each}
<span aria-hidden="true"> ]</span>
</span>
{/if}
</div> </div>
</section> </section>
{:else if mode === "pane"} {:else if mode === "pane"}
@@ -95,12 +126,69 @@
} }
.task-mini-row, .task-mini-row,
.task-heading { .task-heading,
.task-summary-row {
display: flex; display: flex;
min-width: 0; min-width: 0;
gap: 0.5rem; gap: 0.5rem;
} }
.task-summary-row {
align-items: baseline;
}
.task-summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.worker-view-tabs {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
margin-left: auto;
overflow-x: auto;
color: var(--text-muted);
scrollbar-width: none;
white-space: nowrap;
}
.worker-view-tabs::-webkit-scrollbar {
display: none;
}
.worker-view-tabs button {
flex: 0 0 auto;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
cursor: pointer;
}
.worker-view-tabs button:hover,
.worker-view-tabs button:focus-visible {
color: var(--text);
}
.worker-view-tabs button:focus-visible {
outline: 1px solid currentcolor;
outline-offset: 2px;
}
.worker-view-tabs button.active {
color: var(--accent);
font-weight: 700;
}
.task-mark, .task-mark,
.task-id { .task-id {
flex: 0 0 auto; flex: 0 0 auto;
@@ -0,0 +1,45 @@
<script lang="ts">
type Props = {
values: readonly string[];
intervalMs?: number;
ariaLabel?: string;
class?: string;
};
let {
values,
intervalMs = 100,
ariaLabel,
class: className,
}: Props = $props();
let index = $state(0);
const value = $derived(values.length > 0 ? values[index % values.length] : "");
$effect(() => {
const length = values.length;
const delay = Math.max(16, intervalMs);
index = 0;
if (length <= 1) return;
const timer = window.setInterval(() => {
index = (index + 1) % length;
}, delay);
return () => window.clearInterval(timer);
});
</script>
<span
class={className}
class:sequence-loop={true}
aria-label={ariaLabel}
aria-hidden={ariaLabel ? undefined : "true"}
>{value}</span>
<style>
.sequence-loop {
display: inline-block;
min-width: 1ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,35 @@
<script module lang="ts">
export const SPINNER_FRAMES = [
"⣷",
"⣯",
"⣟",
"⡿",
"⢿",
"⣻",
"⣽",
"⣾",
] as const;
</script>
<script lang="ts">
import SequenceLoop from "./SequenceLoop.svelte";
type Props = {
intervalMs?: number;
label?: string;
};
let { intervalMs = 90, label = "Running" }: Props = $props();
</script>
<span class="spinner" role="img" aria-label={label}>
<SequenceLoop values={SPINNER_FRAMES} {intervalMs} />
</span>
<style>
.spinner {
display: inline-flex;
color: var(--spinner-color, var(--accent));
line-height: 1;
}
</style>
@@ -0,0 +1,48 @@
<script lang="ts">
import Spinner from "./Spinner.svelte";
import { formatRunElapsed, formatRunTokens } from "./run-status";
type Props = {
startedAtMs: number | null;
requests: number;
uploadTokens: number;
outputTokens: number;
};
let { startedAtMs, requests, uploadTokens, outputTokens }: Props = $props();
let nowMs = $state(Date.now());
$effect(() => {
startedAtMs;
nowMs = Date.now();
const timer = window.setInterval(() => {
nowMs = Date.now();
}, 1_000);
return () => window.clearInterval(timer);
});
const elapsed = $derived(formatRunElapsed(nowMs - (startedAtMs ?? nowMs)));
const requestLabel = $derived(requests === 1 ? "req" : "reqs");
</script>
<div class="worker-run-status" role="status" aria-live="off">
<Spinner />
<span>{elapsed}</span>
<span aria-hidden="true"></span>
<span>{requests} {requestLabel}</span>
<span aria-hidden="true">|</span>
<span>{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)}</span>
</div>
<style>
.worker-run-status {
display: flex;
align-items: center;
gap: 0.42rem;
min-height: 1.35rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,9 +1,15 @@
import type { Event } from "$lib/generated/protocol"; import type { Event } from "$lib/generated/protocol";
import { import {
type ConsoleEventInput,
type ConsoleLine, type ConsoleLine,
consoleWorkerViews,
createConsoleProjector, createConsoleProjector,
isConsoleProjectionEvent, isConsoleProjectionEvent,
projectConsole, projectConsole,
projectConsoleLines,
projectOverviewLines,
resolveConsoleViewScrollTop,
resolveConsoleWorkerView,
segmentsToText, segmentsToText,
selectConsoleTimelineLines, selectConsoleTimelineLines,
workerConsoleHref, workerConsoleHref,
@@ -889,6 +895,9 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
!toolLines[0].body.includes("another content"), !toolLines[0].body.includes("another content"),
"Read aggregate should not display file contents", "Read aggregate should not display file contents",
); );
const overview = projectOverviewLines(projection.lines);
assertEquals(overview.length, 1);
assertEquals(overview[0].body, "2 files read");
}); });
Deno.test("projectConsole renders Edit calls with structured diff lines", () => { Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
@@ -987,11 +996,13 @@ Deno.test("projectConsole hides lifecycle events and renders system items", () =
}, },
]); ]);
assertEquals(projection.lines.length, 1); assertEquals(projection.lines.length, 2);
assertEquals(projection.lines[0].kind, "system"); assertEquals(projection.lines[0].kind, "run_stats");
assertEquals(projection.lines[0].title, "System · notification"); assertEquals(projection.lines[0].body, "0s ・0 reqs ↑0/↓0");
assertEquals(projection.lines[1].kind, "system");
assertEquals(projection.lines[1].title, "System · notification");
assertEquals( assertEquals(
projection.lines[0].body, projection.lines[1].body,
"Reread Ticket 00001KZ6TSGG5 before acting.", "Reread Ticket 00001KZ6TSGG5 before acting.",
); );
assertEquals(projection.status, "running"); assertEquals(projection.status, "running");
@@ -1435,6 +1446,96 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
}, },
}]); }]);
assertEquals(projection.internalWorkers[0].console.lines.length, 1); assertEquals(projection.internalWorkers[0].console.lines.length, 1);
const views = consoleWorkerViews(projection);
assertEquals(views.map((view) => [view.sessionId, view.label]), [
[null, "main"],
["child-session", "research"],
]);
assertEquals(
resolveConsoleWorkerView(projection, "child-session").console.lines[0].body,
"child output",
);
assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null);
});
Deno.test("console Worker views expose only direct Internal Workers", () => {
const projector = createConsoleProjector();
projector.append([{
eventId: "nested",
event: {
event: "internal_worker",
data: {
worker: {
session_id: "child-session",
name: "research",
parent_session_id: "parent-session",
kind: "sub_worker",
},
revision: 1,
event: {
event: "internal_worker",
data: {
worker: {
session_id: "grandchild-session",
name: "nested",
parent_session_id: "child-session",
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "running" } },
},
},
},
},
}]);
projector.append([{
eventId: "peer",
event: {
event: "internal_worker",
data: {
worker: {
session_id: "peer-other",
name: "research",
parent_session_id: "parent-session",
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "idle" } },
},
},
}]);
const projection = projector.snapshot();
const views = consoleWorkerViews(projection);
assertEquals(views.map((view) => view.sessionId), [
null,
"child-session",
"peer-other",
]);
assertEquals(views[1].label, "research · ession");
assertEquals(views[2].label, "research · -other");
assertEquals(
resolveConsoleWorkerView(projection, "grandchild-session").sessionId,
null,
);
});
Deno.test("console Worker view scroll restores manual offsets and auto-follow", () => {
assertEquals(resolveConsoleViewScrollTop(undefined, 1000, 200), 1000);
assertEquals(
resolveConsoleViewScrollTop({ top: 100, autoFollow: true }, 1000, 200),
1000,
);
assertEquals(
resolveConsoleViewScrollTop({ top: 300, autoFollow: false }, 1000, 200),
300,
);
assertEquals(
resolveConsoleViewScrollTop({ top: 900, autoFollow: false }, 1000, 200),
800,
);
}); });
Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => { Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => {
@@ -1631,3 +1732,182 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
}]); }]);
assertEquals(projection.taskNextId, 4); assertEquals(projection.taskNextId, 4);
}); });
Deno.test("overview hides typed task reminders after restoring TaskStore state", () => {
const body =
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 8, "status": "inprogress", "subject": "Visible in Tasks", "description": "Hidden in overview"}]\n}\n\`\`\``;
const projection = projectConsole([{
eventId: "task-reminder",
event: {
event: "system_item",
data: {
item: { kind: "task_reminder", body },
},
},
}]);
assertEquals(projection.tasks[0]?.taskid, 8);
assertEquals(projection.lines[0]?.systemItemKind, "task_reminder");
assertEquals(projectConsoleLines(projection.lines, "overview"), []);
const normal = projectConsoleLines(projection.lines, "normal");
assertEquals(normal.length, 1);
assertEquals(normal[0].kind, "task_reminder");
assertEquals(
normal[0].body,
"task reminder: [Session TaskStore snapshot]",
);
});
Deno.test("overview hides thinking and aggregates uninterrupted tool activity", () => {
const toolLine = (
id: string,
name: string,
diff?: ConsoleLine["diff"],
): ConsoleLine => ({
id,
kind: "tool",
title: `Call · ${name}`,
body: name,
source: "event",
diff,
toolCall: {
id,
name,
argsStream: "",
state: "done",
},
});
const overview = projectOverviewLines([
consoleLine("user", "user"),
consoleLine("assistant-before", "assistant"),
consoleLine("thought-before-tools", "thinking"),
toolLine("read-a", "Read"),
consoleLine("thought-between-tools", "thinking"),
toolLine("read-b", "Read"),
toolLine("bash-a", "Bash"),
consoleLine("assistant-after-tools", "assistant"),
toolLine("edit-a", "Edit", [
{ kind: "remove", oldNumber: 1, content: "old" },
{ kind: "add", newNumber: 1, content: "new" },
{ kind: "add", newNumber: 2, content: "next" },
]),
]);
assertEquals(overview.map((line) => line.kind), [
"user",
"assistant",
"activity",
"assistant",
"activity",
]);
assertEquals(overview[2].body, "2 files read・ran 1 command");
assertEquals(overview[4].body, "edited +2/-1");
});
Deno.test("overview hides in-flight thinking and keeps tool failures visible", () => {
const overview = projectOverviewLines([
{
...consoleLine("thinking-in-flight", "in_flight"),
title: "in-flight thinking",
},
{
...consoleLine("failed-read", "tool"),
error: true,
toolCall: {
id: "failed-read",
name: "Read",
argsStream: "",
state: "error",
isError: true,
},
},
]);
assertEquals(overview.length, 1);
assertEquals(overview[0].kind, "activity");
assertEquals(overview[0].body, "1 file read\n1 failed");
assertEquals(overview[0].error, true);
});
Deno.test("RunEnd appends TUI-compatible request and token stats", () => {
const events: ConsoleEventInput[] = [
{
eventId: "invoke",
observedAtMs: 1_000,
event: { event: "invoke_start", data: { kind: "user_send" } },
},
...Array.from({ length: 5 }, (_, index) => ({
eventId: `turn-${index}`,
observedAtMs: 1_010 + index,
event: { event: "turn_start", data: { turn: index + 1 } } as Event,
})),
{
eventId: "usage",
observedAtMs: 1_020,
event: {
event: "usage",
data: {
input_tokens: 60_000,
cache_read_input_tokens: 3_500,
output_tokens: 1_200,
},
},
},
{
eventId: "run-end",
observedAtMs: 621_000,
event: { event: "run_end", data: { result: "finished" } },
},
];
const projection = projectConsole(events);
const stats = projection.lines.filter((line) => line.kind === "run_stats");
assertEquals(stats.length, 1);
assertEquals(stats[0].body, "10m20s ・5 reqs ↑56.5k/↓1.2k");
assertEquals(
projectConsoleLines(projection.lines, "overview").at(-1)?.kind,
"run_stats",
);
assertEquals(
projectConsoleLines(projection.lines, "normal").at(-1)?.kind,
"run_stats",
);
});
Deno.test("new invoke resets stats before the next RunEnd", () => {
const projector = createConsoleProjector();
projector.append([
{
eventId: "first-invoke",
event: { event: "invoke_start", data: { kind: "user_send" } },
},
{
eventId: "first-turn",
event: { event: "turn_start", data: { turn: 1 } },
},
{
eventId: "first-usage",
event: {
event: "usage",
data: { input_tokens: 1_000, output_tokens: 100 },
},
},
{
eventId: "first-end",
event: { event: "run_end", data: { result: "finished" } },
},
]);
const projection = projector.append([
{
eventId: "second-invoke",
event: { event: "invoke_start", data: { kind: "notify" } },
},
{
eventId: "second-end",
event: { event: "run_end", data: { result: "finished" } },
},
]);
assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0");
});
+303 -13
View File
@@ -11,6 +11,13 @@ import type {
Segment, Segment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http"; import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
emptyRunActivityStats,
formatRunElapsedCompact,
formatRunTokens,
type RunActivityStats,
} from "./run-status.ts";
import { import {
applyTaskSnapshotText, applyTaskSnapshotText,
applyTaskToolCall, applyTaskToolCall,
@@ -22,6 +29,9 @@ export type ConsoleLineKind =
| "assistant" | "assistant"
| "thinking" | "thinking"
| "tool" | "tool"
| "activity"
| "task_reminder"
| "run_stats"
| "status" | "status"
| "error" | "error"
| "usage" | "usage"
@@ -55,6 +65,8 @@ export type ConsoleDiffLine = {
content: string; content: string;
}; };
export type ConsoleViewMode = "overview" | "normal";
export type ConsoleLine = { export type ConsoleLine = {
id: string; id: string;
kind: ConsoleLineKind; kind: ConsoleLineKind;
@@ -67,6 +79,10 @@ export type ConsoleLine = {
streaming?: boolean; streaming?: boolean;
error?: boolean; error?: boolean;
toolCall?: ToolCallView; toolCall?: ToolCallView;
/** Number of calls represented by a lower-level aggregate line. */
toolCallCount?: number;
/** Typed `SystemItem.kind` used by presentation-only projections. */
systemItemKind?: string;
}; };
export type InternalWorkerProjection = { export type InternalWorkerProjection = {
@@ -75,18 +91,57 @@ export type InternalWorkerProjection = {
console: ConsoleProjection; console: ConsoleProjection;
}; };
export type FlattenedInternalWorkerProjection = InternalWorkerProjection & { export type ConsoleViewScroll = {
depth: number; top: number;
autoFollow: boolean;
}; };
export function flattenInternalWorkers( export function resolveConsoleViewScrollTop(
workers: InternalWorkerProjection[], state: ConsoleViewScroll | undefined,
depth = 0, scrollHeight: number,
): FlattenedInternalWorkerProjection[] { clientHeight: number,
return workers.flatMap((worker) => [ ): number {
{ ...worker, depth }, if (!state || state.autoFollow) return scrollHeight;
...flattenInternalWorkers(worker.console.internalWorkers, depth + 1), return Math.min(state.top, Math.max(0, scrollHeight - clientHeight));
]); }
export type ConsoleWorkerView = {
sessionId: string | null;
label: string;
console: ConsoleProjection;
};
export function consoleWorkerViews(
projection: ConsoleProjection,
): ConsoleWorkerView[] {
const labels = projection.internalWorkers.map((worker) =>
worker.worker.name || "subworker"
);
const labelCounts = new Map<string, number>();
for (const label of labels) {
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
return [
{ sessionId: null, label: "main", console: projection },
...projection.internalWorkers.map((worker, index) => {
const label = labels[index] ?? "subworker";
return {
sessionId: worker.worker.session_id,
label: labelCounts.get(label) === 1
? label
: `${label} · ${worker.worker.session_id.slice(-6)}`,
console: worker.console,
};
}),
];
}
export function resolveConsoleWorkerView(
projection: ConsoleProjection,
selectedSessionId: string | null,
): ConsoleWorkerView {
const views = consoleWorkerViews(projection);
return views.find((view) => view.sessionId === selectedSessionId) ?? views[0];
} }
export type ConsoleProjection = { export type ConsoleProjection = {
@@ -95,6 +150,7 @@ export type ConsoleProjection = {
taskNextId: number; taskNextId: number;
status: string | null; status: string | null;
usage: string | null; usage: string | null;
runActivity: RunActivityStats;
cwd: string | null; cwd: string | null;
lastEventId: string | null; lastEventId: string | null;
internalWorkers: InternalWorkerProjection[]; internalWorkers: InternalWorkerProjection[];
@@ -182,6 +238,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
taskNextId: 1, taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
runActivity: emptyRunActivityStats(),
cwd: null, cwd: null,
lastEventId: null, lastEventId: null,
internalWorkers: [], internalWorkers: [],
@@ -231,6 +288,199 @@ function projectVisibleConsole(
}; };
} }
function isOverviewThinkingLine(line: ConsoleLine): boolean {
return line.kind === "thinking" ||
(line.kind === "in_flight" && line.title === "in-flight thinking");
}
function representedToolCallCount(line: ConsoleLine): number {
return Math.max(1, line.toolCallCount ?? 1);
}
function overviewToolActivityLine(group: ConsoleLine[]): ConsoleLine {
const first = group[0]!;
const last = group[group.length - 1]!;
let readCount = 0;
let searchCount = 0;
let commandCount = 0;
let editCount = 0;
let writeCount = 0;
let additions = 0;
let deletions = 0;
let failedCount = 0;
let activeCount = 0;
let readActive = false;
let searchActive = false;
let commandActive = false;
let editActive = false;
let writeActive = false;
const otherCounts = new Map<string, number>();
for (const line of group) {
const count = representedToolCallCount(line);
const name = line.toolCall?.name ?? "Tool";
const state = line.toolCall?.state;
if (state === "error" || line.error || line.toolCall?.isError) {
failedCount += count;
}
const callActive = state === "pending" || state === "streaming_args" ||
state === "running";
if (callActive) activeCount += count;
switch (name) {
case "Read":
readCount += count;
readActive ||= callActive;
break;
case "Glob":
case "Grep":
case "WebSearch":
case "SearchSessionEntries":
searchCount += count;
searchActive ||= callActive;
break;
case "Bash":
commandCount += count;
commandActive ||= callActive;
break;
case "Edit":
editCount += count;
editActive ||= callActive;
if (state === "done") {
additions += line.diff?.filter((diff) =>
diff.kind === "add"
).length ?? 0;
deletions += line.diff?.filter((diff) =>
diff.kind === "remove"
).length ?? 0;
}
break;
case "Write":
writeCount += count;
writeActive ||= callActive;
break;
default:
otherCounts.set(name, (otherCounts.get(name) ?? 0) + count);
break;
}
}
const active = activeCount > 0;
const primary: string[] = [];
if (readCount > 0) {
primary.push(
readActive
? `reading ${readCount} file${readCount === 1 ? "" : "s"}`
: `${readCount} file${readCount === 1 ? "" : "s"} read`,
);
}
if (searchCount > 0) {
primary.push(
searchActive
? `searching ${searchCount} time${searchCount === 1 ? "" : "s"}`
: `searched ${searchCount} time${searchCount === 1 ? "" : "s"}`,
);
}
if (commandCount > 0) {
primary.push(
commandActive
? `running ${commandCount} command${commandCount === 1 ? "" : "s"}`
: `ran ${commandCount} command${commandCount === 1 ? "" : "s"}`,
);
}
for (
const [name, count] of [...otherCounts].sort(([left], [right]) =>
left.localeCompare(right)
)
) {
primary.push(count === 1 ? name : `${count} ${name}`);
}
const changes: string[] = [];
if (editCount > 0) {
if (editActive) {
changes.push(`editing ${editCount} file${editCount === 1 ? "" : "s"}`);
} else if (additions > 0 || deletions > 0) {
changes.push(`edited +${additions}/-${deletions}`);
} else {
changes.push(`edited ${editCount} file${editCount === 1 ? "" : "s"}`);
}
}
if (writeCount > 0) {
changes.push(
writeActive
? `writing ${writeCount} file${writeCount === 1 ? "" : "s"}`
: `wrote ${writeCount} file${writeCount === 1 ? "" : "s"}`,
);
}
if (failedCount > 0) changes.push(`${failedCount} failed`);
return {
id: `activity-${first.id}-${last.id}`,
kind: "activity",
title: "Activity",
body: [primary.join("・"), ...changes].filter(Boolean).join("\n"),
source: "event",
streaming: active,
error: failedCount > 0,
};
}
/**
* Builds the overview-only Console presentation. Protocol projection retains
* full tool and thinking state for reconciliation, but the visible history
* hides thinking and folds each uninterrupted tool run into one activity.
*/
export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
const overview: ConsoleLine[] = [];
let toolGroup: ConsoleLine[] = [];
const flushTools = () => {
if (toolGroup.length === 0) return;
overview.push(overviewToolActivityLine(toolGroup));
toolGroup = [];
};
for (const line of lines) {
if (line.systemItemKind === "task_reminder") continue;
if (isOverviewThinkingLine(line)) continue;
if (line.kind === "tool" && line.toolCall) {
toolGroup.push(line);
continue;
}
flushTools();
overview.push(line);
}
flushTools();
return overview;
}
export function projectNormalLines(lines: ConsoleLine[]): ConsoleLine[] {
return lines.map((line) => {
if (line.systemItemKind !== "task_reminder") return line;
const first = line.body
.split("\n")
.map((part) => part.trim())
.find(Boolean);
return {
...line,
kind: "task_reminder",
title: "Task reminder",
body: first ? `task reminder: ${first}` : "task reminder",
detail: undefined,
};
});
}
export function projectConsoleLines(
lines: ConsoleLine[],
mode: ConsoleViewMode,
): ConsoleLine[] {
return mode === "overview"
? projectOverviewLines(lines)
: projectNormalLines(lines);
}
function appendSnapshotInFlightLines( function appendSnapshotInFlightLines(
projection: ConsoleProjection, projection: ConsoleProjection,
blocks: InFlightBlock[], blocks: InFlightBlock[],
@@ -416,20 +666,25 @@ function projectInternalWorkerSnapshot(
export function applyProtocolEvent( export function applyProtocolEvent(
projection: ConsoleProjection, projection: ConsoleProjection,
envelope: { eventId: string; event: ProtocolEvent }, envelope: ConsoleEventInput,
): ConsoleProjection { ): ConsoleProjection {
const event = envelope.event;
const next: ConsoleProjection = { const next: ConsoleProjection = {
lines: [...projection.lines], lines: [...projection.lines],
tasks: [...projection.tasks], tasks: [...projection.tasks],
taskNextId: projection.taskNextId, taskNextId: projection.taskNextId,
status: projection.status, status: projection.status,
usage: projection.usage, usage: projection.usage,
runActivity: applyRunActivityEvent(
projection.runActivity,
event,
envelope.observedAtMs ?? 0,
),
cwd: projection.cwd, cwd: projection.cwd,
lastEventId: envelope.eventId, lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers], internalWorkers: [...projection.internalWorkers],
removedInternalWorkers: { ...projection.removedInternalWorkers }, removedInternalWorkers: { ...projection.removedInternalWorkers },
}; };
const event = envelope.event;
switch (event.event) { switch (event.event) {
case "user_message": case "user_message":
@@ -581,6 +836,7 @@ export function applyProtocolEvent(
eventId: eventId:
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`, `${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
event: event.data.event, event: event.data.event,
observedAtMs: envelope.observedAtMs,
}), }),
}; };
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated; if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
@@ -625,7 +881,15 @@ export function applyProtocolEvent(
case "llm_call_end": case "llm_call_end":
case "llm_retry": case "llm_retry":
case "llm_continuation": case "llm_continuation":
break;
case "run_end": case "run_end":
next.lines.push(
runStatsLine(
envelope.eventId,
next.runActivity,
envelope.observedAtMs ?? next.runActivity.startedAtMs ?? 0,
),
);
break; break;
case "alert": case "alert":
appendAlertLine(next, envelope.eventId, event.data); appendAlertLine(next, envelope.eventId, event.data);
@@ -682,6 +946,22 @@ export function segmentsToText(segments: Segment[]): string {
.join("\n"); .join("\n");
} }
function runStatsLine(
eventId: string,
stats: RunActivityStats,
endedAtMs: number,
): ConsoleLine {
const elapsedMs = endedAtMs - (stats.startedAtMs ?? endedAtMs);
return line(
eventId,
"run_stats",
"Run stats",
`${formatRunElapsedCompact(elapsedMs)}${stats.requests} reqs ↑${
formatRunTokens(stats.uploadTokens)
}/${formatRunTokens(stats.outputTokens)}`,
);
}
function line( function line(
eventId: string, eventId: string,
kind: ConsoleLineKind, kind: ConsoleLineKind,
@@ -712,7 +992,10 @@ function systemItemLine(eventId: string, item: unknown): ConsoleLine {
const title = `System · ${itemKind.replaceAll("_", " ")}`; const title = `System · ${itemKind.replaceAll("_", " ")}`;
const body = stringField(item, "body") ?? stringField(item, "message") ?? const body = stringField(item, "body") ?? stringField(item, "message") ??
stringField(item, "content") ?? jsonPreview(item); stringField(item, "content") ?? jsonPreview(item);
return line(eventId, "system", title, body); return {
...line(eventId, "system", title, body),
systemItemKind: itemKind,
};
} }
function upsertStatusLine( function upsertStatusLine(
@@ -1041,6 +1324,12 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
source: "event", source: "event",
streaming: inProgress, streaming: inProgress,
error: hasError, error: hasError,
toolCall: {
...calls[0]!,
state: hasError ? "error" : inProgress ? "running" : "done",
isError: hasError,
},
toolCallCount: count,
}; };
} }
@@ -1513,6 +1802,7 @@ function snapshotProjectionFromEntries(
taskNextId: 1, taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
runActivity: emptyRunActivityStats(),
cwd, cwd,
lastEventId: eventId, lastEventId: eventId,
internalWorkers: [], internalWorkers: [],
@@ -0,0 +1,96 @@
// @ts-nocheck
import {
applyRunActivityEvent,
emptyRunActivityStats,
formatRunElapsed,
formatRunElapsedCompact,
formatRunTokens,
} from "./run-status.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
Deno.test("run activity follows TUI request and net-token accounting", () => {
let stats = applyRunActivityEvent(
emptyRunActivityStats(),
{ event: "invoke_start", data: { kind: "user_send" } },
1_000,
);
stats = applyRunActivityEvent(
stats,
{ event: "turn_start", data: { turn: 1 } },
1_010,
);
stats = applyRunActivityEvent(
stats,
{
event: "usage",
data: {
input_tokens: 25_000,
cache_read_input_tokens: 20_000,
output_tokens: 3_000,
},
},
1_020,
);
stats = applyRunActivityEvent(
stats,
{ event: "turn_start", data: { turn: 2 } },
1_030,
);
assertEquals(stats, {
startedAtMs: 1_000,
requests: 2,
uploadTokens: 5_000,
outputTokens: 3_000,
});
});
Deno.test("new invoke and running snapshot reset run activity", () => {
const previous = {
startedAtMs: 1,
requests: 3,
uploadTokens: 100,
outputTokens: 20,
};
assertEquals(
applyRunActivityEvent(
previous,
{ event: "invoke_start", data: { kind: "notify" } },
9_000,
),
{ startedAtMs: 9_000, requests: 0, uploadTokens: 0, outputTokens: 0 },
);
assertEquals(
applyRunActivityEvent(
previous,
{
event: "snapshot",
data: {
entries: [],
greeting: { text: "", profile: "" },
status: "idle",
in_flight: {},
internal_workers: [],
},
},
10_000,
),
emptyRunActivityStats(),
);
});
Deno.test("run status formatting matches the compact TUI shape", () => {
assertEquals(formatRunElapsed(88_900), "1m 28s");
assertEquals(formatRunElapsed(3_723_000), "1h 2m 3s");
assertEquals(formatRunElapsedCompact(620_000), "10m20s");
assertEquals(formatRunTokens(25_000), "25.0k");
assertEquals(formatRunTokens(3_000), "3.0k");
assertEquals(formatRunTokens(999), "999");
});
@@ -0,0 +1,71 @@
import type { Event as ProtocolEvent } from "$lib/generated/protocol";
export type RunActivityStats = {
startedAtMs: number | null;
requests: number;
uploadTokens: number;
outputTokens: number;
};
export function emptyRunActivityStats(): RunActivityStats {
return {
startedAtMs: null,
requests: 0,
uploadTokens: 0,
outputTokens: 0,
};
}
export function applyRunActivityEvent(
current: RunActivityStats,
event: ProtocolEvent,
observedAtMs: number,
): RunActivityStats {
switch (event.event) {
case "invoke_start":
return { ...emptyRunActivityStats(), startedAtMs: observedAtMs };
case "snapshot":
return event.data.status === "running"
? { ...emptyRunActivityStats(), startedAtMs: observedAtMs }
: emptyRunActivityStats();
case "turn_start":
return {
...current,
startedAtMs: current.startedAtMs ?? observedAtMs,
requests: current.requests + 1,
};
case "usage": {
const input = event.data.input_tokens ?? 0;
const cacheRead = event.data.cache_read_input_tokens ?? 0;
return {
...current,
startedAtMs: current.startedAtMs ?? observedAtMs,
uploadTokens: current.uploadTokens + Math.max(0, input - cacheRead),
outputTokens: current.outputTokens + (event.data.output_tokens ?? 0),
};
}
default:
return current;
}
}
export function formatRunElapsed(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function formatRunElapsedCompact(elapsedMs: number): string {
return formatRunElapsed(elapsedMs).replaceAll(" ", "");
}
/** Match the TUI token abbreviation contract. */
export function formatRunTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
return String(tokens);
}
@@ -797,7 +797,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
assert( assert(
consolePage.includes("ConsoleTasks") && consolePage.includes("ConsoleTasks") &&
consolePage.includes("consoleProjection.tasks") && consolePage.includes("selectedConsoleProjection.tasks") &&
consolePage.includes("taskPaneOpen"), consolePage.includes("taskPaneOpen"),
"Console should expose the projected task store through its existing client model", "Console should expose the projected task store through its existing client model",
); );
@@ -818,3 +818,44 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
"Task projection should replay the protocol client-side without adding a task API", "Task projection should replay the protocol client-side without adding a task API",
); );
}); });
Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const tasksComponent = await Deno.readTextFile(
new URL("./ConsoleTasks.svelte", import.meta.url),
);
const consoleModel = await Deno.readTextFile(
new URL("./model.ts", import.meta.url),
);
assert(
consolePage.includes("selectedWorkerViewSessionId") &&
consolePage.includes("selectConsoleWorkerView") &&
consolePage.includes("selectedConsoleProjection.lines") &&
consolePage.includes("selectedConsoleProjection.tasks") &&
consolePage.includes("onSelectWorkerView") &&
consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") &&
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
!consolePage.includes("internal-worker-pane") &&
!consolePage.includes("flattenInternalWorkers"),
"Console should render one selected transcript/task projection without appending Internal Worker panes",
);
assert(
tasksComponent.includes('role="group"') &&
tasksComponent.includes("aria-pressed") &&
tasksComponent.includes("onclick") &&
tasksComponent.includes("tasks.length > 0 || workerViews.length > 1"),
"Tasks summary should expose a clickable and accessible Worker view selector even with zero tasks",
);
assert(
consoleModel.includes("consoleWorkerViews") &&
consoleModel.includes("projection.internalWorkers.map") &&
consoleModel.includes("resolveConsoleWorkerView"),
"Worker view selection should use direct Internal Worker session identities with main fallback",
);
});
@@ -0,0 +1,80 @@
// @ts-nocheck
import { resolveWorkerControlShortcut } from "./worker-control-shortcuts.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
}
const base = {
protocolOpen: true,
running: false,
paused: false,
composerFocused: false,
draftBlank: true,
editableTarget: false,
hasSelection: false,
};
Deno.test("Worker control shortcuts match TUI pause cancel and resume keys", () => {
assertEquals(
resolveWorkerControlShortcut(
{ key: "c", ctrlKey: true },
{ ...base, running: true },
),
"pause",
);
assertEquals(
resolveWorkerControlShortcut(
{ key: "x", ctrlKey: true },
{ ...base, paused: true },
),
"cancel",
);
assertEquals(
resolveWorkerControlShortcut(
{ key: "Enter" },
{ ...base, paused: true, composerFocused: true },
),
"resume",
);
});
Deno.test("Worker control shortcuts preserve browser editing operations", () => {
for (
const state of [
{ ...base, running: true, editableTarget: true },
{ ...base, running: true, hasSelection: true },
]
) {
assertEquals(
resolveWorkerControlShortcut({ key: "c", ctrlKey: true }, state),
null,
);
}
assertEquals(
resolveWorkerControlShortcut(
{ key: "x", ctrlKey: true },
{ ...base, running: true, editableTarget: true },
),
null,
);
});
Deno.test("Resume requires a blank focused composer and paused Worker", () => {
assertEquals(
resolveWorkerControlShortcut(
{ key: "Enter" },
{ ...base, paused: true, composerFocused: true, draftBlank: false },
),
null,
);
assertEquals(
resolveWorkerControlShortcut(
{ key: "Enter" },
{ ...base, paused: true, composerFocused: false },
),
null,
);
});
@@ -0,0 +1,53 @@
export type WorkerControlShortcut = "pause" | "cancel" | "resume";
export type WorkerControlShortcutEvent = {
key: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
repeat?: boolean;
isComposing?: boolean;
};
export type WorkerControlShortcutState = {
protocolOpen: boolean;
running: boolean;
paused: boolean;
composerFocused: boolean;
draftBlank: boolean;
editableTarget: boolean;
hasSelection: boolean;
};
/** Resolve the TUI-compatible Worker control shortcut without side effects. */
export function resolveWorkerControlShortcut(
event: WorkerControlShortcutEvent,
state: WorkerControlShortcutState,
): WorkerControlShortcut | null {
if (!state.protocolOpen || event.repeat || event.isComposing) return null;
if (
event.key === "Enter" && state.paused && state.composerFocused &&
state.draftBlank && !event.ctrlKey && !event.metaKey && !event.altKey &&
!event.shiftKey
) {
return "resume";
}
if (
!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey ||
state.editableTarget || state.hasSelection
) {
return null;
}
switch (event.key.toLowerCase()) {
case "c":
return state.running ? "pause" : null;
case "x":
return state.running || state.paused ? "cancel" : null;
default:
return null;
}
}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerConsoleHref } from '$lib/workspace/console/model';
import { import {
workspaceWorkersStore, workspaceWorkersStore,
@@ -76,10 +77,12 @@
aria-current={currentPath === href ? 'page' : undefined} aria-current={currentPath === href ? 'page' : undefined}
> >
<span class="worker-status-indicator"> <span class="worker-status-indicator">
{#if worker.state === 'idle'} {#if worker.state === 'running'}
<span class="worker-status-spinner"><Spinner label="Running" /></span>
{:else if worker.has_running_internal_workers}
<span class="worker-status-spinner is-subworker"><Spinner label="SubWorker running" /></span>
{:else if worker.state === 'idle'}
<span class="worker-status-dot" aria-label="Idle"></span> <span class="worker-status-dot" aria-label="Idle"></span>
{:else if worker.state === 'running'}
<span class="worker-status-spinner" aria-label="Running"></span>
{/if} {/if}
</span> </span>
<span class="worker-nav-label">{worker.display_name || worker.label}</span> <span class="worker-nav-label">{worker.display_name || worker.label}</span>
@@ -267,12 +267,17 @@
background: var(--success); background: var(--success);
} }
.worker-status-spinner { .worker-status-spinner {
width: 0.625rem; --spinner-color: var(--success);
height: 0.625rem;
border: 0.125rem solid color-mix(in oklch, var(--accent) 25%, transparent); display: inline-flex;
border-top-color: var(--accent); align-items: center;
border-radius: 50%; justify-content: center;
animation: worker-status-spin 0.8s linear infinite; width: 0.75rem;
font-size: 0.7rem;
line-height: 1;
}
.worker-status-spinner.is-subworker {
--spinner-color: var(--tui-magenta);
} }
.worker-nav-label { .worker-nav-label {
grid-column: 2; grid-column: 2;
@@ -339,16 +344,6 @@
.worker-overflow-toggle[aria-expanded="true"] .worker-overflow-chevron { .worker-overflow-toggle[aria-expanded="true"] .worker-overflow-chevron {
transform: rotate(180deg); transform: rotate(180deg);
} }
@keyframes worker-status-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.worker-status-spinner {
animation: none;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.sidebar-frame, .sidebar-frame,
@@ -20,6 +20,7 @@ function worker(runtimeId: string, workerId: string, revision: number): Subscrip
runtime_id: runtimeId, runtime_id: runtimeId,
subject_revision: revision, subject_revision: revision,
state: 'idle', state: 'idle',
has_running_internal_workers: false,
workspace_id: 'workspace-test', workspace_id: 'workspace-test',
display_name: null, display_name: null,
profile: null, profile: null,
@@ -11,6 +11,7 @@ import type { Worker } from './types';
export type SidebarWorker = Worker & { export type SidebarWorker = Worker & {
repository_id: string | null; repository_id: string | null;
working_directory_id: string | null; working_directory_id: string | null;
has_running_internal_workers: boolean;
}; };
export type WorkspaceWorkersState = { export type WorkspaceWorkersState = {
@@ -96,6 +97,7 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
}, },
repository_id: worker.repository_id ?? null, repository_id: worker.repository_id ?? null,
working_directory_id: worker.working_directory_id ?? null, working_directory_id: worker.working_directory_id ?? null,
has_running_internal_workers: worker.has_running_internal_workers,
working_directory: null, working_directory: null,
diagnostics: [], diagnostics: [],
}; };
@@ -15,15 +15,22 @@
type ComposerCompletionEntry, type ComposerCompletionEntry,
type ComposerCompletionToken, type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion"; } from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { fitTextarea } from "$lib/workspace/console/textarea-fit"; import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
consoleWorkerViews,
createConsoleProjector, createConsoleProjector,
flattenInternalWorkers,
isConsoleProjectionEvent, isConsoleProjectionEvent,
projectConsoleLines,
resolveConsoleViewScrollTop,
resolveConsoleWorkerView,
selectConsoleTimelineLines, selectConsoleTimelineLines,
type ConsoleEventInput, type ConsoleEventInput,
type ConsoleLine, type ConsoleLine,
type ConsoleProjection, type ConsoleProjection,
type ConsoleViewMode,
type ConsoleViewScroll,
} from "$lib/workspace/console/model"; } from "$lib/workspace/console/model";
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol"; import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
import { workspaceApiPath } from "$lib/workspace/api/http"; import { workspaceApiPath } from "$lib/workspace/api/http";
@@ -118,12 +125,16 @@
let streamDiagnostics = $state<Diagnostic[]>([]); let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false); let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false); let taskPaneOpen = $state(false);
let selectedWorkerViewSessionId = $state<string | null>(null);
let workerViewSelectionGeneration = 0;
let timelineOpen = $state(false); let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null; let composerTextareaElement: HTMLTextAreaElement | null = null;
let timelineRailDragCleanup: (() => void) | null = null; let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true); let autoFollowConsole = $state(true);
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 }); let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
const consoleViewScroll = new Map<string, ConsoleViewScroll>();
const eventObservedAtById = new Map<string, number>(); const eventObservedAtById = new Map<string, number>();
let nextEventObservedAtVersion = 0; let nextEventObservedAtVersion = 0;
let eventObservedAtVersion = $state(0); let eventObservedAtVersion = $state(0);
@@ -151,11 +162,18 @@
const consoleTarget = $derived({ workspaceId, runtimeId, workerId }); const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
const lines = $derived(consoleProjection.lines); const workerViews = $derived(consoleWorkerViews(consoleProjection));
const tasks = $derived(consoleProjection.tasks); const selectedWorkerView = $derived(
const internalWorkers = $derived( resolveConsoleWorkerView(
flattenInternalWorkers(consoleProjection.internalWorkers), consoleProjection,
selectedWorkerViewSessionId,
),
); );
const selectedConsoleProjection = $derived(selectedWorkerView.console);
const lines = $derived(
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
);
const tasks = $derived(selectedConsoleProjection.tasks);
const timelineLayout = $derived( const timelineLayout = $derived(
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll), buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
); );
@@ -171,6 +189,7 @@
); );
const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading"); const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading");
const workerRunning = $derived(workerState === "running"); const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const inputReady = $derived(workerState === "idle"); const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending); const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable); const canSubmitDraft = $derived(inputReady && composerEditable);
@@ -406,6 +425,52 @@
} }
} }
function sendWorkerControl(command: "pause" | "cancel" | "resume") {
const label = command[0].toUpperCase() + command.slice(1);
sendControl({ method: command }, label);
}
function isEditableTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}
function targetHasSelection(target: EventTarget | null): boolean {
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement
) {
return (
target.selectionStart !== null &&
target.selectionEnd !== null &&
target.selectionStart !== target.selectionEnd
);
}
return Boolean(window.getSelection()?.toString());
}
function handleWorkerControlShortcut(event: KeyboardEvent) {
const composerFocused = event.target === composerTextareaElement;
const command = resolveWorkerControlShortcut(event, {
protocolOpen: protocolState === "open",
running: workerRunning,
paused: workerPaused,
composerFocused,
draftBlank: draft.trim().length === 0,
editableTarget: isEditableTarget(event.target) && !composerFocused,
hasSelection: targetHasSelection(event.target),
});
if (!command) return;
event.preventDefault();
event.stopPropagation();
sendWorkerControl(command);
}
function requestRewindTargets() { function requestRewindTargets() {
sendControl({ method: "list_rewind_targets" }, "Rewind target request"); sendControl({ method: "list_rewind_targets" }, "Rewind target request");
} }
@@ -1031,6 +1096,47 @@
: value.replaceAll('"', '\\"'); : value.replaceAll('"', '\\"');
} }
function consoleWorkerViewKey(sessionId: string | null): string {
return sessionId === null ? "main" : `internal:${sessionId}`;
}
function consoleWorkerViewSelectionIsResolved(): boolean {
return selectedWorkerViewSessionId === selectedWorkerView.sessionId;
}
function rememberConsoleWorkerViewScroll() {
if (!consoleBodyElement || !consoleWorkerViewSelectionIsResolved()) return;
consoleViewScroll.set(consoleWorkerViewKey(selectedWorkerView.sessionId), {
top: consoleBodyElement.scrollTop,
autoFollow: autoFollowConsole,
});
}
async function selectConsoleWorkerView(
sessionId: string | null,
rememberCurrent = true,
) {
if (sessionId === selectedWorkerViewSessionId) return;
const generation = ++workerViewSelectionGeneration;
if (rememberCurrent) rememberConsoleWorkerViewScroll();
const target = workerViews.find((view) => view.sessionId === sessionId) ??
workerViews[0];
const targetScroll = consoleViewScroll.get(
consoleWorkerViewKey(target.sessionId),
);
autoFollowConsole = targetScroll?.autoFollow ?? true;
selectedWorkerViewSessionId = target.sessionId;
await tick();
if (generation !== workerViewSelectionGeneration) return;
if (!consoleBodyElement) return;
consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(
targetScroll,
consoleBodyElement.scrollHeight,
consoleBodyElement.clientHeight,
);
updateConsoleScrollMetrics();
}
function updateConsoleScrollMetrics() { function updateConsoleScrollMetrics() {
if (!consoleBodyElement) { if (!consoleBodyElement) {
return; return;
@@ -1050,21 +1156,30 @@
} }
function handleConsoleScroll() { function handleConsoleScroll() {
if (!consoleBodyElement) { if (!consoleWorkerViewSelectionIsResolved() || !consoleBodyElement) {
return; return;
} }
autoFollowConsole = isNearConsoleBottom(consoleBodyElement); autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
updateConsoleScrollMetrics(); updateConsoleScrollMetrics();
rememberConsoleWorkerViewScroll();
} }
async function scrollConsoleToBottom() { async function scrollConsoleToBottom() {
if (!consoleWorkerViewSelectionIsResolved()) return;
const sessionId = selectedWorkerView.sessionId;
await tick(); await tick();
if (!consoleBodyElement) { if (
!consoleBodyElement ||
!autoFollowConsole ||
!consoleWorkerViewSelectionIsResolved() ||
selectedWorkerView.sessionId !== sessionId
) {
return; return;
} }
consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight; consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight;
updateConsoleScrollMetrics(); updateConsoleScrollMetrics();
autoFollowConsole = true; autoFollowConsole = true;
rememberConsoleWorkerViewScroll();
} }
const scrollFollowKey = $derived( const scrollFollowKey = $derived(
@@ -1078,10 +1193,32 @@
$effect(() => { $effect(() => {
scrollFollowKey; scrollFollowKey;
if (!consoleWorkerViewSelectionIsResolved()) return;
if (autoFollowConsole) { if (autoFollowConsole) {
void scrollConsoleToBottom(); void scrollConsoleToBottom();
} else { } else {
tick().then(updateConsoleScrollMetrics); const sessionId = selectedWorkerView.sessionId;
tick().then(() => {
if (
consoleWorkerViewSelectionIsResolved() &&
selectedWorkerView.sessionId === sessionId
) {
updateConsoleScrollMetrics();
}
});
}
});
$effect(() => {
const activeViewKeys = new Set(
workerViews.map((view) => consoleWorkerViewKey(view.sessionId)),
);
for (const key of consoleViewScroll.keys()) {
if (!activeViewKeys.has(key)) consoleViewScroll.delete(key);
}
const resolvedSessionId = selectedWorkerView.sessionId;
if (resolvedSessionId !== selectedWorkerViewSessionId) {
void selectConsoleWorkerView(resolvedSessionId, false);
} }
}); });
@@ -1093,6 +1230,10 @@
const target = consoleTarget; const target = consoleTarget;
const targetWorker = data.worker; const targetWorker = data.worker;
const targetWorkerError = data.workerError; const targetWorkerError = data.workerError;
workerViewSelectionGeneration += 1;
selectedWorkerViewSessionId = null;
consoleViewScroll.clear();
autoFollowConsole = true;
resetObservedEvents(); resetObservedEvents();
taskPaneOpen = false; taskPaneOpen = false;
worker = targetWorker; worker = targetWorker;
@@ -1107,6 +1248,8 @@
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget)); $effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
</script> </script>
<svelte:window onkeydown={handleWorkerControlShortcut} />
<svelte:head> <svelte:head>
<title>Worker Console · Yoi Workspace</title> <title>Worker Console · Yoi Workspace</title>
<meta <meta
@@ -1119,35 +1262,27 @@
<section class="console-header card" aria-label="Worker controls"> <section class="console-header card" aria-label="Worker controls">
<div class="console-header-actions"> <div class="console-header-actions">
<div <div
class="console-status-pill" class="console-view-modes"
class:warn={protocolState !== "open"} role="group"
aria-label="Console display mode"
> >
{workerState} · protocol {protocolState} <button
type="button"
class:active={consoleViewMode === "overview"}
aria-pressed={consoleViewMode === "overview"}
onclick={() => (consoleViewMode = "overview")}
>
Overview
</button>
<button
type="button"
class:active={consoleViewMode === "normal"}
aria-pressed={consoleViewMode === "normal"}
onclick={() => (consoleViewMode = "normal")}
>
Normal
</button>
</div> </div>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "cancel" }, "Cancel")}
>
Cancel
</button>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "pause" }, "Pause")}
>
Pause
</button>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => sendControl({ method: "resume" }, "Resume")}
>
Resume
</button>
<button <button
type="button" type="button"
class="secondary-button" class="secondary-button"
@@ -1234,7 +1369,10 @@
bind:this={consoleBodyElement} bind:this={consoleBodyElement}
onscroll={handleConsoleScroll} onscroll={handleConsoleScroll}
> >
<article class="card console-card worker-console-card"> <article
class="card console-card worker-console-card"
aria-label={`${selectedWorkerView.label} transcript`}
>
{#if workerError} {#if workerError}
<p class="error">{workerError}</p> <p class="error">{workerError}</p>
{/if} {/if}
@@ -1249,28 +1387,6 @@
</ol> </ol>
{/if} {/if}
</article> </article>
{#each internalWorkers as internal (internal.worker.session_id)}
<section
class="card internal-worker-pane"
style={`--internal-worker-depth: ${internal.depth}`}
aria-label={`SubWorker ${internal.worker.name}`}
>
<header class="internal-worker-header">
<strong>{internal.worker.name}</strong>
<span>{internal.console.status ?? "unknown"}</span>
</header>
{#if internal.console.lines.length === 0}
<p>No output yet.</p>
{:else}
<ol class="console-log">
{#each internal.console.lines as item (item.id)}
<ConsoleLineItem {item} />
{/each}
</ol>
{/if}
</section>
{/each}
</div> </div>
<ConsoleTimeline <ConsoleTimeline
@@ -1371,7 +1487,27 @@
</aside> </aside>
{/if} {/if}
<ConsoleTasks {tasks} mode="mini" /> {#if workerRunning}
<WorkerRunStatus
startedAtMs={consoleProjection.runActivity.startedAtMs}
requests={consoleProjection.runActivity.requests}
uploadTokens={consoleProjection.runActivity.uploadTokens}
outputTokens={consoleProjection.runActivity.outputTokens}
/>
{/if}
<ConsoleTasks
{tasks}
mode="mini"
workerViews={workerViews.map(({ sessionId, label }) => ({
sessionId,
label,
}))}
selectedWorkerViewSessionId={selectedWorkerView.sessionId}
onSelectWorkerView={(sessionId) => {
void selectConsoleWorkerView(sessionId);
}}
/>
<form class="console-composer card" onsubmit={sendMessage}> <form class="console-composer card" onsubmit={sendMessage}>
<div class="composer-input-shell"> <div class="composer-input-shell">
@@ -1481,25 +1617,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
} }
.console-status-pill { .console-view-modes {
min-width: 14rem; display: inline-flex;
padding: 0.75rem 0.9rem; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 0.55rem;
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.76rem;
text-align: right;
} }
.console-status-pill.warn { .console-view-modes button {
color: var(--warning); border: 0;
background: transparent;
color: var(--text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
cursor: pointer;
}
.console-view-modes button + button {
border-left: 1px solid var(--line);
}
.console-view-modes button:hover {
color: var(--text-strong);
}
.console-view-modes button.active {
background: var(--accent);
color: var(--bg);
} }
.console-notice { .console-notice {
@@ -1797,23 +1948,6 @@
margin-right: auto; margin-right: auto;
} }
.internal-worker-pane {
margin: 0.75rem 0 0 calc((var(--internal-worker-depth) + 1) * 1rem);
border-left: 3px solid var(--color-border-strong, currentColor);
}
.internal-worker-header {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
font-family: var(--font-mono);
}
.internal-worker-header span {
color: var(--color-text-muted);
}
@media (max-width: 960px) { @media (max-width: 960px) {
.console-history.with-task-pane { .console-history.with-task-pane {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -1822,10 +1956,5 @@
.console-header { .console-header {
flex-direction: column; flex-direction: column;
} }
.console-status-pill {
width: 100%;
text-align: left;
}
} }
</style> </style>
@@ -0,0 +1,132 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Console spinner wraps a reusable timed sequence loop", async () => {
const sequenceLoop = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/SequenceLoop.svelte",
import.meta.url,
),
);
const spinner = await Deno.readTextFile(
new URL("../src/lib/workspace/console/Spinner.svelte", import.meta.url),
);
for (
const token of ["values", "intervalMs", "setInterval", "clearInterval"]
) {
assert(
sequenceLoop.includes(token),
`missing sequence-loop token: ${token}`,
);
}
for (const frame of ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"]) {
assert(spinner.includes(frame), `missing spinner frame: ${frame}`);
}
assert(spinner.includes("SequenceLoop"), "Spinner should wrap SequenceLoop");
});
Deno.test("sidebar running status reuses the green symbol spinner", async () => {
const sidebar = await Deno.readTextFile(
new URL(
"../src/lib/workspace/sidebar/WorkersNavSection.svelte",
import.meta.url,
),
);
const sidebarCss = await Deno.readTextFile(
new URL(
"../src/lib/workspace/sidebar/sidebar.css",
import.meta.url,
),
);
assert(
sidebar.includes(
"import Spinner from '$lib/workspace/console/Spinner.svelte'",
),
"Workers sidebar should import the reusable symbol Spinner",
);
assert(
sidebar.includes('<Spinner label="Running" />'),
"running Workers should render the reusable symbol Spinner",
);
assert(
sidebarCss.includes("--spinner-color: var(--success)"),
"sidebar spinner should use the green success token",
);
assert(
sidebar.indexOf("worker.state === 'running'") <
sidebar.indexOf("worker.has_running_internal_workers"),
"parent running state should keep the green Spinner priority",
);
assert(
sidebar.indexOf("worker.has_running_internal_workers") <
sidebar.indexOf("worker.state === 'idle'"),
"SubWorker activity should replace the idle dot with the purple Spinner",
);
assert(
sidebar.includes("worker.has_running_internal_workers"),
"idle parents should render SubWorker activity from the Workspace projection",
);
assert(
sidebar.includes('<Spinner label="SubWorker running" />'),
"running SubWorkers should use the reusable symbol Spinner",
);
assert(
sidebarCss.includes("--spinner-color: var(--tui-magenta)"),
"SubWorker spinner should use the purple TUI token",
);
assert(
!sidebarCss.includes("@keyframes worker-status-spin"),
"legacy rotating ring spinner should be removed",
);
});
Deno.test("running status is Composer-side above mini Tasks", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const runStatus = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/WorkerRunStatus.svelte",
import.meta.url,
),
);
const status = page.indexOf("<WorkerRunStatus");
const miniMode = page.indexOf('mode="mini"');
const tasks = page.lastIndexOf("<ConsoleTasks", miniMode);
const composer = page.indexOf('<form class="console-composer card"');
assert(status >= 0, "WorkerRunStatus should be rendered");
assert(miniMode >= 0 && tasks >= 0, "mini Tasks should be rendered");
assert(status < tasks, "WorkerRunStatus should be above mini Tasks");
assert(tasks < composer, "mini Tasks should remain above Composer");
assert(
runStatus.includes("nowMs - (startedAtMs ?? nowMs)"),
"running elapsed should be recomputed from timestamps",
);
});
Deno.test("RunEnd stats render as a right-aligned Console item", async () => {
const lineItem = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/ConsoleLineItem.svelte",
import.meta.url,
),
);
for (
const token of [
"item.kind === 'run_stats'",
'class="run-stats"',
"text-align: right",
]
) {
assert(lineItem.includes(token), `missing run stats token: ${token}`);
}
});
@@ -0,0 +1,28 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Worker Console exposes Overview and Normal display modes", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
for (
const token of [
'consoleViewMode = $state<ConsoleViewMode>("overview")',
'aria-label="Console display mode"',
'consoleViewMode = "overview"',
'consoleViewMode = "normal"',
"projectConsoleLines(consoleProjection.lines, consoleViewMode)",
"projectConsoleLines(internal.console.lines, consoleViewMode)",
"resolveWorkerControlShortcut",
"handleWorkerControlShortcut",
]
) {
assert(page.includes(token), `missing Console view-mode token: ${token}`);
}
});