Skip to content

🤖 feat: keep the VS Code host's workspace list live - #5651

Merged
ThomasK33 merged 3 commits into
mainfrom
xum-5109-host-live-list
Oct 4, 2026
Merged

ThomasK33 merged 3 commits into
mainfrom
xum-5109-host-live-list

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Second layer of the #5109 stack (the first, #5634, has merged). The VS Code extension host now keeps its workspace list live between refreshes. One workspace.onMetadata subscription feeds the host. For each update, the host compares only the changed workspace's projection with what it last posted. Events that change nothing the webview receives post nothing and touch no list. The webview UI and the dock activity are unchanged in this layer. The next layer (PR2b) adds the selected + descendants activity pump and the sub-agent tasks strip.

Refs #5109

Implementation

  • vscode/src/workspaceMetadataPump.ts (new, unit-tested outside extension.ts): it forwards the snapshot once (onSnapshot) and then one onUpdate(workspaceId, metadata | null) per event. Archived workspaces arrive as removals, as workspace.list omits them. An aborted subscription ignores late events and errors.
  • extension.ts:
    • refreshWorkspaces starts one metadata subscription on the client that just listed the workspaces, so it needs no extra discovery or auth ping. It aborts the previous one. A mux.serverUrl or auth token change, file mode, refresh failures and dispose stop it, so no event from an old endpoint lands.
    • The host keeps a per-workspace map of the JSON it last posted (postedProjections). An update computes toUiWorkspace for that one workspace and compares the string. Equal means no list rebuild, no sort and no post: the host only refreshes its stored copy of that workspace in a map (for example the name that opening the workspace reads). Removal of an unknown workspace also returns at once.
    • A real change (creation, removal, archive, or a projected field such as the title) re-sorts the list once, built from that map, and posts it. The snapshot takes the same path, and posts only if the whole list differs. A refresh always posts, because a new webview has no list.
    • If the selected workspace disappears, the host clears the selection, as a refresh does.
    • There is no timer debounce and no onChat batchReplay or replayWindow opt-in.

Numbers (perf-owner scenario: ~4.5k workspaces, 10 unrelated metadata events per second for 60 s)

Two runs, both with the real XumChatViewProvider from extension.ts. "Unrelated" means a change to fields the webview is never sent. The harness runs under Bun. The handler time comes from a timing patch applied only for these runs (script below).

In-process (local oRPC server in the test process, 4,502 workspaces):

This PR Previous design of this PR (full list rebuild per event)
Unrelated events 593 593
workspaces posts from them 0 0
Host handler time for them 11.72 ms in total (0.02 ms each) 3,395.6 ms for 604 calls (5.6 ms each)
Process CPU during the 60 s load (idle 60 s: 1,356 ms / 1,375 ms) 7,908 ms 14,242 ms
Event-loop delay under load p50 1.00 ms, p99 3.26 ms p50 1.00 ms, p99 5.94 ms
10 real task renames 10 posts, latency p50 6.25 ms, max 13.24 ms 10 posts, p50 10.15 ms, max 12.02 ms

Rerun after the round-1 review fix (head 40d359fe64, which adds one Map.set to the skip path): 594 unrelated events, 0 workspaces posts, skip-path handler time 13.10 ms in total (0.02 ms each), process CPU 8,990 ms under load vs 1,511 ms idle, event-loop delay p99 2.40 ms under load vs 1.60 ms idle. 10 real task renames gave 10 posts, latency p50 14.23 ms, max 15.43 ms. The table above and the live table below were measured at b21cc4494f; the two later review fixes touch only the skip path's Map.set and the subscription's shutdown on connection changes.

Live xum server (mock AI, a fresh root seeded with 4,502 workspaces, one of them a sub-agent task). The load driver alternates setPinned and updateTags on synthetic workspaces at 10 calls per second. The real change is updateTitle on the sub-agent task.

Measure Result
Unrelated API changes / metadata events delivered 593 / 445
workspaces posts from them 0
Host handler time for the 445 events 11.03 ms in total (0.025 ms each)
Process CPU, 60 s load vs 60 s idle 8,279 ms vs 1,050 ms
Event-loop delay, load vs idle p99 2.49 ms vs 1.88 ms (max 6.70 ms vs 4.38 ms)
10 sub-agent retitles 10 posts; latency from the API call to the post p50 131.78 ms, max 232.72 ms

