pi-crew 0.6.0 → 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 +225 -0
- package/README.md +70 -31
- 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/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -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/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- 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 +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -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/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- 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/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- 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 +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- 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 +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- 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/contracts.ts +2 -1
- 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 +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -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 +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- 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 +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- 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
|
/**
|
|
@@ -208,32 +342,92 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
208
342
|
* intended use case. Single-update + read-update loops (e.g.
|
|
209
343
|
* persistSingleTaskUpdate) should keep using saveRunTasks.
|
|
210
344
|
*/
|
|
211
|
-
|
|
212
|
-
|
|
345
|
+
/** @internal */
|
|
346
|
+
function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
|
|
347
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
213
348
|
invalidateRunCache(manifest.stateRoot);
|
|
349
|
+
try { fs.statSync(manifest.stateRoot); } catch { return; }
|
|
350
|
+
atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
|
|
214
351
|
}
|
|
215
352
|
|
|
216
353
|
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
|
217
|
-
|
|
354
|
+
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
218
355
|
invalidateRunCache(manifest.stateRoot);
|
|
356
|
+
try { fs.statSync(manifest.stateRoot); } catch { return; }
|
|
357
|
+
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
|
|
219
358
|
}
|
|
220
359
|
|
|
221
360
|
/**
|
|
222
361
|
* Save manifest and tasks files with individual atomic writes.
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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.
|
|
228
371
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
372
|
+
/** @internal */
|
|
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 };
|
|
237
431
|
}
|
|
238
432
|
|
|
239
433
|
export interface UpdateRunStatusOptions {
|
|
@@ -243,7 +437,7 @@ export interface UpdateRunStatusOptions {
|
|
|
243
437
|
|
|
244
438
|
export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManifest["status"], summary?: string, options: UpdateRunStatusOptions = {}): TeamRunManifest {
|
|
245
439
|
if (!canTransitionRunStatus(manifest.status, status)) {
|
|
246
|
-
throw
|
|
440
|
+
throw errors.invalidStatusTransition(manifest.status, status);
|
|
247
441
|
}
|
|
248
442
|
const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
|
|
249
443
|
saveRunManifest(updated);
|
|
@@ -281,11 +475,23 @@ export function __test__clearManifestCache(): void {
|
|
|
281
475
|
async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
|
|
282
476
|
try {
|
|
283
477
|
return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as T;
|
|
284
|
-
} 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
|
+
}
|
|
285
483
|
return undefined;
|
|
286
484
|
}
|
|
287
485
|
}
|
|
288
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
|
+
*/
|
|
289
495
|
export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
|
|
290
496
|
const stateRoot = resolveRunStateRoot(cwd, runId);
|
|
291
497
|
if (!stateRoot) return undefined;
|
|
@@ -299,6 +505,12 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
299
505
|
return undefined;
|
|
300
506
|
}
|
|
301
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.
|
|
302
514
|
let tasksStat: fs.Stats | undefined;
|
|
303
515
|
try {
|
|
304
516
|
tasksStat = fs.statSync(tasksPath);
|
|
@@ -312,28 +524,43 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
312
524
|
&& cached.manifestSize === manifestStat.size
|
|
313
525
|
&& cached.tasksMtimeMs === tasksMtimeMs
|
|
314
526
|
&& cached.tasksSize === (tasksStat?.size ?? 0)
|
|
527
|
+
&& cached.generation === manifestCacheGeneration
|
|
315
528
|
) {
|
|
316
529
|
// TTL eviction: expire stale entries even if mtime matches
|
|
317
|
-
|
|
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) {
|
|
318
536
|
manifestCache.delete(stateRoot);
|
|
319
537
|
} else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
|
|
320
538
|
manifestCache.delete(stateRoot);
|
|
321
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;
|
|
322
550
|
} else {
|
|
323
|
-
return { manifest: cached.manifest, tasks: cached.tasks };
|
|
551
|
+
return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
|
|
324
552
|
}
|
|
325
553
|
}
|
|
326
554
|
|
|
327
|
-
// FIX:
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// 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.
|
|
333
560
|
let attempts = 0;
|
|
334
561
|
let manifest: TeamRunManifest | undefined;
|
|
335
562
|
let tasks: TeamTaskState[] | undefined;
|
|
336
|
-
while (attempts <
|
|
563
|
+
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
337
564
|
const freshStat = fs.statSync(manifestPath);
|
|
338
565
|
manifest = readJsonFile<TeamRunManifest>(manifestPath);
|
|
339
566
|
const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
|
|
@@ -347,6 +574,21 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
347
574
|
manifestStat = freshStat;
|
|
348
575
|
tasksStat = freshTasksStat;
|
|
349
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.
|
|
350
592
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
351
593
|
setManifestCache(stateRoot, {
|
|
352
594
|
manifest,
|
|
@@ -364,6 +606,7 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
364
606
|
if (!stateRoot) return undefined;
|
|
365
607
|
const manifestPath = path.join(stateRoot, "manifest.json");
|
|
366
608
|
const tasksPath = path.join(stateRoot, "tasks.json");
|
|
609
|
+
|
|
367
610
|
let manifestStat: fs.Stats;
|
|
368
611
|
try {
|
|
369
612
|
manifestStat = await fs.promises.stat(manifestPath);
|
|
@@ -371,6 +614,8 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
371
614
|
return undefined;
|
|
372
615
|
}
|
|
373
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.
|
|
374
619
|
let tasksStat: fs.Stats | undefined;
|
|
375
620
|
try {
|
|
376
621
|
tasksStat = await fs.promises.stat(tasksPath);
|
|
@@ -378,20 +623,64 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
378
623
|
tasksStat = undefined;
|
|
379
624
|
}
|
|
380
625
|
const tasksMtimeMs = tasksStat?.mtimeMs ?? 0;
|
|
381
|
-
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) {
|
|
382
627
|
// TTL eviction: expire stale entries even if mtime matches
|
|
383
|
-
|
|
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) {
|
|
384
634
|
manifestCache.delete(stateRoot);
|
|
385
635
|
} else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
|
|
386
636
|
manifestCache.delete(stateRoot);
|
|
387
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;
|
|
388
642
|
} else {
|
|
389
|
-
return { manifest: cached.manifest, tasks: cached.tasks };
|
|
643
|
+
return { manifest: cached.manifest, tasks: cached.tasks ?? [] };
|
|
390
644
|
}
|
|
391
645
|
}
|
|
392
|
-
|
|
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;
|
|
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`);
|
|
673
|
+
}
|
|
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
|
+
|
|
393
683
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
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 ?? [] };
|
|
397
686
|
}
|
package/src/state/task-claims.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { TeamTaskState } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export interface TaskClaimState {
|
|
@@ -18,8 +18,15 @@ export function isTaskClaimExpired(claim: TaskClaimState | undefined, now = new
|
|
|
18
18
|
return Number.isFinite(parsed) ? parsed <= now.getTime() : true;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
22
|
+
const bufA = Buffer.from(String(a));
|
|
23
|
+
const bufB = Buffer.from(String(b));
|
|
24
|
+
if (bufA.length !== bufB.length) return false;
|
|
25
|
+
return timingSafeEqual(bufA, bufB);
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export function canUseTaskClaim(task: Pick<TeamTaskState, "claim">, owner: string, token: string, now = new Date()): boolean {
|
|
22
|
-
return task.claim?.owner === owner && task.claim.token
|
|
29
|
+
return task.claim?.owner === owner && timingSafeTokenMatch(task.claim.token, token) && !isTaskClaimExpired(task.claim, now);
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export function claimTask<T extends TeamTaskState>(task: T, owner: string, leaseMs?: number, now = new Date()): T {
|