pi-crew 0.9.42 → 0.9.46
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 +223 -0
- package/README.md +35 -0
- package/dist/build-meta.json +4778 -11087
- package/dist/index.mjs +54648 -65435
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/scripts/build-bundle.mjs +2 -1
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config.ts +1 -1
- package/src/config/types.ts +0 -2
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/cross-extension-rpc.ts +8 -4
- package/src/extension/registration/commands.ts +5 -1
- package/src/extension/registration/foreground-run-controller.ts +28 -0
- package/src/extension/registration/lifecycle-handlers.ts +34 -3
- package/src/extension/registration/subagent-manager-setup.ts +178 -59
- package/src/extension/run-import.ts +21 -1
- package/src/extension/team-tool/api.ts +4 -2
- package/src/extension/team-tool.ts +5 -0
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/async-runner.ts +9 -1
- package/src/runtime/child-pi-constants.ts +42 -0
- package/src/runtime/child-pi-kill.ts +180 -0
- package/src/runtime/child-pi-spawn.ts +234 -0
- package/src/runtime/child-pi-steering.ts +128 -0
- package/src/runtime/child-pi-streams.ts +296 -0
- package/src/runtime/child-pi-transcript.ts +169 -0
- package/src/runtime/child-pi.ts +75 -837
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/dwf-state-store.ts +5 -0
- package/src/runtime/dynamic-workflow-context.ts +78 -18
- package/src/runtime/dynamic-workflow-runner.ts +18 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +40 -11
- package/src/runtime/live-agent-manager.ts +18 -0
- package/src/runtime/manifest-cache.ts +30 -0
- package/src/runtime/pi-json-output.ts +2 -1
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/resilient-edit.ts +16 -15
- package/src/runtime/role-permission.ts +27 -2
- package/src/runtime/run-coalesced-task-group.ts +90 -25
- package/src/runtime/task-packet.ts +1 -1
- package/src/runtime/task-runner/prompt-builder.ts +2 -2
- package/src/runtime/team-runner.ts +6 -1
- package/src/state/event-log.ts +89 -34
- package/src/state/locks.ts +185 -49
- package/src/state/mailbox.ts +165 -4
- package/src/state/run-metrics.ts +40 -12
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/ui/live-run-sidebar.ts +1 -9
- package/src/ui/run-dashboard.ts +1 -9
- package/src/utils/incremental-reader.ts +105 -0
- package/src/utils/paths.ts +1 -1
- package/src/utils/visual.ts +27 -91
- package/src/worktree/worktree-manager.ts +47 -19
- package/src/runtime/auto-resume.ts +0 -100
- package/src/runtime/notebook-helpers.ts +0 -88
- package/src/runtime/orphan-sentinel.ts +0 -7
package/src/state/locks.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { DEFAULT_LOCKS } from "../config/defaults.ts";
|
|
6
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
7
|
import { sleepSync } from "../utils/sleep.ts";
|
|
6
8
|
import { isSymlinkSafePath } from "./atomic-write.ts";
|
|
7
9
|
import type { TeamRunManifest } from "./types.ts";
|
|
@@ -103,41 +105,56 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
103
105
|
*
|
|
104
106
|
* See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
|
|
105
107
|
*/
|
|
106
|
-
function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
|
|
108
|
+
function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOwnPidAsStealable?: boolean }): { canSteal: boolean } {
|
|
109
|
+
const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
|
|
107
110
|
let stat: fs.Stats | undefined;
|
|
108
111
|
let raw: string | undefined;
|
|
109
112
|
try {
|
|
110
113
|
stat = fs.statSync(filePath);
|
|
111
114
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
112
|
-
} catch {
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// FIX (CI flake): when the lock file vanishes between writeLockFile's
|
|
117
|
+
// EEXIST and our read here, the holder has released — return canSteal:true
|
|
118
|
+
// so the acquire loop retries the create instead of throwing a false
|
|
119
|
+
// "locked" error.
|
|
120
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
121
|
+
if (code === "ENOENT") {
|
|
122
|
+
return { canSteal: true };
|
|
123
|
+
}
|
|
124
|
+
// Transient I/O error — be conservative (don't steal, retry the read on
|
|
125
|
+
// next attempt).
|
|
115
126
|
return { canSteal: false };
|
|
116
127
|
}
|
|
117
128
|
// Staleness from a single snapshot.
|
|
118
129
|
let createdAt = parseCreatedAtFromLock(raw);
|
|
119
130
|
if (createdAt === undefined) createdAt = stat.mtimeMs;
|
|
120
131
|
const isStale = Date.now() - createdAt > staleMs;
|
|
121
|
-
|
|
122
|
-
let isAlive = true;
|
|
132
|
+
let holderPid: number | undefined;
|
|
133
|
+
let isAlive = true;
|
|
123
134
|
try {
|
|
124
135
|
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
125
|
-
|
|
126
|
-
if (pid !== undefined) {
|
|
127
|
-
try {
|
|
128
|
-
process.kill(pid, 0);
|
|
129
|
-
isAlive = true;
|
|
130
|
-
} catch (error) {
|
|
131
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
132
|
-
// EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
|
|
133
|
-
isAlive = false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
+
holderPid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
|
136
137
|
} catch {
|
|
137
138
|
/* malformed payload — keep isAlive=true */
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
if (holderPid !== undefined) {
|
|
141
|
+
try {
|
|
142
|
+
process.kill(holderPid, 0);
|
|
143
|
+
isAlive = true;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
146
|
+
// EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
|
|
147
|
+
isAlive = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Steal if stale OR holder dead. Optionally steal if holder is our own pid
|
|
151
|
+
// (used by withRunLock* which is single-process — a fresh lock with our pid
|
|
152
|
+
// between acquisitions is just a leftover from a previous call that
|
|
153
|
+
// releaseOwnLock didn't get to delete yet; safe to steal).
|
|
154
|
+
const isOurOwnHolder = holderPid === process.pid;
|
|
155
|
+
return {
|
|
156
|
+
canSteal: isStale || !isAlive || (treatOwnPidAsStealable && isOurOwnHolder),
|
|
157
|
+
};
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
/**
|
|
@@ -221,6 +238,34 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
|
221
238
|
return timingSafeEqual(bufA, bufB);
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Release the lock we (this process) just acquired. Unlike releaseLock, this
|
|
243
|
+
* unconditionally removes the file because we know we created it in the same
|
|
244
|
+
* withRunLock* call — token matching is only needed for multi-process scenarios
|
|
245
|
+
* (where a different holder's token must not be deleted). Within the same process
|
|
246
|
+
* the new acquire uses a fresh randomUUID token, so token matching would falsely
|
|
247
|
+
* fail and leak the file across releases.
|
|
248
|
+
*
|
|
249
|
+
* Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
|
|
250
|
+
* don't rm it (defense against attacker-planted symlinks).
|
|
251
|
+
*/
|
|
252
|
+
export function releaseOwnLock(filePath: string, _token: string): void {
|
|
253
|
+
try {
|
|
254
|
+
const stat = fs.lstatSync(filePath);
|
|
255
|
+
if (stat.isSymbolicLink()) return;
|
|
256
|
+
} catch {
|
|
257
|
+
/* ENOENT is fine */
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
fs.rmSync(filePath, { force: true });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
263
|
+
if (code !== "ENOENT") {
|
|
264
|
+
logInternalError("lock-release-own", error, filePath, "warn");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
224
269
|
function releaseLock(filePath: string, token: string): void {
|
|
225
270
|
// FIX: Do not delete a symlink — it may have been planted by an attacker
|
|
226
271
|
// after our lock was released. A legitimate lock file should never be a
|
|
@@ -265,7 +310,9 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
|
|
|
265
310
|
}
|
|
266
311
|
// Round 26 (BUG 1): single-snapshot read closes the TOCTOU window between
|
|
267
312
|
// separate stale + alive reads (which could race stale→fresh).
|
|
268
|
-
|
|
313
|
+
// treatOwnPidAsStealable=false (this is the sync file-lock path used
|
|
314
|
+
// by withFileLockSync — never steal a fresh lock even if it has our pid).
|
|
315
|
+
const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
|
|
269
316
|
if (!canSteal) {
|
|
270
317
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
271
318
|
}
|
|
@@ -300,7 +347,13 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
300
347
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
301
348
|
}
|
|
302
349
|
// Round 26 (BUG 1): single-snapshot read (see sync variant).
|
|
303
|
-
|
|
350
|
+
// FIX (CI flake): treatOwnPidAsStealable=true for the run-lock async path
|
|
351
|
+
// (withRunLock). Between acquisitions in parallel-research scaffold mode,
|
|
352
|
+
// the previous releaseOwnLock leaves a microsecond window where the
|
|
353
|
+
// file still exists with our own pid. Stealing it avoids spurious 'locked'
|
|
354
|
+
// errors. The sync file-lock path (withFileLockSync) above uses false to
|
|
355
|
+
// preserve the multi-process safety guarantee.
|
|
356
|
+
const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: true });
|
|
304
357
|
if (!canSteal) {
|
|
305
358
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
306
359
|
}
|
|
@@ -379,48 +432,131 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
379
432
|
}
|
|
380
433
|
}
|
|
381
434
|
|
|
435
|
+
// H-1: track re-entrant run-lock acquisitions PER ASYNC CONTEXT (not process-global).
|
|
436
|
+
// Previously a module-global Map caused a bug: when withRunLock (async) yielded at
|
|
437
|
+
// `await fn()`, a concurrent withRunLockSync for the same run — fired from a
|
|
438
|
+
// different async context (e.g. a child stdout event handler) — saw the holder's
|
|
439
|
+
// token and bypassed the file lock entirely, allowing two writers in the critical
|
|
440
|
+
// section simultaneously. AsyncLocalStorage scopes the "held" set to the current
|
|
441
|
+
// async context, so a call from a different async context no longer bypasses and
|
|
442
|
+
// properly serializes against the on-disk lock. True nested calls in the SAME
|
|
443
|
+
// async context (e.g. handleResume -> executeTeamRun -> executeTeamRunCore) still
|
|
444
|
+
// bypass via lockCtx.getStore(), avoiding the deadlock the bypass was designed for.
|
|
445
|
+
const lockCtx = new AsyncLocalStorage<Set<string>>();
|
|
446
|
+
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
447
|
+
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
448
|
+
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
449
|
+
|
|
450
|
+
// --- Async file lock (non-blocking alternative to withFileLockSync) ---
|
|
451
|
+
// Uses a promise-chain pattern to serialize per-path access without blocking
|
|
452
|
+
// the Node.js event loop. Unlike withFileLockSync (which uses O_EXCL +
|
|
453
|
+
// sleepSync for cross-process safety), this is **in-process only** —
|
|
454
|
+
// sufficient for single-process mailbox writes (team-runner is single-process).
|
|
455
|
+
// Mirrors the structure of withEventLogLockAsync in event-log.ts.
|
|
456
|
+
const fileAsyncLocks = new Map<string, Promise<unknown>>();
|
|
457
|
+
|
|
458
|
+
// FIND-02 follow-up (P3): re-entrance guard for the async file lock, mirroring
|
|
459
|
+
// the lockCtx pattern used by withRunLock. A future caller doing same-path
|
|
460
|
+
// nested withFileLockAsync(path, ...) inside another withFileLockAsync(path,
|
|
461
|
+
// ...) in the SAME async context would otherwise deadlock (the nested call
|
|
462
|
+
// chains after the outer's still-pending promise). AsyncLocalStorage scopes
|
|
463
|
+
// the held set to the current async context so cross-context callers still
|
|
464
|
+
// serialize via the promise chain (Phase 1 mailbox path is unaffected).
|
|
465
|
+
const fileAsyncLockCtx = new AsyncLocalStorage<Set<string>>();
|
|
466
|
+
|
|
467
|
+
export async function withFileLockAsync<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
468
|
+
// Re-entrant within the same async context — run fn() directly (no chaining).
|
|
469
|
+
// Same semantics as the sync guard in withFileLockSync (fileLockHeldByUs)
|
|
470
|
+
// and the async guard in withRunLock (lockCtx).
|
|
471
|
+
if (fileAsyncLockCtx.getStore()?.has(filePath)) {
|
|
472
|
+
return await fn();
|
|
473
|
+
}
|
|
474
|
+
// Merge with the parent context's held set so nested DIFFERENT-path locks
|
|
475
|
+
// also bypass correctly (prevents cross-path deadlock, matching withRunLock).
|
|
476
|
+
const prevHeld = fileAsyncLockCtx.getStore() ?? new Set<string>();
|
|
477
|
+
const held = new Set(prevHeld);
|
|
478
|
+
held.add(filePath);
|
|
479
|
+
const prev = fileAsyncLocks.get(filePath) ?? Promise.resolve();
|
|
480
|
+
// Chain fn after the previous holder. `next` may reject (propagating to the
|
|
481
|
+
// caller), but `stored` never rejects so subsequent waiters aren't blocked.
|
|
482
|
+
// fn() is wrapped in fileAsyncLockCtx.run so nested same-context calls see
|
|
483
|
+
// `held` and bypass the promise chain (re-entrant), while cross-context
|
|
484
|
+
// callers chain normally via `prev`.
|
|
485
|
+
const next = prev.then(() => fileAsyncLockCtx.run(held, () => fn()));
|
|
486
|
+
const stored = next.then(
|
|
487
|
+
() => undefined,
|
|
488
|
+
() => undefined,
|
|
489
|
+
);
|
|
490
|
+
fileAsyncLocks.set(filePath, stored);
|
|
491
|
+
try {
|
|
492
|
+
return await next;
|
|
493
|
+
} finally {
|
|
494
|
+
// Compare-and-delete: only remove our entry if it still points at `stored`.
|
|
495
|
+
// With 3+ overlapping callers, an earlier caller's finally would otherwise
|
|
496
|
+
// delete a later caller's promise, breaking mutual exclusion.
|
|
497
|
+
if (fileAsyncLocks.get(filePath) === stored) {
|
|
498
|
+
fileAsyncLocks.delete(filePath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
382
503
|
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
|
|
383
504
|
const filePath = lockPath(manifest);
|
|
384
505
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
506
|
+
// H-1: re-entrance check is scoped to the current async context, not process-global.
|
|
507
|
+
// A call from a DIFFERENT async context (e.g. a child-stdout event handler that
|
|
508
|
+
// runs while an async holder is awaiting) sees no held set and properly serializes.
|
|
509
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
510
|
+
// Re-entrant within the same async context — already hold this lock.
|
|
388
511
|
return fn();
|
|
389
512
|
}
|
|
390
513
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
391
514
|
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
515
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
516
|
+
const newHeld = new Set(prevHeld);
|
|
517
|
+
newHeld.add(filePath);
|
|
518
|
+
// Enter a new async context (or extend the current one) carrying the held set.
|
|
519
|
+
// On exit (sync return OR async resume after an awaited child), the context is
|
|
520
|
+
// restored — so the held set only contains locks acquired within fn()'s subtree.
|
|
521
|
+
return lockCtx.run(newHeld, () => {
|
|
522
|
+
try {
|
|
523
|
+
return fn();
|
|
524
|
+
} finally {
|
|
525
|
+
// FIX (CI flake): releaseLock uses token matching to prevent the
|
|
526
|
+
// "losing contender wipes winner's lock" race in multi-process scenarios.
|
|
527
|
+
// But within withRunLockSync/withRunLock (same process), the new acquire
|
|
528
|
+
// uses a FRESH random token — the previous stored token doesn't match the
|
|
529
|
+
// current token — and the lock file is never removed. Repeat acquisitions
|
|
530
|
+
// then keep failing with EEXIST because the file lingers.
|
|
531
|
+
// Since withRunLock* is always single-process, unconditionally delete
|
|
532
|
+
// (symlink guard is still applied for safety).
|
|
533
|
+
releaseOwnLock(filePath, token);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
399
536
|
}
|
|
400
537
|
|
|
401
|
-
// Track re-entrant lock acquisitions per lock file path. When a lock is
|
|
402
|
-
// already held by this call stack (handleResume -> executeTeamRun ->
|
|
403
|
-
// executeTeamRunCore), we skip re-acquisition to avoid deadlock.
|
|
404
|
-
const runLockHeldByUs = new Map<string, string>(); // filePath -> token
|
|
405
|
-
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
406
|
-
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
407
|
-
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
408
|
-
|
|
409
538
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
410
539
|
const filePath = lockPath(manifest);
|
|
411
540
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// Re-entrant: already hold this lock, just run the callback.
|
|
541
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
542
|
+
// Re-entrant within the same async context.
|
|
415
543
|
return await fn();
|
|
416
544
|
}
|
|
417
545
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
418
546
|
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
547
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
548
|
+
const newHeld = new Set(prevHeld);
|
|
549
|
+
newHeld.add(filePath);
|
|
550
|
+
// AsyncLocalStorage propagates across await boundaries, so any code that
|
|
551
|
+
// resumes after an await inside fn() (including microtasks, timers, and
|
|
552
|
+
// awaited child Pi results) still sees newHeld.
|
|
553
|
+
return await lockCtx.run(newHeld, async () => {
|
|
554
|
+
try {
|
|
555
|
+
return await fn();
|
|
556
|
+
} finally {
|
|
557
|
+
// FIX (CI flake): see withRunLockSync above — use releaseOwnLock for
|
|
558
|
+
// unconditional deletion of the lock file we created (same process).
|
|
559
|
+
releaseOwnLock(filePath, token);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
426
562
|
}
|
package/src/state/mailbox.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { logInternalError } from "../utils/internal-error.ts";
|
|
|
5
5
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
6
6
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
7
7
|
import { withEventLogLockSync } from "./event-log.ts";
|
|
8
|
-
import { withFileLockSync } from "./locks.ts";
|
|
8
|
+
import { withFileLockAsync, withFileLockSync } from "./locks.ts";
|
|
9
9
|
import type { TeamRunManifest } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
export type MailboxDirection = "inbox" | "outbox";
|
|
@@ -377,19 +377,61 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
|
|
|
377
377
|
return readAllMessages(manifest, "inbox");
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
|
|
381
|
+
// Keyed by delivery file path; invalidated by mtime check on read + updated on
|
|
382
|
+
// write. Team-runner is single-process so in-process caching is sufficient.
|
|
383
|
+
const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
|
|
384
|
+
const MAX_DELIVERY_CACHE_ENTRIES = 256;
|
|
385
|
+
// R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
|
|
386
|
+
// copy of `messages`) so callers mutating the returned state cannot corrupt
|
|
387
|
+
// the cache (TOCTOU race), and bounds the map size with FIFO eviction to
|
|
388
|
+
// prevent unbounded growth across runs.
|
|
389
|
+
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
|
|
390
|
+
if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
|
|
391
|
+
const oldest = deliveryCache.keys().next().value;
|
|
392
|
+
if (oldest !== undefined) deliveryCache.delete(oldest);
|
|
393
|
+
}
|
|
394
|
+
deliveryCache.set(filePath, {
|
|
395
|
+
mtimeMs: entry.mtimeMs,
|
|
396
|
+
state: { ...entry.state, messages: { ...entry.state.messages } },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
380
400
|
export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliveryState {
|
|
401
|
+
const filePath = deliveryFile(manifest);
|
|
402
|
+
let stat: fs.Stats;
|
|
381
403
|
try {
|
|
382
|
-
|
|
404
|
+
stat = fs.statSync(filePath);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// R1 review fix: narrow to ENOENT so permission errors aren't silently
|
|
407
|
+
// treated as "missing file" (would wipe a valid cache entry).
|
|
408
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
409
|
+
deliveryCache.delete(filePath);
|
|
410
|
+
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
411
|
+
}
|
|
412
|
+
const cached = deliveryCache.get(filePath);
|
|
413
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
414
|
+
// R2 review fix: return a copy so callers mutating the result cannot
|
|
415
|
+
// leak into the cached snapshot (residual TOCTOU: the cache holds the
|
|
416
|
+
// snapshot until the next write replaces it; without this copy, a
|
|
417
|
+
// pre-write mutation by one caller would be persisted into the
|
|
418
|
+
// post-write snapshot by the next writer's setDeliveryCacheEntry).
|
|
419
|
+
return { ...cached.state, messages: { ...cached.state.messages } };
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
|
|
383
423
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid delivery state.");
|
|
384
424
|
const obj = raw as Record<string, unknown>;
|
|
385
425
|
const messages: Record<string, MailboxMessageStatus> = {};
|
|
386
426
|
if (obj.messages && typeof obj.messages === "object" && !Array.isArray(obj.messages)) {
|
|
387
427
|
for (const [id, status] of Object.entries(obj.messages)) if (isStatus(status)) messages[id] = status;
|
|
388
428
|
}
|
|
389
|
-
|
|
429
|
+
const state: MailboxDeliveryState = {
|
|
390
430
|
messages,
|
|
391
431
|
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
|
|
392
432
|
};
|
|
433
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
|
|
434
|
+
return state;
|
|
393
435
|
} catch {
|
|
394
436
|
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
395
437
|
}
|
|
@@ -414,9 +456,20 @@ function writeDeliveryState(
|
|
|
414
456
|
// F4: mailbox delivery is informational — accept losing the very last write on
|
|
415
457
|
// a hard crash (the next message will overwrite it on disk). Cheaper fsync on
|
|
416
458
|
// the hot path; terminal/reply paths still pass full durability below.
|
|
417
|
-
|
|
459
|
+
const filePath = deliveryFile(manifest, true);
|
|
460
|
+
atomicWriteFile(filePath, `${JSON.stringify(redactSecrets(state), null, 2)}\n`, {
|
|
418
461
|
durability: options?.durability ?? "best-effort",
|
|
419
462
|
});
|
|
463
|
+
// FIND-01: update cache with post-write mtime so subsequent reads get a hit.
|
|
464
|
+
try {
|
|
465
|
+
const postStat = fs.statSync(filePath);
|
|
466
|
+
// setDeliveryCacheEntry stores an immutable snapshot (deep copy of
|
|
467
|
+
// messages) so subsequent read-modify-write callers mutating the
|
|
468
|
+
// returned state cannot corrupt the cache.
|
|
469
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
|
|
470
|
+
} catch {
|
|
471
|
+
deliveryCache.delete(filePath);
|
|
472
|
+
}
|
|
420
473
|
}
|
|
421
474
|
|
|
422
475
|
/**
|
|
@@ -541,6 +594,114 @@ export function appendFollowUpMessage(
|
|
|
541
594
|
});
|
|
542
595
|
}
|
|
543
596
|
|
|
597
|
+
/**
|
|
598
|
+
* FIND-02: Async variant of appendMailboxMessage for the live-session path.
|
|
599
|
+
* Uses withFileLockAsync (promise-chain, no sleepSync) instead of
|
|
600
|
+
* withEventLogLockSync/withFileLockSync, preventing event-loop stalls during
|
|
601
|
+
* steering/follow-up delivery. readDeliveryState/writeDeliveryState remain
|
|
602
|
+
* sync but are cheap thanks to the FIND-01 delivery cache.
|
|
603
|
+
*/
|
|
604
|
+
export async function appendMailboxMessageAsync(
|
|
605
|
+
manifest: TeamRunManifest,
|
|
606
|
+
message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
|
|
607
|
+
id?: string;
|
|
608
|
+
status?: MailboxMessageStatus;
|
|
609
|
+
},
|
|
610
|
+
): Promise<MailboxMessage> {
|
|
611
|
+
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
612
|
+
else ensureRunMailbox(manifest);
|
|
613
|
+
const createdAt = new Date().toISOString();
|
|
614
|
+
const complete: MailboxMessage = {
|
|
615
|
+
id: message.id ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
616
|
+
runId: manifest.runId,
|
|
617
|
+
direction: message.direction,
|
|
618
|
+
from: message.from,
|
|
619
|
+
to: message.to,
|
|
620
|
+
body: message.body,
|
|
621
|
+
createdAt,
|
|
622
|
+
status: message.status ?? "queued",
|
|
623
|
+
kind: message.kind,
|
|
624
|
+
priority: message.priority,
|
|
625
|
+
deliveryMode: message.deliveryMode,
|
|
626
|
+
taskId: message.taskId,
|
|
627
|
+
data: message.data,
|
|
628
|
+
replyTo: message.replyTo,
|
|
629
|
+
replyFrom: message.replyFrom,
|
|
630
|
+
replyDeadline: message.replyDeadline,
|
|
631
|
+
repliedAt: message.repliedAt,
|
|
632
|
+
replyContent: message.replyContent,
|
|
633
|
+
};
|
|
634
|
+
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
635
|
+
await withFileLockAsync(mbFile, async () => {
|
|
636
|
+
await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
637
|
+
rotateMailboxFileIfNeeded(mbFile);
|
|
638
|
+
});
|
|
639
|
+
// R1 review fix / plan §5 #6: delivery RMW uses the cross-process sync
|
|
640
|
+
// lock (withFileLockSync) so sync callers (acknowledgeMailboxMessage,
|
|
641
|
+
// replayPendingMailboxMessages) serialize against this async path. The
|
|
642
|
+
// body has no await, so sync locking is safe and restores the
|
|
643
|
+
// cross-process safety net that the async lock cannot provide.
|
|
644
|
+
withFileLockSync(deliveryFile(manifest, true), () => {
|
|
645
|
+
const delivery = readDeliveryState(manifest);
|
|
646
|
+
delivery.messages[complete.id] = complete.status;
|
|
647
|
+
delivery.updatedAt = createdAt;
|
|
648
|
+
writeDeliveryState(manifest, delivery, { durability: "full" });
|
|
649
|
+
});
|
|
650
|
+
return complete;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export async function appendSteeringMessageAsync(
|
|
654
|
+
manifest: TeamRunManifest,
|
|
655
|
+
input: {
|
|
656
|
+
taskId: string;
|
|
657
|
+
body: string;
|
|
658
|
+
from?: string;
|
|
659
|
+
to?: string;
|
|
660
|
+
priority?: MailboxMessagePriority;
|
|
661
|
+
status?: MailboxMessageStatus;
|
|
662
|
+
data?: Record<string, unknown>;
|
|
663
|
+
},
|
|
664
|
+
): Promise<MailboxMessage> {
|
|
665
|
+
return appendMailboxMessageAsync(manifest, {
|
|
666
|
+
direction: "inbox",
|
|
667
|
+
from: input.from ?? "leader",
|
|
668
|
+
to: input.to ?? input.taskId,
|
|
669
|
+
taskId: input.taskId,
|
|
670
|
+
body: input.body,
|
|
671
|
+
kind: "steer",
|
|
672
|
+
priority: input.priority ?? "urgent",
|
|
673
|
+
deliveryMode: "interrupt",
|
|
674
|
+
status: input.status,
|
|
675
|
+
data: { ...(input.data ?? {}), kind: "steer" },
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function appendFollowUpMessageAsync(
|
|
680
|
+
manifest: TeamRunManifest,
|
|
681
|
+
input: {
|
|
682
|
+
taskId: string;
|
|
683
|
+
body: string;
|
|
684
|
+
from?: string;
|
|
685
|
+
to?: string;
|
|
686
|
+
priority?: MailboxMessagePriority;
|
|
687
|
+
status?: MailboxMessageStatus;
|
|
688
|
+
data?: Record<string, unknown>;
|
|
689
|
+
},
|
|
690
|
+
): Promise<MailboxMessage> {
|
|
691
|
+
return appendMailboxMessageAsync(manifest, {
|
|
692
|
+
direction: "inbox",
|
|
693
|
+
from: input.from ?? "leader",
|
|
694
|
+
to: input.to ?? input.taskId,
|
|
695
|
+
taskId: input.taskId,
|
|
696
|
+
body: input.body,
|
|
697
|
+
kind: "follow-up",
|
|
698
|
+
priority: input.priority ?? "normal",
|
|
699
|
+
deliveryMode: "next_turn",
|
|
700
|
+
status: input.status,
|
|
701
|
+
data: { ...(input.data ?? {}), kind: "follow-up" },
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
544
705
|
export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessageKind, direction?: MailboxDirection): MailboxMessage[] {
|
|
545
706
|
const messages = direction
|
|
546
707
|
? readAllMessages(manifest, direction)
|
package/src/state/run-metrics.ts
CHANGED
|
@@ -107,6 +107,16 @@ export function loadRunMetrics(cwd: string, runId: string): RunMetrics | undefin
|
|
|
107
107
|
* List recent metrics files up to `limit` entries (newest first).
|
|
108
108
|
* Returns an array of { runId, timestamp, taskCount, completedCount, failedCount, totalTokens, totalCost, durationMs, consistencyScore }.
|
|
109
109
|
* Gracefully skips files that cannot be read or parsed.
|
|
110
|
+
*
|
|
111
|
+
* FIND-04 perf: sort dirents by mtime descending BEFORE reading any of
|
|
112
|
+
* them, then read at most `limit` files. Malformed files within that
|
|
113
|
+
* selected window reduce the returned count; the scan does not continue
|
|
114
|
+
* past the window to backfill them. Previously the function read up
|
|
115
|
+
* to MAX_METRIC_FILES_TO_SCAN (500) files via loadRunMetrics() and only
|
|
116
|
+
* THEN sorted+sliced to `limit` (default 25) — wasting 475 readFileSync +
|
|
117
|
+
* JSON.parse calls on the hot dashboard path. The sort is O(N log N) and
|
|
118
|
+
* uses the run-id timestamp prefix as a cheap secondary signal when
|
|
119
|
+
* mtimes tie (e.g. multiple files saved in the same millisecond).
|
|
110
120
|
*/
|
|
111
121
|
export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
112
122
|
const dir = metricsDir(cwd);
|
|
@@ -117,21 +127,39 @@ export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
|
117
127
|
return [];
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
// Cap the directory-order candidate set before collecting mtimes. This
|
|
131
|
+
// is a safety valve against unbounded scan cost; the mtime sort below
|
|
132
|
+
// only reorders entries within this bounded candidate window.
|
|
133
|
+
const cap = Math.max(0, limit);
|
|
134
|
+
const sorted = entries
|
|
135
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
136
|
+
.slice(0, MAX_METRIC_FILES_TO_SCAN)
|
|
137
|
+
.map((entry) => {
|
|
138
|
+
const fullPath = path.join(dir, entry.name);
|
|
139
|
+
let mtimeMs = 0;
|
|
140
|
+
try {
|
|
141
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
142
|
+
} catch {
|
|
143
|
+
// stat failure: fall through with mtimeMs=0; the sort still
|
|
144
|
+
// produces a deterministic order (filenames tiebreak).
|
|
145
|
+
}
|
|
146
|
+
return { name: entry.name, mtimeMs };
|
|
147
|
+
})
|
|
148
|
+
.sort((a, b) => {
|
|
149
|
+
const diff = b.mtimeMs - a.mtimeMs;
|
|
150
|
+
if (diff !== 0) return diff;
|
|
151
|
+
// Filename tiebreaker: the run-id prefix is a YYYYMMDDhhmmss
|
|
152
|
+
// timestamp (e.g. team_20260720050617_abc123) so a descending
|
|
153
|
+
// string compare yields newest-first within a tie group.
|
|
154
|
+
return b.name.localeCompare(a.name);
|
|
155
|
+
});
|
|
156
|
+
|
|
120
157
|
const metrics: RunMetrics[] = [];
|
|
121
|
-
for (const
|
|
122
|
-
|
|
123
|
-
const runId = entry.name.replace(/\.json$/, "");
|
|
158
|
+
for (const { name } of sorted.slice(0, cap)) {
|
|
159
|
+
const runId = name.replace(/\.json$/, "");
|
|
124
160
|
const m = loadRunMetrics(cwd, runId);
|
|
125
161
|
if (m) metrics.push(m);
|
|
126
|
-
if (metrics.length >= MAX_METRIC_FILES_TO_SCAN) break;
|
|
127
162
|
}
|
|
128
163
|
|
|
129
|
-
|
|
130
|
-
metrics.sort((a, b) => {
|
|
131
|
-
const diff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
132
|
-
if (diff !== 0) return diff;
|
|
133
|
-
return b.runId.localeCompare(a.runId);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return metrics.slice(0, limit);
|
|
164
|
+
return metrics;
|
|
137
165
|
}
|
|
@@ -137,6 +137,7 @@ function getWorker(): Worker {
|
|
|
137
137
|
// Reject ALL pending requests — worker died.
|
|
138
138
|
for (const [, entry] of pending) entry.reject(error);
|
|
139
139
|
pending.clear();
|
|
140
|
+
worker = undefined;
|
|
140
141
|
});
|
|
141
142
|
worker.unref(); // don't keep event loop alive (unless tests request it)
|
|
142
143
|
if (keepRefForTests) worker.ref();
|
|
@@ -8,7 +8,6 @@ import type { TeamTaskState } from "../state/types.ts";
|
|
|
8
8
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
9
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
10
10
|
import { pad, truncate } from "../utils/visual.ts";
|
|
11
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
12
11
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
13
12
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
14
13
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
@@ -16,17 +15,10 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
16
15
|
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
17
16
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
18
17
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
18
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
19
19
|
|
|
20
20
|
const TASK_READ_TTL_MS = 200;
|
|
21
21
|
|
|
22
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
23
|
-
const box = new Box(0, 0);
|
|
24
|
-
for (const line of lines) {
|
|
25
|
-
box.addChild(new Text(line));
|
|
26
|
-
}
|
|
27
|
-
return box.render(width);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
22
|
type Done = (value: undefined) => void;
|
|
31
23
|
|
|
32
24
|
function line(text: string, width: number): string {
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
|
|
19
19
|
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
20
20
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
21
21
|
import { dashboardActionForKey } from "./keybinding-map.ts";
|
|
22
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
23
22
|
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
24
23
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
25
24
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
@@ -28,6 +27,7 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
28
27
|
import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
29
28
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
30
29
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
30
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
31
31
|
|
|
32
32
|
/** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */
|
|
33
33
|
function safeRenderPane(name: string, fn: () => string[]): string[] {
|
|
@@ -159,14 +159,6 @@ function formatAge(iso: string | undefined): string | undefined {
|
|
|
159
159
|
return `${Math.floor(ms / 3_600_000)}h`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
163
|
-
const box = new Box(0, 0);
|
|
164
|
-
for (const line of lines) {
|
|
165
|
-
box.addChild(new Text(line));
|
|
166
|
-
}
|
|
167
|
-
return box.render(width);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
162
|
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
|
|
171
163
|
// P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
|
|
172
164
|
// progress artifact on every render. The progress artifact content is captured
|