The process CPU includes the oRPC transport and, in the live run, the load driver's 593 HTTP calls in the same process. The host's own share is the handler time row.

Earlier small live run (fresh root, 7 workspaces): 31 metadata events, of which 20 changed no projected field (pin and tag updates, unchanged titles, the second event of each creation or archive). Those 20 caused 0 posts. The other 11 caused 11 posts.

Benchmark harness (kept outside the repo)

make-bench.sh copies vscode/src/extension.test.ts (mocked vscode, local oRPC server, real chat view provider), turns off Bun.serve's 10 s idle timeout, optionally points the provider at a live server, and appends one benchmark. Run from vscode/: bun test src/bench5109.test.ts -t bench5109 (in-process) or LIVE_URL=... LIVE_TOKEN=... bun test src/bench5109.test.ts -t live5109.

# usage: bash make-bench.sh.txt <worktree> <append-file>
# Copies extension.test.ts (its harness: mocked vscode, local oRPC server, real chat view
# provider), disables the local server's 10 s idle timeout, and appends the benchmark.
set -euo pipefail
W=$1; A=$2
sed -e 's/^    port: 0,$/    port: 0,\n    idleTimeout: 0,/' \
    -e 's#settings.set("mux.serverUrl", server.url);#settings.set("mux.serverUrl", process.env.LIVE_URL ?? server.url);#' \
    -e 's#\[\[SECRET_KEY, "token-a"\]\]#[[SECRET_KEY, process.env.LIVE_TOKEN ?? "token-a"]]#' \
    "$W/vscode/src/extension.test.ts" > "$W/vscode/src/bench5109.test.ts"
cat "$A" >> "$W/vscode/src/bench5109.test.ts"
grep -c 'idleTimeout: 0\|LIVE_URL ?? server.url\|LIVE_TOKEN ?? "token-a"' "$W/vscode/src/bench5109.test.ts"

Timing patch applied to extension.ts for the handler rows:

diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index 31eba1b190..e8722738ab 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -1781,13 +1781,14 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab
       signal: controller.signal,
       onSnapshot: (workspaces) => this.relist(workspaces),
       onUpdate: async (workspaceId, metadata) => {
+        const __t0 = performance.now(); (globalThis as any).__benchN = ((globalThis as any).__benchN ?? 0) + 1; queueMicrotask(() => { (globalThis as any).__benchMs = ((globalThis as any).__benchMs ?? 0) + performance.now() - __t0; });
         const previous = this.workspacesById.get(workspaceId);
         const next = metadata && { ...metadata, extensionMetadata: previous?.extensionMetadata };
         // Compare only this workspace with its last posted projection: most events change fields
         // the webview never sees, and rebuilding a list of thousands per event is too slow. Such
         // an event also leaves the stored copy as is; the host reads nothing it changed.
         const posted = this.postedProjections.get(workspaceId);
-        if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) return;
+        if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) { (globalThis as any).__skipMs = ((globalThis as any).__skipMs ?? 0) + performance.now() - __t0; (globalThis as any).__skipN = ((globalThis as any).__skipN ?? 0) + 1; return; }
         const others = this.workspaces.filter((w) => w.id !== workspaceId);
         await this.relist(next ? [...others, next] : others);
       },

In-process benchmark:

