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
|
@@ -7,7 +7,7 @@ import { emitFromTeamEvent } from "../../ui/run-event-bus.ts";
|
|
|
7
7
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
8
8
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
9
9
|
import { sleep, sleepSync } from "../../utils/sleep.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { applyCompactionAppendGuarded, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
|
|
11
11
|
import {
|
|
12
12
|
advanceSequenceCounter,
|
|
13
13
|
persistSequenceMonotonic,
|
|
@@ -219,6 +219,125 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* R4-2 (DR-02 Wave 2, 2026-10-15): SYNC-mode acquisition of the ASYNC family's
|
|
224
|
+
* `.alock` lock dir (`${eventsPath}.alock`). Mirrors the `withEventLogLockSync`
|
|
225
|
+
* protocol exactly — `fs.mkdirSync` (O_EXCL semantics on POSIX), pid file via
|
|
226
|
+
* `openSync(pidFile, "wx")` + plain write, mtime-stale reclaim checked FIRST for
|
|
227
|
+
* every holder (Round 26 BUG 4 pattern), dead-pid secondary fast-path that only
|
|
228
|
+
* WAITS (never steals a fresh lock), `sleepSync(50)` bounded retry loop, and a
|
|
229
|
+
* PID-guarded release that never deletes a stealer's dir (Round 26 BUG 5).
|
|
230
|
+
*
|
|
231
|
+
* WHY this exists: the async append family (`withEventLogLockAsync` →
|
|
232
|
+
* `appendEventAsync`) holds `.alock` ACROSS await gaps — notably between
|
|
233
|
+
* `fs.promises.open(eventsPath, "a")` and `fd.appendFile(...)`. Compaction's
|
|
234
|
+
* `applyCompactionUnlocked` replaces the events file via `atomicWriteFile`
|
|
235
|
+
* (temp+rename → new inode). When compaction ran under `.mkdirlock` only, an
|
|
236
|
+
* fd opened by a mid-gap async append kept pointing at the OLD inode, which the
|
|
237
|
+
* rename orphaned → the append was silently lost (C2 recovery only re-splices
|
|
238
|
+
* events in the `kept` window). Acquiring `.alock` around the compaction write
|
|
239
|
+
* phase closes that window: the rename can no longer interleave with an
|
|
240
|
+
* in-flight async-family append, cross-process included (same on-disk dir +
|
|
241
|
+
* pid-file protocol as the async acquirer).
|
|
242
|
+
*
|
|
243
|
+
* LOCK ORDER: strictly `.mkdirlock` → `.alock`. The async family NEVER takes
|
|
244
|
+
* `.mkdirlock` (its bodies call the unlocked compaction/rotation variants and
|
|
245
|
+
* the short `.seqlock` only), so a sync caller holding `.mkdirlock` may safely
|
|
246
|
+
* acquire this — but NEVER the reverse, and never call this from INSIDE
|
|
247
|
+
* `withEventLogLockAsync` (the dir would be our own — the retry loop would
|
|
248
|
+
* sleepSync-starve the very holder that must release it → guaranteed timeout).
|
|
249
|
+
*
|
|
250
|
+
* IN-PROCESS HAZARD (same rationale as the two-family split): if a SAME-PROCESS
|
|
251
|
+
* async-family holder is mid-await-gap on this `.alock`, this sync acquirer's
|
|
252
|
+
* `sleepSync` retry blocks the event loop the async holder needs to finish —
|
|
253
|
+
* the acquire can only end in the bounded timeout (throw), never in a steal.
|
|
254
|
+
* That is why the only in-tree caller is `compactPreparedEventLog`
|
|
255
|
+
* (event-log-rotation.ts), whose callers are cross-process / maintenance / test
|
|
256
|
+
* entry points, NOT the in-process append paths (those already hold `.alock`
|
|
257
|
+
* or call the unlocked variants directly).
|
|
258
|
+
*/
|
|
259
|
+
export function withEventLogAppendLockSync<T>(eventsPath: string, fn: () => T, options?: { timeoutMs?: number; staleMs?: number }): T {
|
|
260
|
+
// Ensure parent directory exists before attempting lock (mirrors the sync family).
|
|
261
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
262
|
+
const lockDir = `${eventsPath}.alock`;
|
|
263
|
+
const pidFile = path.join(lockDir, "pid");
|
|
264
|
+
const start = Date.now();
|
|
265
|
+
const timeout = options?.timeoutMs ?? 5000;
|
|
266
|
+
const staleMs = options?.staleMs ?? 10000;
|
|
267
|
+
let acquired = false;
|
|
268
|
+
while (true) {
|
|
269
|
+
try {
|
|
270
|
+
fs.mkdirSync(lockDir);
|
|
271
|
+
try {
|
|
272
|
+
// Same disposable pid file as both existing families: "wx" open +
|
|
273
|
+
// plain write — the lock dir itself is the mutex; the pid file only
|
|
274
|
+
// feeds the stale/dead detection and the guarded release.
|
|
275
|
+
const pidFd = fs.openSync(pidFile, "wx");
|
|
276
|
+
try {
|
|
277
|
+
fs.writeSync(pidFd, String(process.pid));
|
|
278
|
+
} finally {
|
|
279
|
+
fs.closeSync(pidFd);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
/* best-effort — e.g. EEXIST under a re-taken dir; stale pid is mtime-detected */
|
|
283
|
+
}
|
|
284
|
+
acquired = true;
|
|
285
|
+
break;
|
|
286
|
+
} catch {
|
|
287
|
+
if (Date.now() - start > timeout) {
|
|
288
|
+
throw errors.eventLogLockTimeout(eventsPath, timeout);
|
|
289
|
+
}
|
|
290
|
+
// Mtime-stale reclaim FIRST, independent of the pid file (Round 26 BUG 3
|
|
291
|
+
// pattern): a holder that crashed between mkdir and the pid write has no
|
|
292
|
+
// pid file — only an aged mtime can reclaim its dir.
|
|
293
|
+
try {
|
|
294
|
+
const dirStat = fs.statSync(lockDir);
|
|
295
|
+
if (Date.now() - dirStat.mtimeMs > staleMs) {
|
|
296
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
/* dir vanished — let loop retry */
|
|
301
|
+
}
|
|
302
|
+
// Dead-pid secondary fast-path (Round 26 BUG 4 pattern): mtime already
|
|
303
|
+
// handled above for ALL holders; a provably-dead pid that is not stale
|
|
304
|
+
// yet only makes us keep waiting — never steal a fresh lock on a pid
|
|
305
|
+
// lookup race.
|
|
306
|
+
try {
|
|
307
|
+
const raw = fs.readFileSync(pidFile, "utf-8").trim();
|
|
308
|
+
const ownerPid = Number.parseInt(raw, 10);
|
|
309
|
+
if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
|
|
310
|
+
try {
|
|
311
|
+
process.kill(ownerPid, 0);
|
|
312
|
+
} catch {
|
|
313
|
+
/* dead — but mtime not stale yet, keep waiting */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
/* no pid file — mtime check above already handles it */
|
|
318
|
+
}
|
|
319
|
+
sleepSync(50);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
return fn();
|
|
324
|
+
} finally {
|
|
325
|
+
if (acquired) {
|
|
326
|
+
// PID-guarded release (Round 26 BUG 5 pattern): only remove the dir if
|
|
327
|
+
// the pid file still records OUR pid — if our fn outlived staleMs and a
|
|
328
|
+
// stealer re-took the dir, the stealer owns it now.
|
|
329
|
+
try {
|
|
330
|
+
const currentPid = fs.readFileSync(pidFile, "utf-8").trim();
|
|
331
|
+
if (currentPid === String(process.pid)) {
|
|
332
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
/* lock stolen or already gone — do not touch */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
222
341
|
export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
|
|
223
342
|
return createHash("sha256")
|
|
224
343
|
.update(
|
|
@@ -306,8 +425,12 @@ async function drainAsyncQueues(): Promise<void> {
|
|
|
306
425
|
*
|
|
307
426
|
* NOT re-entrant: callers inside this lock must use unlocked compaction
|
|
308
427
|
* variants (prepareCompaction + applyCompactionUnlocked, rotateEventLogUnlocked)
|
|
309
|
-
* to avoid self-deadlock.
|
|
310
|
-
|
|
428
|
+
* to avoid self-deadlock.
|
|
429
|
+
*
|
|
430
|
+
* R4-2 note (2026-10-15): exported so tests (and future async callers) can pin
|
|
431
|
+
* the `.alock` family's mutual exclusion against the sync-mode acquirer
|
|
432
|
+
* `withEventLogAppendLockSync` used by the compaction path. */
|
|
433
|
+
export async function withEventLogLockAsync<T>(
|
|
311
434
|
eventsPath: string,
|
|
312
435
|
fn: () => Promise<T>,
|
|
313
436
|
options?: { timeoutMs?: number; staleMs?: number },
|
|
@@ -520,7 +643,11 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
520
643
|
overflowHandled = true;
|
|
521
644
|
try {
|
|
522
645
|
const prepared = prepareCompaction(eventsPath);
|
|
523
|
-
|
|
646
|
+
// R7-2 (DR-04): LOCK STATE — `.alock` HELD. We are inside
|
|
647
|
+
// doAppendUnderLock, which runs under withEventLogLockAsync
|
|
648
|
+
// (appendEventAsync). The lock is NOT re-entrant, so the guarded
|
|
649
|
+
// apply must NOT re-acquire it — tail-merge only.
|
|
650
|
+
if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, { holdsAppendLock: true });
|
|
524
651
|
} catch (error) {
|
|
525
652
|
logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
526
653
|
}
|
|
@@ -624,7 +751,11 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
624
751
|
if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
|
|
625
752
|
try {
|
|
626
753
|
const prepared = prepareCompaction(eventsPath);
|
|
627
|
-
|
|
754
|
+
// R7-2 (DR-04): LOCK STATE — `.alock` HELD. Same scope as the
|
|
755
|
+
// immediate-compact site above: doAppendUnderLock runs under
|
|
756
|
+
// withEventLogLockAsync (appendEventAsync). Tail-merge only; the
|
|
757
|
+
// sync-family bytes appended since the prepare-read are spliced.
|
|
758
|
+
if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, { holdsAppendLock: true });
|
|
628
759
|
} catch (error) {
|
|
629
760
|
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
630
761
|
}
|
|
@@ -706,8 +837,17 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
706
837
|
* buffered write path, which is the entire point of buffering — the previous
|
|
707
838
|
* per-event fsync made buffer coalescing useless and added ~30ms/event on
|
|
708
839
|
* tmpfs.
|
|
840
|
+
*
|
|
841
|
+
* R7-2 (DR-04, 2026-10-16): `locks.holdsAppendLock` declares the CALLING
|
|
842
|
+
* family's lock state so the inline overflow compaction below can pick the
|
|
843
|
+
* right guarded-apply mode — see the site comment and
|
|
844
|
+
* `applyCompactionAppendGuarded` (event-log-rotation.ts).
|
|
709
845
|
*/
|
|
710
|
-
async function appendEventBatchInsideLock(
|
|
846
|
+
async function appendEventBatchInsideLock(
|
|
847
|
+
eventsPath: string,
|
|
848
|
+
queue: BufferedAppend[],
|
|
849
|
+
locks?: { holdsAppendLock?: boolean },
|
|
850
|
+
): Promise<void> {
|
|
711
851
|
if (queue.length === 0) return;
|
|
712
852
|
// P0-2: keep the per-path counter honest for cross-path rotation sampling.
|
|
713
853
|
tickAppendCounter(eventsPath, queue.length);
|
|
@@ -732,7 +872,14 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
732
872
|
if (preStat.size > MAX_EVENTS_BYTES) {
|
|
733
873
|
try {
|
|
734
874
|
const prepared = prepareCompaction(eventsPath);
|
|
735
|
-
|
|
875
|
+
// R7-2 (DR-04): LOCK STATE — MIXED, declared by the CALLER. This
|
|
876
|
+
// function is entered from BOTH families: the async flush
|
|
877
|
+
// (flushOneEventLogBuffer, inside withEventLogLockAsync → `.alock`
|
|
878
|
+
// HELD → holdsAppendLock: true, re-acquiring would self-timeout)
|
|
879
|
+
// and the EL-2 sync flush (flushBufferedQueuesSync, inside
|
|
880
|
+
// withEventLogLockSync → only `.mkdirlock` held → the helper
|
|
881
|
+
// must acquire `.alock` itself, nested in the R4-2 order).
|
|
882
|
+
if (prepared) applyCompactionAppendGuarded(eventsPath, prepared, locks);
|
|
736
883
|
} catch (error) {
|
|
737
884
|
logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
738
885
|
}
|
|
@@ -926,7 +1073,14 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
926
1073
|
// unbounded log growth → events silently dropped past 50MB).
|
|
927
1074
|
try {
|
|
928
1075
|
const prepared = prepareCompaction(eventsPath);
|
|
929
|
-
|
|
1076
|
+
// R7-2 (DR-04): LOCK STATE — `.mkdirlock` HELD, `.alock` NOT held.
|
|
1077
|
+
// appendEventInsideLock runs under withEventLogLockSync (appendEvent,
|
|
1078
|
+
// :366). The guarded apply acquires `.alock` nested under our
|
|
1079
|
+
// `.mkdirlock` (R4-2 lock order) so the inode-replacing write cannot
|
|
1080
|
+
// interleave with an in-flight async appender parked between open()
|
|
1081
|
+
// and appendFile(); the tail-merge splices sync-family bytes that
|
|
1082
|
+
// landed after the prepare-read.
|
|
1083
|
+
if (prepared) applyCompactionAppendGuarded(eventsPath, prepared);
|
|
930
1084
|
} catch (error) {
|
|
931
1085
|
logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
932
1086
|
}
|
|
@@ -1017,9 +1171,12 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
1017
1171
|
// locked compactEventLog would deadlock → 5s timeout → compaction never
|
|
1018
1172
|
// ran → unbounded log growth → events silently dropped past 50MB. Use the
|
|
1019
1173
|
// unlocked apply path instead (lock already held).
|
|
1174
|
+
// R7-2 (DR-04): LOCK STATE — `.mkdirlock` HELD, `.alock` NOT held (same
|
|
1175
|
+
// scope as the immediate-compact site above). applyCompactionAppendGuarded
|
|
1176
|
+
// acquires `.alock` itself (nested R4-2 order) + tail-merges.
|
|
1020
1177
|
try {
|
|
1021
1178
|
const prepared = prepareCompaction(eventsPath);
|
|
1022
|
-
if (prepared)
|
|
1179
|
+
if (prepared) applyCompactionAppendGuarded(eventsPath, prepared);
|
|
1023
1180
|
} catch (error) {
|
|
1024
1181
|
logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
|
|
1025
1182
|
}
|
|
@@ -1136,7 +1293,9 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
|
|
|
1136
1293
|
// turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
|
|
1137
1294
|
// 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
|
|
1138
1295
|
await withEventLogLockAsync(eventsPath, async () => {
|
|
1139
|
-
|
|
1296
|
+
// R7-2 (DR-04): `.alock` HELD here (async family) — the batch's inline
|
|
1297
|
+
// compaction must not re-acquire it.
|
|
1298
|
+
await appendEventBatchInsideLock(eventsPath, queue, { holdsAppendLock: true });
|
|
1140
1299
|
});
|
|
1141
1300
|
} catch (error) {
|
|
1142
1301
|
// Lock acquire failed — fail every queued item so callers can fall back.
|
|
@@ -1176,6 +1335,9 @@ export function flushBufferedQueuesSync(): void {
|
|
|
1176
1335
|
// synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
|
|
1177
1336
|
// no awaits). Invoking without await runs the body synchronously and
|
|
1178
1337
|
// returns a resolved Promise that we discard.
|
|
1338
|
+
// R7-2 (DR-04): only `.mkdirlock` is held here (sync family) — the
|
|
1339
|
+
// batch's inline compaction acquires `.alock` itself via the helper
|
|
1340
|
+
// (nested in the R4-2 lock order).
|
|
1179
1341
|
void appendEventBatchInsideLock(eventsPath, queue);
|
|
1180
1342
|
});
|
|
1181
1343
|
} catch (error) {
|
|
@@ -1193,8 +1355,15 @@ export function flushBufferedQueuesSync(): void {
|
|
|
1193
1355
|
// this hook lets T2 assert the fsync gate directly. Both production call paths
|
|
1194
1356
|
// (async flush via withEventLogLockAsync and the EL-2 sync flush via
|
|
1195
1357
|
// withEventLogLockSync) route through appendEventBatchInsideLock.
|
|
1196
|
-
export function __test__appendBatchForUnitTest(
|
|
1197
|
-
|
|
1358
|
+
export function __test__appendBatchForUnitTest(
|
|
1359
|
+
eventsPath: string,
|
|
1360
|
+
queue: BufferedAppend[],
|
|
1361
|
+
locks?: { holdsAppendLock?: boolean },
|
|
1362
|
+
): Promise<void> {
|
|
1363
|
+
// R7-2 (DR-04): `locks` mirrors the production flush paths' declarations for
|
|
1364
|
+
// tests that reproduce a specific family's lock context (default = no lock
|
|
1365
|
+
// held, matching a bare direct call — the helper then acquires `.alock`).
|
|
1366
|
+
return appendEventBatchInsideLock(eventsPath, queue, locks);
|
|
1198
1367
|
}
|
|
1199
1368
|
|
|
1200
1369
|
/**
|
|
@@ -336,8 +336,11 @@ function filterAliveEntries(entries: ActiveRunRegistryEntry[]): {
|
|
|
336
336
|
} catch (err) {
|
|
337
337
|
// Only treat "process does not exist" as dead (ESRCH/ENOENT).
|
|
338
338
|
// EPERM means process is alive but protected; treat as alive.
|
|
339
|
+
// R7-15 (2026-10-16): both branches previously `return false`d,
|
|
340
|
+
// contradicting the comment and silently dropping live EPERM
|
|
341
|
+
// entries from the registry.
|
|
339
342
|
const code = (err as NodeJS.ErrnoException).code;
|
|
340
|
-
if (code !== "ESRCH" && code !== "ENOENT") return
|
|
343
|
+
if (code !== "ESRCH" && code !== "ENOENT") return true;
|
|
341
344
|
return false;
|
|
342
345
|
}
|
|
343
346
|
// FIX Issue 2: Async runs older than the staleness horizon are stale even if
|
|
@@ -437,8 +440,12 @@ export function activeRunEntries(): ActiveRunRegistryEntry[] {
|
|
|
437
440
|
if (manifest.async?.pid) {
|
|
438
441
|
try {
|
|
439
442
|
process.kill(manifest.async.pid, 0);
|
|
440
|
-
} catch {
|
|
441
|
-
|
|
443
|
+
} catch (err) {
|
|
444
|
+
// R7-15 (2026-10-16): only ESRCH/ENOENT mean the process is gone.
|
|
445
|
+
// EPERM (or any other code) means it exists but we cannot signal
|
|
446
|
+
// it — keep the entry, mirroring filterAliveEntries.
|
|
447
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
448
|
+
if (code === "ESRCH" || code === "ENOENT") continue;
|
|
442
449
|
}
|
|
443
450
|
}
|
|
444
451
|
// Stale non-async run: live-session/scaffold runs older than 30 min
|
package/src/state/types.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface VerificationEvidence {
|
|
|
43
43
|
satisfied: boolean;
|
|
44
44
|
commands: VerificationCommandResult[];
|
|
45
45
|
notes?: string;
|
|
46
|
+
/** R4-5 (DR-02 wave 2): true when the contract REQUIRES a green level but
|
|
47
|
+
* configures zero verification commands — satisfaction can then only come
|
|
48
|
+
* from self-attestation (allowManualEvidence). Surfacing flag only: the
|
|
49
|
+
* satisfied logic and defaultVerificationContract are unchanged. */
|
|
50
|
+
decorative?: boolean;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export interface TaskOutputSchema {
|
|
@@ -162,7 +167,10 @@ export type PolicyDecisionReason =
|
|
|
162
167
|
| "review_rejected"
|
|
163
168
|
| "branch_stale"
|
|
164
169
|
| "scope_mismatch"
|
|
165
|
-
| "ineffective_worker"
|
|
170
|
+
| "ineffective_worker"
|
|
171
|
+
/** R4-4 (DR-02 wave 2): task terminalised needs_attention without ever
|
|
172
|
+
* submitting a result (surface-lost / no-yield paths). Notify-level only. */
|
|
173
|
+
| "task_needs_attention";
|
|
166
174
|
|
|
167
175
|
export interface PolicyDecision {
|
|
168
176
|
action: PolicyDecisionAction;
|
|
@@ -266,7 +266,9 @@ export class AgentsJobsBrowser {
|
|
|
266
266
|
/** Transient header notice (e.g. why `p` did nothing) — auto-expires. */
|
|
267
267
|
private notice: { text: string; until: number } | undefined;
|
|
268
268
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
269
|
-
|
|
269
|
+
/** Manifest reads, cached until the next force refresh (R5-3: misses are
|
|
270
|
+
* never stored — see manifestFor). */
|
|
271
|
+
private readonly manifests = new Map<string, TeamRunManifest>();
|
|
270
272
|
/** U7: committed-state caches (default data path only). */
|
|
271
273
|
private readonly manifestCache?: ManifestCache;
|
|
272
274
|
private readonly snapshotCache?: RunSnapshotCache;
|
|
@@ -361,6 +363,10 @@ export class AgentsJobsBrowser {
|
|
|
361
363
|
|
|
362
364
|
/** Reload the merged list when the TTL expired (or `force`). */
|
|
363
365
|
refreshData(force = false): void {
|
|
366
|
+
// R5-3 (DR-02 wave 2): a force refresh invalidates the manifest cache too
|
|
367
|
+
// — manifests used to live for the browser's whole lifetime, so a manifest
|
|
368
|
+
// created/renamed while the overlay was open never became visible.
|
|
369
|
+
if (force) this.manifests.clear();
|
|
364
370
|
const ttl = this.options.refreshTtlMs ?? REFRESH_TTL_MS_DEFAULT;
|
|
365
371
|
const at = this.nowMs();
|
|
366
372
|
if (!force && ttl > 0 && at - this.cachedAt < ttl) return;
|
|
@@ -519,16 +525,21 @@ export class AgentsJobsBrowser {
|
|
|
519
525
|
}
|
|
520
526
|
|
|
521
527
|
private manifestFor(runId: string): TeamRunManifest | null {
|
|
522
|
-
|
|
523
|
-
|
|
528
|
+
const cached = this.manifests.get(runId);
|
|
529
|
+
if (cached) return cached;
|
|
530
|
+
// R5-3 (DR-02 wave 2): a miss (manifest not found / transient read
|
|
531
|
+
// failure — loadRunManifestById takes no run lock, so a read can race a
|
|
532
|
+
// write) is NEVER cached: returning null without touching the map makes
|
|
533
|
+
// the next call retry instead of pinning the miss for the overlay's
|
|
534
|
+
// lifetime. Successful loads still cache until the next force refresh.
|
|
524
535
|
try {
|
|
525
536
|
const loaded = loadRunManifestById(this.options.cwd, runId); // NOTE: no run lock — best-effort read, mirrors viewers.ts
|
|
526
|
-
|
|
537
|
+
if (!loaded) return null;
|
|
538
|
+
this.manifests.set(runId, loaded.manifest);
|
|
539
|
+
return loaded.manifest;
|
|
527
540
|
} catch {
|
|
528
|
-
|
|
541
|
+
return null;
|
|
529
542
|
}
|
|
530
|
-
this.manifests.set(runId, manifest);
|
|
531
|
-
return manifest;
|
|
532
543
|
}
|
|
533
544
|
|
|
534
545
|
private recordFor(runId: string, taskId: string): CrewAgentRecord | undefined {
|
|
@@ -559,7 +570,10 @@ export class AgentsJobsBrowser {
|
|
|
559
570
|
return lines;
|
|
560
571
|
}
|
|
561
572
|
const record = entry.record ?? this.recordFor(entry.runId, entry.taskId);
|
|
562
|
-
|
|
573
|
+
// R5-3: prefer the FRESH entry.manifest the list pipeline already
|
|
574
|
+
// carries (re-read on every refresh tick) — manifestFor is the fallback
|
|
575
|
+
// for provider-based entries without one.
|
|
576
|
+
const manifest = entry.manifest ?? this.manifestFor(entry.runId);
|
|
563
577
|
if (!record || !manifest) {
|
|
564
578
|
return [
|
|
565
579
|
`▸ ${entry.taskId ?? "?"} (${entry.role ?? "?"})`,
|
|
@@ -2,7 +2,7 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
|
2
2
|
import { type LiveAgentHandle, listLiveAgents, listLiveAgentsByWorkspace } from "../../runtime/live-session/live-agent-manager.ts";
|
|
3
3
|
import { formatCost } from "../../state/usage.ts";
|
|
4
4
|
import { visibleWidth } from "../../utils/visual.ts";
|
|
5
|
-
import { formatCount, formatDuration } from "../format-helpers.ts";
|
|
5
|
+
import { formatCount, formatDuration, truncLine } from "../format-helpers.ts";
|
|
6
6
|
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
7
7
|
import { overflowHint, statusIcon } from "../rail.ts";
|
|
8
8
|
import type { RunDashboardOptions } from "../run-dashboard.ts";
|
|
@@ -99,7 +99,10 @@ function describeActivity(handle: LiveAgentHandle): string {
|
|
|
99
99
|
.split("\n")
|
|
100
100
|
.find((l) => l.trim())
|
|
101
101
|
?.trim() ?? "";
|
|
102
|
-
|
|
102
|
+
// O-5 (DR-04): grapheme-aware truncation at the original budget (≤40
|
|
103
|
+
// content columns + 1 ellipsis column) — the ad-hoc slice(0, 40) split
|
|
104
|
+
// surrogate pairs (emoji → stray U+FFFD).
|
|
105
|
+
return truncLine(line, 41);
|
|
103
106
|
}
|
|
104
107
|
return "thinking…";
|
|
105
108
|
}
|
|
@@ -120,6 +120,19 @@ interface SourceInternals {
|
|
|
120
120
|
forceDispose(): void;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** O-7 (0.11.22): a scan result of "empty" only counts as a committed compaction
|
|
124
|
+
* when the path is a genuine regular file. A directory (or any non-regular
|
|
125
|
+
* object) that some readers tolerate into an empty cursor must be treated as a
|
|
126
|
+
* FAILED scan so the guard behaves identically on POSIX and Windows. Returns
|
|
127
|
+
* false when statSync throws (missing file shares the same transient policy). */
|
|
128
|
+
function isRegularFile(eventsPath: string): boolean {
|
|
129
|
+
try {
|
|
130
|
+
return fs.statSync(eventsPath).isFile();
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
function createEventsStateSource(
|
|
124
137
|
runId: string,
|
|
125
138
|
eventsPath: string,
|
|
@@ -153,8 +166,11 @@ function createEventsStateSource(
|
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
/** STORAGE SCAN (spec U7): one parse of the (tail-capped) log to the
|
|
156
|
-
* current seq. No event replay — the root is rebuilt from storage.
|
|
157
|
-
|
|
169
|
+
* current seq. No event replay — the root is rebuilt from storage.
|
|
170
|
+
* O-7 (DR-04 R7): returns whether the scan SUCCEEDED — on failure the
|
|
171
|
+
* committed state, the inode/size stamps AND `resynced`/`version` are all
|
|
172
|
+
* left untouched so the next poll re-detects the rewrite and retries. */
|
|
173
|
+
function rootBuild(): boolean {
|
|
158
174
|
let scanned: TeamEvent[] | undefined;
|
|
159
175
|
let scannedSeq = 0;
|
|
160
176
|
try {
|
|
@@ -171,14 +187,23 @@ function createEventsStateSource(
|
|
|
171
187
|
scanned.length === 0 &&
|
|
172
188
|
scannedSeq === 0 &&
|
|
173
189
|
(seq > 0 || events.length > 0) &&
|
|
174
|
-
!
|
|
190
|
+
!isRegularFile(eventsPath)
|
|
175
191
|
) {
|
|
176
192
|
// readEventsCursor reports a MISSING log as an empty cursor — but a
|
|
177
|
-
// log that carried committed events a beat ago and now
|
|
178
|
-
// is a transient disappearance (run dir moving /
|
|
179
|
-
// window of rotation)
|
|
180
|
-
//
|
|
181
|
-
//
|
|
193
|
+
// log that carried committed events a beat ago and now is not a
|
|
194
|
+
// readable regular file is a transient disappearance (run dir moving /
|
|
195
|
+
// the rename→wx-create window of rotation) OR an unreadable anomaly
|
|
196
|
+
// (a directory/torn state some readers tolerate into an empty result),
|
|
197
|
+
// NOT a compaction to empty. Keep the committed state — same policy as
|
|
198
|
+
// poll()'s miss-file branch — and keep the inode stamp so the next poll
|
|
199
|
+
// re-detects the rewrite and retries the resync.
|
|
200
|
+
//
|
|
201
|
+
// O-7 platform parity (0.11.22 CI): this guard used `!fs.existsSync`,
|
|
202
|
+
// which is TRUE for a directory — Windows' reader tolerates an EISDIR
|
|
203
|
+
// open into an empty cursor while POSIX throws, so the same replacement
|
|
204
|
+
// committed an empty root on Windows and returned false on Linux.
|
|
205
|
+
// Requiring a regular file makes the failed-scan verdict OS-uniform
|
|
206
|
+
// while a genuine compaction-to-empty (regular empty file) still commits.
|
|
182
207
|
scanned = undefined;
|
|
183
208
|
}
|
|
184
209
|
// Stamp the inode/size the root was built from so the FIRST poll after
|
|
@@ -186,30 +211,43 @@ function createEventsStateSource(
|
|
|
186
211
|
// instead of learning the inode one poll late. Stamp ONLY on a successful
|
|
187
212
|
// scan — on a failed scan keep the previous stamp so the next poll
|
|
188
213
|
// re-detects the rewrite and RETRIES the resync.
|
|
189
|
-
if (scanned
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
if (scanned === undefined) {
|
|
215
|
+
// O-7: failed scan — do NOT claim a fresh root: `resynced`/`version`
|
|
216
|
+
// stay untouched and no resync frame is emitted (see resyncAttempt).
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const stat = fs.statSync(eventsPath);
|
|
221
|
+
lastIno = stat.ino;
|
|
222
|
+
lastSize = stat.size;
|
|
223
|
+
} catch {
|
|
224
|
+
lastIno = -1;
|
|
225
|
+
lastSize = -1;
|
|
200
226
|
}
|
|
227
|
+
events = scanned.slice(-windowSize);
|
|
228
|
+
seq = scannedSeq;
|
|
201
229
|
resynced = true;
|
|
202
230
|
version += 1;
|
|
231
|
+
return true;
|
|
203
232
|
}
|
|
204
233
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
234
|
+
/** Root-replace and emit ONE resync frame. Returns false (state untouched,
|
|
235
|
+
* no frame) when the storage scan failed — O-7: a failed resync must not
|
|
236
|
+
* count as a committed frame. `stats.resyncs` counts ATTEMPTS either way
|
|
237
|
+
* (MINOR-1 contract: "the resync attempt still counts"). */
|
|
238
|
+
function resyncAttempt(): boolean {
|
|
208
239
|
stats.resyncs += 1;
|
|
240
|
+
const fromSeq = seq;
|
|
241
|
+
if (!rootBuild()) return false;
|
|
209
242
|
stats.frames += 1;
|
|
210
243
|
const frame: EventsStateFrame = { fromSeq, toSeq: seq, events: [...events], version, resync: true };
|
|
211
244
|
stats.eventsDelivered += frame.events.length;
|
|
212
245
|
emit(frame);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resync(): EventsStateSnapshot {
|
|
250
|
+
resyncAttempt();
|
|
213
251
|
return snapshot();
|
|
214
252
|
}
|
|
215
253
|
|
|
@@ -224,10 +262,11 @@ function createEventsStateSource(
|
|
|
224
262
|
try {
|
|
225
263
|
const stat = fs.statSync(eventsPath);
|
|
226
264
|
if (lastIno !== -1 && (stat.ino !== lastIno || stat.size < lastSize)) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
265
|
+
// O-7: do NOT stamp the NEW inode/size before the resync — if the
|
|
266
|
+
// scan inside fails, the OLD stamps are exactly what makes the
|
|
267
|
+
// next poll re-detect the rewrite and retry it. rootBuild()
|
|
268
|
+
// stamps after a successful scan.
|
|
269
|
+
return resyncAttempt() ? 1 : 0;
|
|
231
270
|
}
|
|
232
271
|
lastIno = stat.ino;
|
|
233
272
|
lastSize = stat.size;
|
|
@@ -262,8 +301,8 @@ function createEventsStateSource(
|
|
|
262
301
|
backlog = false;
|
|
263
302
|
}
|
|
264
303
|
if (backlog) {
|
|
265
|
-
resync
|
|
266
|
-
return committed + 1;
|
|
304
|
+
// O-7: count the resync frame only when the root scan succeeded.
|
|
305
|
+
return committed + (resyncAttempt() ? 1 : 0);
|
|
267
306
|
}
|
|
268
307
|
break;
|
|
269
308
|
}
|
|
@@ -86,6 +86,9 @@ export class CrewAgentPane {
|
|
|
86
86
|
private readonly paneOptions: CrewAgentPaneOptions | undefined;
|
|
87
87
|
/** Current agent target, read fresh from the panel store each render. */
|
|
88
88
|
private currentTaskId: string | undefined;
|
|
89
|
+
/** Run of {@link currentTaskId} — F3: the pane's switch guard is run-scoped
|
|
90
|
+
* so the same-named task in another run resets the transcript state. */
|
|
91
|
+
private currentRunId: string | undefined;
|
|
89
92
|
/** Cached manifest for the current run, to avoid re-reading on every tick. */
|
|
90
93
|
private cachedRunId: string | undefined;
|
|
91
94
|
private cachedManifest: TeamRunManifest | undefined;
|
|
@@ -294,11 +297,15 @@ export class CrewAgentPane {
|
|
|
294
297
|
const viewed = getViewedAgent();
|
|
295
298
|
if (!viewed) return [];
|
|
296
299
|
|
|
297
|
-
// Detect agent switch:
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
// Detect agent switch (F3: run OR task — task ids are positional per
|
|
301
|
+
// run, so a taskId-only guard let a same-named task in another run
|
|
302
|
+
// reuse the stale cursor/buffer): reset the transcript cursor so the
|
|
303
|
+
// pane shows the new agent's full history instead of continuing from
|
|
304
|
+
// the old cursor.
|
|
305
|
+
if (this.currentTaskId !== viewed.taskId || this.currentRunId !== viewed.runId) {
|
|
306
|
+
resetAgentTranscriptCursor(viewed.runId, viewed.taskId);
|
|
301
307
|
this.currentTaskId = viewed.taskId;
|
|
308
|
+
this.currentRunId = viewed.runId;
|
|
302
309
|
this.scrollBack = 0;
|
|
303
310
|
this.lastItems = [];
|
|
304
311
|
this.lastTranscriptReadAt = 0;
|
|
@@ -371,8 +378,8 @@ export class CrewAgentPane {
|
|
|
371
378
|
this.unsubscribePanel();
|
|
372
379
|
this.cachedManifest = undefined;
|
|
373
380
|
this.cachedRunId = undefined;
|
|
374
|
-
// Free the per-task transcript ring buffer (≤500 items with full
|
|
381
|
+
// Free the per-run::task transcript ring buffer (≤500 items with full
|
|
375
382
|
// message bodies) instead of retaining it for process lifetime.
|
|
376
|
-
if (this.currentTaskId) resetAgentTranscriptCursor(this.currentTaskId);
|
|
383
|
+
if (this.currentRunId && this.currentTaskId) resetAgentTranscriptCursor(this.currentRunId, this.currentTaskId);
|
|
377
384
|
}
|
|
378
385
|
}
|