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.
Files changed (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. 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 process.env.PI_CREW_WORKER_ATOMIC_WRITER === "1" || process.env.PI_TEAMS_WORKER_ATOMIC_WRITER === "1";
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, 0o644);
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
- console.warn(`unregisterActiveRun: invalid runId ignored: ${runId}`);
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
+ }