// ---- #5109 PR2a benchmark (scratch, not committed): appended to a copy of extension.test.ts ----
describe("bench5109", () => {
  test("bench5109: 10 unrelated metadata events/s for 60 s with 4.5k workspaces", async () => {
    const SECONDS = Number(process.env.BENCH_SECONDS ?? 60);
    const synthetic = Array.from({ length: 4_500 }, (_, i) => ({
      ...WORKSPACE,
      id: `ws-s${i}`,
      name: `s${i}`,
      createdAt: "2026-09-01T00:00:00.000Z",
    }));
    const child = { ...WORKSPACE, id: "ws-child", name: "child", parentWorkspaceId: WORKSPACE.id };
    const harness = await setup([WORKSPACE, child, ...synthetic]);
    const { state } = harness.server;
    type Msg = PostedMessage & { workspaces?: unknown[] };
    const lists = () => (harness.posted as Msg[]).filter((m) => m.type === "workspaces").length;
    await until(() => lists() >= 1, "the first list");
    await new Promise((r) => setTimeout(r, 1000)); // let the snapshot settle

    // Event-loop delay: a 10 ms sampler; delay = actual interval - 10 ms.
    const sampleLoop = (ms: number) =>
      new Promise<number[]>((resolve) => {
        const delays: number[] = [];
        let last = performance.now();
        const timer = setInterval(() => {
          const now = performance.now();
          delays.push(Math.max(0, now - last - 10));
          last = now;
        }, 10);
        setTimeout(() => {
          clearInterval(timer);
          resolve(delays);
        }, ms);
      });
    const stats = (values: number[]) => {
      const s = [...values].sort((a, b) => a - b);
      const at = (p: number) => s[Math.min(s.length - 1, Math.floor(p * s.length))].toFixed(2);
      return `p50 ${at(0.5)} ms, p99 ${at(0.99)} ms, max ${s[s.length - 1].toFixed(2)} ms`;
    };
    const cpuMs = (u: NodeJS.CpuUsage) => ((u.user + u.system) / 1000).toFixed(0);

    // Baseline: same window, no events.
    let cpu0 = process.cpuUsage();
    const idleDelays = await sampleLoop(SECONDS * 1000);
    const idleCpu = process.cpuUsage(cpu0);

    // Load: 10 unrelated events per second (fields the webview is never sent).
    const posts0 = lists();
    cpu0 = process.cpuUsage();
    let sent = 0;
    const emitter = setInterval(() => {
      const target = synthetic[(sent * 37) % synthetic.length];
      state.metadata.push({
        workspaceId: target.id,
        metadata: { ...target, namedWorkspacePath: `/tmp/n${sent}`, pinned: sent % 2 === 0 },
      });
      sent += 1;
      notify();
    }, 100);
    const loadDelays = await sampleLoop(SECONDS * 1000);
    clearInterval(emitter);
    await new Promise((r) => setTimeout(r, 500));
    const loadCpu = process.cpuUsage(cpu0);
    const unrelatedPosts = lists() - posts0;

    // Real task changes: rename the sub-agent 10 times; each must post once.
    const latencies: number[] = [];
    const posts1 = lists();
    for (let i = 0; i < 10; i++) {
      const before = lists();
      const t0 = performance.now();
      state.metadata.push({ workspaceId: child.id, metadata: { ...child, title: `Task ${i}` } });
      notify();
      await until(() => lists() > before, "the rename post");
      latencies.push(performance.now() - t0);
      await new Promise((r) => setTimeout(r, 200));
    }
    const realPosts = lists() - posts1;

    const report = [
      `workspaces listed: ${synthetic.length + 2}; window: ${SECONDS} s`,
      `idle:  CPU ${cpuMs(idleCpu)} ms; event-loop delay ${stats(idleDelays)}`,
      `load:  ${sent} unrelated events; workspaces posts ${unrelatedPosts}; CPU ${cpuMs(loadCpu)} ms; event-loop delay ${stats(loadDelays)}`,
      `real:  10 task renames; workspaces posts ${realPosts}; latency ${stats(latencies)}`,
    ].join("\n");
    console.log(`BENCH5109\n${report}\nhandler: ${(globalThis as any).__benchN} calls, ${(globalThis as any).__benchMs?.toFixed(1)} ms; skip path ${(globalThis as any).__skipN} calls ${(globalThis as any).__skipMs?.toFixed(2)} ms`);
  }, 10 * 60_000);
});

Live benchmark (the server ran as XUM_ROOT=<seeded root> XUM_MOCK_AI=1 node dist/cli/index.js server --auth-token ..., with 4,500 s<i> workspaces plus a parent and one sub-agent task in config.json):

