pi-crew 0.11.21 → 0.11.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.mjs +537 -526
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool.ts +24 -2
  16. package/src/prompt/inbox-poll.ts +77 -25
  17. package/src/prompt/message-tool.ts +62 -4
  18. package/src/prompt/prompt-runtime.ts +82 -32
  19. package/src/runtime/broker/crew-broker-client.ts +74 -16
  20. package/src/runtime/broker/crew-broker.ts +111 -77
  21. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  22. package/src/runtime/broker/wait-status-cache.ts +14 -5
  23. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  24. package/src/runtime/child-pi/child-pi.ts +20 -2
  25. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  26. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  27. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  28. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  29. package/src/runtime/crew-agent-records.ts +7 -1
  30. package/src/runtime/delivery-coordinator.ts +30 -0
  31. package/src/runtime/dwf-state-store.ts +41 -7
  32. package/src/runtime/finalize-run.ts +13 -2
  33. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  34. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  35. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  36. package/src/runtime/live-session/embedded-tier.ts +3 -1
  37. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  38. package/src/runtime/model/model-fallback.ts +23 -1
  39. package/src/runtime/output/pi-json-output.ts +26 -1
  40. package/src/runtime/policy-engine.ts +18 -0
  41. package/src/runtime/process/zombie-scanner.ts +78 -14
  42. package/src/runtime/process-status.ts +6 -1
  43. package/src/runtime/recovery/crash-recovery.ts +170 -28
  44. package/src/runtime/rpc/rpc-worker.ts +44 -1
  45. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  46. package/src/runtime/scheduling/scheduler.ts +74 -11
  47. package/src/runtime/scratchpad/engine.ts +26 -3
  48. package/src/runtime/settings-store.ts +22 -2
  49. package/src/runtime/surface/tmux-provider.ts +24 -3
  50. package/src/runtime/task-health.ts +16 -1
  51. package/src/runtime/task-output-context.ts +143 -71
  52. package/src/runtime/task-packet.ts +70 -17
  53. package/src/runtime/task-runner/child-executor.ts +27 -0
  54. package/src/runtime/task-runner/output-splitter.ts +79 -16
  55. package/src/runtime/task-runner/post-execution.ts +75 -15
  56. package/src/runtime/task-runner/state-helpers.ts +92 -2
  57. package/src/runtime/usage-tracker.ts +11 -0
  58. package/src/runtime/verification/green-contract.ts +17 -0
  59. package/src/runtime/verification/verification-gates.ts +24 -6
  60. package/src/runtime/workspace-lock.ts +53 -0
  61. package/src/schema/team-tool-schema.ts +10 -4
  62. package/src/state/coordination/mailbox.ts +133 -38
  63. package/src/state/decision-ledger.ts +56 -8
  64. package/src/state/event-log/event-log-rotation.ts +83 -5
  65. package/src/state/event-log/event-log.ts +181 -12
  66. package/src/state/stores/active-run-registry.ts +10 -3
  67. package/src/state/types.ts +9 -1
  68. package/src/ui/agents-jobs-browser.ts +22 -8
  69. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  70. package/src/ui/events-state-source.ts +67 -28
  71. package/src/ui/inline-panel/agent-pane.ts +13 -6
  72. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  73. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  74. package/src/ui/inline-panel/index.ts +21 -1
  75. package/src/ui/live-run-sidebar.ts +14 -3
  76. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  77. package/src/ui/powerbar-publisher.ts +6 -3
  78. package/src/ui/rail.ts +7 -1
  79. package/src/ui/render-scheduler.ts +53 -1
  80. package/src/ui/run-action-dispatcher.ts +68 -20
  81. package/src/ui/status-colors.ts +9 -0
  82. package/src/utils/env-filter.ts +8 -0
@@ -13,8 +13,8 @@
13
13
  * detached spawner, so a memory feed would show an empty pane for exactly the
14
14
  * runs users background. Disk works for both with one parser.
15
15
  *
16
- * Per task, a module-level ring buffer accumulates parsed items across reads
17
- * (cursor + persisted parse state), mirroring pi-subtask's `fork.transcript`.
16
+ * Per run::task (F3), a module-level ring buffer accumulates parsed items across
17
+ * reads (cursor + persisted parse state), mirroring pi-subtask's `fork.transcript`.
18
18
  * This matters for two reasons the naive delta-read gets wrong:
19
19
  * - tool `start`/`end` events can straddle a read boundary, so the
20
20
  * unmatched-start map must survive between reads;
@@ -83,13 +83,20 @@ export type CrewTranscriptItem =
83
83
 
