pi-crew 0.11.21 → 0.11.23
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 +41 -0
- package/dist/index.mjs +542 -531
- 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/respond.ts +8 -6
- package/src/extension/team-tool/run.ts +4 -4
- 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/prompt/surface-worker.ts +13 -1
- package/src/runtime/broker/crew-broker-client.ts +74 -16
- package/src/runtime/broker/crew-broker.ts +120 -97
- package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
- package/src/runtime/broker/protocol/request-parsers.ts +68 -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 +32 -14
- package/src/runtime/delivery-coordinator.ts +30 -0
- package/src/runtime/dispatch-batch.ts +9 -6
- 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 +84 -16
- 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 +73 -6
- 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/stale-reconciler.ts +4 -1
- 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/cursor.ts +14 -2
- 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
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
4
4
|
import { atomicWriteFile } from "../atomic-write.ts";
|
|
5
|
-
import { type TeamEvent, withEventLogLockSync } from "./event-log.ts";
|
|
5
|
+
import { type TeamEvent, withEventLogAppendLockSync, withEventLogLockSync } from "./event-log.ts";
|
|
6
6
|
import { poisonMarkerPath } from "./poison-marker.ts";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -169,7 +169,11 @@ export interface CompactionResult {
|
|
|
169
169
|
* 5. If events were lost, append them
|
|
170
170
|
* 6. Return compaction stats
|
|
171
171
|
*/
|
|
172
|
-
export function compactEventLog(
|
|
172
|
+
export function compactEventLog(
|
|
173
|
+
eventsPath: string,
|
|
174
|
+
config?: Partial<RotationConfig>,
|
|
175
|
+
options?: CompactionLockOptions,
|
|
176
|
+
): CompactionResult | undefined {
|
|
173
177
|
// US-001 (2026-09-22): lock-scope reduction. The expensive part is the READ +
|
|
174
178
|
// parse of the whole log (prepareCompaction). It is read-only, so it does NOT
|
|
175
179
|
// need the append lock: hold the lock ONLY for the write+recover phase
|
|
@@ -196,7 +200,15 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
|
|
|
196
200
|
// log grew unbounded until events were silently dropped past 50MB).
|
|
197
201
|
const prepared = prepareCompaction(eventsPath, config);
|
|
198
202
|
if (!prepared) return undefined;
|
|
199
|
-
return compactPreparedEventLog(eventsPath, prepared);
|
|
203
|
+
return compactPreparedEventLog(eventsPath, prepared, options);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Options for the locked write phase of compaction. `lock` is forwarded to
|
|
207
|
+
* `withEventLogAppendLockSync` (and transitively `withEventLogLockSync`) so
|
|
208
|
+
* callers/tests can bound the wait — the defaults (5s timeout / 10s stale)
|
|
209
|
+
* mirror both existing lock families. */
|
|
210
|
+
export interface CompactionLockOptions {
|
|
211
|
+
lock?: { timeoutMs?: number; staleMs?: number };
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/**
|
|
@@ -212,15 +224,81 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
|
|
|
212
224
|
* ones it never saw. So under the lock we read the bytes appended after the
|
|
213
225
|
* snapshot offset and append them to the write. Appenders hold the same lock,
|
|
214
226
|
* so this read is a complete tail snapshot.
|
|
227
|
+
*
|
|
228
|
+
* R4-2 (DR-02 Wave 2, 2026-10-15): the write phase now ADDITIONALLY acquires the
|
|
229
|
+
* async family's `.alock` (sync-mode, via `withEventLogAppendLockSync`) BEFORE
|
|
230
|
+
* `readTailSince`/the `atomicWriteFile` rename, releasing it only after the C2
|
|
231
|
+
* recovery check completes. Rationale: async-family appends hold `.alock`
|
|
232
|
+
* ACROSS await gaps (`open(eventsPath, "a")` → `appendFile`); running the rename
|
|
233
|
+
* under `.mkdirlock` alone let it interleave with that gap, orphaning the
|
|
234
|
+
* appender's fd — its event landed on the old inode and was silently lost (C2
|
|
235
|
+
* only re-splices the `kept` window). Holding `.alock` across the whole write
|
|
236
|
+
* phase excludes in-flight async appends cross-process (same on-disk lock dir
|
|
237
|
+
* + pid-file protocol). Lock order stays strictly `.mkdirlock` → `.alock` (the
|
|
238
|
+
* async family never takes `.mkdirlock`), so no deadlock. NOT archive-style by
|
|
239
|
+
* lead decision: rotation's rename-to-archive already preserves in-flight fds
|
|
240
|
+
* (ST-8), so only compaction's inode-replacing write needed the guard.
|
|
215
241
|
*/
|
|
216
242
|
export function compactPreparedEventLog(
|
|
217
243
|
eventsPath: string,
|
|
218
244
|
prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
|
|
245
|
+
options?: CompactionLockOptions,
|
|
219
246
|
): CompactionResult | undefined {
|
|
220
|
-
|
|
247
|
+
// R7-2 (DR-04, 2026-10-16): the write phase is now `applyCompactionAppendGuarded`
|
|
248
|
+
// (below) — `.alock` nested inside the `.mkdirlock` held here, plus the
|
|
249
|
+
// mandatory `readTailSince` splice. This wrapper keeps the historical
|
|
250
|
+
// locked entry point (public API + tests) with identical semantics.
|
|
251
|
+
return withEventLogLockSync(eventsPath, () => applyCompactionAppendGuarded(eventsPath, prepared, options), options?.lock);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** R7-2 (DR-04, 2026-10-16): options for `applyCompactionAppendGuarded`.
|
|
255
|
+
* `lock` is forwarded to `withEventLogAppendLockSync` so callers/tests can
|
|
256
|
+
* bound the wait; `holdsAppendLock` declares the caller's lock state (see the
|
|
257
|
+
* helper docblock). */
|
|
258
|
+
export interface ApplyCompactionGuardOptions extends CompactionLockOptions {
|
|
259
|
+
/** Pass `true` ONLY when the caller ALREADY holds the async family's
|
|
260
|
+
* `.alock` (i.e. the guarded apply runs inside `withEventLogLockAsync`).
|
|
261
|
+
* The on-disk lock is NOT re-entrant — re-acquiring our own `.alock` in
|
|
262
|
+
* sync mode can only end in the bounded timeout (sleepSync starves the
|
|
263
|
+
* very in-process holder that must release it). With `true` the helper
|
|
264
|
+
* only tail-merges; with `false`/omitted it acquires `.alock` itself. */
|
|
265
|
+
holdsAppendLock?: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* R7-2 (DR-04, 2026-10-16): the guarded write phase for INLINE compaction
|
|
270
|
+
* call sites (the overflow/rotation-tick paths inside event-log.ts) that
|
|
271
|
+
* previously called `prepareCompaction` + `applyCompactionUnlocked` bare —
|
|
272
|
+
* no `.alock` and no tail-merge, so the inode-replacing write could interleave
|
|
273
|
+
* with an in-flight async-family append parked between `open()` and
|
|
274
|
+
* `appendFile()` (its fd kept pointing at the renamed-away inode → silent
|
|
275
|
+
* loss; C2 recovery only re-splices the `kept` window), and sync-family bytes
|
|
276
|
+
* appended between the prepare-read and the write were clobbered.
|
|
277
|
+
*
|
|
278
|
+
* Contract:
|
|
279
|
+
* - ALWAYS merges `readTailSince(eventsPath, prepared.originalSize)` into
|
|
280
|
+
* `prepared.lines` before `applyCompactionUnlocked` — sync-family appenders
|
|
281
|
+
* never hold `.alock`, so they can land in the prepare→apply window even
|
|
282
|
+
* while an async-family caller holds `.alock`; the splice rescues them.
|
|
283
|
+
* - `opts.holdsAppendLock === true` (caller is inside `withEventLogLockAsync`):
|
|
284
|
+
* tail-merge ONLY — never re-acquire `.alock` (non-reentrant → self-timeout).
|
|
285
|
+
* - `opts.holdsAppendLock` false/omitted (caller holds `.mkdirlock` — sync
|
|
286
|
+
* family — or no lock): acquire `.alock` in sync mode nested inside the
|
|
287
|
+
* held `.mkdirlock`, per the R4-2 lock order (`.mkdirlock` → `.alock`; the
|
|
288
|
+
* async family never takes `.mkdirlock`, so no inversion). NEVER call this
|
|
289
|
+
* with `false` while `.alock` is already held by this process.
|
|
290
|
+
*/
|
|
291
|
+
export function applyCompactionAppendGuarded(
|
|
292
|
+
eventsPath: string,
|
|
293
|
+
prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
|
|
294
|
+
options?: ApplyCompactionGuardOptions,
|
|
295
|
+
): CompactionResult | undefined {
|
|
296
|
+
const applyWithTailMerge = (): CompactionResult | undefined => {
|
|
221
297
|
const merged = { ...prepared, lines: prepared.lines + readTailSince(eventsPath, prepared.originalSize) };
|
|
222
298
|
return applyCompactionUnlocked(eventsPath, merged);
|
|
223
|
-
}
|
|
299
|
+
};
|
|
300
|
+
if (options?.holdsAppendLock) return applyWithTailMerge();
|
|
301
|
+
return withEventLogAppendLockSync(eventsPath, applyWithTailMerge, options?.lock);
|
|
224
302
|
}
|
|
225
303
|
|
|
226
304
|
/**
|
|
@@ -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
|
}
|