// ---- #5109 PR2a live benchmark (scratch, not committed): real xum server with ~4.5k workspaces ----
import { createApiClient as createLiveClient } from "./api/client";
describe("live5109", () => {
  test("live5109: 10 unrelated metadata changes/s for 60 s against a live server", async () => {
    const SECONDS = Number(process.env.BENCH_SECONDS ?? 60);
    const live = createLiveClient({ baseUrl: process.env.LIVE_URL!, authToken: process.env.LIVE_TOKEN! });
    const all = await live.workspace.list();
    const child = all.find((w) => w.parentWorkspaceId != null);
    const synthetic = all.filter((w) => w.name.startsWith("s"));
    if (!child || synthetic.length < 4000) throw new Error(`bad root: ${all.length}`);
    const harness = await setup();
    type Msg = PostedMessage & { workspaces?: unknown[] };
    const lists = () => (harness.posted as Msg[]).filter((m) => m.type === "workspaces");
    await until(() => lists().length >= 1, "the first list");
    await new Promise((r) => setTimeout(r, 3000)); // the snapshot after the refresh
    const listedCount = lists().at(-1)?.workspaces?.length;

    const sampleLoop = (ms: number) =>
      new Promise<number[]>((resolve) => {
        const delays: number[] = [];
        let last = performance.now();
        const timer = setInterval(() => {
          const now = performance.now();
          delays.push(Math.max(0, now - last - 10));
          last = now;
        }, 10);
        setTimeout(() => { clearInterval(timer); resolve(delays); }, ms);
      });
    const stats = (values: number[]) => {
      const s = [...values].sort((a, b) => a - b);
      const at = (p: number) => s[Math.min(s.length - 1, Math.floor(p * s.length))].toFixed(2);
      return `p50 ${at(0.5)} ms, p99 ${at(0.99)} ms, max ${s[s.length - 1].toFixed(2)} ms`;
    };
    const cpuMs = (u: NodeJS.CpuUsage) => ((u.user + u.system) / 1000).toFixed(0);

    let cpu0 = process.cpuUsage();
    const idleDelays = await sampleLoop(SECONDS * 1000);
    const idleCpu = process.cpuUsage(cpu0);

    // Unrelated: pin/unpin and tag edits, which the webview is never sent.
    const posts0 = lists().length;
    cpu0 = process.cpuUsage();
    let sent = 0;
    const calls: Promise<unknown>[] = [];
    const emitter = setInterval(() => {
      const target = synthetic[(sent * 37) % synthetic.length];
      calls.push(
        sent % 2 === 0
          ? live.workspace.setPinned({ workspaceId: target.id, pinned: (sent >> 1) % 2 === 0 } as never)
          : live.workspace.updateTags({ workspaceId: target.id, tags: { bench: String(sent) } } as never)
      );
      sent += 1;
    }, 100);
    const loadDelays = await sampleLoop(SECONDS * 1000);
    clearInterval(emitter);
    await Promise.all(calls);
    await new Promise((r) => setTimeout(r, 1000));
    const loadCpu = process.cpuUsage(cpu0);
    const unrelatedPosts = lists().length - posts0;

    // Real task change: retitle the sub-agent workspace; latency from the API call to the post.
    const latencies: number[] = [];
    const posts1 = lists().length;
    for (let i = 0; i < 10; i++) {
      const before = lists().length;
      const t0 = performance.now();
      await live.workspace.updateTitle({ workspaceId: child.id, title: `Task ${Date.now()}-${i}` });
      await until(() => lists().length > before, "the retitle post");
      latencies.push(performance.now() - t0);
      await new Promise((r) => setTimeout(r, 300));
    }
    await new Promise((r) => setTimeout(r, 1000));
    const realPosts = lists().length - posts1;

    console.log(
      [
        "LIVE5109",
        `server workspaces: ${all.length}; host list: ${listedCount}; window: ${SECONDS} s`,
        `idle:  CPU ${cpuMs(idleCpu)} ms; event-loop delay ${stats(idleDelays)}`,
        `load:  ${sent} unrelated changes; workspaces posts ${unrelatedPosts}; CPU ${cpuMs(loadCpu)} ms; event-loop delay ${stats(loadDelays)}`,
        `real:  10 sub-agent retitles; workspaces posts ${realPosts}; latency ${stats(latencies)}`,
        `handler: ${(globalThis as any).__benchN} calls; skip path ${(globalThis as any).__skipN} calls ${(globalThis as any).__skipMs?.toFixed(2)} ms`,
      ].join("\n")
    );
  }, 10 * 60_000);
});

Tests

  • extension.test.ts: a burst of 21 metadata events against 4,500 synthetic workspaces plus the real ones. Only the creation, the rename, the archive and a final creation post. Five unrelated updates to synthetic workspaces, five to the new child, a removal of an unknown workspace and a removal of an already archived one post nothing. Each post still carries all 4,500 synthetic workspaces.
  • extension.test.ts: with a display title set, a rename changes only name; opening the workspace afterwards uses the new checkout path.
  • extension.test.ts: a server URL change and an auth token change each close the subscription; a refresh opens a new one.
  • workspaceMetadataPump.test.ts: a subscription replaced mid-stream ignores its late event and its error.

Pre-fix: with extension.ts reverted to the base commit, the new burst case in extension.test.ts fails (timed out waiting for the end of the burst); the other 8 cases in the two files pass.

