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.
- package/CHANGELOG.md +30 -0
- package/dist/index.mjs +537 -526
- package/package.json +2 -1
- package/src/errors.ts +14 -1
- package/src/extension/async-notifier.ts +42 -17
- package/src/extension/cross-extension-rpc.ts +42 -5
- package/src/extension/notification-router.ts +15 -2
- package/src/extension/registration/lifecycle-handlers.ts +36 -13
- package/src/extension/registration/runtime-cleanup.ts +17 -0
- package/src/extension/registration/wire-cross-extension.ts +26 -0
- package/src/extension/rpc-hmac.ts +53 -2
- package/src/extension/team-tool/cancel.ts +34 -21
- package/src/extension/team-tool/dispatch/index.ts +6 -5
- package/src/extension/team-tool/dispatch/status.ts +42 -2
- package/src/extension/team-tool.ts +24 -2
- package/src/prompt/inbox-poll.ts +77 -25
- package/src/prompt/message-tool.ts +62 -4
- package/src/prompt/prompt-runtime.ts +82 -32
- package/src/runtime/broker/crew-broker-client.ts +74 -16
- package/src/runtime/broker/crew-broker.ts +111 -77
- package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
- package/src/runtime/broker/wait-status-cache.ts +14 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
- package/src/runtime/child-pi/child-pi.ts +20 -2
- package/src/runtime/child-pi/mock-fixtures.ts +23 -0
- package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
- package/src/runtime/compaction/important-line-classifier.ts +85 -11
- package/src/runtime/compaction/tool-output-pruner.ts +35 -11
- package/src/runtime/crew-agent-records.ts +7 -1
- package/src/runtime/delivery-coordinator.ts +30 -0
- package/src/runtime/dwf-state-store.ts +41 -7
- package/src/runtime/finalize-run.ts +13 -2
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
- package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/embedded-tier.ts +3 -1
- package/src/runtime/live-session/live-session-runtime.ts +54 -1
- package/src/runtime/model/model-fallback.ts +23 -1
- package/src/runtime/output/pi-json-output.ts +26 -1
- package/src/runtime/policy-engine.ts +18 -0
- package/src/runtime/process/zombie-scanner.ts +78 -14
- package/src/runtime/process-status.ts +6 -1
- package/src/runtime/recovery/crash-recovery.ts +170 -28
- package/src/runtime/rpc/rpc-worker.ts +44 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
- package/src/runtime/scheduling/scheduler.ts +74 -11
- package/src/runtime/scratchpad/engine.ts +26 -3
- package/src/runtime/settings-store.ts +22 -2
- package/src/runtime/surface/tmux-provider.ts +24 -3
- package/src/runtime/task-health.ts +16 -1
- package/src/runtime/task-output-context.ts +143 -71
- package/src/runtime/task-packet.ts +70 -17
- package/src/runtime/task-runner/child-executor.ts +27 -0
- package/src/runtime/task-runner/output-splitter.ts +79 -16
- package/src/runtime/task-runner/post-execution.ts +75 -15
- package/src/runtime/task-runner/state-helpers.ts +92 -2
- package/src/runtime/usage-tracker.ts +11 -0
- package/src/runtime/verification/green-contract.ts +17 -0
- package/src/runtime/verification/verification-gates.ts +24 -6
- package/src/runtime/workspace-lock.ts +53 -0
- package/src/schema/team-tool-schema.ts +10 -4
- package/src/state/coordination/mailbox.ts +133 -38
- package/src/state/decision-ledger.ts +56 -8
- package/src/state/event-log/event-log-rotation.ts +83 -5
- package/src/state/event-log/event-log.ts +181 -12
- package/src/state/stores/active-run-registry.ts +10 -3
- package/src/state/types.ts +9 -1
- package/src/ui/agents-jobs-browser.ts +22 -8
- package/src/ui/dashboard-panes/agents-pane.ts +5 -2
- package/src/ui/events-state-source.ts +67 -28
- package/src/ui/inline-panel/agent-pane.ts +13 -6
- package/src/ui/inline-panel/agent-transcript.ts +33 -21
- package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
- package/src/ui/inline-panel/index.ts +21 -1
- package/src/ui/live-run-sidebar.ts +14 -3
- package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
- package/src/ui/powerbar-publisher.ts +6 -3
- package/src/ui/rail.ts +7 -1
- package/src/ui/render-scheduler.ts +53 -1
- package/src/ui/run-action-dispatcher.ts +68 -20
- package/src/ui/status-colors.ts +9 -0
- 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
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
300
|
+
if (nextSeq > sinceSeq) cursors.set(key, nextSeq);
|
|
293
301
|
|
|
294
|
-
let buffer = buffers.get(
|
|
302
|
+
let buffer = buffers.get(key) ?? [];
|
|
295
303
|
if (events.length > 0) {
|
|
296
|
-
const pending = pendingByTask.get(
|
|
297
|
-
pendingByTask.set(
|
|
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(
|
|
313
|
+
if (buffer.length > 0 && !promptSeeded.has(key)) {
|
|
306
314
|
const prompt = readWorkerPrompt(manifest, taskId);
|
|
307
315
|
if (prompt !== undefined) {
|
|
308
|
-
promptSeeded.add(
|
|
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(
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
248
|
-
//
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
|
158
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
126
159
|
if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
151
|
-
data: { count },
|
|
198
|
+
message,
|
|
199
|
+
data: { count: applied.count },
|
|
152
200
|
};
|
|
153
201
|
} catch (error) {
|
|
154
202
|
return err(error);
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -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":
|
package/src/utils/env-filter.ts
CHANGED
|
@@ -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
|