pi-crew 0.9.41 → 0.9.44
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 +253 -0
- package/README.md +14 -0
- package/dist/build-meta.json +6450 -12419
- package/dist/index.mjs +57998 -68417
- package/dist/index.mjs.map +4 -4
- package/docs/migration/atomic-write-v2-migration.md +2 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
- package/package.json +4 -2
- package/scripts/README.md +6 -0
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/check-all-skills.ts +3 -3
- package/scripts/dev-runner.mjs +106 -0
- package/scripts/postinstall.mjs +6 -2
- package/scripts/verify-skill.ts +2 -2
- package/src/agents/agent-config.ts +1 -1
- package/src/config/config.ts +20 -1
- package/src/config/drift-detector.ts +97 -15
- package/src/config/resilient-parser.ts +4 -4
- package/src/config/types.ts +0 -2
- package/src/extension/crew-cleanup.ts +17 -3
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/crew-vibes/index.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +26 -9
- package/src/extension/management.ts +6 -5
- package/src/extension/register.ts +58 -1573
- package/src/extension/registration/command-registration.ts +57 -0
- package/src/extension/registration/commands.ts +7 -2
- package/src/extension/registration/context-builder.ts +131 -0
- package/src/extension/registration/crash-recovery-cache.ts +54 -0
- package/src/extension/registration/foreground-run-controller.ts +238 -0
- package/src/extension/registration/hook-registration.ts +119 -0
- package/src/extension/registration/lazy-configurers.ts +124 -0
- package/src/extension/registration/lifecycle-handlers.ts +731 -0
- package/src/extension/registration/observability.ts +33 -4
- package/src/extension/registration/registration-types.ts +149 -0
- package/src/extension/registration/runtime-cleanup.ts +230 -0
- package/src/extension/registration/subagent-manager-setup.ts +211 -0
- package/src/extension/registration/tool-registration.ts +50 -0
- package/src/extension/registration/wire-cross-extension.ts +37 -0
- package/src/extension/rpc-hmac.ts +21 -1
- package/src/extension/run-import.ts +3 -3
- package/src/extension/team-tool/auto-summarize.ts +4 -4
- package/src/extension/team-tool/cancel.ts +5 -5
- package/src/extension/team-tool/chain-executor.ts +5 -4
- package/src/extension/team-tool/context.ts +11 -1
- package/src/extension/team-tool/doctor.ts +2 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +25 -36
- package/src/extension/team-tool/orchestrate.ts +0 -1
- package/src/extension/team-tool/parallel-dispatch.ts +5 -0
- package/src/extension/team-tool/run.ts +220 -214
- package/src/extension/team-tool/workflow-manage.ts +1 -1
- package/src/extension/team-tool.ts +69 -16
- package/src/hooks/registry.ts +7 -2
- package/src/observability/correlation.ts +4 -3
- package/src/observability/event-bus.ts +10 -1
- package/src/observability/event-to-metric.ts +8 -7
- package/src/observability/exporters/adapter.ts +6 -9
- package/src/observability/exporters/otlp-exporter.ts +81 -0
- package/src/observability/metric-retention.ts +4 -0
- package/src/observability/metric-sink.ts +14 -4
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/adaptive-plan.ts +1 -1
- package/src/runtime/async-runner.ts +13 -4
- package/src/runtime/background-runner.ts +32 -23
- package/src/runtime/capability-inventory.ts +1 -1
- package/src/runtime/chain-runner.ts +5 -5
- package/src/runtime/checkpoint.ts +6 -16
- 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 +80 -822
- package/src/runtime/coalesce-tasks.ts +1 -1
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/crash-recovery.ts +63 -47
- package/src/runtime/delivery-coordinator.ts +1 -1
- package/src/runtime/deterministic-ast.ts +2 -2
- package/src/runtime/diagnostic-export.ts +2 -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/foreground-control.ts +3 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +46 -13
- package/src/runtime/goal-state-store.ts +1 -1
- package/src/runtime/handoff-manager.ts +0 -2
- package/src/runtime/intercom-bridge.ts +0 -2
- package/src/runtime/live-agent-manager.ts +30 -5
- package/src/runtime/live-extension-bridge.ts +0 -2
- package/src/runtime/live-session-runtime.ts +65 -57
- package/src/runtime/manifest-cache.ts +59 -0
- package/src/runtime/mcp-proxy.ts +1 -1
- package/src/runtime/orphan-worker-registry.ts +3 -3
- package/src/runtime/output-validator.ts +1 -1
- package/src/runtime/path-overlap.ts +1 -1
- package/src/runtime/pi-args.ts +3 -3
- package/src/runtime/pi-json-output.ts +4 -3
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/pipeline-runner.ts +1 -4
- package/src/runtime/post-checks.ts +0 -1
- package/src/runtime/retry-runner.ts +1 -1
- package/src/runtime/run-coalesced-task-group.ts +18 -23
- package/src/runtime/scheduler.ts +2 -0
- package/src/runtime/session-resources.ts +1 -1
- package/src/runtime/settings-store.ts +31 -1
- package/src/runtime/skill-effectiveness.ts +2 -2
- package/src/runtime/stale-reconciler.ts +23 -53
- package/src/runtime/subagent-manager.ts +19 -3
- package/src/runtime/task-output-context.ts +2 -1
- package/src/runtime/task-runner/prompt-builder.ts +3 -3
- package/src/runtime/task-runner/run-projection.ts +3 -1
- package/src/runtime/task-runner/state-helpers.ts +13 -1
- package/src/runtime/task-runner.ts +31 -6
- package/src/runtime/team-runner.ts +8 -2
- package/src/runtime/verification-gates.ts +54 -5
- package/src/schema/config-schema.ts +19 -1
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/skills/discover-skills.ts +25 -15
- package/src/state/active-run-registry.ts +0 -1
- package/src/state/atomic-write.ts +37 -11
- package/src/state/blob-store.ts +3 -9
- package/src/state/contracts.ts +5 -1
- package/src/state/crew-init.ts +3 -2
- package/src/state/decision-ledger.ts +2 -2
- package/src/state/event-log-rotation.ts +1 -1
- package/src/state/event-log.ts +4 -3
- package/src/state/gitignore-manager.ts +2 -1
- package/src/state/health-store.ts +5 -2
- package/src/state/instinct-store.ts +11 -2
- package/src/state/locks.ts +132 -49
- package/src/state/mailbox.ts +3 -4
- package/src/state/observation-store.ts +1 -13
- package/src/state/run-cache.ts +12 -12
- package/src/state/run-graph.ts +2 -1
- package/src/state/run-metrics.ts +1 -1
- package/src/state/schedule.ts +7 -1
- package/src/state/state-store.ts +9 -3
- package/src/state/tiered-eval.ts +1 -1
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/ui/dynamic-border.ts +13 -3
- package/src/ui/live-conversation-overlay.ts +7 -27
- package/src/ui/live-duration.ts +2 -2
- package/src/ui/live-run-sidebar.ts +56 -32
- package/src/ui/mascot.ts +19 -13
- package/src/ui/powerbar-publisher.ts +244 -178
- package/src/ui/render-coalescer.ts +44 -3
- package/src/ui/render-diff.ts +2 -2
- package/src/ui/run-dashboard.ts +122 -21
- package/src/ui/run-event-bus.ts +66 -0
- package/src/ui/run-snapshot-cache.ts +84 -81
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/status-colors.ts +24 -0
- package/src/ui/tool-progress-formatter.ts +1 -1
- package/src/ui/tool-render.ts +3 -3
- package/src/ui/transcript-cache.ts +5 -10
- package/src/ui/transcript-entries.ts +68 -72
- package/src/ui/transcript-viewer.ts +54 -14
- package/src/ui/widget/index.ts +26 -15
- package/src/ui/widget/widget-model.ts +18 -9
- package/src/ui/widget/widget-renderer.ts +2 -5
- package/src/utils/fingerprint.ts +1 -1
- package/src/utils/paths.ts +1 -1
- package/src/utils/project-detector.ts +1 -1
- package/src/worktree/worktree-manager.ts +47 -19
- package/src/state/atomic-write-v2.ts +0 -108
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,78 @@ 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
|
+
|
|
382
450
|
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
|
|
383
451
|
const filePath = lockPath(manifest);
|
|
384
452
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
453
|
+
// H-1: re-entrance check is scoped to the current async context, not process-global.
|
|
454
|
+
// A call from a DIFFERENT async context (e.g. a child-stdout event handler that
|
|
455
|
+
// runs while an async holder is awaiting) sees no held set and properly serializes.
|
|
456
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
457
|
+
// Re-entrant within the same async context — already hold this lock.
|
|
388
458
|
return fn();
|
|
389
459
|
}
|
|
390
460
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
391
461
|
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
462
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
463
|
+
const newHeld = new Set(prevHeld);
|
|
464
|
+
newHeld.add(filePath);
|
|
465
|
+
// Enter a new async context (or extend the current one) carrying the held set.
|
|
466
|
+
// On exit (sync return OR async resume after an awaited child), the context is
|
|
467
|
+
// restored — so the held set only contains locks acquired within fn()'s subtree.
|
|
468
|
+
return lockCtx.run(newHeld, () => {
|
|
469
|
+
try {
|
|
470
|
+
return fn();
|
|
471
|
+
} finally {
|
|
472
|
+
// FIX (CI flake): releaseLock uses token matching to prevent the
|
|
473
|
+
// "losing contender wipes winner's lock" race in multi-process scenarios.
|
|
474
|
+
// But within withRunLockSync/withRunLock (same process), the new acquire
|
|
475
|
+
// uses a FRESH random token — the previous stored token doesn't match the
|
|
476
|
+
// current token — and the lock file is never removed. Repeat acquisitions
|
|
477
|
+
// then keep failing with EEXIST because the file lingers.
|
|
478
|
+
// Since withRunLock* is always single-process, unconditionally delete
|
|
479
|
+
// (symlink guard is still applied for safety).
|
|
480
|
+
releaseOwnLock(filePath, token);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
399
483
|
}
|
|
400
484
|
|
|
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
485
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
410
486
|
const filePath = lockPath(manifest);
|
|
411
487
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// Re-entrant: already hold this lock, just run the callback.
|
|
488
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
489
|
+
// Re-entrant within the same async context.
|
|
415
490
|
return await fn();
|
|
416
491
|
}
|
|
417
492
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
418
493
|
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
494
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
495
|
+
const newHeld = new Set(prevHeld);
|
|
496
|
+
newHeld.add(filePath);
|
|
497
|
+
// AsyncLocalStorage propagates across await boundaries, so any code that
|
|
498
|
+
// resumes after an await inside fn() (including microtasks, timers, and
|
|
499
|
+
// awaited child Pi results) still sees newHeld.
|
|
500
|
+
return await lockCtx.run(newHeld, async () => {
|
|
501
|
+
try {
|
|
502
|
+
return await fn();
|
|
503
|
+
} finally {
|
|
504
|
+
// FIX (CI flake): see withRunLockSync above — use releaseOwnLock for
|
|
505
|
+
// unconditional deletion of the lock file we created (same process).
|
|
506
|
+
releaseOwnLock(filePath, token);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
426
509
|
}
|
package/src/state/mailbox.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as path from "node:path";
|
|
|
3
3
|
import { DEFAULT_MAILBOX } from "../config/defaults.ts";
|
|
4
4
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
5
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
6
|
-
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
7
6
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
8
7
|
import { withEventLogLockSync } from "./event-log.ts";
|
|
9
8
|
import { withFileLockSync } from "./locks.ts";
|
|
@@ -184,13 +183,13 @@ function ensureRunMailbox(manifest: TeamRunManifest): void {
|
|
|
184
183
|
// Ensure parent dir exists (may have been lost due to race or
|
|
185
184
|
// Windows path normalization mismatch)
|
|
186
185
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
187
|
-
|
|
186
|
+
atomicWriteFile(filePath, "");
|
|
188
187
|
}
|
|
189
188
|
}
|
|
190
189
|
const delivery = deliveryFile(manifest, true);
|
|
191
190
|
if (!fs.existsSync(delivery)) {
|
|
192
191
|
fs.mkdirSync(path.dirname(delivery), { recursive: true });
|
|
193
|
-
|
|
192
|
+
atomicWriteFile(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
194
193
|
}
|
|
195
194
|
}
|
|
196
195
|
|
|
@@ -312,7 +311,7 @@ function rotateMailboxFileIfNeeded(filePath: string, thresholdBytes = MAILBOX_AR
|
|
|
312
311
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
313
312
|
const archivePath = `${filePath}.${ts}.archive.jsonl`;
|
|
314
313
|
fs.renameSync(filePath, archivePath);
|
|
315
|
-
|
|
314
|
+
atomicWriteFile(filePath, "");
|
|
316
315
|
// FIX: Prune old archives so total per-direction count stays bounded.
|
|
317
316
|
pruneOldMailboxArchives(filePath);
|
|
318
317
|
return true;
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Observation capture and compression system.
|
|
3
|
-
*
|
|
4
|
-
* Pattern origin: claude-mem — captures tool usage across sessions,
|
|
5
|
-
* compresses via AI, injects into future sessions.
|
|
6
|
-
*
|
|
7
|
-
* This module provides the observation store and compression logic.
|
|
8
|
-
* Actual capture hooks into the lifecycle events (Pattern 12).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import * as fs from "node:fs";
|
|
12
|
-
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
13
|
-
import * as path from "node:path";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
2
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
15
3
|
import { atomicWriteJson } from "./atomic-write.ts";
|
|
16
4
|
|
package/src/state/run-cache.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as crypto from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { atomicWriteJson } from "./atomic-write.ts";
|
|
6
6
|
import { withFileLockSync } from "./locks.ts";
|
|
7
7
|
import type { TeamTaskState } from "./types.ts";
|
|
8
8
|
|
|
@@ -67,7 +67,7 @@ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
|
|
|
67
67
|
}
|
|
68
68
|
const updatedIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
|
|
69
69
|
delete updatedIndex[cacheKey];
|
|
70
|
-
|
|
70
|
+
atomicWriteJson(indexPath, updatedIndex);
|
|
71
71
|
});
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
@@ -109,18 +109,18 @@ export function saveRunToCache(
|
|
|
109
109
|
};
|
|
110
110
|
|
|
111
111
|
const entryPath = path.join(dir, `${cacheKey}.json`);
|
|
112
|
-
|
|
112
|
+
// ST-4: atomic entry write (was raw writeFileSync — inverted priority vs the index).
|
|
113
|
+
atomicWriteJson(entryPath, entry);
|
|
113
114
|
|
|
114
|
-
// Update index
|
|
115
|
+
// Update index under a file lock (prevents concurrent saveCache from racing on the
|
|
116
|
+
// RMW) and write atomically (unique temp + rename — fixes the fixed "index.json.tmp"
|
|
117
|
+
// name collision the old manual temp+rename had).
|
|
115
118
|
const indexPath = path.join(dir, "index.json");
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const tempPath = path.join(dir, "index.json.tmp");
|
|
122
|
-
fs.writeFileSync(tempPath, JSON.stringify(index), "utf-8");
|
|
123
|
-
fs.renameSync(tempPath, indexPath);
|
|
119
|
+
withFileLockSync(indexPath, () => {
|
|
120
|
+
const index: CacheIndex = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf-8")) : {};
|
|
121
|
+
index[cacheKey] = entryPath;
|
|
122
|
+
atomicWriteJson(indexPath, index);
|
|
123
|
+
});
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
package/src/state/run-graph.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
4
4
|
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
5
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
5
6
|
import type { TeamRunManifest, TeamTaskState } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
export interface RunGraphNode {
|
|
@@ -138,7 +139,7 @@ export function saveRunGraph(graph: RunGraph, cwd: string): string {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
const graphPath = path.join(graphsDir, `${graph.runId}.json`);
|
|
141
|
-
|
|
142
|
+
atomicWriteFile(graphPath, JSON.stringify(graph, null, 2));
|
|
142
143
|
|
|
143
144
|
return graphPath;
|
|
144
145
|
}
|
package/src/state/run-metrics.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function collectRunMetrics(cwd: string, runId: string): RunMetrics | unde
|
|
|
66
66
|
// Duration: from run createdAt to updatedAt (manifest timestamps), or 0 if unavailable.
|
|
67
67
|
const createdAt = new Date(manifest.createdAt).getTime();
|
|
68
68
|
const updatedAt = new Date(manifest.updatedAt).getTime();
|
|
69
|
-
const durationMs = isNaN(createdAt) || isNaN(updatedAt) ? 0 : Math.max(0, updatedAt - createdAt);
|
|
69
|
+
const durationMs = Number.isNaN(createdAt) || Number.isNaN(updatedAt) ? 0 : Math.max(0, updatedAt - createdAt);
|
|
70
70
|
|
|
71
71
|
// Consistency score: proportion of tasks that completed successfully among all non-skipped tasks.
|
|
72
72
|
const nonSkippedTasks = tasks.filter((t) => t.status !== "skipped");
|
package/src/state/schedule.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
13
|
+
import { atomicWriteJson } from "./atomic-write.ts";
|
|
13
14
|
|
|
14
15
|
import type { ScheduledTask, ScheduleStoreData } from "./types.ts";
|
|
15
16
|
|
|
@@ -112,7 +113,12 @@ export class ScheduleStore {
|
|
|
112
113
|
private save(): void {
|
|
113
114
|
try {
|
|
114
115
|
fs.mkdirSync(path.dirname(this.path), { recursive: true });
|
|
115
|
-
|
|
116
|
+
// Atomic (temp + rename + fsync) — matches the class docstring's "atomic JSON
|
|
117
|
+
// persistence" claim. A raw writeFileSync could truncate schedules.json on
|
|
118
|
+
// crash and the constructor silently resets to {jobs:[]} on parse failure.
|
|
119
|
+
// NOTE: this class is currently not instantiated in production (scheduling
|
|
120
|
+
// persists via settings-store updateCrewSettings); kept consistent for safety.
|
|
121
|
+
atomicWriteJson(this.path, this.data);
|
|
116
122
|
} catch (error) {
|
|
117
123
|
// FIX (Round 21, L1): Use logInternalError for consistency with
|
|
118
124
|
// the rest of the codebase. Previously console.warn may not be
|
package/src/state/state-store.ts
CHANGED
|
@@ -14,7 +14,6 @@ import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
|
14
14
|
import { atomicWriteJson, atomicWriteJsonAsync, atomicWriteJsonCoalesced, flushPendingAtomicWrites, readJsonFile } from "./atomic-write.ts";
|
|
15
15
|
import { canTransitionRunStatus } from "./contracts.ts";
|
|
16
16
|
import { appendEvent } from "./event-log.ts";
|
|
17
|
-
import { HealthStore } from "./health-store.ts";
|
|
18
17
|
import { withRunLock, withRunLockSync } from "./locks.ts";
|
|
19
18
|
import type { TeamRunManifest, TeamTaskState } from "./types.ts";
|
|
20
19
|
|
|
@@ -477,9 +476,16 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
477
476
|
* (perf review 2026-07 F4) — now used by persistSingleTaskUpdate's
|
|
478
477
|
* non-terminal checkpoint path; that caller calls flushPendingAtomicWrites()
|
|
479
478
|
* before its read-modify-write load to defeat the stale-read window.
|
|
479
|
+
*
|
|
480
|
+
* ST-7: pass `skipCoalesce: true` for terminal task status transitions
|
|
481
|
+
* (completed/failed/cancelled/needs_attention/skipped) to bypass the
|
|
482
|
+
* 50ms coalesce window — a SIGKILL in that window would otherwise leave
|
|
483
|
+
* tasks.json stale (showing "running") while events.jsonl already shows
|
|
484
|
+
* the terminal event, causing false zombie detection / double-execution
|
|
485
|
+
* on crash recovery.
|
|
480
486
|
*/
|
|
481
487
|
/** @internal */
|
|
482
|
-
export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
|
|
488
|
+
export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[], skipCoalesce: boolean = false): void {
|
|
483
489
|
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
484
490
|
invalidateRunCache(manifest.stateRoot);
|
|
485
491
|
try {
|
|
@@ -487,7 +493,7 @@ export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTask
|
|
|
487
493
|
} catch {
|
|
488
494
|
return;
|
|
489
495
|
}
|
|
490
|
-
atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
|
|
496
|
+
atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, undefined, skipCoalesce);
|
|
491
497
|
}
|
|
492
498
|
|
|
493
499
|
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
package/src/state/tiered-eval.ts
CHANGED
|
@@ -142,7 +142,7 @@ export class TieredEvalRunner {
|
|
|
142
142
|
if (config?.tierConfigs) {
|
|
143
143
|
for (const [tierStr, tierConfig] of Object.entries(config.tierConfigs)) {
|
|
144
144
|
const tier = Number(tierStr) as EvalTier;
|
|
145
|
-
if (tierConfig && !isNaN(tier)) {
|
|
145
|
+
if (tierConfig && !Number.isNaN(tier)) {
|
|
146
146
|
this.tierConfigs[tier] = {
|
|
147
147
|
...this.tierConfigs[tier],
|
|
148
148
|
...tierConfig,
|
|
@@ -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();
|
package/src/ui/dynamic-border.ts
CHANGED
|
@@ -9,6 +9,8 @@ export class DynamicCrewBorder {
|
|
|
9
9
|
private readonly theme: CrewTheme;
|
|
10
10
|
private readonly color?: (value: string) => string;
|
|
11
11
|
private readonly char: string;
|
|
12
|
+
private cachedWidth = -1;
|
|
13
|
+
private cachedLine = "";
|
|
12
14
|
|
|
13
15
|
constructor(theme: CrewTheme, options: DynamicCrewBorderOptions = {}) {
|
|
14
16
|
this.theme = theme;
|
|
@@ -17,9 +19,17 @@ export class DynamicCrewBorder {
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
render(width: number): string[] {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
+
const w = Math.max(0, width);
|
|
23
|
+
if (w !== this.cachedWidth) {
|
|
24
|
+
const line = this.char.repeat(w);
|
|
25
|
+
this.cachedLine = this.color ? this.color(line) : this.theme.fg("border", line);
|
|
26
|
+
this.cachedWidth = w;
|
|
27
|
+
}
|
|
28
|
+
return [this.cachedLine];
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
invalidate(): void {
|
|
31
|
+
invalidate(): void {
|
|
32
|
+
this.cachedWidth = -1;
|
|
33
|
+
this.cachedLine = "";
|
|
34
|
+
}
|
|
25
35
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { LiveAgentHandle } from "../runtime/live-agent-manager.ts";
|
|
8
8
|
import { pad, truncate } from "../utils/visual.ts";
|
|
9
|
+
import { computeLiveDurationMs } from "./live-duration.ts";
|
|
9
10
|
import { spinnerFrame } from "./spinner.ts";
|
|
10
11
|
import { iconForStatus } from "./status-colors.ts";
|
|
11
12
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
@@ -17,7 +18,6 @@ export class LiveConversationOverlay {
|
|
|
17
18
|
private scrollOffset = 0;
|
|
18
19
|
private autoScroll = true;
|
|
19
20
|
private closed = false;
|
|
20
|
-
private frame = 0;
|
|
21
21
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
22
22
|
cachedLines: string[] = [];
|
|
23
23
|
// H-4 fix (code-review 2026-06-23): cap the in-memory line buffer to avoid
|
|
@@ -53,10 +53,12 @@ export class LiveConversationOverlay {
|
|
|
53
53
|
/* ignore */
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
// Also poll for summary updates
|
|
56
|
+
// Also poll for summary updates. Skip when the user has scrolled up
|
|
57
|
+
// (autoScroll === false): the summary refresh also bumps scrollOffset
|
|
58
|
+
// to the tail, which would yank the viewport out from under them.
|
|
57
59
|
this.pollTimer = setInterval(() => {
|
|
58
60
|
if (this.closed) return;
|
|
59
|
-
this.
|
|
61
|
+
if (!this.autoScroll) return;
|
|
60
62
|
try {
|
|
61
63
|
this.refreshSummary();
|
|
62
64
|
} catch {
|
|
@@ -82,31 +84,9 @@ export class LiveConversationOverlay {
|
|
|
82
84
|
|
|
83
85
|
private static readonly SUMMARY_PREFIX = "\u200B"; // zero-width space as summary sentinel
|
|
84
86
|
|
|
85
|
-
private safeElapsedMs(act: typeof this.handle.activity): number {
|
|
86
|
-
const rawStarted = act.startedAtMs || 0;
|
|
87
|
-
const rawCompleted = act.completedAtMs || 0;
|
|
88
|
-
const nowMs = Date.now();
|
|
89
|
-
const nowSec = Math.floor(nowMs / 1000);
|
|
90
|
-
// Simple fix: detect if value is Unix seconds and convert properly
|
|
91
|
-
const toMs = (v: number): number => {
|
|
92
|
-
if (v <= 0) return 0;
|
|
93
|
-
// If 10 digits (or 9 with recent), treat as seconds
|
|
94
|
-
if (v > 1000000000 && v < 10000000000) return v * 1000;
|
|
95
|
-
// If 13 digits, treat as ms
|
|
96
|
-
if (v > 100000000000 && v < 10000000000000) return v;
|
|
97
|
-
// Fallback: use as-is
|
|
98
|
-
return v;
|
|
99
|
-
};
|
|
100
|
-
const startedMs = toMs(rawStarted);
|
|
101
|
-
const completedMs = rawCompleted > 0 ? toMs(rawCompleted) : 0;
|
|
102
|
-
// Validate bounds
|
|
103
|
-
const isValidStarted = startedMs > 0 && startedMs < nowMs + 60000 && startedMs > nowMs - 3155692600000;
|
|
104
|
-
const isValidCompleted = completedMs === 0 || (completedMs > 0 && completedMs < nowMs + 60000);
|
|
105
|
-
return (isValidCompleted ? completedMs : nowMs) - (isValidStarted ? startedMs : nowMs);
|
|
106
|
-
}
|
|
107
87
|
private refreshSummary(): void {
|
|
108
88
|
const act = this.handle.activity;
|
|
109
|
-
const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(
|
|
89
|
+
const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
|
|
110
90
|
const lastLine = this.cachedLines[this.cachedLines.length - 1];
|
|
111
91
|
if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
|
|
112
92
|
this.cachedLines[this.cachedLines.length - 1] = summary;
|
|
@@ -140,7 +120,7 @@ export class LiveConversationOverlay {
|
|
|
140
120
|
: iconForStatus(this.handle.status);
|
|
141
121
|
const name = this.handle.agent ?? this.handle.taskId;
|
|
142
122
|
const act = this.handle.activity;
|
|
143
|
-
const elapsed = `${(
|
|
123
|
+
const elapsed = `${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`;
|
|
144
124
|
const headerParts: string[] = [];
|
|
145
125
|
if (act.maxTurns != null) headerParts.push(`turn ${act.turnCount}/${act.maxTurns}`);
|
|
146
126
|
else if (act.turnCount > 0) headerParts.push(`turn ${act.turnCount}`);
|
package/src/ui/live-duration.ts
CHANGED
|
@@ -21,8 +21,8 @@ function toMs(v: number): number {
|
|
|
21
21
|
if (v <= 0) return 0;
|
|
22
22
|
// 1e9 < seconds < 1e10 → seconds, scale up
|
|
23
23
|
if (v > 1_000_000_000 && v < 10_000_000_000) return v * 1000;
|
|
24
|
-
//
|
|
25
|
-
if (v >
|
|
24
|
+
// 1e10 < ms < 1e13 → already ms (>=10B seconds is 317+ years, implausible)
|
|
25
|
+
if (v > 10_000_000_000 && v < 10_000_000_000_000) return v;
|
|
26
26
|
return v;
|
|
27
27
|
}
|
|
28
28
|
|