Risks

Low. The host now posts workspaces between refreshes. The webview already handles that message idempotently: it re-seeds sub-agent AI settings and does not reset the transcript.

Size: 313 changed lines (304+, 9-), tests included.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $39.94

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T21:53:39.017563Z 85cd59b New commits
🔒 Security Review ✅ Completed 2026-10-04T21:55:04.318611Z 85cd59b New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ThomasK33 ThomasK33 changed the title xum 5109 host live list 🤖 feat: keep the VS Code host's workspace list live Oct 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b21cc4494f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vscode/src/extension.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40d359fe64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vscode/src/extension.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

Readiness record

  • Final commit: 85cd59bf315a2f590a5c9e9cac5b58122743a9d8
  • Local validation on this head: make static-check passed; bun test ./vscode/src/ 151 pass, 0 fail.
  • Required CI: all passing on this head (the optional Pixel / Review is ignored).
  • Review count: 7 assessments. 6 automatic (3 rounds: 1 P2, 1 P2, clean) and 1 final independent check. Slot 7 granted by the coordinator for the final check.
  • Both P2 findings were defects this PR introduced. Each was fixed with a failing-first test (40d359f, 85cd59b).
  • Perf requirements: per-workspace projection compare, 0 workspaces posts for unrelated events at 4,502 workspaces (in-process and live server numbers in the PR body), no timer debounce.
  • Final independent check: "ready with tracked follow-ups". No P1. Its non-blocking suggestions (refresh race on a connection change, reconnect after a stream error, multi-project filter on updates, two optional tests) are tracked in 🤖 fix: VS Code live workspace list follow-ups (refresh race, reconnect, multi-project filter) #5654 (backlog).
  • Reason for stopping: required checks green, all threads resolved, final check recommends ready.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 9cfe504 Oct 4, 2026
30 of 31 checks passed
@ThomasK33
ThomasK33 deleted the xum-5109-host-live-list branch October 4, 2026 22:15
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 5, 2026
## Summary

