feat: project SubWorker activity in sidebar

This commit is contained in:
2026-08-21 07:12:04 +09:00
parent 8bedfcda84
commit 18112d29a6
9 changed files with 409 additions and 26 deletions
+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()),
+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()?;
@@ -1471,7 +1473,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);
@@ -1528,6 +1531,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()?;
@@ -1940,6 +1944,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(),
}, },
); );
} }
@@ -2260,6 +2265,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,
@@ -2402,6 +2411,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(())
@@ -2455,7 +2465,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,
@@ -2498,6 +2635,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,
@@ -2508,6 +2651,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 {
@@ -2730,6 +2874,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();
@@ -3083,16 +3347,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]
+1 -1
View File
@@ -128,7 +128,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, };
@@ -29,7 +29,7 @@
<style> <style>
.spinner { .spinner {
display: inline-flex; display: inline-flex;
color: var(--accent); color: var(--spinner-color, var(--accent));
line-height: 1; line-height: 1;
} }
</style> </style>
@@ -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: [],
}; };
@@ -28,6 +28,62 @@ Deno.test("Console spinner wraps a reusable timed sequence loop", async () => {
assert(spinner.includes("SequenceLoop"), "Spinner should wrap SequenceLoop"); 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 () => { Deno.test("running status is Composer-side above mini Tasks", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(