pi-crew 0.9.68 → 0.10.2
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 +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { errors } from "../../errors.ts";
|
|
4
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
5
|
+
import { sleepSync } from "../../utils/sleep.ts";
|
|
6
|
+
import { atomicWriteFile } from "../atomic-write.ts";
|
|
7
|
+
import type { TeamEvent } from "./event-log.ts";
|
|
8
|
+
|
|
9
|
+
export const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number; lastAccessMs: number }>();
|
|
10
|
+
export const MAX_SEQUENCE_CACHE_ENTRIES = 256;
|
|
11
|
+
|
|
12
|
+
export function evictOldestSequenceCacheEntries(): void {
|
|
13
|
+
// FIX: Evict by lastAccessMs (access time), not insertion order.
|
|
14
|
+
// Frequently accessed entries should be retained even if older.
|
|
15
|
+
const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
|
|
16
|
+
// Sort entries by lastAccessMs ascending (oldest first)
|
|
17
|
+
const entries = [...sequenceCache.entries()].sort((a, b) => a[1].lastAccessMs - b[1].lastAccessMs);
|
|
18
|
+
// Evict the oldest half
|
|
19
|
+
for (let i = 0; i < toEvict && i < entries.length; i++) {
|
|
20
|
+
sequenceCache.delete(entries[i][0]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal — exported for sequence-cache LRU testing (Round 19). */
|
|
25
|
+
export function __test__sequenceCacheSize(): number {
|
|
26
|
+
return sequenceCache.size;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @internal — seed an entry into the sequence cache for testing. */
|
|
30
|
+
export function __test__seedSequenceCache(eventsPath: string, lastAccessMs: number): void {
|
|
31
|
+
sequenceCache.set(eventsPath, {
|
|
32
|
+
size: 1,
|
|
33
|
+
mtimeMs: 0,
|
|
34
|
+
seq: 0,
|
|
35
|
+
lastAccessMs,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal — expose eviction for testing. */
|
|
40
|
+
export function __test__evictOldestSequenceCacheEntries(): void {
|
|
41
|
+
evictOldestSequenceCacheEntries();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @internal — clear the sequence cache. */
|
|
45
|
+
export function __test__clearSequenceCache(): void {
|
|
46
|
+
sequenceCache.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal — clear the in-process seqCounters Map so nextSequence seeds
|
|
50
|
+
* fresh from the sidecar/file (simulates a process restart for testing). */
|
|
51
|
+
export function __test__clearSeqCounters(): void {
|
|
52
|
+
seqCounters.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @internal — the raw nextSequence for testing (forces re-seed from disk
|
|
56
|
+
* by requiring the caller to have already cleared both caches). */
|
|
57
|
+
export function __test__nextSequence(eventsPath: string): number {
|
|
58
|
+
return nextSequence(eventsPath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @internal — the max sequence cache entries bound. */
|
|
62
|
+
export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
|
|
63
|
+
|
|
64
|
+
export function sequencePath(eventsPath: string): string {
|
|
65
|
+
return `${eventsPath}.seq`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseSequence(raw: string): number | undefined {
|
|
69
|
+
const value = Number.parseInt(raw.trim(), 10);
|
|
70
|
+
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function scanSequence(eventsPath: string): number {
|
|
74
|
+
if (!fs.existsSync(eventsPath)) return 0;
|
|
75
|
+
let max = 0;
|
|
76
|
+
let skipped = 0;
|
|
77
|
+
for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
try {
|
|
80
|
+
const event = JSON.parse(line) as TeamEvent;
|
|
81
|
+
max = Math.max(max, event.metadata?.seq ?? 0);
|
|
82
|
+
} catch {
|
|
83
|
+
skipped++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (skipped > 0) {
|
|
87
|
+
logInternalError("event-log.scanSequence.corrupt_lines", undefined, `${eventsPath}: skipped ${skipped} corrupt line(s)`);
|
|
88
|
+
}
|
|
89
|
+
return max;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readStoredSequence(eventsPath: string): number | undefined {
|
|
93
|
+
try {
|
|
94
|
+
return parseSequence(fs.readFileSync(sequencePath(eventsPath), "utf-8"));
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nextSequence(eventsPath: string): number {
|
|
101
|
+
if (!fs.existsSync(eventsPath)) return 1;
|
|
102
|
+
const stat = fs.statSync(eventsPath);
|
|
103
|
+
const cached = sequenceCache.get(eventsPath);
|
|
104
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
105
|
+
return cached.seq + 1;
|
|
106
|
+
}
|
|
107
|
+
// FIX: Trust the sidecar seq file if it exists and the file is non-empty.
|
|
108
|
+
// Explicitly check for file shrinkage (stat.size < cached.size) to trigger
|
|
109
|
+
// re-scan when rotation or compaction has occurred.
|
|
110
|
+
const stored = readStoredSequence(eventsPath);
|
|
111
|
+
const fileShrunk = cached && stat.size < cached.size;
|
|
112
|
+
if (stored !== undefined && !fileShrunk) {
|
|
113
|
+
// Trust the sidecar, but guard against a REGRESSED sidecar (e.g. the
|
|
114
|
+
// async path persisted a lower seq, rolling the sidecar back below the
|
|
115
|
+
// file's true max). Take max with a full scan so a regressed sidecar
|
|
116
|
+
// cannot produce a duplicate sequence number (EL-1 regression guard).
|
|
117
|
+
// NOTE (ST-12): a full scan here is acceptable because all three append
|
|
118
|
+
// paths now allocate seqs via reserveSequence/reserveSequenceUnderLock
|
|
119
|
+
// (ST-5: re-read sidecar under lock + max with in-process counter);
|
|
120
|
+
// nextSequence is only consulted for seeding/test helpers, NOT on the
|
|
121
|
+
// production append path. ST-12's perf goal (avoid 4MB scan on first
|
|
122
|
+
// append) is therefore met by ST-5 at the reserveSequence layer.
|
|
123
|
+
const fileMax = scanSequence(eventsPath);
|
|
124
|
+
const safeSeq = Math.max(stored, fileMax);
|
|
125
|
+
sequenceCache.set(eventsPath, {
|
|
126
|
+
size: stat.size,
|
|
127
|
+
mtimeMs: stat.mtimeMs,
|
|
128
|
+
seq: safeSeq,
|
|
129
|
+
lastAccessMs: Date.now(),
|
|
130
|
+
});
|
|
131
|
+
return safeSeq + 1;
|
|
132
|
+
}
|
|
133
|
+
const current = scanSequence(eventsPath);
|
|
134
|
+
sequenceCache.set(eventsPath, {
|
|
135
|
+
size: stat.size,
|
|
136
|
+
mtimeMs: stat.mtimeMs,
|
|
137
|
+
seq: current,
|
|
138
|
+
lastAccessMs: Date.now(),
|
|
139
|
+
});
|
|
140
|
+
persistSequence(eventsPath, current);
|
|
141
|
+
return current + 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function persistSequence(eventsPath: string, seq: number): void {
|
|
145
|
+
try {
|
|
146
|
+
// P0-4: the .seq sidecar is disposable (event-reconstructor tolerates an
|
|
147
|
+
// inconsistent tail); best-effort avoids 2 fsyncs per event append.
|
|
148
|
+
atomicWriteFile(sequencePath(eventsPath), String(seq), { durability: "best-effort" });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logInternalError("event-log.persist-sequence-file", error, `eventsPath=${eventsPath}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// R16-B1 (Phase 3.6): THIRD lock namespace `${eventsPath}.seqlock` — a tiny
|
|
155
|
+
// cross-process lock that serializes ONLY the .seq sidecar read-compute-write
|
|
156
|
+
// in reserveSequenceUnderLock. Empirically justified by Round 17: 3000 events
|
|
157
|
+
// across 2 processes on the same eventsPath produced 527 duplicate seq values
|
|
158
|
+
// (75% duplicate events) because the sync family (.mkdirlock) and async family
|
|
159
|
+
// (.alock) are DISJOINT lock namespaces that both trust the sidecar with no
|
|
160
|
+
// mutual exclusion (read sidecar=N → both reserve N+1).
|
|
161
|
+
//
|
|
162
|
+
// Lock-ordering contract (Round 18 Part C): L1(run) → L2(event-log family:
|
|
163
|
+
// .mkdirlock/.alock) → L3(.seqlock). The .seqlock is a PURE-SYNC, SHORT
|
|
164
|
+
// critical section (sidecar read + counter update + best-effort persist —
|
|
165
|
+
// ~2 atomic writes). NEVER acquire L1/L2 while holding L3.
|
|
166
|
+
//
|
|
167
|
+
// Do NOT naively merge .mkdirlock+.alock instead — that reintroduces the
|
|
168
|
+
// v0.9.26 sleepSync-vs-async-timer deadlock (see the withEventLogLockAsync
|
|
169
|
+
// docblock): the sync retry loop's sleepSync starves the async path's
|
|
170
|
+
// event-loop-dependent acquire. The family split is intentional; .seqlock
|
|
171
|
+
// closes the seq race WITHOUT coupling the two families' retry loops.
|
|
172
|
+
//
|
|
173
|
+
// Two acquire wrappers share ONE lock dir + pid file:
|
|
174
|
+
// - withSeqLock: sync acquire (sleepSync backoff) — ALL families.
|
|
175
|
+
// - withSeqLockAsync: thin alias of withSeqLock (see below).
|
|
176
|
+
// Bounds are tighter than the family locks because the section is tiny:
|
|
177
|
+
// timeout 2s / stale 1s / retry 5ms. NOTE: staleMs MUST be < timeoutMs — a
|
|
178
|
+
// crashed holder leaves the lock dir behind, and the acquirer must get the
|
|
179
|
+
// chance to stale-steal it BEFORE its own timeout throws (observed flake:
|
|
180
|
+
// mailbox-api symlink test, killed scaffold holder → 2s spin → throw). The
|
|
181
|
+
// 1s staleness is still ~1000x the sub-millisecond critical section; the only
|
|
182
|
+
// theoretical over-run is a missing-.seq sidecar forcing scanSequence over a
|
|
183
|
+
// multi-MB file, which is far below 1s at the 4MB rotation threshold.
|
|
184
|
+
const SEQ_LOCK_TIMEOUT_MS = 2000;
|
|
185
|
+
const SEQ_LOCK_STALE_MS = 1000;
|
|
186
|
+
const SEQ_LOCK_RETRY_MS = 5;
|
|
187
|
+
|
|
188
|
+
function seqLockPath(eventsPath: string): string {
|
|
189
|
+
return `${eventsPath}.seqlock`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function reserveSequenceLocked(eventsPath: string, count: number): number {
|
|
193
|
+
let stored = readStoredSequence(eventsPath);
|
|
194
|
+
if (stored === undefined) {
|
|
195
|
+
stored = scanSequence(eventsPath);
|
|
196
|
+
}
|
|
197
|
+
const inProcess = seqCounters.get(eventsPath) ?? 0;
|
|
198
|
+
const last = Math.max(stored, inProcess);
|
|
199
|
+
const start = last + 1;
|
|
200
|
+
seqCounters.set(eventsPath, start + count - 1);
|
|
201
|
+
enforceSeqCountersCap();
|
|
202
|
+
// R16-B1: advance-on-reserve. The sidecar MUST be persisted INSIDE the
|
|
203
|
+
// .seqlock — persisting only after the append would leave the window where
|
|
204
|
+
// two processes in different family locks both read sidecar=N and both
|
|
205
|
+
// reserve N+1 (the exact Round-17-confirmed race). Inverting the old
|
|
206
|
+
// "persist only after successful append" ordering (see the sync :persist
|
|
207
|
+
// and async :persist comments) is strictly safer: a failed append after
|
|
208
|
+
// reservation yields a seq GAP, never a duplicate, and the
|
|
209
|
+
// event-reconstructor already tolerates a sidecar ahead of the data
|
|
210
|
+
// (lossless recovery ignores appends beyond the claimed seq — F3a).
|
|
211
|
+
persistSequence(eventsPath, start + count - 1);
|
|
212
|
+
return start;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function withSeqLock<T>(eventsPath: string, fn: () => T): T {
|
|
216
|
+
const lockDir = seqLockPath(eventsPath);
|
|
217
|
+
const pidFile = path.join(lockDir, "pid");
|
|
218
|
+
const start = Date.now();
|
|
219
|
+
let acquired = false;
|
|
220
|
+
while (!acquired) {
|
|
221
|
+
try {
|
|
222
|
+
fs.mkdirSync(lockDir);
|
|
223
|
+
try {
|
|
224
|
+
// P0-4: the lock pid file is disposable stale-lock state; best-effort.
|
|
225
|
+
atomicWriteFile(pidFile, String(process.pid), { durability: "best-effort" });
|
|
226
|
+
} catch {
|
|
227
|
+
/* best-effort */
|
|
228
|
+
}
|
|
229
|
+
acquired = true;
|
|
230
|
+
} catch {
|
|
231
|
+
// Stale detection: mtime-first (handles crash between mkdir and pidFile).
|
|
232
|
+
try {
|
|
233
|
+
if (Date.now() - fs.statSync(lockDir).mtimeMs > SEQ_LOCK_STALE_MS) {
|
|
234
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
/* dir vanished — let loop retry */
|
|
239
|
+
}
|
|
240
|
+
if (Date.now() - start > SEQ_LOCK_TIMEOUT_MS) {
|
|
241
|
+
throw errors.eventLogLockTimeout(eventsPath, SEQ_LOCK_TIMEOUT_MS);
|
|
242
|
+
}
|
|
243
|
+
sleepSync(SEQ_LOCK_RETRY_MS);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
return fn();
|
|
248
|
+
} finally {
|
|
249
|
+
// PID-guarded release: don't delete a stealer's dir if fn exceeded staleMs.
|
|
250
|
+
try {
|
|
251
|
+
if (fs.readFileSync(pidFile, "utf-8").trim() === String(process.pid)) {
|
|
252
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
/* lock stolen or already gone — do not touch */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function withSeqLockAsync<T>(eventsPath: string, fn: () => T): Promise<T> {
|
|
261
|
+
// DELIBERATELY delegates to the SYNC acquire — this is NOT a v0.9.26
|
|
262
|
+
// repeat. The v0.9.26 deadlock was the async family AWAITING a lock whose
|
|
263
|
+
// in-process holder needed event-loop iterations (promise-chain family
|
|
264
|
+
// lock) while the sync path sleepSync-spinned. Here the ENTIRE
|
|
265
|
+
// acquire+body+release is one synchronous block with no internal awaits,
|
|
266
|
+
// so within one process (single JS thread) the lock can never be held
|
|
267
|
+
// across an await — in-process sync-vs-async contention is IMPOSSIBLE,
|
|
268
|
+
// and a spin only ever waits on ANOTHER PROCESS (sub-millisecond section,
|
|
269
|
+
// stale-steal at 1s). An earlier draft gave this wrapper its own
|
|
270
|
+
// `await sleep` backoff; that REINTRODUCED starvation: a sync spinner's
|
|
271
|
+
// sleepSync blocked the loop while the in-process async holder's
|
|
272
|
+
// `await`-based release could never run (mailbox-api 30s timeout flake).
|
|
273
|
+
return withSeqLock(eventsPath, fn);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Monotonic sidecar persist under the .seqlock (L2→L3). Used where a value
|
|
277
|
+
* is persisted OUTSIDE reservation (e.g. batch lastSeq after the fact) so a
|
|
278
|
+
* lower explicit seq can never REGRESS the sidecar below values another
|
|
279
|
+
* process may have already reserved. Writes max(stored, inProcess, seq). */
|
|
280
|
+
export function persistSequenceMonotonic(eventsPath: string, seq: number): void {
|
|
281
|
+
withSeqLock(eventsPath, () => {
|
|
282
|
+
const stored = readStoredSequence(eventsPath) ?? 0;
|
|
283
|
+
const inProcess = seqCounters.get(eventsPath) ?? 0;
|
|
284
|
+
const value = Math.max(stored, inProcess, seq);
|
|
285
|
+
if (value !== stored) persistSequence(eventsPath, value);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// B7: single in-process monotonic sequence counter per eventsPath. The three
|
|
290
|
+
// append paths — sync appendEvent (withEventLogLockSync file lock), buffered
|
|
291
|
+
// flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
|
|
292
|
+
// promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
|
|
293
|
+
// persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
|
|
294
|
+
// produced duplicate sequence numbers (observed live: distinct events sharing
|
|
295
|
+
// a seq; no data loss — only the counter collided). A single in-process counter
|
|
296
|
+
// makes assignment atomic (JS is single-threaded); persistSequence() keeps the
|
|
297
|
+
// sidecar durable for crash recovery across restarts.
|
|
298
|
+
export const seqCounters = new Map<string, number>();
|
|
299
|
+
// H6 (2026-08-10): FIFO cap — mirrors appendCounters (APPEND_COUNTER_MAX_ENTRIES = 256)
|
|
300
|
+
// and agentEventSeqCache (cap at :434). Without this, a long-lived parent pi process
|
|
301
|
+
// that observes many runs (dev sessions with hundreds of background runs over a week)
|
|
302
|
+
// accumulates one entry per distinct eventsPath forever. Eviction is safe: the next
|
|
303
|
+
// append re-seeds from the `.seq` sidecar via reserveSequenceUnderLock.
|
|
304
|
+
const SEQ_COUNTERS_MAX_ENTRIES = 256;
|
|
305
|
+
|
|
306
|
+
/** Atomically reserve the next sequence number for `eventsPath`.
|
|
307
|
+
*
|
|
308
|
+
* ST-5 (v0.9.56): previously this seeded the in-process `seqCounters` counter
|
|
309
|
+
* ONCE per process (reading the `.seq` sidecar a single time via
|
|
310
|
+
* `nextSequence`) and then served every subsequent call purely from that
|
|
311
|
+
* process-local counter. Two processes that both seeded before either had
|
|
312
|
+
* persisted ended up sharing the same counter base -> duplicate sequence
|
|
313
|
+
* numbers -> `sinceSeq` streaming readers silently dropped the second event.
|
|
314
|
+
* The async path was already fixed via `reserveSequenceUnderLock` (which
|
|
315
|
+
* re-reads the sidecar every call); the sync (`appendEvent`) and buffered
|
|
316
|
+
* (`appendEventBatchInsideLock`) paths were NOT.
|
|
317
|
+
*
|
|
318
|
+
* Now ALL three append paths share one body: re-read the authoritative `.seq`
|
|
319
|
+
* sidecar on EVERY call and take `max(sidecar, inProcess)` so a counter that
|
|
320
|
+
* lags behind a sidecar advanced by another process can never assign a
|
|
321
|
+
* regressed (duplicate) seq. Every call site already holds a cross-process
|
|
322
|
+
* file lock (`withEventLogLockSync` for sync/buffered, `withEventLogLockAsync`
|
|
323
|
+
* for async), so the sidecar re-read is race-free within each lock class.
|
|
324
|
+
*
|
|
325
|
+
* R16-B1/R17 CORRECTION (Phase 3.6): the claim above — "race-free within
|
|
326
|
+
* each lock class" — is TRUE but INSUFFICIENT: the sync (.mkdirlock) and
|
|
327
|
+
* async (.alock) families are DISJOINT cross-process namespaces, so a sync
|
|
328
|
+
* appender in one process and an async appender in another still both read
|
|
329
|
+
* sidecar=N and both reserve N+1. Round 17 proved this empirically (527 dup
|
|
330
|
+
* seq / 75% dup events over 3000 events across 2 processes). reserveSequence
|
|
331
|
+
* UnderLock now additionally wraps the sidecar read-compute-write in the
|
|
332
|
+
* shared `.seqlock` (L3) and persists ADVANCE-ON-RESERVE inside it, so no
|
|
333
|
+
* two processes can ever reserve the same seq regardless of lock family. */
|
|
334
|
+
export function reserveSequence(eventsPath: string, count = 1): number {
|
|
335
|
+
return reserveSequenceUnderLock(eventsPath, count);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
|
|
339
|
+
* (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
|
|
340
|
+
export function advanceSequenceCounter(eventsPath: string, seq: number): void {
|
|
341
|
+
const last = seqCounters.get(eventsPath);
|
|
342
|
+
if (last === undefined || seq > last) {
|
|
343
|
+
seqCounters.set(eventsPath, seq);
|
|
344
|
+
enforceSeqCountersCap();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** H6: FIFO eviction when the seqCounters map exceeds its bounded size. */
|
|
349
|
+
function enforceSeqCountersCap(): void {
|
|
350
|
+
if (seqCounters.size > SEQ_COUNTERS_MAX_ENTRIES) {
|
|
351
|
+
const oldest = seqCounters.keys().next().value;
|
|
352
|
+
if (oldest !== undefined) seqCounters.delete(oldest);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** C-01: Reserve sequence INSIDE the cross-process lock. Reads the authoritative
|
|
357
|
+
* sidecar (.seq file) for the last seq persisted by ANY process, ensuring
|
|
358
|
+
* cross-process uniqueness. Falls back to scanSequence if no sidecar exists.
|
|
359
|
+
* The in-process seqCounters is kept monotonic via Math.max for defensive
|
|
360
|
+
* consistency with any in-process sequencing that hasn't been persisted yet.
|
|
361
|
+
* R16-B1 (Phase 3.6): the body now runs under the shared `.seqlock` (L3,
|
|
362
|
+
* acquired L2→L3) and persists the reserved end value inside it
|
|
363
|
+
* (advance-on-reserve) — see reserveSequenceLocked for the rationale.
|
|
364
|
+
* `count` lets the buffered batch reserve a contiguous range in one acquire.
|
|
365
|
+
* Callers on the async family use reserveSequenceUnderLockAsync (same lock
|
|
366
|
+
* dir, async acquire backoff — never sleepSync on the event loop). */
|
|
367
|
+
function reserveSequenceUnderLock(eventsPath: string, count = 1): number {
|
|
368
|
+
return withSeqLock(eventsPath, () => reserveSequenceLocked(eventsPath, count));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function reserveSequenceUnderLockAsync(eventsPath: string, count = 1): Promise<number> {
|
|
372
|
+
return withSeqLockAsync(eventsPath, () => reserveSequenceLocked(eventsPath, count));
|
|
373
|
+
}
|
|
@@ -25,6 +25,7 @@ import * as fs from "node:fs";
|
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
import { Worker } from "node:worker_threads";
|
|
28
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
28
29
|
|
|
29
30
|
const require = createRequire(import.meta.url);
|
|
30
31
|
|
|
@@ -163,7 +164,7 @@ function dispatch(kind: "write" | "mkdir" | "append", payload: Record<string, un
|
|
|
163
164
|
|
|
164
165
|
/** Whether the worker writer is enabled (env var opt-in). */
|
|
165
166
|
export function isWorkerAtomicWriterEnabled(): boolean {
|
|
166
|
-
return
|
|
167
|
+
return getCrewEnv("PI_CREW_WORKER_ATOMIC_WRITER") === "1" || getCrewEnv("PI_TEAMS_WORKER_ATOMIC_WRITER") === "1";
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
/** Atomic-write a file via the worker thread. Sync fs ops inside worker. */
|
|
@@ -2,6 +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 { DEFAULT_CACHE, DEFAULT_PATHS } from "../../config/defaults.ts";
|
|
5
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
5
6
|
import { userCrewRoot } from "../../utils/paths.ts";
|
|
6
7
|
import { isSafePathId } from "../../utils/safe-paths.ts";
|
|
7
8
|
import { sharedScanCache } from "../../utils/scan-cache.ts";
|
|
@@ -68,7 +69,7 @@ function withRegistryLock<T>(fn: () => T): T {
|
|
|
68
69
|
const deadline = Date.now() + 10_000;
|
|
69
70
|
while (true) {
|
|
70
71
|
try {
|
|
71
|
-
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
72
|
+
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
72
73
|
try {
|
|
73
74
|
fs.writeSync(
|
|
74
75
|
fd,
|
|
@@ -371,7 +372,7 @@ export function registerActiveRun(manifest: TeamRunManifest): void {
|
|
|
371
372
|
|
|
372
373
|
export function unregisterActiveRun(runId: string): void {
|
|
373
374
|
if (!isSafePathId(runId)) {
|
|
374
|
-
|
|
375
|
+
logInternalError("active-run-registry", new Error(`unregisterActiveRun: invalid runId ignored: ${runId}`), undefined, "warn");
|
|
375
376
|
return;
|
|
376
377
|
}
|
|
377
378
|
withRegistryLock(() => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest/tasks load paths with corruption recovery and quarantine
|
|
3
|
+
* (ST-4, ST-9, STATE-3). Extracted verbatim from state-store.ts (Phase 2.5
|
|
4
|
+
* god-file decomposition — pure code motion, no behavior change).
|
|
5
|
+
*
|
|
6
|
+
* Import direction is one-way: state-store.ts imports from this module;
|
|
7
|
+
* this module does NOT import values from state-store.ts.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
11
|
+
import { atomicWriteJson, atomicWriteJsonAsync } from "../atomic-write.ts";
|
|
12
|
+
import { isTeamTaskStatus } from "../contracts.ts";
|
|
13
|
+
import { reconstructTasksFromEvents } from "../event-log/event-reconstructor.ts";
|
|
14
|
+
import type { TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
15
|
+
import { CURRENT_TASKS_SCHEMA_VERSION } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ST-4: Rename a corrupt file to a quarantine path (`.corrupt-<ts>`) so it is
|
|
19
|
+
* preserved for debugging but no longer read as the primary source of truth.
|
|
20
|
+
*/
|
|
21
|
+
/** @internal — Phase 2.5 split: also used by state-store.ts (loadRunManifestById loaders). */
|
|
22
|
+
export function quarantineCorruptFile(filePath: string): void {
|
|
23
|
+
try {
|
|
24
|
+
fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
|
|
25
|
+
} catch {
|
|
26
|
+
// Best-effort — if rename fails (file already gone, permission, etc.),
|
|
27
|
+
// we still proceed with reconstruction from the event log.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ST-4: Convert event-reconstructor output into TeamTaskState[].
|
|
33
|
+
* Reconstructed tasks carry lifecycle data (id, status, timing) from the
|
|
34
|
+
* event log; auxiliary fields (role, agent, title) are filled with defaults
|
|
35
|
+
* since they are not present in lifecycle events.
|
|
36
|
+
*/
|
|
37
|
+
function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTaskState[] {
|
|
38
|
+
try {
|
|
39
|
+
const result = reconstructTasksFromEvents(eventsPath);
|
|
40
|
+
const tasks: TeamTaskState[] = [];
|
|
41
|
+
for (const [, rt] of result.tasks) {
|
|
42
|
+
tasks.push({
|
|
43
|
+
id: rt.id,
|
|
44
|
+
runId,
|
|
45
|
+
role: "unknown",
|
|
46
|
+
agent: "unknown",
|
|
47
|
+
title: "reconstructed from events",
|
|
48
|
+
status: isTeamTaskStatus(rt.status) ? rt.status : "queued",
|
|
49
|
+
dependsOn: [],
|
|
50
|
+
cwd: "",
|
|
51
|
+
startedAt: rt.startedAt,
|
|
52
|
+
finishedAt: rt.finishedAt,
|
|
53
|
+
error: rt.error,
|
|
54
|
+
segment: rt.segment,
|
|
55
|
+
diagnostics: rt.diagnostics,
|
|
56
|
+
metrics: rt.metrics,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return tasks;
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ST-4: Load tasks.json with corruption recovery (sync path).
|
|
67
|
+
*
|
|
68
|
+
* Distinguishes:
|
|
69
|
+
* - ENOENT / ENOTDIR → legitimate empty → [] (NOT quarantined).
|
|
70
|
+
* - SyntaxError (parse failure) → corrupt → quarantine `.corrupt-<ts>` AND
|
|
71
|
+
* reconstruct from events.jsonl. If reconstruction yields tasks, persist
|
|
72
|
+
* them so subsequent loads see a valid file.
|
|
73
|
+
* - Non-array JSON (e.g. `{}`) → corrupt → same as SyntaxError.
|
|
74
|
+
* - Valid array → return as-is.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ST-9: Extract the task array from a tasks.json payload.
|
|
79
|
+
*
|
|
80
|
+
* Accepts both the v0 legacy bare-array format and the v1+ envelope
|
|
81
|
+
* `{ schemaVersion, tasks }`. Returns [] for unrecognized shapes.
|
|
82
|
+
*/
|
|
83
|
+
/** @internal — Phase 2.5 split: also used by state-store.ts (shouldPersistTasks). */
|
|
84
|
+
export function extractTaskArray(raw: unknown): TeamTaskState[] {
|
|
85
|
+
if (Array.isArray(raw)) return raw as TeamTaskState[];
|
|
86
|
+
if (raw !== null && typeof raw === "object" && "tasks" in raw) {
|
|
87
|
+
const envelope = raw as { tasks?: unknown };
|
|
88
|
+
if (Array.isArray(envelope.tasks)) return envelope.tasks as TeamTaskState[];
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ST-9: Whether `parsed` is a recognizable tasks.json shape (v0 bare array
|
|
95
|
+
* or v1+ envelope). Used to distinguish legitimate formats from corruption.
|
|
96
|
+
*/
|
|
97
|
+
function isRecognizableTasksPayload(parsed: unknown): boolean {
|
|
98
|
+
if (Array.isArray(parsed)) return true;
|
|
99
|
+
if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
|
|
100
|
+
return Array.isArray((parsed as { tasks?: unknown }).tasks);
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ST-9: Version-check + migration hook for tasks.json.
|
|
107
|
+
*
|
|
108
|
+
* tasks.json has two on-disk shapes:
|
|
109
|
+
* - v0 (current): bare JSON array `TeamTaskState[]` — what saveRunTasks*
|
|
110
|
+
* write today (backward-compatible; no schemaVersion envelope).
|
|
111
|
+
* - v1+ (future): envelope `{ schemaVersion: number, tasks: TeamTaskState[] }`
|
|
112
|
+
* — read-supported defensively for a future write-side switch.
|
|
113
|
+
*
|
|
114
|
+
* This detects the shape and returns the task array. v0 needs NO migration
|
|
115
|
+
* (it IS the current write format). For v1+ envelopes, a schemaVersion
|
|
116
|
+
* mismatch warns (mirroring the manifest check). Future breaking changes
|
|
117
|
+
* add real migration logic here.
|
|
118
|
+
*/
|
|
119
|
+
function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
|
|
120
|
+
// v0 current: bare array (no schemaVersion envelope) — what writers produce.
|
|
121
|
+
if (Array.isArray(parsed)) {
|
|
122
|
+
// v0 bare array is the CURRENT write format (saveRunTasks* write the
|
|
123
|
+
// array directly, by design — backward compat). Nothing to migrate:
|
|
124
|
+
// return as-is. (v1+ envelope read-support below is defensive, for a
|
|
125
|
+
// future write-side switch.) Do NOT warn here — it would fire for 100%
|
|
126
|
+
// of runs on every load and flood the UI on startup.
|
|
127
|
+
return parsed as TeamTaskState[];
|
|
128
|
+
}
|
|
129
|
+
// v1+ envelope: { schemaVersion, tasks }.
|
|
130
|
+
if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
|
|
131
|
+
const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
|
|
132
|
+
const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
|
|
133
|
+
if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
|
|
134
|
+
logInternalError(
|
|
135
|
+
"state-store",
|
|
136
|
+
new Error(
|
|
137
|
+
`tasks.json schemaVersion mismatch: expected ${CURRENT_TASKS_SCHEMA_VERSION}, got ${detected}. Run ${runId} may be incompatible.`,
|
|
138
|
+
),
|
|
139
|
+
undefined,
|
|
140
|
+
"warn",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return extractTaskArray(parsed);
|
|
145
|
+
}
|
|
146
|
+
export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
|
|
147
|
+
let content: string;
|
|
148
|
+
try {
|
|
149
|
+
content = fs.readFileSync(tasksPath, "utf-8");
|
|
150
|
+
} catch {
|
|
151
|
+
// ENOENT / ENOTDIR / other read errors → empty (retry loop handles
|
|
152
|
+
// transient instability; ENOENT is a legitimate empty run).
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
let parsed: unknown;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(content);
|
|
158
|
+
} catch {
|
|
159
|
+
// SyntaxError — corrupt file.
|
|
160
|
+
quarantineCorruptFile(tasksPath);
|
|
161
|
+
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
162
|
+
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
163
|
+
return reconstructed;
|
|
164
|
+
}
|
|
165
|
+
if (!isRecognizableTasksPayload(parsed)) {
|
|
166
|
+
// Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
|
|
167
|
+
quarantineCorruptFile(tasksPath);
|
|
168
|
+
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
169
|
+
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
170
|
+
return reconstructed;
|
|
171
|
+
}
|
|
172
|
+
return migrateTasksFile(parsed, runId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes:
|
|
177
|
+
* - ENOENT / read error → undefined (legitimate missing run — NOT quarantined).
|
|
178
|
+
* - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-<ts>` + log + undefined.
|
|
179
|
+
* Manifest CANNOT be reconstructed from events.jsonl (run.created only carries
|
|
180
|
+
* {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt
|
|
181
|
+
* reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest
|
|
182
|
+
* from silently making a run invisible (STATE-3).
|
|
183
|
+
*/
|
|
184
|
+
export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined {
|
|
185
|
+
let content: string;
|
|
186
|
+
try {
|
|
187
|
+
content = fs.readFileSync(manifestPath, "utf-8");
|
|
188
|
+
} catch {
|
|
189
|
+
// ENOENT / ENOTDIR / other read error → legitimate missing run.
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(content) as TeamRunManifest;
|
|
194
|
+
} catch {
|
|
195
|
+
// SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
|
|
196
|
+
// then treat as missing. Do NOT reconstruct (infeasible from events).
|
|
197
|
+
quarantineCorruptFile(manifestPath);
|
|
198
|
+
logInternalError(
|
|
199
|
+
"state-store",
|
|
200
|
+
new Error(
|
|
201
|
+
`STATE-3: manifest.json for run ${runId} is corrupt (unparseable) — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
|
|
202
|
+
),
|
|
203
|
+
undefined,
|
|
204
|
+
"error",
|
|
205
|
+
);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* ST-4: async twin of {@link loadTasksWithRecovery}.
|
|
212
|
+
*/
|
|
213
|
+
/** @internal — Phase 2.5 split: async twin used by state-store.ts (loadRunManifestByIdAsync). */
|
|
214
|
+
export async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string, runId: string): Promise<TeamTaskState[]> {
|
|
215
|
+
let content: string;
|
|
216
|
+
try {
|
|
217
|
+
content = await fs.promises.readFile(tasksPath, "utf-8");
|
|
218
|
+
} catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
let parsed: unknown;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(content);
|
|
224
|
+
} catch {
|
|
225
|
+
quarantineCorruptFile(tasksPath);
|
|
226
|
+
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
227
|
+
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
228
|
+
return reconstructed;
|
|
229
|
+
}
|
|
230
|
+
if (!isRecognizableTasksPayload(parsed)) {
|
|
231
|
+
quarantineCorruptFile(tasksPath);
|
|
232
|
+
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
233
|
+
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
234
|
+
return reconstructed;
|
|
235
|
+
}
|
|
236
|
+
return migrateTasksFile(parsed, runId);
|
|
237
|
+
}
|