Last layer of the coder#5109 stack, after coder#5651 (merged: live host workspace
list). The VS Code webview dock now shows the desktop sub-agent tasks
strip above the background processes strip. It reuses the shared
`SubAgentTasksContent` (coder#5634). A new `SubAgentTasksDock` feeds it from
the host's live workspace list and a per-workspace activity map.
Clicking a row asks the host to select that sub-agent.
`chatUiCapabilities.subAgentTasks` is now `supported`.

This layer also moves the host's activity pump from "selected workspace,
tied to the chat stream" to "selected workspace and its descendants,
with its own controller". The activity set is recomputed only when
membership changes.

Refs coder#5109. Nested-run discovery is deferred to coder#5652; see Scope. I will
close coder#5109 by hand after this PR merges, with a residual comment.

## Implementation

Host (`extension.ts`, `workspaceActivityPump.ts`):

- The activity pump runs for `[selected, ...descendants]` and posts a
per-workspace map `{ activeBashMonitorCount, streaming,
activeWorkflowRunIds }`. Unchanged maps are not posted. Goal-only
(`transientGoalOnly`) events are skipped because they carry stale
baseline fields.
- The selected + descendants set is computed in one pass over the list
(a parent → children map). The host recomputes it only on a selection
change, the snapshot, or a metadata update that changes a workspace's
parent link (task creation or removal included). It restarts the pump
(one bulk `activity.list`) only when the set differs. Activity events
never recompute it: the pump's tracked set is fixed when it starts.
- The pump has its own controller, so it no longer stops with the chat
stream. It stops on deselection, file mode, refresh failure or dispose.
`isSelected` also ignores callbacks from a replaced pump.
- The host's `UiWorkspace` gains a narrow `task` slice, for sub-agent
workspaces only: `title`, `taskStatus`, `taskExecutionStatus`,
`workflowTask`, `reportedAt`, `archivedAt`. No paths or prompts. The
per-workspace projection compare from coder#5651 includes it, so a task
status change re-posts the list and an unrelated event still posts
nothing.

Webview and shared code:

- The webview takes the selected workspace's monitor count from the
activity map, so the waiting-on-monitor barrier works as before.
- The tray helpers take a `SubAgentTaskWorkspace`
(`Pick<FrontendWorkspaceMetadata, …>`) instead of the full metadata.
`isWorkspaceDelegatedActivityActive` takes the four fields it reads.
Desktop callers pass full metadata unchanged, and the webview builds the
Pick from `UiWorkspace` without fabricating fields.
- `SubAgentTasksDock` runs the same `collectDescendantAgents` and
`mergeActiveWorkflowGroups` as desktop. Its per-descendant hints come
from activity: monitors, plus a "working" hint = streaming, active
workflow runs or armed monitors. The expanded state uses the desktop's
persisted key.
- No new IPC message type, no new persisted field, no new bridge
procedure, no manual memo.

## Scope: nested-run discovery deferred to coder#5652

Desktop also calls `workflows.listActiveRuns` (cold-mount discovery) and
polls `workflows.getRunStatuses` for nested (workflow-in-workflow) runs
between workers. Those procedures are not bridged. Without them, a
nested run's header leaves the strip once its workers finish. Its
enclosing top-level run stays visible from activity as "Workflow ·
running" (tested), so the strip never reads as empty or "all finished"
while a workflow runs. coder#5652 tracks bridging both procedures.

## Activity set measurement (perf-owner requirement 3)

In-process harness (the same one as coder#5651: real `XumChatViewProvider`,
local oRPC server, 4,502 workspaces), with `ws-1` selected and one
sub-agent task under it. A timing-free counter patch counts calls of the
set computation. Measured before the two coder#5651 review fixes were rebased
in; they do not touch the activity path.

| Measure | Result |
| --- | --- |
| Metadata events in 60 s (10 per second) | 592: 533 on unrelated
workspaces, 59 renames of the selected task |
| Selected + descendants recomputes during them | **0** |
| Activity pump restarts during them | 0 |
| `workspaces` posts during them | 59 (one per task rename, 0 for the
unrelated events) |
| A new task appears under the selection | 1 recompute, 1 restart, 27.83
ms to the new activity post, which tracks `ws-1`, `ws-child`,
`ws-child-2` |

<details>
<summary>Harness (kept outside the repo)</summary>

Built with the `make-bench.sh` from coder#5651, then run from `vscode/` as
`bun test src/bench5109.test.ts -t bench5109b`.

Counter patch applied to `extension.ts` for the run:

```diff
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index d5a74af34e..191328dd56 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -160,6 +160,7 @@ function toUiWorkspace(workspace: WorkspaceWithContext): UiWorkspace {
 
 /** The workspace and its descendants, parents before children; one pass over the list. */
 function selfAndDescendantIds(workspaces: readonly WorkspaceWithContext[], rootId: string): string[] {
+  (globalThis as any).__recomputes = ((globalThis as any).__recomputes ?? 0) + 1;
   const children = new Map<string, string[]>();
   for (const { id, parentWorkspaceId } of workspaces) {
     if (parentWorkspaceId == null) continue;
```

Benchmark appended to the copy of `extension.test.ts`:

```ts
// ---- coder#5109 PR2b benchmark (scratch, not committed): activity set recomputes under load ----
describe("bench5109b", () => {
  test("bench5109b: 10 unrelated metadata events/s for 60 s, selected workspace with a task", async () => {
    const SECONDS = Number(process.env.BENCH_SECONDS ?? 60);
    const synthetic = Array.from({ length: 4_500 }, (_, i) => ({
      ...WORKSPACE,
      id: `ws-s${i}`,
      name: `s${i}`,
      createdAt: "2026-09-01T00:00:00.000Z",
    }));
    const child = { ...WORKSPACE, id: "ws-child", name: "child", parentWorkspaceId: WORKSPACE.id };
    const harness = await setup([WORKSPACE, child, ...synthetic]);
    const { state } = harness.server;
    type Msg = PostedMessage & { workspaces?: unknown[]; activity?: Record<string, unknown> };
    const posted = harness.posted as Msg[];
    const count = (type: string) => posted.filter((m) => m.type === type).length;
    harness.send({ type: "selectWorkspace", workspaceId: WORKSPACE.id });
    await until(() => count("workspaceActivity") >= 1, "the first activity post");
    await new Promise((r) => setTimeout(r, 1000));

    const lists0 = state.activityLists;
    const recomputes0 = (globalThis as any).__recomputes ?? 0;
    const posts0 = count("workspaces");
    let sent = 0;
    const emitter = setInterval(() => {
      // Unrelated to the activity set: synthetic workspaces, plus a rename of the selected task.
      const target = sent % 10 === 9 ? { ...child, title: `T${sent}` } : synthetic[(sent * 37) % synthetic.length];
      state.metadata.push({ workspaceId: target.id, metadata: { ...target, pinned: sent % 2 === 0 } });
      sent += 1;
      notify();
    }, 100);
    await new Promise((r) => setTimeout(r, SECONDS * 1000));
    clearInterval(emitter);
    await new Promise((r) => setTimeout(r, 500));
    const restartsUnderLoad = state.activityLists - lists0;
    const recomputesUnderLoad = ((globalThis as any).__recomputes ?? 0) - recomputes0;
    const listPosts = count("workspaces") - posts0;

    // A membership change: a second task appears under the selection.
    const t0 = performance.now();
    const activityPosts = count("workspaceActivity");
    state.metadata.push({ workspaceId: "ws-child-2", metadata: { ...child, id: "ws-child-2", name: "child-2" } });
    notify();
    await until(() => count("workspaceActivity") > activityPosts, "the restarted pump's post");
    const restartMs = performance.now() - t0;
    const last = posted.filter((m) => m.type === "workspaceActivity").at(-1);

    console.log(
      [
        "BENCH5109B",
        `workspaces listed: ${synthetic.length + 2}; window: ${SECONDS} s; selected ws-1 with 1 task`,
        `load: ${sent} metadata events (${Math.floor(sent / 10)} task renames, the rest unrelated); selected + descendants recomputes ${recomputesUnderLoad}; activity pump restarts ${restartsUnderLoad}; workspaces posts ${listPosts}`,
        `membership change (new task): recomputes ${((globalThis as any).__recomputes ?? 0) - recomputes0 - recomputesUnderLoad}; restarts ${state.activityLists - lists0 - restartsUnderLoad}; latency to the new activity post ${restartMs.toFixed(2)} ms; tracked ${Object.keys(last?.activity ?? {}).join(",")}`,
      ].join("\n")
    );
  }, 10 * 60_000);
});
```

</details>

## Tests

- Host lifecycle (`extension.test.ts`):
- Switching workspaces: selecting none and then a workspace starts a new
pump (a new `activity.list`).
- Removing a descendant restarts the pump for the smaller set, and the
next post no longer contains the child. An unrelated update and a rename
of the child (a list re-post) keep the running pump.
- Late events from a replaced pump: the first pump's `activity.list` is
held while removing the descendant replaces the pump. When the held read
answers, it posts nothing; only the replacement's map is posted. With
the abort and the `isSelected` controller check both removed, this case
fails (12 extra lines received).
- `workspaceActivityPump.test.ts`: descendants are tracked; each
workspace's monitors, stream flag and workflow runs are posted;
goal-only events and other workspaces are ignored; null resets a
workspace.
- Webview (`vscode webview sub-agent tasks strip (coder#5109)`):
- The strip appears when the host's list gains a child: "1 sub-agent · 1
active".
  - A row click sends `selectWorkspace`.
- A reported child reads "inactive" until activity reports an armed
monitor; then it shows "1 active" and "Monitoring".
  - An archived child leaves the strip.
  - A worker-less active run shows "running".
- Pre-fix: with the webview `App.tsx` from coder#5651, both strip tests fail
(2 fail): the strip is absent.
- Drift guard `bun test ./vscode/src/webview/webviewCss.test.ts` passes.
No CSS files changed.
- `make check-react-compiler`: 23/24, with only the existing
ProjectSidebarInner skip.

Webview at 300 px (narrow VS Code sidebar). It shows nested sub-agents,
a Monitoring child and a workflow group:

![webview
300px](https://github.com/user-attachments/assets/4cf07653-f2e0-49e6-b207-5859fa77adec)

390 px:

![webview
390px](https://github.com/user-attachments/assets/5d5a4dfb-4ee1-4417-96e7-d3255795148e)

900 px (same rows and labels as the desktop tray):

![webview
900px](https://github.com/user-attachments/assets/6d49e23e-e4a6-4f35-8fee-9d3671ab9073)

## Risks

Low to medium. Desktop only sees type narrowing, and the existing tray
tests pass unchanged. Activity no longer stops when the chat stream
ends; it stops on deselection, file mode, refresh failure or dispose.

Size: 559 changed lines (477+, 82-), tests included. The coordinator
accepted up to 560. The reason: the activity pump moved here from coder#5651
to keep that layer small, and this layer must also delete main's
chat-tied pump (23 lines of deletions).

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high` • Cost: `$48.29`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=48.29 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant