pi-crew 0.9.41 → 0.9.42
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 +106 -0
- package/dist/build-meta.json +7087 -6726
- package/dist/index.mjs +43043 -42426
- 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/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/config/config.ts +19 -0
- package/src/config/drift-detector.ts +97 -15
- package/src/config/resilient-parser.ts +4 -4
- package/src/extension/crew-cleanup.ts +17 -3
- package/src/extension/crew-vibes/index.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +18 -5
- 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 +2 -1
- 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 +210 -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 +64 -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/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.ts +22 -2
- package/src/runtime/coalesce-tasks.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/foreground-control.ts +3 -2
- package/src/runtime/goal-loop-runner.ts +6 -2
- 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 +12 -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 +2 -2
- 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 +0 -13
- 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 +1 -1
- 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 +3 -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 +3 -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/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/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/project-detector.ts +1 -1
- package/src/state/atomic-write-v2.ts +0 -108
|
@@ -315,8 +315,13 @@ function isRetryableLinkError(error: unknown): boolean {
|
|
|
315
315
|
* It atomically creates a hard link to the source. We then unlink the source.
|
|
316
316
|
* This prevents TOCTOU attacks where an attacker plants a symlink at the
|
|
317
317
|
* destination between the check and the rename operation.
|
|
318
|
+
*
|
|
319
|
+
* ST-8: this is the canonical symlink-safe rename helper used by both
|
|
320
|
+
* `atomicWriteFile` (sync) and `atomicWriteFileAsync` (async) paths. The
|
|
321
|
+
* helper is exported so any future caller can share the same symlink-safe
|
|
322
|
+
* rename semantics; keep this signature stable.
|
|
318
323
|
*/
|
|
319
|
-
function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
|
|
324
|
+
export function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
|
|
320
325
|
let lastError: unknown;
|
|
321
326
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
322
327
|
try {
|
|
@@ -371,7 +376,12 @@ function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): vo
|
|
|
371
376
|
throw lastError;
|
|
372
377
|
}
|
|
373
378
|
|
|
374
|
-
|
|
379
|
+
/**
|
|
380
|
+
* ST-8: async twin of `renameWithLinkSync`. Exported so any future caller
|
|
381
|
+
* can share the same symlink-safe rename semantics as the primary
|
|
382
|
+
* `atomicWriteFileAsync` path.
|
|
383
|
+
*/
|
|
384
|
+
export async function renameWithLinkAsync(tempPath: string, filePath: string, retries = 8): Promise<void> {
|
|
375
385
|
let lastError: unknown;
|
|
376
386
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
377
387
|
try {
|
|
@@ -496,21 +506,21 @@ export type WriteDurability = "full" | "best-effort";
|
|
|
496
506
|
/** Options accepted by atomicWriteFile (forward-compatible string | object form). */
|
|
497
507
|
export type AtomicWriteOptions =
|
|
498
508
|
| string // legacy: expectedHash
|
|
499
|
-
| { expectedHash?: string; durability?: WriteDurability };
|
|
509
|
+
| { expectedHash?: string; durability?: WriteDurability; mode?: number };
|
|
500
510
|
|
|
501
|
-
function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability } {
|
|
502
|
-
if (typeof arg === "string") return { expectedHash: arg, durability: "full" };
|
|
511
|
+
function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability; mode?: number } {
|
|
512
|
+
if (typeof arg === "string") return { expectedHash: arg, durability: "full", mode: undefined };
|
|
503
513
|
if (arg && typeof arg === "object") {
|
|
504
|
-
const o = arg as { expectedHash?: string; durability?: WriteDurability };
|
|
514
|
+
const o = arg as { expectedHash?: string; durability?: WriteDurability; mode?: number };
|
|
505
515
|
const durability: WriteDurability = o.durability === "best-effort" ? "best-effort" : "full";
|
|
506
|
-
return { expectedHash: o.expectedHash, durability };
|
|
516
|
+
return { expectedHash: o.expectedHash, durability, mode: o.mode };
|
|
507
517
|
}
|
|
508
|
-
return { durability: "full" };
|
|
518
|
+
return { durability: "full", mode: undefined };
|
|
509
519
|
}
|
|
510
520
|
|
|
511
521
|
export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
|
|
512
522
|
cancelPendingCoalescedWrite(filePath);
|
|
513
|
-
const { durability } = normalizeOptions(options);
|
|
523
|
+
const { durability, mode } = normalizeOptions(options);
|
|
514
524
|
if (!isSymlinkSafeDirCached(filePath))
|
|
515
525
|
throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
516
526
|
// On Windows the parent directory may be referenced via a short-name alias
|
|
@@ -556,7 +566,7 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
|
|
|
556
566
|
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
557
567
|
let fd: number | undefined;
|
|
558
568
|
try {
|
|
559
|
-
fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
569
|
+
fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
|
|
560
570
|
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
561
571
|
const openedStat = fs.fstatSync(fd);
|
|
562
572
|
if (!openedStat.isFile()) {
|
|
@@ -747,6 +757,14 @@ let flushInProgress = 0;
|
|
|
747
757
|
* Buffer a JSON write and flush it after `coalesceMs` ms (default 50).
|
|
748
758
|
* Multiple writes to the same path within the window collapse to one disk write.
|
|
749
759
|
*
|
|
760
|
+
* ST-7 escape hatch: pass `skipCoalesce: true` to bypass the buffer entirely
|
|
761
|
+
* and write synchronously via `atomicWriteJson`. Use for terminal task status
|
|
762
|
+
* transitions where a SIGKILL during the 50ms coalesce window must NOT lose
|
|
763
|
+
* the terminal status update — otherwise crash recovery would see "running"
|
|
764
|
+
* in tasks.json while events.jsonl already shows "completed", causing
|
|
765
|
+
* double-execution / zombie detection. Defaults to `false` (buffered write)
|
|
766
|
+
* so all existing callers preserve their behavior.
|
|
767
|
+
*
|
|
750
768
|
* @see The coalescing caveat: a `readJsonFile` call between buffer and flush
|
|
751
769
|
* sees the previous on-disk content. Callers needing read-after-write
|
|
752
770
|
* must call `flushPendingAtomicWrites()` first or use `atomicWriteJson`.
|
|
@@ -754,9 +772,17 @@ let flushInProgress = 0;
|
|
|
754
772
|
export function atomicWriteJsonCoalesced<T>(
|
|
755
773
|
filePath: string,
|
|
756
774
|
value: T,
|
|
757
|
-
coalesceMs = DEFAULT_ATOMIC_COALESCE_MS,
|
|
775
|
+
coalesceMs: number = DEFAULT_ATOMIC_COALESCE_MS,
|
|
758
776
|
options?: AtomicWriteOptions,
|
|
777
|
+
skipCoalesce: boolean = false,
|
|
759
778
|
): void {
|
|
779
|
+
// ST-7: bypass coalescing for callers that need durable synchronous
|
|
780
|
+
// semantics. atomicWriteJson goes through atomicWriteFile which uses
|
|
781
|
+
// renameWithLinkSync + fsync — fully durable, no SIGKILL window.
|
|
782
|
+
if (skipCoalesce) {
|
|
783
|
+
atomicWriteJson(filePath, value, options);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
760
786
|
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
761
787
|
const previous = pendingAtomicWrites.get(filePath);
|
|
762
788
|
if (previous) clearTimeout(previous.timer);
|
package/src/state/blob-store.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
5
|
-
import { sleepSync } from "../utils/sleep.ts";
|
|
6
5
|
import { atomicWriteFile, renameWithRetry } from "./atomic-write.ts";
|
|
7
6
|
import { withFileLockSync } from "./locks.ts";
|
|
8
7
|
|
|
@@ -152,14 +151,9 @@ export function writeBlob(
|
|
|
152
151
|
// FIX: Write blob content FIRST, then metadata. If blob write fails, no orphan metadata.
|
|
153
152
|
// Previous order was metadata first, then blob - causing orphan metadata on blob failure.
|
|
154
153
|
let blobContentWritten = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
blobContentWritten = true;
|
|
159
|
-
} catch (error) {
|
|
160
|
-
// Blob write failed - no metadata to clean up since we haven't written it yet
|
|
161
|
-
throw error;
|
|
162
|
-
}
|
|
154
|
+
// Write blob content first (immutable, content-addressed)
|
|
155
|
+
atomicWriteBuffer(blobPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf-8"));
|
|
156
|
+
blobContentWritten = true;
|
|
163
157
|
|
|
164
158
|
// Metadata only after blob content is successfully written
|
|
165
159
|
// Issue 3 fix: Use two-phase commit for metadata - write to temp file first,
|
package/src/state/contracts.ts
CHANGED
|
@@ -44,7 +44,7 @@ export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, reado
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
/** @internal */
|
|
47
|
-
const TEAM_EVENT_TYPES = [
|
|
47
|
+
export const TEAM_EVENT_TYPES = [
|
|
48
48
|
"run.created",
|
|
49
49
|
"run.queued",
|
|
50
50
|
"run.planning",
|
|
@@ -56,6 +56,10 @@ const TEAM_EVENT_TYPES = [
|
|
|
56
56
|
"task.created",
|
|
57
57
|
"task.started",
|
|
58
58
|
"task.progress",
|
|
59
|
+
"hook.pre_step_started",
|
|
60
|
+
"hook.pre_step_completed",
|
|
61
|
+
"hook.pre_step_failed",
|
|
62
|
+
"hook.pre_step_optional_failed",
|
|
59
63
|
"task.blocked",
|
|
60
64
|
"task.green",
|
|
61
65
|
"task.red",
|
package/src/state/crew-init.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
20
21
|
import { updateGitignore } from "./gitignore-manager.ts";
|
|
21
22
|
|
|
22
23
|
// Re-export updateGitignore for backwards compatibility with tests.
|
|
@@ -238,12 +239,12 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
|
|
|
238
239
|
|
|
239
240
|
for (const placeholder of placeholders) {
|
|
240
241
|
if (!fs.existsSync(placeholder)) {
|
|
241
|
-
|
|
242
|
+
atomicWriteFile(placeholder, "");
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
// 3. Write README.md (always overwrite to keep it current)
|
|
246
|
-
|
|
247
|
+
atomicWriteFile(safeJoin(crewRoot, "README.md"), buildCrewReadme());
|
|
247
248
|
|
|
248
249
|
// 4. Update .gitignore at project root
|
|
249
250
|
const repoRoot = findProjectRoot(cwd);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import { dirname, join } from "path";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
4
4
|
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
@@ -220,7 +220,7 @@ export function rotateEventLogUnlocked(eventsPath: string): boolean {
|
|
|
220
220
|
// copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
|
|
221
221
|
// (no missing-file window for concurrent readers).
|
|
222
222
|
fs.copyFileSync(eventsPath, archivePath);
|
|
223
|
-
|
|
223
|
+
atomicWriteFile(eventsPath, "");
|
|
224
224
|
return true;
|
|
225
225
|
} catch (error) {
|
|
226
226
|
logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
|
package/src/state/event-log.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
93
93
|
const pidFile = path.join(lockDir, "pid");
|
|
94
94
|
const start = Date.now();
|
|
95
95
|
// SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
|
|
96
|
-
// event loop indefinitely.
|
|
96
|
+
// event loop indefinitely. ~100 retries × 50ms ≈ 5s max. After timeout, we
|
|
97
97
|
// throw a clear error instead of blocking forever. This ensures AbortSignal
|
|
98
98
|
// handlers, SIGTERM, and graceful shutdown can fire within seconds.
|
|
99
99
|
const timeout = options?.timeoutMs ?? 5000;
|
|
@@ -109,7 +109,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
109
109
|
// (reduced from 120s) is appropriate.
|
|
110
110
|
fs.mkdirSync(lockDir);
|
|
111
111
|
try {
|
|
112
|
-
|
|
112
|
+
atomicWriteFile(pidFile, String(process.pid));
|
|
113
113
|
} catch {
|
|
114
114
|
/* best-effort */
|
|
115
115
|
}
|
|
@@ -162,7 +162,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
162
162
|
} catch {
|
|
163
163
|
/* no pid file — mtime check above already handles it */
|
|
164
164
|
}
|
|
165
|
-
sleepSync(
|
|
165
|
+
sleepSync(50);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
try {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Only adds entries if not already present; preserves existing content.
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Entries to add to .gitignore for .crew directory management.
|
|
@@ -25,7 +26,7 @@ const CREW_GITIGNORE_ENTRIES = [
|
|
|
25
26
|
*/
|
|
26
27
|
export async function updateGitignore(gitignorePath: string): Promise<void> {
|
|
27
28
|
if (!fs.existsSync(gitignorePath)) {
|
|
28
|
-
|
|
29
|
+
atomicWriteFile(gitignorePath, CREW_GITIGNORE_ENTRIES.join("\n") + "\n");
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { ManifestSummary
|
|
3
|
+
import type { ManifestSummary } from "../runtime/task-health.ts";
|
|
4
4
|
import { computeRunHealth } from "../runtime/task-health.ts";
|
|
5
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
5
6
|
|
|
6
7
|
// Relative to the crew root (`<cwd>/.crew`). BUG A fix (pts/2 hang
|
|
7
8
|
// investigation 2026-06-16): this was `.crew/state/health`, which double-joined
|
|
@@ -41,7 +42,9 @@ export class HealthStore {
|
|
|
41
42
|
const dir = this.healthDir();
|
|
42
43
|
fs.mkdirSync(dir, { recursive: true });
|
|
43
44
|
const file = path.join(dir, `${manifest.runId}.json`);
|
|
44
|
-
|
|
45
|
+
// ST-2: atomic (temp + rename + fsync) — a crash mid-writeFileSync would truncate
|
|
46
|
+
// the snapshot and loadAllSnapshots() silently skips unparseable files.
|
|
47
|
+
atomicWriteFile(file, JSON.stringify(snapshot, null, 2) + "\n");
|
|
45
48
|
return snapshot;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
5
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Represents a learned instinct that guides agent behavior.
|
|
@@ -87,7 +88,13 @@ export class InstinctStore {
|
|
|
87
88
|
private appendInstinctToFile(filePath: string, instinct: Instinct): void {
|
|
88
89
|
const dir = path.dirname(filePath);
|
|
89
90
|
this.ensureDir(dir);
|
|
90
|
-
|
|
91
|
+
// ST-3: read existing + append + atomic rewrite. appendFileSync has no fsync and
|
|
92
|
+
// can interleave bytes on concurrent writes. Callers should wrap this in
|
|
93
|
+
// withFileLockSync(filePath) to serialize concurrent RMW on the same file.
|
|
94
|
+
const existing = fs.existsSync(filePath) ? this.readInstinctsFromFile(filePath) : [];
|
|
95
|
+
existing.push(instinct);
|
|
96
|
+
const content = existing.map((i) => JSON.stringify(i)).join("\n") + "\n";
|
|
97
|
+
atomicWriteFile(filePath, content);
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
/**
|
|
@@ -97,7 +104,9 @@ export class InstinctStore {
|
|
|
97
104
|
const dir = path.dirname(filePath);
|
|
98
105
|
this.ensureDir(dir);
|
|
99
106
|
const content = instincts.map((i) => JSON.stringify(i)).join("\n") + "\n";
|
|
100
|
-
|
|
107
|
+
// ST-3: atomic write (temp + rename + fsync) — crash mid-writeFileSync truncates
|
|
108
|
+
// the JSONL and readInstinctsFromFile silently returns [] (all instincts lost).
|
|
109
|
+
atomicWriteFile(filePath, content);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
/**
|
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,
|
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
|
|