pi-crew 0.6.1 → 0.6.3
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 +160 -0
- package/README.md +70 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +2 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/state/run-metrics.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { loadRunManifestById } from "./state-store.ts";
|
|
4
3
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
4
|
+
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
5
5
|
import { atomicWriteJson, readJsonFile } from "./atomic-write.ts";
|
|
6
|
+
import { loadRunManifestById } from "./state-store.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Run metrics snapshot captured after a run completes (or on demand).
|
|
@@ -28,6 +29,7 @@ function metricsDir(cwd: string): string {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function metricsFilePath(cwd: string, runId: string): string {
|
|
32
|
+
assertSafePathId("runId", runId);
|
|
31
33
|
return path.join(metricsDir(cwd), `${runId}.json`);
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -35,8 +37,12 @@ function metricsFilePath(cwd: string, runId: string): string {
|
|
|
35
37
|
* Collect metrics for a run by reading its manifest, tasks, and event log.
|
|
36
38
|
* Returns undefined if the run cannot be loaded.
|
|
37
39
|
*/
|
|
38
|
-
export function collectRunMetrics(
|
|
39
|
-
|
|
40
|
+
export function collectRunMetrics(
|
|
41
|
+
cwd: string,
|
|
42
|
+
runId: string,
|
|
43
|
+
): RunMetrics | undefined {
|
|
44
|
+
assertSafePathId("runId", runId);
|
|
45
|
+
const result = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
40
46
|
if (!result) return undefined;
|
|
41
47
|
|
|
42
48
|
const { manifest, tasks } = result;
|
|
@@ -63,11 +69,17 @@ export function collectRunMetrics(cwd: string, runId: string): RunMetrics | unde
|
|
|
63
69
|
// Duration: from run createdAt to updatedAt (manifest timestamps), or 0 if unavailable.
|
|
64
70
|
const createdAt = new Date(manifest.createdAt).getTime();
|
|
65
71
|
const updatedAt = new Date(manifest.updatedAt).getTime();
|
|
66
|
-
const durationMs =
|
|
72
|
+
const durationMs =
|
|
73
|
+
isNaN(createdAt) || isNaN(updatedAt)
|
|
74
|
+
? 0
|
|
75
|
+
: Math.max(0, updatedAt - createdAt);
|
|
67
76
|
|
|
68
77
|
// Consistency score: proportion of tasks that completed successfully among all non-skipped tasks.
|
|
69
78
|
const nonSkippedTasks = tasks.filter((t) => t.status !== "skipped");
|
|
70
|
-
const consistencyScore =
|
|
79
|
+
const consistencyScore =
|
|
80
|
+
nonSkippedTasks.length > 0
|
|
81
|
+
? completedCount / nonSkippedTasks.length
|
|
82
|
+
: 1.0;
|
|
71
83
|
|
|
72
84
|
return {
|
|
73
85
|
runId,
|
|
@@ -96,7 +108,10 @@ export function saveRunMetrics(cwd: string, metrics: RunMetrics): void {
|
|
|
96
108
|
* Load a previously saved metrics snapshot.
|
|
97
109
|
* Returns undefined if the file does not exist or cannot be parsed.
|
|
98
110
|
*/
|
|
99
|
-
export function loadRunMetrics(
|
|
111
|
+
export function loadRunMetrics(
|
|
112
|
+
cwd: string,
|
|
113
|
+
runId: string,
|
|
114
|
+
): RunMetrics | undefined {
|
|
100
115
|
return readJsonFile<RunMetrics>(metricsFilePath(cwd, runId));
|
|
101
116
|
}
|
|
102
117
|
|
|
@@ -125,10 +140,11 @@ export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
|
125
140
|
|
|
126
141
|
// Sort newest first (by timestamp, then runId as tiebreaker).
|
|
127
142
|
metrics.sort((a, b) => {
|
|
128
|
-
const diff =
|
|
143
|
+
const diff =
|
|
144
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
129
145
|
if (diff !== 0) return diff;
|
|
130
146
|
return b.runId.localeCompare(a.runId);
|
|
131
147
|
});
|
|
132
148
|
|
|
133
149
|
return metrics.slice(0, limit);
|
|
134
|
-
}
|
|
150
|
+
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -9,10 +9,13 @@ import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
|
|
|
9
9
|
import { createRunId, createTaskId } from "../utils/ids.ts";
|
|
10
10
|
import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
11
11
|
import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
12
|
-
import { withRunLock } from "./locks.ts";
|
|
12
|
+
import { withRunLock, withRunLockSync } from "./locks.ts";
|
|
13
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
14
|
+
import { errors } from "../errors.ts";
|
|
13
15
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
14
16
|
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
|
|
15
17
|
import { toPiSessionId } from "../utils/session-utils.ts";
|
|
18
|
+
import { HealthStore } from "./health-store.ts";
|
|
16
19
|
|
|
17
20
|
export interface RunPaths {
|
|
18
21
|
runId: string;
|
|
@@ -31,19 +34,43 @@ interface ManifestCacheEntry {
|
|
|
31
34
|
tasksMtimeMs: number;
|
|
32
35
|
tasksSize: number;
|
|
33
36
|
cachedAt?: number;
|
|
37
|
+
generation?: number;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
// Global generation counter incremented on each cache invalidation.
|
|
41
|
+
// Detects staleness even when mtime/size are spoofed by an attacker.
|
|
42
|
+
let manifestCacheGeneration = 0;
|
|
43
|
+
|
|
44
|
+
const MANIFEST_CACHE_TTL_MS = 15 * 1000; // 15 seconds (FIX: increased from 5s for read-heavy workloads; 5s was too short causing unnecessary cache invalidation)
|
|
45
|
+
const LOAD_MANIFEST_RETRY_LIMIT = 5; // Configurable retry limit for mtime/size stability checks under contention
|
|
37
46
|
const manifestCache = new Map<string, ManifestCacheEntry>();
|
|
38
47
|
|
|
39
48
|
function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void {
|
|
40
49
|
if (manifestCache.has(stateRoot)) manifestCache.delete(stateRoot);
|
|
41
50
|
entry.cachedAt = Date.now();
|
|
51
|
+
entry.generation = manifestCacheGeneration;
|
|
52
|
+
// FIX: Evict all stale entries by TTL before adding new entry.
|
|
53
|
+
// This ensures entries that are never accessed still get evicted
|
|
54
|
+
// based on TTL, not just entries that are hit.
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const [key, val] of manifestCache.entries()) {
|
|
57
|
+
if (val.cachedAt && now - val.cachedAt > MANIFEST_CACHE_TTL_MS) {
|
|
58
|
+
manifestCache.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
manifestCache.set(stateRoot, entry);
|
|
43
62
|
while (manifestCache.size > DEFAULT_CACHE.manifestMaxEntries) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
// FIX: Evict oldest entry by cachedAt (LRU), not insertion order.
|
|
64
|
+
// cachedAt is set on both initial insertion and cache hits, so
|
|
65
|
+
// frequently accessed entries bubble to the end and survive longer.
|
|
66
|
+
let oldestKey: string | undefined;
|
|
67
|
+
let oldestTime = Infinity;
|
|
68
|
+
for (const [key, val] of manifestCache.entries()) {
|
|
69
|
+
const t = val.cachedAt ?? 0;
|
|
70
|
+
if (t < oldestTime) { oldestTime = t; oldestKey = key; }
|
|
71
|
+
}
|
|
72
|
+
if (!oldestKey) break;
|
|
73
|
+
manifestCache.delete(oldestKey);
|
|
47
74
|
}
|
|
48
75
|
}
|
|
49
76
|
|
|
@@ -53,6 +80,7 @@ function useProjectState(cwd: string): boolean {
|
|
|
53
80
|
|
|
54
81
|
function invalidateRunCache(stateRoot: string): void {
|
|
55
82
|
manifestCache.delete(stateRoot);
|
|
83
|
+
manifestCacheGeneration++;
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
function scopeBaseRoot(cwd: string): string {
|
|
@@ -76,10 +104,19 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
|
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
|
|
107
|
+
// Issue 2 fix: Reject manifests missing status field to prevent undefined
|
|
108
|
+
// behavior in callers like canTransitionRunStatus(manifest.status, newStatus).
|
|
109
|
+
if (!manifest.status || typeof manifest.status !== "string") return false;
|
|
79
110
|
if (manifest.runId !== runId || manifest.stateRoot !== stateRoot || manifest.tasksPath !== tasksPath || manifest.eventsPath !== path.join(stateRoot, "events.jsonl")) return false;
|
|
80
111
|
const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
|
|
81
112
|
const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
|
|
82
113
|
if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
|
|
114
|
+
// Always validate artifactsRoot is not a symlink, even when manifest has
|
|
115
|
+
// no artifacts entries. A symlinked artifactsRoot pointing outside the
|
|
116
|
+
// artifacts parent is a security violation (could write to attacker-
|
|
117
|
+
// controlled locations). This check catches the case where the run was
|
|
118
|
+
// created legitimately but the artifactsRoot was later replaced with a
|
|
119
|
+
// symlink between manifest creation and load.
|
|
83
120
|
if (fs.existsSync(expectedArtifactsRoot)) {
|
|
84
121
|
try {
|
|
85
122
|
if (fs.lstatSync(expectedArtifactsRoot).isSymbolicLink()) return false;
|
|
@@ -87,6 +124,10 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
87
124
|
} catch {
|
|
88
125
|
return false;
|
|
89
126
|
}
|
|
127
|
+
} else if (manifest.artifacts && manifest.artifacts.length > 0) {
|
|
128
|
+
// Has artifacts entries but directory doesn't exist - benign state for
|
|
129
|
+
// runs still in progress.
|
|
130
|
+
return true;
|
|
90
131
|
}
|
|
91
132
|
return true;
|
|
92
133
|
}
|
|
@@ -167,8 +208,14 @@ export function createRunManifest(params: {
|
|
|
167
208
|
};
|
|
168
209
|
fs.mkdirSync(paths.stateRoot, { recursive: true });
|
|
169
210
|
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
|
|
170
|
-
|
|
171
|
-
|
|
211
|
+
// FIX: Use saveManifestAndTasksAtomicSync and check result to detect
|
|
212
|
+
// partial failure. If tasksWritten=false when manifestWritten=true,
|
|
213
|
+
// throw to ensure manifest and tasks are always consistent.
|
|
214
|
+
const result = saveManifestAndTasksAtomicSync(manifest, tasks);
|
|
215
|
+
if (!result.manifestWritten || !result.tasksWritten) {
|
|
216
|
+
throw errors.fileWrite(paths.stateRoot, result.error as unknown as NodeJS.ErrnoException)
|
|
217
|
+
.withContext(`saveManifestAndTasksAtomicSync: manifestWritten=${result.manifestWritten}, tasksWritten=${result.tasksWritten}`);
|
|
218
|
+
}
|
|
172
219
|
appendEvent(paths.eventsPath, {
|
|
173
220
|
type: "run.created",
|
|
174
221
|
runId: paths.runId,
|
|
@@ -186,18 +233,105 @@ export function createRunManifest(params: {
|
|
|
186
233
|
}
|
|
187
234
|
|
|
188
235
|
export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
189
|
-
|
|
236
|
+
// FIX: Invalidate cache BEFORE atomic write. The order matters for crash
|
|
237
|
+
// safety: if we invalidated after the write and crashed before invalidation,
|
|
238
|
+
// the stale cache entry (up to MANIFEST_CACHE_TTL_MS old) could be served.
|
|
239
|
+
// By invalidating first, the worst case is a cache miss forcing a disk read,
|
|
240
|
+
// which is always safe.
|
|
190
241
|
invalidateRunCache(manifest.stateRoot);
|
|
242
|
+
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
243
|
+
atomicWriteJson(manifestPath, manifest);
|
|
244
|
+
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
245
|
+
// doesn't miss the cache on next read. Without this, every load until
|
|
246
|
+
// TTL expires would hit disk because cached 0 !== any real mtime.
|
|
247
|
+
// NOTE: tasks is set to [] here because saveRunManifest only writes the
|
|
248
|
+
// manifest file, not tasks.json. Callers that need tasks should call
|
|
249
|
+
// saveRunTasks or loadRunTasks separately. If loadRunManifestById is called
|
|
250
|
+
// immediately after saveRunManifest, it may return empty tasks even if
|
|
251
|
+
// tasks.json exists on disk — the mtime/size cache check should invalidate
|
|
252
|
+
// on next read, but the returned empty tasks array could confuse callers
|
|
253
|
+
// that don't re-read.
|
|
254
|
+
const manifestStat = fs.statSync(manifestPath);
|
|
255
|
+
setManifestCache(manifest.stateRoot, {
|
|
256
|
+
manifest,
|
|
257
|
+
tasks: [],
|
|
258
|
+
manifestMtimeMs: manifestStat.mtimeMs,
|
|
259
|
+
manifestSize: manifestStat.size,
|
|
260
|
+
tasksMtimeMs: 0,
|
|
261
|
+
tasksSize: 0,
|
|
262
|
+
});
|
|
191
263
|
}
|
|
192
264
|
|
|
193
265
|
export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<void> {
|
|
194
|
-
|
|
266
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving
|
|
267
|
+
// after a crash. See saveRunManifest for full explanation.
|
|
195
268
|
invalidateRunCache(manifest.stateRoot);
|
|
269
|
+
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
270
|
+
await atomicWriteJsonAsync(manifestPath, manifest);
|
|
271
|
+
// FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
|
|
272
|
+
const manifestStat = await fs.promises.stat(manifestPath);
|
|
273
|
+
setManifestCache(manifest.stateRoot, {
|
|
274
|
+
manifest,
|
|
275
|
+
tasks: [],
|
|
276
|
+
manifestMtimeMs: manifestStat.mtimeMs,
|
|
277
|
+
manifestSize: manifestStat.size,
|
|
278
|
+
tasksMtimeMs: 0,
|
|
279
|
+
tasksSize: 0,
|
|
280
|
+
});
|
|
196
281
|
}
|
|
197
282
|
|
|
198
283
|
export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
|
|
199
|
-
|
|
284
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
200
285
|
invalidateRunCache(manifest.stateRoot);
|
|
286
|
+
|
|
287
|
+
// Guard: if the run state directory has been removed (prune/forget/cleanup),
|
|
288
|
+
// silently return — there is nothing to persist and the run is gone.
|
|
289
|
+
try {
|
|
290
|
+
fs.statSync(manifest.stateRoot);
|
|
291
|
+
} catch {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
atomicWriteJson(manifest.tasksPath, tasks);
|
|
296
|
+
// FIX: Re-populate cache with actual mtime/size for manifest and tasks.
|
|
297
|
+
// Note: We re-read manifest from disk to get its current mtime/size
|
|
298
|
+
// since we only wrote tasks here.
|
|
299
|
+
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
300
|
+
let manifestStat: fs.Stats;
|
|
301
|
+
let tasksStat: fs.Stats;
|
|
302
|
+
try {
|
|
303
|
+
manifestStat = fs.statSync(manifestPath);
|
|
304
|
+
tasksStat = fs.statSync(manifest.tasksPath);
|
|
305
|
+
} catch {
|
|
306
|
+
// Run state disappeared between the guard above and now — give up gracefully.
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// FIX: If cache was evicted, re-read manifest from disk rather than using
|
|
310
|
+
// a minimal fallback. A stale minimal manifest with only runId populated
|
|
311
|
+
// would cause manifest.status to be undefined, breaking status checks.
|
|
312
|
+
// If the manifest cannot be read, throw an error — callers using withRunLock
|
|
313
|
+
// should never hit this case, and the error indicates a serious problem.
|
|
314
|
+
// FIX: Also check that the re-read manifest has a status field. If not,
|
|
315
|
+
// fall back to the manifest parameter's status rather than serving a
|
|
316
|
+
// degraded manifest that would break status transition checks.
|
|
317
|
+
const cached = manifestCache.get(manifest.stateRoot);
|
|
318
|
+
const manifestEntry = cached?.manifest ?? readJsonFile<TeamRunManifest>(manifestPath);
|
|
319
|
+
if (!manifestEntry) {
|
|
320
|
+
return; // Run deleted between guard and read
|
|
321
|
+
}
|
|
322
|
+
// Preserve current status from the manifest parameter if the on-disk
|
|
323
|
+
// manifest is missing it (could be a partial write).
|
|
324
|
+
if (!manifestEntry.status) {
|
|
325
|
+
manifestEntry.status = manifest.status;
|
|
326
|
+
}
|
|
327
|
+
setManifestCache(manifest.stateRoot, {
|
|
328
|
+
manifest: manifestEntry,
|
|
329
|
+
tasks,
|
|
330
|
+
manifestMtimeMs: manifestStat.mtimeMs,
|
|
331
|
+
manifestSize: manifestStat.size,
|
|
332
|
+
tasksMtimeMs: tasksStat.mtimeMs,
|
|
333
|
+
tasksSize: tasksStat.size,
|
|
334
|
+
});
|
|
201
335
|
}
|
|
202
336
|
|
|
203
337
|
/**
|
|
@@ -210,32 +344,90 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
210
344
|
*/
|
|
211
345
|
/** @internal */
|
|
212
346
|
function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
|
|
213
|
-
|
|
347
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
214
348
|
invalidateRunCache(manifest.stateRoot);
|
|
349
|
+
try { fs.statSync(manifest.stateRoot); } catch { return; }
|
|
350
|
+
atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
|
|
215
351
|
}
|
|
216
352
|
|
|
217
353
|
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
|
218
|
-
|
|
354
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
219
355
|
invalidateRunCache(manifest.stateRoot);
|
|
356
|
+
try { fs.statSync(manifest.stateRoot); } catch { return; }
|
|
357
|
+
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
|
|
220
358
|
}
|
|
221
359
|
|
|
222
360
|
/**
|
|
223
361
|
* Save manifest and tasks files with individual atomic writes.
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
362
|
+
* FIX: Changed from Promise.all (parallel, non-jointly-atomic) to sequential
|
|
363
|
+
* writes to ensure manifest is written before tasks. A crash between writes
|
|
364
|
+
* leaves them in a known state (manifest is older, tasks is newer) which
|
|
365
|
+
* loadRunManifestById's retry loop can detect via mtime comparison.
|
|
366
|
+
* NOTE: There is no stale-reconciler component — the retry loop provides
|
|
367
|
+
* best-effort detection of mid-write crashes by re-reading until mtime/size
|
|
368
|
+
* are stable. For strict atomicity, callers should use withRunLock().
|
|
369
|
+
* FIX: Returns a result object so callers know which write step failed.
|
|
370
|
+
* If manifest write succeeds but tasks write fails, the caller can recover.
|
|
229
371
|
*/
|
|
230
372
|
/** @internal */
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
373
|
+
interface SaveManifestAndTasksResult {
|
|
374
|
+
manifestWritten: boolean;
|
|
375
|
+
tasksWritten: boolean;
|
|
376
|
+
error?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<SaveManifestAndTasksResult> {
|
|
380
|
+
let manifestWritten = false;
|
|
381
|
+
let tasksWritten = false;
|
|
382
|
+
try {
|
|
383
|
+
await withRunLock(manifest, async () => {
|
|
384
|
+
// FIX: Invalidate cache BEFORE writes to prevent stale cache serving.
|
|
385
|
+
// Sequential writes instead of Promise.all to ensure manifest is
|
|
386
|
+
// written before tasks. If a crash occurs between writes, manifest is
|
|
387
|
+
// the older timestamp which stale-reconciler uses to detect inconsistency.
|
|
388
|
+
invalidateRunCache(manifest.stateRoot);
|
|
389
|
+
await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
|
|
390
|
+
manifestWritten = true;
|
|
391
|
+
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
|
|
392
|
+
tasksWritten = true;
|
|
393
|
+
});
|
|
394
|
+
} catch (err) {
|
|
395
|
+
return {
|
|
396
|
+
manifestWritten,
|
|
397
|
+
tasksWritten,
|
|
398
|
+
// FIX: Use String(err) to safely convert any thrown value (Error,
|
|
399
|
+
// string, number, null, undefined) to a string. err.message would be
|
|
400
|
+
// undefined for non-Error throwables, losing useful context.
|
|
401
|
+
error: String(err),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return { manifestWritten: true, tasksWritten: true };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** @internal */
|
|
408
|
+
function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTaskState[]): SaveManifestAndTasksResult {
|
|
409
|
+
let manifestWritten = false;
|
|
410
|
+
let tasksWritten = false;
|
|
411
|
+
try {
|
|
412
|
+
withRunLockSync(manifest, () => {
|
|
413
|
+
// FIX: Invalidate cache BEFORE writes to prevent stale cache serving.
|
|
414
|
+
invalidateRunCache(manifest.stateRoot);
|
|
415
|
+
atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
|
|
416
|
+
manifestWritten = true;
|
|
417
|
+
atomicWriteJson(manifest.tasksPath, tasks);
|
|
418
|
+
tasksWritten = true;
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return {
|
|
422
|
+
manifestWritten,
|
|
423
|
+
tasksWritten,
|
|
424
|
+
// FIX: Use String(err) to safely convert any thrown value (Error,
|
|
425
|
+
// string, number, null, undefined) to a string. err.message would be
|
|
426
|
+
// undefined for non-Error throwables, losing useful context.
|
|
427
|
+
error: String(err),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return { manifestWritten: true, tasksWritten: true };
|
|
239
431
|
}
|
|
240
432
|
|
|
241
433
|
export interface UpdateRunStatusOptions {
|
|
@@ -245,7 +437,7 @@ export interface UpdateRunStatusOptions {
|
|
|
245
437
|
|
|
246
438
|
export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManifest["status"], summary?: string, options: UpdateRunStatusOptions = {}): TeamRunManifest {
|
|
247
439
|
if (!canTransitionRunStatus(manifest.status, status)) {
|
|
248
|
-
throw
|
|
440
|
+
throw errors.invalidStatusTransition(manifest.status, status);
|
|
249
441
|
}
|
|
250
442
|
const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
|
|
251
443
|
saveRunManifest(updated);
|
|
@@ -283,11 +475,23 @@ export function __test__clearManifestCache(): void {
|
|
|
283
475
|
async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
|
|
284
476
|
try {
|
|
285
477
|
return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as T;
|
|
286
|
-
} catch {
|
|
478
|
+
} catch (err) {
|
|
479
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
480
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
481
|
+
logInternalError("readJsonFileAsync", err, `filePath=${filePath}`);
|
|
482
|
+
}
|
|
287
483
|
return undefined;
|
|
288
484
|
}
|
|
289
485
|
}
|
|
290
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Load a run manifest and its tasks by runId.
|
|
489
|
+
* WARNING: This function provides best-effort consistency only. The sentinel-based
|
|
490
|
+
* retry loop does NOT guarantee manifest/tasks consistency under contention —
|
|
491
|
+
* a concurrent writer can complete a full write cycle between the final stat
|
|
492
|
+
* and the read. For strict consistency, callers MUST wrap load+modify+save in
|
|
493
|
+
* withRunLock(). Callers that need guaranteed consistency should use the lock.
|
|
494
|
+
*/
|
|
291
495
|
export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
|
|
292
496
|
const stateRoot = resolveRunStateRoot(cwd, runId);
|
|
293
497
|
if (!stateRoot) return undefined;
|
|
@@ -301,6 +505,12 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
301
505
|
return undefined;
|
|
302
506
|
}
|
|
303
507
|
const cached = manifestCache.get(stateRoot);
|
|
508
|
+
// Issue 1 fix: Note that the cache read-check-use sequence below is NOT atomic
|
|
509
|
+
// under concurrent modification. While individual JS Map operations are atomic,
|
|
510
|
+
// the sequence spanning multiple filesystem operations is not atomic. Callers MUST
|
|
511
|
+
// NOT invoke loadRunManifestById concurrently with cache-modifying operations
|
|
512
|
+
// (setManifestCache, invalidateRunCache) for the same stateRoot. The generation
|
|
513
|
+
// counter provides some protection, but does not make the read-check-use atomic.
|
|
304
514
|
let tasksStat: fs.Stats | undefined;
|
|
305
515
|
try {
|
|
306
516
|
tasksStat = fs.statSync(tasksPath);
|
|
@@ -314,28 +524,43 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
314
524
|
&& cached.manifestSize === manifestStat.size
|
|
315
525
|
&& cached.tasksMtimeMs === tasksMtimeMs
|
|
316
526
|
&& cached.tasksSize === (tasksStat?.size ?? 0)
|
|
527
|
+
&& cached.generation === manifestCacheGeneration
|
|
317
528
|
) {
|
|
318
529
|
// TTL eviction: expire stale entries even if mtime matches
|
|
319
|
-
|
|
530
|
+
// FIX: Also evict entries where cachedAt is undefined — such entries are
|
|
531
|
+
// effectively immortal otherwise (the `cached.cachedAt &&` check would skip
|
|
532
|
+
// them every time). This can happen if a cache entry was created by
|
|
533
|
+
// setManifestCache that didn't set cachedAt (shouldn't happen in current
|
|
534
|
+
// code, but defensive against future regressions).
|
|
535
|
+
if (!cached.cachedAt || Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
|
|
320
536
|
manifestCache.delete(stateRoot);
|
|
321
537
|
} else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
|
|
322
538
|
manifestCache.delete(stateRoot);
|
|
323
539
|
return undefined;
|
|
540
|
+
} else if (!fs.existsSync(tasksPath)) {
|
|
541
|
+
// Tasks file was deleted after cache was populated — this is a cache miss,
|
|
542
|
+
// not a manifest inconsistency. The cache check passes because
|
|
543
|
+
// tasksMtimeMs=0 and tasksSize=0 match a cache entry written when tasks.json
|
|
544
|
+
// didn't exist. We fall through to the retry loop which will correctly
|
|
545
|
+
// detect the missing file and return undefined. The alternative (treating
|
|
546
|
+
// this as an inconsistency) would require failing the cache hit path, which
|
|
547
|
+
// is unnecessary since the retry loop handles it correctly anyway.
|
|
548
|
+
manifestCache.delete(stateRoot);
|
|
549
|
+
return undefined;
|
|
324
550
|
} else {
|
|
325
|
-
return { manifest: cached.manifest, tasks: cached.tasks };
|
|
551
|
+
return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
|
|
326
552
|
}
|
|
327
553
|
}
|
|
328
554
|
|
|
329
|
-
// FIX:
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// should use withRunLock() around the whole load+modify+save sequence.
|
|
555
|
+
// FIX: Sentinel-based retry loop for best-effort consistency. Re-stat and
|
|
556
|
+
// re-read until mtime/size are stable. The retry limit is now configurable
|
|
557
|
+
// via LOAD_MANIFEST_RETRY_LIMIT (was hardcoded 3). High contention can still
|
|
558
|
+
// cause non-convergence — for strict consistency, callers MUST use withRunLock().
|
|
559
|
+
// Issue 3 fix: Made retry limit configurable instead of hardcoded 3.
|
|
335
560
|
let attempts = 0;
|
|
336
561
|
let manifest: TeamRunManifest | undefined;
|
|
337
562
|
let tasks: TeamTaskState[] | undefined;
|
|
338
|
-
while (attempts <
|
|
563
|
+
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
339
564
|
const freshStat = fs.statSync(manifestPath);
|
|
340
565
|
manifest = readJsonFile<TeamRunManifest>(manifestPath);
|
|
341
566
|
const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
|
|
@@ -349,6 +574,21 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
349
574
|
manifestStat = freshStat;
|
|
350
575
|
tasksStat = freshTasksStat;
|
|
351
576
|
}
|
|
577
|
+
// WARNING: Best-effort consistency only — retry loop detected mtime/size
|
|
578
|
+
// instability. A concurrent writer can still complete a full write cycle
|
|
579
|
+
// between the final stat and the read. Callers needing strict consistency
|
|
580
|
+
// MUST use withRunLock() around load+modify+save.
|
|
581
|
+
if (attempts > 0) {
|
|
582
|
+
console.warn(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
583
|
+
}
|
|
584
|
+
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
585
|
+
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
|
586
|
+
// if a crash occurs AFTER tasks is written but BEFORE manifest is written,
|
|
587
|
+
// tasks mtime would be > manifest mtime (the opposite). The retry loop
|
|
588
|
+
// above detects this crash state by re-reading until mtime/size are stable
|
|
589
|
+
// — the final stable state is what gets used. Because the retry loop
|
|
590
|
+
// handles the crash case, we do NOT fail based on this comparison alone.
|
|
591
|
+
// It does not indicate corruption on its own.
|
|
352
592
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
353
593
|
setManifestCache(stateRoot, {
|
|
354
594
|
manifest,
|
|
@@ -366,6 +606,7 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
366
606
|
if (!stateRoot) return undefined;
|
|
367
607
|
const manifestPath = path.join(stateRoot, "manifest.json");
|
|
368
608
|
const tasksPath = path.join(stateRoot, "tasks.json");
|
|
609
|
+
|
|
369
610
|
let manifestStat: fs.Stats;
|
|
370
611
|
try {
|
|
371
612
|
manifestStat = await fs.promises.stat(manifestPath);
|
|
@@ -373,6 +614,8 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
373
614
|
return undefined;
|
|
374
615
|
}
|
|
375
616
|
const cached = manifestCache.get(stateRoot);
|
|
617
|
+
// Issue 1 fix: Note that the cache read-check-use sequence below is NOT atomic
|
|
618
|
+
// under concurrent modification. Same as sync version — see loadRunManifestById.
|
|
376
619
|
let tasksStat: fs.Stats | undefined;
|
|
377
620
|
try {
|
|
378
621
|
tasksStat = await fs.promises.stat(tasksPath);
|
|
@@ -380,20 +623,64 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
380
623
|
tasksStat = undefined;
|
|
381
624
|
}
|
|
382
625
|
const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
|
|
383
|
-
if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0)) {
|
|
626
|
+
if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0) && cached.generation === manifestCacheGeneration) {
|
|
384
627
|
// TTL eviction: expire stale entries even if mtime matches
|
|
385
|
-
|
|
628
|
+
// FIX: Also evict entries where cachedAt is undefined — such entries are
|
|
629
|
+
// effectively immortal otherwise (the `cached.cachedAt &&` check would skip
|
|
630
|
+
// them every time). This can happen if a cache entry was created by
|
|
631
|
+
// setManifestCache that didn't set cachedAt (shouldn't happen in current
|
|
632
|
+
// code, but defensive against future regressions).
|
|
633
|
+
if (!cached.cachedAt || Date.now() - cached.cachedAt > MANIFEST_CACHE_TTL_MS) {
|
|
386
634
|
manifestCache.delete(stateRoot);
|
|
387
635
|
} else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
|
|
388
636
|
manifestCache.delete(stateRoot);
|
|
389
637
|
return undefined;
|
|
638
|
+
} else if (!fs.existsSync(tasksPath)) {
|
|
639
|
+
// Tasks file was deleted after cache was populated — do not serve stale cache.
|
|
640
|
+
manifestCache.delete(stateRoot);
|
|
641
|
+
return undefined;
|
|
390
642
|
} else {
|
|
391
|
-
return { manifest: cached.manifest, tasks: cached.tasks };
|
|
643
|
+
return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// FIX: Sentinel-based retry loop to close TOCTOU window between stat and read.
|
|
648
|
+
// Matches the pattern used in the sync loadRunManifestById.
|
|
649
|
+
// Issue 3 fix: Made retry limit configurable instead of hardcoded 3.
|
|
650
|
+
let manifest: TeamRunManifest | undefined;
|
|
651
|
+
let tasks: TeamTaskState[] | undefined;
|
|
652
|
+
let attempts = 0;
|
|
653
|
+
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
654
|
+
const freshStat = await fs.promises.stat(manifestPath);
|
|
655
|
+
manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
|
|
656
|
+
const freshTasksStat = await fs.promises.stat(tasksPath).catch(() => undefined);
|
|
657
|
+
tasks = (await readJsonFileAsync<TeamTaskState[]>(tasksPath)) ?? [];
|
|
658
|
+
// If size/mtime didn't change between stat and read, we're consistent.
|
|
659
|
+
if (freshStat.mtimeMs === manifestStat.mtimeMs && freshStat.size === manifestStat.size
|
|
660
|
+
&& (!freshTasksStat || (freshTasksStat.mtimeMs === tasksStat?.mtimeMs && freshTasksStat.size === tasksStat?.size))) {
|
|
661
|
+
break;
|
|
392
662
|
}
|
|
663
|
+
attempts += 1;
|
|
664
|
+
manifestStat = freshStat;
|
|
665
|
+
tasksStat = freshTasksStat;
|
|
666
|
+
}
|
|
667
|
+
// WARNING: Best-effort consistency only — retry loop detected mtime/size
|
|
668
|
+
// instability. A concurrent writer can still complete a full write cycle
|
|
669
|
+
// between the final stat and the read. Callers needing strict consistency
|
|
670
|
+
// MUST use withRunLock() around load+modify+save.
|
|
671
|
+
if (attempts > 0) {
|
|
672
|
+
console.warn(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
393
673
|
}
|
|
394
|
-
|
|
674
|
+
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
675
|
+
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
|
676
|
+
// if a crash occurs AFTER tasks is written but BEFORE manifest is written,
|
|
677
|
+
// tasks mtime would be > manifest mtime (the opposite). The retry loop
|
|
678
|
+
// above detects this crash state by re-reading until mtime/size are stable
|
|
679
|
+
// — the final stable state is what gets used. Because the retry loop
|
|
680
|
+
// handles the crash case, we do NOT fail based on this comparison alone.
|
|
681
|
+
// It does not indicate corruption on its own.
|
|
682
|
+
|
|
395
683
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return { manifest, tasks };
|
|
684
|
+
setManifestCache(stateRoot, { manifest, tasks: tasks ?? [], manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0 });
|
|
685
|
+
return { manifest, tasks: tasks ?? [] };
|
|
399
686
|
}
|
|
@@ -109,7 +109,7 @@ export class LiveRunSidebar {
|
|
|
109
109
|
|
|
110
110
|
render(width: number): string[] {
|
|
111
111
|
const w = Math.max(36, width);
|
|
112
|
-
const loaded = loadRunManifestById(this.cwd, this.runId);
|
|
112
|
+
const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
113
113
|
if (!loaded) {
|
|
114
114
|
return renderLines(
|
|
115
115
|
[
|
package/src/ui/loaders.ts
CHANGED
|
@@ -131,6 +131,10 @@ export class CountdownTimer {
|
|
|
131
131
|
this.emitExpire();
|
|
132
132
|
}
|
|
133
133
|
}, 1000);
|
|
134
|
+
// Defense-in-depth: never let the countdown timer keep the event loop
|
|
135
|
+
// alive. If dispose() is missed (e.g. UI unmount race), the timer must
|
|
136
|
+
// not block process exit.
|
|
137
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
private emitExpire(): void {
|
|
@@ -15,7 +15,7 @@ export class AgentPickerOverlay {
|
|
|
15
15
|
private selected = 0;
|
|
16
16
|
|
|
17
17
|
constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
|
|
18
|
-
const loaded = loadRunManifestById(opts.cwd, opts.runId);
|
|
18
|
+
const loaded = loadRunManifestById(opts.cwd, opts.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
19
19
|
this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
|
|
20
20
|
this.done = opts.done;
|
|
21
21
|
this.theme = asCrewTheme(opts.theme ?? {});
|
|
@@ -32,7 +32,7 @@ export class MailboxDetailOverlay {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
private refresh(): void {
|
|
35
|
-
const loaded = loadRunManifestById(this.cwd, this.runId);
|
|
35
|
+
const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
36
36
|
if (!loaded) return;
|
|
37
37
|
// Track task count changes to trigger re-render
|
|
38
38
|
const taskCount = loaded.tasks.length;
|