84
84
  type PendingTool = CrewTranscriptItem & { type: "tool" };
85
85
 
86
- /** Per-task parse state, persisted across reads. */
86
+ /** Per-run::task parse state, persisted across reads (F3: keyed by run —
87
+ * task ids are positional per run, so a taskId-only key would let the same
88
+ * task name in a LATER run reuse the earlier run's cursor/buffer). */
87
89
  const buffers = new Map<string, CrewTranscriptItem[]>();
88
90
  const pendingByTask = new Map<string, Map<string, PendingTool>>();
89
91
  const cursors = new Map<string, number>();
90
- /** Tasks whose worker prompt has been prepended to the buffer. */
92
+ /** Run::task keys whose worker prompt has been prepended to the buffer. */
91
93
  const promptSeeded = new Set<string>();
92
94
 
95
+ /** F3 (DR-02 wave 2): module-state key — `${runId}::${taskId}`. */
96
+ function transcriptStateKey(runId: string | undefined, taskId: string): string {
97
+ return `${runId ?? ""}::${taskId}`;
98
+ }
99
+
93
100
  /**
94
101
  * The child pi never logs its INITIAL user message (input is not an event),
95
102
  * so the transcript would open on the first assistant message — unlike a
@@ -287,14 +294,15 @@ function matchPending(
287
294
  * cursor skips re-parse and the existing buffer is returned as-is.
288
295
  */
289
296
  export function readAgentTranscript(manifest: TeamRunManifest, taskId: string): CrewTranscriptItem[] {
290
- const sinceSeq = cursors.get(taskId) ?? 0;
297
+ const key = transcriptStateKey(manifest.runId, taskId);
298
+ const sinceSeq = cursors.get(key) ?? 0;
291
299
  const { events, nextSeq } = readCrewAgentEventsCursor(manifest, taskId, { sinceSeq });
292
- if (nextSeq > sinceSeq) cursors.set(taskId, nextSeq);
300
+ if (nextSeq > sinceSeq) cursors.set(key, nextSeq);
293
301
 
294
- let buffer = buffers.get(taskId) ?? [];
302
+ let buffer = buffers.get(key) ?? [];
295
303
  if (events.length > 0) {
296
- const pending = pendingByTask.get(taskId) ?? new Map<string, PendingTool>();
297
- pendingByTask.set(taskId, pending);
304
+ const pending = pendingByTask.get(key) ?? new Map<string, PendingTool>();
305
+ pendingByTask.set(key, pending);
298
306
 
299
307
  for (const record of events) {
300
308
  const parsed = asRecord(record);
@@ -302,26 +310,29 @@ export function readAgentTranscript(manifest: TeamRunManifest, taskId: string):
302
310
  buffer.push(...parseEventRecord(parsed, pending));
303
311
  }
304
312
  }
305
- if (buffer.length > 0 && !promptSeeded.has(taskId)) {
313
+ if (buffer.length > 0 && !promptSeeded.has(key)) {
306
314
  const prompt = readWorkerPrompt(manifest, taskId);
307
315
  if (prompt !== undefined) {
308
- promptSeeded.add(taskId);
316
+ promptSeeded.add(key);
309
317
  buffer.unshift({ type: "user", text: prompt, seq: 0 });
310
318
  }
311
319
  }
312
320
  if (buffer.length > MAX_TRANSCRIPT_ITEMS) {
313
321
  buffer = buffer.slice(buffer.length - MAX_TRANSCRIPT_ITEMS);
314
322
  }
315
- buffers.set(taskId, buffer);
323
+ buffers.set(key, buffer);
316
324
  return buffer;
317
325
  }
318
326
 
319
- /** Drop everything learned about a task (used when the pane switches agents). */
320
- export function resetAgentTranscriptCursor(taskId: string): void {
321
- cursors.delete(taskId);
322
- buffers.delete(taskId);
323
- pendingByTask.delete(taskId);
324
- promptSeeded.delete(taskId);
327
+ /** Drop everything learned about a run's task (used when the pane switches
328
+ * agents or runs — F3: scoped to the run so switching runs never leaves the
329
+ * old run's ring buffer reachable under the new one's key). */
330
+ export function resetAgentTranscriptCursor(runId: string, taskId: string): void {
331
+ const key = transcriptStateKey(runId, taskId);
332
+ cursors.delete(key);
333
+ buffers.delete(key);
334
+ pendingByTask.delete(key);
335
+ promptSeeded.delete(key);
325
336
  }
326
337
 
327
338
  /** Clear all per-task state (session teardown / test isolation). */
@@ -332,7 +343,8 @@ export function resetAllAgentTranscriptCursors(): void {
332
343
  promptSeeded.clear();
333
344
  }
334
345
 
335
- /** Test-only: whether the module currently holds state for a task. */
336
- export function __hasAgentTranscriptState(taskId: string): boolean {
337
- return buffers.has(taskId) || cursors.has(taskId) || pendingByTask.has(taskId);
346
+ /** Test-only: whether the module currently holds state for a run's task. */
347
+ export function __hasAgentTranscriptState(runId: string, taskId: string): boolean {
348
+ const key = transcriptStateKey(runId, taskId);
349
+ return buffers.has(key) || cursors.has(key) || pendingByTask.has(key);
338
350
  }
@@ -37,11 +37,25 @@ export interface CrewAgentOverlayOptions {
37
37
  const INPUT_PROMPT = " ❯ ";
38
38
 
39
39
  /** Printable single keypress or paste chunk: no control bytes, no ESC
40
- * sequences (arrow keys etc. must not land in the steer text). */
41
- function isPrintableInput(data: string): boolean {
40
+ * sequences (arrow keys etc. must not land in the steer text).
41
+ *
42
+ * R7-14 (DR-04): shared with mailbox-compose-overlay — its free-text
43
+ * passthrough previously accepted only single chars, so a multi-char
44
+ * bracketed-paste chunk ("hello world") was dropped. */
45
+ export function isPrintableInput(data: string): boolean {
42
46
  return data.length > 0 && !data.startsWith("\x1b") && !/[\x00-\x1f\x7f]/.test(data);
43
47
  }
44
48
 
49
+ /** O-4 (DR-04): surrogate-safe backspace for editors that edit at
50
+ * end-of-value. When the LAST UTF-16 unit is a low surrogate it completes a
51
+ * pair — remove BOTH units so astral characters (emoji) never split into a
52
+ * stray high surrogate / U+FFFD. Shared with mailbox-compose-overlay. */
53
+ export function backspaceChar(text: string): string {
54
+ const last = text.length > 0 ? text.charCodeAt(text.length - 1) : 0;
55
+ if (last >= 0xdc00 && last <= 0xdfff && text.length >= 2) return text.slice(0, -2);
56
+ return text.slice(0, -1);
57
+ }
58
+
45
59
  export class CrewAgentOverlay {
46
60
  /** The transcript core; exposed so the host can wire scroll/steer keys. */
47
61
  readonly pane: CrewAgentPane;
@@ -136,7 +150,8 @@ export class CrewAgentOverlay {
136
150
  } else if (matchesKey(data, "return")) {
137
151
  this.sendSteer();
138
152
  } else if (matchesKey(data, "backspace")) {
139
- this.inputText = this.inputText.slice(0, -1);
153
+ // O-4: astral-safe backspace (shared helper).
154
+ this.inputText = backspaceChar(this.inputText);
140
155
  } else if (data === "\x03") {
141
156
  // ctrl+c inside steer input cancels the input, not the view.
142
157
  this.inputMode = false;
@@ -47,6 +47,13 @@ const PANE_LIVE_TICK_MS = 700;
47
47
 
48
48
  let livePane: CrewAgentPane | undefined;
49
49
  let liveOverlay: CrewAgentOverlay | undefined;
50
+ /** O-6 (DR-04 R7): double-mount guard — true between the `ctx.ui.custom()`
51
+ * call and the moment its factory actually runs (or the call rejects). A
52
+ * second openPane in exactly that window (key-mash, dock re-entry, rapid
53
+ * `/crew-view`) would otherwise mount a SECOND full-screen overlay on top
54
+ * of the first. Cleared by the factory (overlay live), by rejection (state
55
+ * reset below) and on the custom() promise settling (overlay closed). */
56
+ let paneMounting = false;
50
57
  let paneTickTimer: ReturnType<typeof setInterval> | undefined;
51
58
  let editorInstalled = false;
52
59
  /** Guards the one-time per-process event hooks. */
@@ -124,7 +131,8 @@ function notifyResult(ctx: ExtensionContext, result: PiTeamsToolResult): void {
124
131
  */
125
132
  function openPane(ctx: ExtensionContext, target: PanelTarget): void {
126
133
  setViewedAgent(target);
127
- if (!liveOverlay) {
134
+ if (!liveOverlay && !paneMounting) {
135
+ paneMounting = true;
128
136
  try {
129
137
  void ctx.ui
130
138
  .custom(
@@ -145,6 +153,9 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
145
153
  });
146
154
  liveOverlay = overlay;
147
155
  livePane = overlay.pane;
156
+ // O-6: the factory ran — the mount window is over; from here on
157
+ // the `!liveOverlay` check alone owns re-entry.
158
+ paneMounting = false;
148
159
  return overlay as never;
149
160
  },
150
161
  {
@@ -152,10 +163,17 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
152
163
  overlayOptions: { width: "100%", margin: 0, maxHeight: "100%", anchor: "top-left" },
153
164
  },
154
165
  )
166
+ .then(() => {
167
+ // Resolves when the host tears the overlay down (done()) — the
168
+ // mount window is definitely over (belt and braces for a host
169
+ // that never invoked the factory).
170
+ paneMounting = false;
171
+ })
155
172
  .catch((error) => {
156
173
  // Factory throw or the host tearing the overlay down: drop the
157
174
  // view state so the dock does not point at a view that no
158
175
  // longer exists.
176
+ paneMounting = false;
159
177
  liveOverlay = undefined;
160
178
  livePane = undefined;
161
179
  setViewedAgent(undefined);
@@ -163,6 +181,7 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
163
181
  logInternalError("view.openOverlay", error, target.taskId);
164
182
  });
165
183
  } catch (error) {
184
+ paneMounting = false;
166
185
  // A stale ctx (session replaced between keypress and here) drops the
167
186
  // overlay spawn; the fallback editor path still closes cleanly.
168
187
  logInternalError("view.openPane", error, target.taskId);
@@ -326,6 +345,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
326
345
  export function __resetInlinePanelForTest(): void {
327
346
  editorInstalled = false;
328
347
  liveOverlay = undefined;
348
+ paneMounting = false;
329
349
  stopPaneTicker();
330
350
  }
331
351
 
@@ -117,9 +117,10 @@ export class LiveRunSidebar {
117
117
  agents: ReturnType<typeof readCrewAgents>,
118
118
  waitingCount: number,
119
119
  snapshot?: RunUiSnapshot,
120
+ countdownBucket = "",
120
121
  ): string {
121
122
  const animation = agents.some((agent) => agent.status === "running") ? `:spin=${spinnerBucket()}` : "";
122
- if (snapshot) return `${snapshot.signature}:${waitingCount}${animation}`;
123
+ if (snapshot) return `${snapshot.signature}:${waitingCount}${animation}${countdownBucket}`;
123
124
  const taskSig = tasks
124
125
  .map(
125
126
  (task) =>
@@ -143,7 +144,17 @@ export class LiveRunSidebar {
143
144
  ].join(":"),
144
145
  )
145
146
  .join("|");
146
- return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}${animation}`;
147
+ return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}${animation}${countdownBucket}`;
148
+ }
149
+
150
+ /** F5 (DR-02 wave 2): while the terminal auto-close countdown is armed, a
151
+ * coarse per-second bucket joins the render signature — the base signature
152
+ * only changes on state, so the countdown line used to freeze inside
153
+ * cachedLines until some OTHER state changed. The close timer itself is
154
+ * untouched (L4 fixed-deadline semantics stay exactly as they were). */
155
+ private autoCloseSignatureFragment(): string {
156
+ if (this.hasAutoClosed || this.autoCloseDeadline === undefined) return "";
157
+ return `:close=${Math.ceil(Math.max(0, this.autoCloseDeadline - Date.now()) / 1000)}`;
147
158
  }
148
159
 
149
160
  private colorLine(line: string): string {
@@ -231,7 +242,7 @@ export class LiveRunSidebar {
231
242
  const active = agents.filter((agent) => agent.status === "running");
232
243
  const completed = agents.filter((agent) => agent.status !== "running").slice(-5);
233
244
  const waiting = tasks.filter((task) => task.status === "queued");
234
- const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
245
+ const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot, this.autoCloseSignatureFragment());
235
246
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
236
247
  // RAIL (§2.E): the rounded `╭─╮│╰─╯` box is retired — the surface is a
237
248
  // `┏ LIVE ▸ <run8>` canopy, `┃` body rows, `┣ SECTION` headers and a
@@ -26,6 +26,7 @@
26
26
  import * as piTui from "@earendil-works/pi-tui";
27
27
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
28
28
  import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
29
+ import { backspaceChar, isPrintableInput } from "../inline-panel/agent-view-overlay.ts";
29
30
  import { overlayActionForKey } from "../keybinding-map.ts";
30
31
  import { CURSOR, canopyLine, formatHint, RAIL, railLine, railRaw } from "../rail.ts";
31
32
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
@@ -169,7 +170,9 @@ export class MailboxComposeOverlay {
169
170
  if (field === "direction") return;
170
171
  this.fields = {
171
172
  ...this.fields,
172
- [field]: (this.fields[field] ?? "").slice(0, -1),
173
+ // O-4: astral-safe backspace (shared helper) — a trailing surrogate
174
+ // pair is one code point and must leave together.
175
+ [field]: backspaceChar(this.fields[field] ?? ""),
173
176
  };
174
177
  }
175
178
 
@@ -244,8 +247,11 @@ export class MailboxComposeOverlay {
244
247
  else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
245
248
  return;
246
249
  }
247
- // Free-text passthrough (unchanged): any other printable char is typed
248
- // into the active field.
249
- if (data.length === 1 && data >= " ") this.appendText(data);
250
+ // Free-text passthrough (R7-14): any other PRINTABLE input is typed into
251
+ // the active field — single chars AND multi-char paste chunks (the old
252
+ // `data.length === 1` guard dropped pasted "hello world" entirely).
253
+ // Shares the agent-view-overlay classifier: no control bytes, no ESC
254
+ // sequences, so arrow keys still never land in the field.
255
+ if (isPrintableInput(data)) this.appendText(data);
250
256
  }
251
257
  }
@@ -12,6 +12,7 @@ import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
13
  import { extractSessionId } from "../utils/session-utils.ts";
14
14
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
15
+ import { truncLine } from "./format-helpers.ts";
15
16
  import { RenderCoalescer } from "./render-coalescer.ts";
16
17
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
17
18
  import { notificationBadge } from "./widget/widget-formatters.ts";
@@ -158,7 +159,9 @@ export function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[
158
159
  const parts = current.phases.map((phase) => {
159
160
  const status = phase.status === "done" ? "completed" : statusOf(phase.itemIds);
160
161
  const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
161
- const name = phase.title.length > 10 ? `${phase.title.slice(0, 9)}…` : phase.title;
162
+ // O-5 (DR-04): grapheme-aware truncation — the ad-hoc slice(0, 9)
163
+ // split surrogate pairs (emoji phase titles → stray U+FFFD).
164
+ const name = truncLine(phase.title, 10);
162
165
  return `${icon}${name}`;
163
166
  });
164
167
  if (parts.length) {
@@ -196,8 +199,8 @@ export function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[
196
199
  for (const step of workflow.steps) {
197
200
  const status = stepStatus.get(step.id) ?? "pending";
198
201
  const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
199
- // Shorten long step names
200
- const stepName = step.id.length > 10 ? step.id.slice(0, 9) + "…" : step.id;
202
+ // Shorten long step names (O-5: grapheme-aware — slice(0, 9) split emoji).
203
+ const stepName = truncLine(step.id, 10);
201
204
  stepParts.push(`${icon}${stepName}`);
202
205
  }
203
206
  const stepsText = stepParts.join(" › ");
package/src/ui/rail.ts CHANGED
@@ -107,7 +107,12 @@ export function shortId(id: string | undefined): string {
107
107
 
108
108
  export type RailSlot = "border" | "borderAccent" | "success" | "error" | "warning";
109
109
 
110
- /** Status → rail colour. One mapping for every surface (was: per-surface maps). */
110
+ /** Status → rail colour. One mapping for every surface (was: per-surface maps).
111
+ * F4 (DR-02 wave 2): "blocked" joins the warning tier — parity with
112
+ * status-colors.ts (colorForStatus("blocked") === "warning"), so the rail no
113
+ * longer falls to the neutral border while the glyph next to it is painted
114
+ * warning. "planning" stays in the default border tier (informational/dim —
115
+ * same tier status-colors assigns it). */
111
116
  export function statusSlot(status: string | undefined): RailSlot {
112
117
  switch (status) {
113
118
  case "completed":
@@ -128,6 +133,7 @@ export function statusSlot(status: string | undefined): RailSlot {
128
133
  return "borderAccent";
129
134
  case "waiting":
130
135
  case "needs_attention":
136
+ case "blocked":
131
137
  case "stale":
132
138
  case "pending":
133
139
  return "warning";
@@ -28,6 +28,16 @@ export interface RenderSchedulerOptions {
28
28
  * count and re-arms the loop. Defaults to 8.
29
29
  */
30
30
  maxIdleFallbackRenders?: number;
31
+ /**
32
+ * R7-12 (DR-04): maximum time (ms) a PENDING render may be starved by a
33
+ * sustained event stream. While events keep arriving faster than
34
+ * `debounceMs`, every schedule() re-arms the debounce timer and the flush
35
+ * would otherwise be postponed forever. At the cap the pending batch is
36
+ * force-flushed — from schedule() itself and, as a safety net, from the
37
+ * fallback loop. Defaults to `max(3 × debounceMs, 250)`, floored at 250 so
38
+ * an explicit small override can never disable the cap entirely.
39
+ */
40
+ maxDelayMs?: number;
31
41
  }
32
42
 
33
43
  const DEFAULT_EVENTS = [
@@ -61,6 +71,8 @@ export class RenderScheduler {
61
71
  private readonly fallbackMs: number;
62
72
  private readonly invalidateCoalesceMs: number;
63
73
  private readonly maxIdleFallbackRenders: number;
74
+ /** R7-12: pending-starvation cap — see {@link RenderSchedulerOptions.maxDelayMs}. */
75
+ private readonly maxDelayMs: number;
64
76
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
65
77
  private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
66
78
  private invalidateTimer: ReturnType<typeof setTimeout> | undefined;
@@ -74,6 +86,9 @@ export class RenderScheduler {
74
86
  private idleFallbackRenders = 0;
75
87
  /** R4: consecutive re-entrancy cap-hits — drives exponential backoff. */
76
88
  private consecutiveCapHits = 0;
89
+ /** R7-12: when the CURRENT pending batch was first scheduled (undefined =
90
+ * nothing pending). Reset whenever a flush actually renders. */
91
+ private pendingSince: number | undefined;
77
92
  private readonly unsubs: Array<() => void> = [];
78
93
 
79
94
  constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
@@ -85,6 +100,7 @@ export class RenderScheduler {
85
100
  this.fallbackMs = typeof fallback === "number" ? fallback : 750;
86
101
  this.invalidateCoalesceMs = options.invalidateCoalesceMs ?? 50;
87
102
  this.maxIdleFallbackRenders = options.maxIdleFallbackRenders ?? 8;
103
+ this.maxDelayMs = Math.max(options.maxDelayMs ?? 3 * this.debounceMs, 250);
88
104
  for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
89
105
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
90
106
  this.fallbackTimer.unref();
@@ -118,6 +134,15 @@ export class RenderScheduler {
118
134
  if (Date.now() - this.lastEventAt < fallbackMs) {
119
135
  // A real external event arrived recently — not idle. Keep polling.
120
136
  this.idleFallbackRenders = 0;
137
+ // R7-12: BUT a sustained stream (events faster than debounceMs) keeps
138
+ // re-arming the debounce and the pending render starves. When the
139
+ // pending batch has waited past the max-delay cap, flush it HERE —
140
+ // this loop is the safety net for exactly that starvation (it used
141
+ // to refuse to render while any event was recent).
142
+ if (this.pendingSince !== undefined && Date.now() - this.pendingSince >= this.maxDelayMs) {
143
+ this.cancelDebounce();
144
+ this.flush();
145
+ }
121
146
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), fallbackMs);
122
147
  this.fallbackTimer.unref();
123
148
  return;
@@ -145,7 +170,17 @@ export class RenderScheduler {
145
170
  this.idleFallbackRenders = 0;
146
171
  this.consecutiveCapHits = 0;
147
172
  this.invalidate(payload);
148
- this.armDebouncedRender();
173
+ // R7-12: mark when the CURRENT pending batch started (first schedule
174
+ // since the last actual render).
175
+ if (this.pendingSince === undefined) this.pendingSince = this.lastEventAt;
176
+ if (this.lastEventAt - this.pendingSince >= this.maxDelayMs) {
177
+ // Force flush NOW, even mid-burst: cancel the pending debounce timer
178
+ // (its render would be redundant) and render synchronously.
179
+ this.cancelDebounce();
180
+ this.flush();
181
+ } else {
182
+ this.armDebouncedRender();
183
+ }
149
184
  // R1: if the fallback loop stopped itself after a sustained idle period,
150
185
  // a real event means activity resumed — re-arm the safety-net loop.
151
186
  if (!this.fallbackTimer) {
@@ -169,6 +204,13 @@ export class RenderScheduler {
169
204
  this.debounceTimer.unref();
170
205
  }
171
206
 
207
+ /** R7-12: cancel a pending debounce flush (its render would be redundant
208
+ * next to a force-flush). Does not touch lastEventAt or pendingSince. */
209
+ private cancelDebounce(): void {
210
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
211
+ this.debounceTimer = undefined;
212
+ }
213
+
172
214
  /**
173
215
  * 1.9: forward `onInvalidate` immediately when the payload has no `runId`
174
216
  * (we cannot dedup it). When it carries a runId, buffer the latest payload
@@ -229,6 +271,10 @@ export class RenderScheduler {
229
271
  }
230
272
  this.rendering = true;
231
273
  this.pendingRender = false;
274
+ // R7-12: this flush satisfies the current pending batch — the next
275
+ // starvation window starts at the NEXT schedule() (set after this, so a
276
+ // render() that synchronously schedules() still marks a fresh batch).
277
+ this.pendingSince = undefined;
232
278
  let iterations = 0;
233
279
  try {
234
280
  do {
@@ -254,6 +300,12 @@ export class RenderScheduler {
254
300
  } else {
255
301
  this.consecutiveCapHits = 0;
256
302
  }
303
+ // R7-12 note: the backoff drain above arms a LONG debounce while
304
+ // pendingRender is still true, but it never sets pendingSince — only
305
+ // real events (schedule()) do. Pathological render() re-entrancy
306
+ // without external events therefore still backs off undisturbed (R4
307
+ // invariant); the max-delay cap only bounds waits caused by real
308
+ // incoming events.
257
309
  }
258
310
  }
259
311
 
@@ -13,11 +13,14 @@ async function handleTeamTool(
13
13
  }
14
14
 
15
15
  import { isToolError, textFromToolResult } from "../extension/tool-result.ts";
16
+ import { isRunLockBusyError } from "../runtime/broker/protocol/lock-busy.ts";
16
17
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
17
18
  import { exportDiagnostic } from "../runtime/diagnostic-export.ts";
19
+ import { withRunLockSync } from "../state/coordination/locks.ts";
18
20
  import type { MailboxDirection, MailboxMessage } from "../state/coordination/mailbox.ts";
19
21
  import { appendEventBuffered } from "../state/event-log/event-log.ts";
20
22
  import { loadRunManifestById, saveRunTasks } from "../state/stores/state-store.ts";
23
+ import type { TeamTaskState } from "../state/types.ts";
21
24
  import { logInternalError } from "../utils/internal-error.ts";
22
25
  import { formatCount } from "./format-helpers.ts";
23
26
 
@@ -120,35 +123,80 @@ export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Pr
120
123
  });
121
124
  }
122
125
 
126
+ /** Staleness window for the kill-stale sweep (R4-6). */
127
+ const KILL_STALE_HEARTBEAT_MS = 60_000;
128
+
129
+ /**
130
+ * R4-6 (DR-02 Wave 2 Cluster C): pure RMW core of the kill-stale sweep —
131
+ * mark running/queued tasks whose heartbeat is older than `staleMs` dead.
132
+ * Exported so the locked dispatcher body stays unit-testable without a
133
+ * harness (repo seam pattern; see state-helpers-cas-contention.test.ts).
134
+ * Predicate is byte-identical to the previous inline map.
135
+ */
136
+ export function markStaleWorkerHeartbeatsDead(
137
+ tasks: readonly TeamTaskState[],
138
+ currentMs: number,
139
+ staleMs: number = KILL_STALE_HEARTBEAT_MS,
140
+ ): { tasks: TeamTaskState[]; count: number } {
141
+ const now = new Date(currentMs).toISOString();
142
+ let count = 0;
143
+ const updated = tasks.map((task) => {
144
+ if ((task.status !== "running" && task.status !== "queued") || !task.heartbeat || task.heartbeat.alive === false) return task;
145
+ const lastSeenMs = Date.parse(task.heartbeat.lastSeenAt);
146
+ if (!Number.isFinite(lastSeenMs) || currentMs - lastSeenMs <= staleMs) return task;
147
+ count += 1;
148
+ return {
149
+ ...task,
150
+ heartbeat: { ...task.heartbeat, alive: false, lastSeenAt: now },
151
+ };
152
+ });
153
+ return { tasks: updated, count };
154
+ }
155
+
123
156
  export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
124
157
  try {
125
- const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
158
+ const loaded = loadRunManifestById(ctx.cwd, runId);
126
159
  if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
127
- const currentMs = Date.now();
128
- const staleMs = 60_000;
129
- const now = new Date(currentMs).toISOString();
130
- let count = 0;
131
- const tasks = loaded.tasks.map((task) => {
132
- if ((task.status !== "running" && task.status !== "queued") || !task.heartbeat || task.heartbeat.alive === false) return task;
133
- const lastSeenMs = Date.parse(task.heartbeat.lastSeenAt);
134
- if (!Number.isFinite(lastSeenMs) || currentMs - lastSeenMs <= staleMs) return task;
135
- count += 1;
136
- return {
137
- ...task,
138
- heartbeat: { ...task.heartbeat, alive: false, lastSeenAt: now },
139
- };
140
- });
141
- saveRunTasks(loaded.manifest, tasks);
160
+ // R4-6 (DR-02 Wave 2 Cluster C): this sweep used to be an UNLOCKED
161
+ // load→map→save (":125 no withRunLock - best-effort") — a cross-process
162
+ // writer holding the run lock (leader heartbeats, task claims, delegate
163
+ // flushes) could land a fresh tasks.json between our read and write and
164
+ // get clobbered by the STALE array. Now a run-locked RMW with the fresh
165
+ // re-read INSIDE the lock (R13-S1 canonical pattern, team-tool/api/heartbeat.ts).
166
+ // Best-effort semantics are preserved on contention: the UI never blocks —
167
+ // the sweep is skipped with a warn, never a stale write.
168
+ let applied: { tasks: TeamTaskState[]; count: number } | undefined;
169
+ try {
170
+ applied = withRunLockSync(loaded.manifest, () => {
171
+ const fresh = loadRunManifestById(loaded.manifest.cwd, loaded.manifest.runId);
172
+ if (!fresh) return undefined;
173
+ const result = markStaleWorkerHeartbeatsDead(fresh.tasks, Date.now());
174
+ // count=0 → nothing changed; skip the identical-content rewrite.
175
+ if (result.count > 0) saveRunTasks(fresh.manifest, result.tasks);
176
+ return result;
177
+ });
178
+ } catch (error) {
179
+ if (isRunLockBusyError(error)) {
180
+ logInternalError("run-action-dispatcher.killStale", error, `runId=${runId} skipped: run lock busy`, "warn");
181
+ return {
182
+ ok: false,
183
+ message: `Skipped kill-stale for run '${runId}': another operation holds the run lock. Try again in a moment.`,
184
+ };
185
+ }
186
+ throw error;
187
+ }
188
+ if (!applied) return { ok: false, message: `Run '${runId}' not found.` };
189
+ const message = `Marked ${applied.count} stale worker heartbeat(s) dead.`;
142
190
  appendEventBuffered(loaded.manifest.eventsPath, {
143
191
  type: "worker.kill_stale",
144
192
  runId,
145
- message: `Marked ${count} stale worker heartbeat(s) dead.`,
146
- data: { count },
193
+ message,
194
+ data: { count: applied.count },
147
195
  }).catch((e) => logInternalError("run_action_dispatcher.buffered", e, "type=worker.kill_stale"));
148
196
  return {
149
197
  ok: true,
150
- message: `Marked ${count} stale worker heartbeat(s) dead.`,
151
- data: { count },
198
+ message,
199
+ data: { count: applied.count },
152
200
  };
153
201
  } catch (error) {
154
202
  return err(error);
@@ -19,6 +19,11 @@ export function colorForStatus(status: RunStatus): CrewThemeColor {
19
19
  return "accent";
20
20
  case "waiting":
21
21
  return "muted";
22
+ // F4 (DR-02 wave 2): "planning" was missing from BOTH palettes — it
23
+ // fell through to this default, which was correct-by-luck but unsigned.
24
+ // Informational/dim, same tier as queued's default fall-through.
25
+ case "planning":
26
+ return "dim";
22
27
  case "completed":
23
28
  return "success";
24
29
  case "failed":
@@ -52,6 +57,10 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
52
57
  return "⏳";
53
58
  case "queued":
54
59
  return "◦";
60
+ case "planning":
61
+ // F4: informational — the dim dot already used for unknown-but-known
62
+ // states; kept in the colorizeStatusGlyphs vocabulary (dim tier).
63
+ return "·";
55
64
  case "blocked":
56
65
  return "⏸";
57
66
  case "needs_attention":
@@ -60,6 +60,14 @@ export function providerEnvKeys(modelId: string | undefined): string[] {
60
60
  * Build a scoped allowlist that includes system vars + only the provider keys
61
61
  * relevant to the assigned model (and its fallback chain).
62
62
  *
63
+ * R6-8 (DR-03 round-6) contract note: callers SHOULD pass the task's full
64
+ * candidate chain (primary + fallbacks) — child-pi-spawn.ts threads it from
65
+ * the executor's attempt chain. Bare model ids (no "provider/" prefix)
66
+ * contribute ZERO keys here (providerEnvKeys returns [] for them); scoping is
67
+ * never widened to all-providers to compensate — instead the spawn layer warns
68
+ * when a truly-bare single model resolves zero provider keys (see
69
+ * child-pi-spawn.ts R6-8 warn; auth.json users are unaffected).
70
+ *
63
71
  * @param baseAllowList - Non-provider system vars to always include
64
72
  * @param models - Model IDs to include provider keys for (primary + fallbacks)
65
73
  * @returns Combined allowlist with scoped provider keys