pi-crew 0.9.67 → 0.10.1

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 (222) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65847 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +9 -6
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +36 -14
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/scratchpad/README.md +10 -5
  111. package/src/runtime/scratchpad/guest.ts +103 -2
  112. package/src/runtime/scratchpad/protocol.ts +13 -1
  113. package/src/runtime/scratchpad/transform.ts +206 -12
  114. package/src/runtime/settings-store.ts +219 -0
  115. package/src/runtime/spawn-policy.ts +217 -0
  116. package/src/runtime/stale-reconciler.ts +87 -6
  117. package/src/runtime/subagent-manager.ts +25 -1
  118. package/src/runtime/task-output-context.ts +230 -9
  119. package/src/runtime/task-packet.ts +23 -1
  120. package/src/runtime/task-runner/child-executor.ts +106 -7
  121. package/src/runtime/task-runner/post-execution.ts +125 -1
  122. package/src/runtime/task-runner/pre-execution.ts +39 -1
  123. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  124. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  125. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  126. package/src/runtime/task-runner/state-helpers.ts +26 -24
  127. package/src/runtime/task-runner.ts +11 -0
  128. package/src/runtime/team-runner.ts +129 -1671
  129. package/src/runtime/verification/spec-sandbox.ts +255 -0
  130. package/src/runtime/verification/verification-gates.ts +3 -2
  131. package/src/runtime/verification/verification-worktree.ts +2 -1
  132. package/src/runtime/workflow-phase-advance.ts +100 -0
  133. package/src/runtime/workspace-tree.ts +9 -0
  134. package/src/schema/config-schema.ts +65 -25
  135. package/src/schema/sensitive-config-paths.ts +64 -0
  136. package/src/schema/team-tool-schema.ts +13 -3
  137. package/src/state/README.md +4 -10
  138. package/src/state/atomic-write.ts +20 -3
  139. package/src/state/contracts.ts +38 -0
  140. package/src/state/coordination/mailbox.ts +12 -2
  141. package/src/state/event-log/cursor.ts +223 -0
  142. package/src/state/event-log/event-log-rotation.ts +12 -4
  143. package/src/state/event-log/event-log.ts +152 -369
  144. package/src/state/event-log/sequence-cache.ts +373 -0
  145. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  146. package/src/state/stores/active-run-registry.ts +3 -2
  147. package/src/state/stores/manifest-io.ts +237 -0
  148. package/src/state/stores/ownership-map.ts +162 -0
  149. package/src/state/stores/plan-store.ts +241 -0
  150. package/src/state/stores/run-cache.ts +0 -90
  151. package/src/state/stores/spec-store.ts +189 -0
  152. package/src/state/stores/state-store.ts +66 -229
  153. package/src/state/types.ts +197 -0
  154. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  155. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  156. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  157. package/src/ui/heartbeat-aggregator.ts +9 -1
  158. package/src/ui/keybinding-map.ts +54 -13
  159. package/src/ui/powerbar-publisher.ts +52 -1
  160. package/src/ui/run-dashboard.ts +31 -5
  161. package/src/ui/run-snapshot-cache.ts +57 -30
  162. package/src/ui/snapshot-types.ts +6 -1
  163. package/src/ui/widget/widget-renderer.ts +9 -1
  164. package/src/utils/file-coalescer.ts +0 -4
  165. package/src/utils/fs-errno.ts +66 -0
  166. package/src/utils/fs-watch.ts +1 -1
  167. package/src/utils/internal-error.ts +3 -1
  168. package/src/utils/paths.ts +11 -3
  169. package/src/utils/task-name-generator.ts +1 -8
  170. package/src/workflows/discover-workflows.ts +19 -2
  171. package/src/workflows/validate-workflow.ts +7 -1
  172. package/src/workflows/workflow-config.ts +10 -0
  173. package/src/workflows/workflow-serializer.ts +3 -0
  174. package/src/worktree/worktree-manager.ts +22 -0
  175. package/src/agents/agent-search.ts +0 -98
  176. package/src/benchmark/benchmark-runner.ts +0 -313
  177. package/src/benchmark/feedback-loop.ts +0 -73
  178. package/src/config/resilient-parser.ts +0 -117
  179. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  180. package/src/extension/result-watcher.ts +0 -139
  181. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  182. package/src/observability/metric-retention.ts +0 -64
  183. package/src/runtime/compaction/compaction-summary.ts +0 -278
  184. package/src/runtime/errors/crew-errors.ts +0 -162
  185. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  186. package/src/runtime/loop-gates.ts +0 -128
  187. package/src/runtime/metric-parser.ts +0 -36
  188. package/src/runtime/output/stream-preview.ts +0 -184
  189. package/src/runtime/output/tool-progress.ts +0 -278
  190. package/src/runtime/phase-tracker.ts +0 -385
  191. package/src/runtime/pipeline-runner.ts +0 -523
  192. package/src/runtime/process/process-lifecycle.ts +0 -491
  193. package/src/runtime/recovery/retry-runner.ts +0 -330
  194. package/src/runtime/run-drift.ts +0 -219
  195. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
  196. package/src/runtime/task-quality.ts +0 -199
  197. package/src/runtime/task-runner/run-projection.ts +0 -128
  198. package/src/runtime/verification/post-checks.ts +0 -142
  199. package/src/state/coordination/schedule.ts +0 -166
  200. package/src/state/event-log/jsonl-writer.ts +0 -115
  201. package/src/state/hook-instinct-bridge.ts +0 -94
  202. package/src/state/hook-integrations.ts +0 -51
  203. package/src/state/session-state-map.ts +0 -51
  204. package/src/state/stores/blob-store.ts +0 -308
  205. package/src/state/stores/instinct-store.ts +0 -275
  206. package/src/state/stores/observation-store.ts +0 -176
  207. package/src/state/tiered-eval.ts +0 -480
  208. package/src/state/types-eval.ts +0 -58
  209. package/src/tools/safe-bash-extension.ts +0 -54
  210. package/src/tools/safe-bash.ts +0 -505
  211. package/src/ui/agent-management-overlay.ts +0 -160
  212. package/src/ui/crew-footer.ts +0 -102
  213. package/src/ui/crew-select-list.ts +0 -114
  214. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  215. package/src/ui/transcript-entries.ts +0 -256
  216. package/src/utils/conflict-detect.ts +0 -721
  217. package/src/utils/fingerprint.ts +0 -180
  218. package/src/utils/gh-protocol.ts +0 -556
  219. package/src/utils/project-detector.ts +0 -160
  220. package/src/utils/sse-parser.ts +0 -131
  221. package/src/workflows/cost-estimator.ts +0 -34
  222. package/src/workflows/intermediate-store.ts +0 -166
@@ -0,0 +1,189 @@
1
+ /**
2
+ * spec-store.ts — SpecRecord store + provenance mint (ADR-6 §1/§4, WP-6 step 2;
3
+ * round-1 security fix: provenance v2).
4
+ *
5
+ * TWO stores, split by trust:
6
+ * - WORKSPACE store `<projectCrewRoot(cwd)>/state/specs/<id>.json` — the
7
+ * generated/worker-influenced store. NEVER trusted by the strict gate.
8
+ * - USER store `~/.pi/agent/specs/<projectSlug>/<id>.json` — the ONLY store a
9
+ * user action (CLI import / explicit command) mints into. Trust lives here
10
+ * because `.crew/state/` is worker-writable runtime state: any prompt-injected
11
+ * worker can write a workspace `*.json` + `*.trusted` pair (round-1 P1).
12
+ * Raising the anchor out of the workspace makes workspace tampering
13
+ * structurally insufficient — no documented worker path writes `~/.pi/agent/specs`.
14
+ *
15
+ * Digest-bound sidecar: the user-store `<id>.trusted` marker contains the
16
+ * sha-256 of the canonical record JSON. `isSpecTrusted` verifies the binding,
17
+ * so editing a legitimately-minted record while keeping its real sidecar
18
+ * (content-swap attack) degrades to untrusted. Residual (documented in ADR
19
+ * erratum): a fully malicious same-user process can forge both files in
20
+ * `~/.pi/agent/specs` — but such a process already has arbitrary exec as the
21
+ * user; the gate defends against prompt-injected WORKERS via documented paths.
22
+ *
23
+ * Trust is evaluated ONCE at freeze (dispatch): `freezeSpecSnapshot` records
24
+ * `trustedAtFreeze` into the snapshot, and the strict gate reads ONLY that
25
+ * frozen bit — post-freeze mint/delete cannot affect a running task (TOCTOU
26
+ * window closed, round-1 P1 variant c).
27
+ */
28
+
29
+ import { createHash } from "node:crypto";
30
+ import * as fs from "node:fs";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { projectCrewRoot } from "../../utils/paths.ts";
34
+ import { atomicWriteJson } from "../atomic-write.ts";
35
+ import type { SpecRecord, SpecSnapshot } from "../types.ts";
36
+
37
+ function specsDir(cwd: string): string {
38
+ return path.join(projectCrewRoot(cwd), "state", "specs");
39
+ }
40
+
41
+ function userSpecsDir(cwd: string): string {
42
+ const slugSource = (() => {
43
+ try {
44
+ return fs.realpathSync(path.resolve(cwd));
45
+ } catch {
46
+ return path.resolve(cwd);
47
+ }
48
+ })();
49
+ const slug = createHash("sha256").update(slugSource).digest("hex").slice(0, 16);
50
+ return path.join(os.homedir(), ".pi", "agent", "specs", slug);
51
+ }
52
+
53
+ function recordPath(dir: string, id: string): string {
54
+ assertSafeSpecId(id);
55
+ return path.join(dir, `${id}.json`);
56
+ }
57
+
58
+ function sidecarPath(dir: string, id: string): string {
59
+ assertSafeSpecId(id);
60
+ return path.join(dir, `${id}.trusted`);
61
+ }
62
+
63
+ const SPEC_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
64
+
65
+ function assertSafeSpecId(id: string): void {
66
+ if (!SPEC_ID_PATTERN.test(id)) throw new Error(`Invalid spec id: ${id}`);
67
+ }
68
+
69
+ /** Stable canonical JSON (sorted keys, recursive) — the digest basis. */
70
+ export function canonicalSpecJson(value: unknown): string {
71
+ const sort = (v: unknown): unknown => {
72
+ if (Array.isArray(v)) return v.map(sort);
73
+ if (v && typeof v === "object") {
74
+ return Object.fromEntries(
75
+ Object.entries(v as Record<string, unknown>)
76
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
77
+ .map(([k, val]) => [k, sort(val)]),
78
+ );
79
+ }
80
+ return v;
81
+ };
82
+ return JSON.stringify(sort(value));
83
+ }
84
+
85
+ export interface SaveSpecOptions {
86
+ /** Only USER-facing actions (CLI import / explicit user command) may mint. */
87
+ userAction?: boolean;
88
+ }
89
+
90
+ export function saveSpecRecord(cwd: string, record: SpecRecord, options: SaveSpecOptions = {}): SpecRecord {
91
+ assertSafeSpecId(record.id);
92
+ const userAction = options.userAction === true;
93
+ if (userAction && record.source.kind === "manual") {
94
+ // USER store mint — the only trusted path. persisted.trusted mirrors the
95
+ // digest sidecar (informational); the gate reads the sidecar digest.
96
+ const persisted: SpecRecord = { ...record, trusted: true };
97
+ const dir = userSpecsDir(cwd);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ atomicWriteJson(recordPath(dir, record.id), persisted);
100
+ const digest = createHash("sha256").update(canonicalSpecJson(persisted), "utf8").digest("hex");
101
+ fs.writeFileSync(sidecarPath(dir, record.id), `${digest}\n`, "utf-8");
102
+ return persisted;
103
+ }
104
+ // Workspace store — the worker/skill path AND user-imported generated
105
+ // records. ALWAYS persisted generated + untrusted regardless of payload.
106
+ const persisted: SpecRecord = {
107
+ ...record,
108
+ source: { ...record.source, kind: "generated" },
109
+ trusted: false,
110
+ };
111
+ fs.mkdirSync(specsDir(cwd), { recursive: true });
112
+ atomicWriteJson(recordPath(specsDir(cwd), record.id), persisted);
113
+ // Teardown any stale workspace sidecar (defense in depth: a hand-forged
114
+ // workspace sidecar must never resurrect trust).
115
+ try {
116
+ fs.unlinkSync(sidecarPath(specsDir(cwd), record.id));
117
+ } catch {
118
+ /* absent — fine */
119
+ }
120
+ return persisted;
121
+ }
122
+
123
+ function readRecordAt(file: string, id: string): SpecRecord | undefined {
124
+ try {
125
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as SpecRecord;
126
+ if (parsed?.id !== id) return undefined;
127
+ return parsed;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** User store first (authoritative for trusted specs), then workspace.
134
+ * Invalid/unsafe ids degrade to undefined (read path never throws). */
135
+ export function loadSpecRecord(cwd: string, id: string): SpecRecord | undefined {
136
+ if (!SPEC_ID_PATTERN.test(id)) return undefined;
137
+ const fromUser = readRecordAt(recordPath(userSpecsDir(cwd), id), id);
138
+ if (fromUser) return fromUser;
139
+ return readRecordAt(recordPath(specsDir(cwd), id), id);
140
+ }
141
+
142
+ /** Strict-gate trust check — USER-store sidecar exists AND its sha-256 digest
143
+ * binds to the record content. When `record` is provided (freeze path) the
144
+ * binding is checked against THAT record, killing the content-swap attack on
145
+ * legitimately-minted specs. */
146
+ export function isSpecTrusted(cwd: string, id: string, record?: SpecRecord): boolean {
147
+ if (!SPEC_ID_PATTERN.test(id)) return false;
148
+ const dir = userSpecsDir(cwd);
149
+ const candidate = record ?? readRecordAt(recordPath(dir, id), id);
150
+ if (!candidate) return false;
151
+ try {
152
+ const sidecar = fs.readFileSync(sidecarPath(dir, id), "utf-8").trim();
153
+ const digest = createHash("sha256").update(canonicalSpecJson(candidate), "utf8").digest("hex");
154
+ return sidecar === digest;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ export function listSpecIds(cwd: string): string[] {
161
+ const ids = new Set<string>();
162
+ for (const dir of [userSpecsDir(cwd), specsDir(cwd)]) {
163
+ try {
164
+ for (const f of fs.readdirSync(dir)) {
165
+ if (!f.endsWith(".json")) continue;
166
+ const id = f.slice(0, -".json".length);
167
+ if (SPEC_ID_PATTERN.test(id)) ids.add(id);
168
+ }
169
+ } catch {
170
+ /* absent dir — fine */
171
+ }
172
+ }
173
+ return [...ids];
174
+ }
175
+
176
+ /** Freeze the current record into an immutable SpecSnapshot (dispatch time).
177
+ * `trustedAtFreeze` is decided HERE — the strict gate never re-reads the live
178
+ * sidecar, so post-freeze mint/delete cannot change a running task's trust. */
179
+ export function freezeSpecSnapshot(record: SpecRecord, cwd: string): SpecSnapshot {
180
+ return {
181
+ specId: record.id,
182
+ version: record.version,
183
+ frozenAt: new Date().toISOString(),
184
+ trustedAtFreeze: isSpecTrusted(cwd, record.id, record),
185
+ items: record.requirements.flatMap((requirement) =>
186
+ record.acceptance.filter((a) => a.requirementId === requirement.id).map((acceptance) => ({ requirement, acceptance })),
187
+ ),
188
+ };
189
+ }
@@ -17,13 +17,15 @@ import {
17
17
  flushPendingAtomicWrites,
18
18
  readJsonFile,
19
19
  } from "../atomic-write.ts";
20
- import { canTransitionRunStatus, isTeamTaskStatus } from "../contracts.ts";
20
+ import { canTransitionRunStatus } from "../contracts.ts";
21
21
  import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
22
22
  import { appendEvent } from "../event-log/event-log.ts";
23
- import { reconstructTasksFromEvents } from "../event-log/event-reconstructor.ts";
24
23
  import type { TeamRunManifest, TeamTaskState } from "../types.ts";
25
- import { CURRENT_SCHEMA_VERSION, CURRENT_TASKS_SCHEMA_VERSION } from "../types.ts";
24
+ import { CURRENT_SCHEMA_VERSION } from "../types.ts";
26
25
  import { unregisterActiveRun } from "./active-run-registry.ts";
26
+ import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
27
+
28
+ export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
27
29
 
28
30
  /**
29
31
  * stat() the manifest with a brief retry on Windows for the AV-scan window.
@@ -87,6 +89,10 @@ export interface ManifestCacheEntry {
87
89
  // (e.g., parent + child processes writing to the same run) relies on the
88
90
  // mtime/size checks in loadRunManifestById, not on this counter.
89
91
  const manifestCacheGeneration = new Map<string, number>();
92
+ // R5-M1 (Round 5 MEDIUM-1): safety-net FIFO cap. stateRoots are few in
93
+ // practice, but nothing else bounded this Map — it leaked ~1 entry per run
94
+ // for the process lifetime (manifestCache itself has TTL + LRU eviction).
95
+ const MANIFEST_CACHE_GENERATION_MAX = 64;
90
96
  function genOf(stateRoot: string): number {
91
97
  return manifestCacheGeneration.get(stateRoot) ?? 0;
92
98
  }
@@ -151,7 +157,20 @@ function invalidateRunCache(stateRoot: string): void {
151
157
  // F1: only bump the generation of THIS stateRoot — sibling runs keep their
152
158
  // generation intact, so concurrent writes to run A no longer evict run B's
153
159
  // hot cache.
154
- manifestCacheGeneration.set(stateRoot, genOf(stateRoot) + 1);
160
+ // R5-M1: delete-then-set couples this generation entry to the cache entry it
161
+ // guards (same lifecycle point) and refreshes its insertion order, so the
162
+ // FIFO safety net below evicts the least-recently-invalidated stateRoot.
163
+ const nextGen = genOf(stateRoot) + 1;
164
+ manifestCacheGeneration.delete(stateRoot);
165
+ manifestCacheGeneration.set(stateRoot, nextGen);
166
+ while (manifestCacheGeneration.size > MANIFEST_CACHE_GENERATION_MAX) {
167
+ const oldest = manifestCacheGeneration.keys().next().value;
168
+ if (oldest === undefined) break;
169
+ // Dropping a generation entry makes genOf read 0 — also drop that root's
170
+ // manifest cache entry so a 0-stamped entry can never falsely match.
171
+ manifestCache.delete(oldest);
172
+ manifestCacheGeneration.delete(oldest);
173
+ }
155
174
  }
156
175
 
157
176
  function scopeBaseRoot(cwd: string): string {
@@ -444,8 +463,13 @@ function shouldPersistTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
444
463
  if (tasks.length > 0) return true;
445
464
  const existing = extractTaskArray(readJsonFile<unknown>(manifest.tasksPath));
446
465
  if (existing.length > 0) {
447
- console.warn(
448
- `[state-store] refusing to persist empty tasks over ${existing.length} existing task(s) — possible corrupt-load cascade (runId=${manifest.runId})`,
466
+ logInternalError(
467
+ "state-store",
468
+ new Error(
469
+ `refusing to persist empty tasks over ${existing.length} existing task(s) — possible corrupt-load cascade (runId=${manifest.runId})`,
470
+ ),
471
+ undefined,
472
+ "warn",
449
473
  );
450
474
  return false;
451
475
  }
@@ -693,6 +717,7 @@ export function __test__manifestCacheSize(): number {
693
717
 
694
718
  export function __test__clearManifestCache(): void {
695
719
  manifestCache.clear();
720
+ manifestCacheGeneration.clear();
696
721
  }
697
722
 
698
723
  /**
@@ -707,17 +732,18 @@ export function __test__clearManifestCache(): void {
707
732
  * `fs.statSync`-based invalidation. It is a safe exit hatch for callers that
708
733
  * want a guaranteed cache drop (e.g. cleanup paths, end-of-run shutdown).
709
734
  *
710
- * Scope note: `flushPendingAtomicWrites()` flushes ALL pending coalesced
711
- * writes process-wide, not just those under `stateRoot`. The coalescer has no
712
- * per-stateRoot filter; flushing the whole queue is the simplest correct
713
- * behavior and matches the existing `flushPendingAtomicWrites()` contract
714
- * used by cleanupRuntime / process exit handlers.
735
+ * Scope note (R10-2): the flush is scoped to the run's `tasks.json` — the only
736
+ * file that (a) feeds this cache and (b) can have a pending coalesced write
737
+ * (`manifest.json` is only ever written through immediate/durable paths, and
738
+ * `agents.json`/`status.json` coalesced writes live outside this cache).
739
+ * Unrelated coalesced writes for OTHER runs stay pending on their own timers;
740
+ * unloading one run must not block on them.
715
741
  */
716
742
  export async function unloadRun(stateRoot: string): Promise<void> {
717
743
  // Flush first so any in-flight buffered write lands on disk before we drop
718
744
  // the cache entry. Otherwise a coalesced write could fire AFTER unloadRun
719
745
  // returns, re-populate the cache from disk, and re-stale it.
720
- flushPendingAtomicWrites();
746
+ flushPendingAtomicWrites(path.join(stateRoot, "tasks.json"));
721
747
  // invalidateRunCache bumps the per-stateRoot generation counter so even if
722
748
  // some in-process reader has a stale reference, the next cache lookup
723
749
  // misses (generation mismatch) and re-reads from disk.
@@ -769,215 +795,6 @@ async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
769
795
  }
770
796
  }
771
797
 
772
- /**
773
- * ST-4: Rename a corrupt file to a quarantine path (`.corrupt-<ts>`) so it is
774
- * preserved for debugging but no longer read as the primary source of truth.
775
- */
776
- function quarantineCorruptFile(filePath: string): void {
777
- try {
778
- fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
779
- } catch {
780
- // Best-effort — if rename fails (file already gone, permission, etc.),
781
- // we still proceed with reconstruction from the event log.
782
- }
783
- }
784
-
785
- /**
786
- * ST-4: Convert event-reconstructor output into TeamTaskState[].
787
- * Reconstructed tasks carry lifecycle data (id, status, timing) from the
788
- * event log; auxiliary fields (role, agent, title) are filled with defaults
789
- * since they are not present in lifecycle events.
790
- */
791
- function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTaskState[] {
792
- try {
793
- const result = reconstructTasksFromEvents(eventsPath);
794
- const tasks: TeamTaskState[] = [];
795
- for (const [, rt] of result.tasks) {
796
- tasks.push({
797
- id: rt.id,
798
- runId,
799
- role: "unknown",
800
- agent: "unknown",
801
- title: "reconstructed from events",
802
- status: isTeamTaskStatus(rt.status) ? rt.status : "queued",
803
- dependsOn: [],
804
- cwd: "",
805
- startedAt: rt.startedAt,
806
- finishedAt: rt.finishedAt,
807
- error: rt.error,
808
- segment: rt.segment,
809
- diagnostics: rt.diagnostics,
810
- metrics: rt.metrics,
811
- });
812
- }
813
- return tasks;
814
- } catch {
815
- return [];
816
- }
817
- }
818
-
819
- /**
820
- * ST-4: Load tasks.json with corruption recovery (sync path).
821
- *
822
- * Distinguishes:
823
- * - ENOENT / ENOTDIR → legitimate empty → [] (NOT quarantined).
824
- * - SyntaxError (parse failure) → corrupt → quarantine `.corrupt-<ts>` AND
825
- * reconstruct from events.jsonl. If reconstruction yields tasks, persist
826
- * them so subsequent loads see a valid file.
827
- * - Non-array JSON (e.g. `{}`) → corrupt → same as SyntaxError.
828
- * - Valid array → return as-is.
829
- */
830
-
831
- /**
832
- * ST-9: Extract the task array from a tasks.json payload.
833
- *
834
- * Accepts both the v0 legacy bare-array format and the v1+ envelope
835
- * `{ schemaVersion, tasks }`. Returns [] for unrecognized shapes.
836
- */
837
- function extractTaskArray(raw: unknown): TeamTaskState[] {
838
- if (Array.isArray(raw)) return raw as TeamTaskState[];
839
- if (raw !== null && typeof raw === "object" && "tasks" in raw) {
840
- const envelope = raw as { tasks?: unknown };
841
- if (Array.isArray(envelope.tasks)) return envelope.tasks as TeamTaskState[];
842
- }
843
- return [];
844
- }
845
-
846
- /**
847
- * ST-9: Whether `parsed` is a recognizable tasks.json shape (v0 bare array
848
- * or v1+ envelope). Used to distinguish legitimate formats from corruption.
849
- */
850
- function isRecognizableTasksPayload(parsed: unknown): boolean {
851
- if (Array.isArray(parsed)) return true;
852
- if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
853
- return Array.isArray((parsed as { tasks?: unknown }).tasks);
854
- }
855
- return false;
856
- }
857
-
858
- /**
859
- * ST-9: Version-check + migration hook for tasks.json.
860
- *
861
- * tasks.json has two on-disk shapes:
862
- * - v0 (current): bare JSON array `TeamTaskState[]` — what saveRunTasks*
863
- * write today (backward-compatible; no schemaVersion envelope).
864
- * - v1+ (future): envelope `{ schemaVersion: number, tasks: TeamTaskState[] }`
865
- * — read-supported defensively for a future write-side switch.
866
- *
867
- * This detects the shape and returns the task array. v0 needs NO migration
868
- * (it IS the current write format). For v1+ envelopes, a schemaVersion
869
- * mismatch warns (mirroring the manifest check). Future breaking changes
870
- * add real migration logic here.
871
- */
872
- function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
873
- // v0 current: bare array (no schemaVersion envelope) — what writers produce.
874
- if (Array.isArray(parsed)) {
875
- // v0 bare array is the CURRENT write format (saveRunTasks* write the
876
- // array directly, by design — backward compat). Nothing to migrate:
877
- // return as-is. (v1+ envelope read-support below is defensive, for a
878
- // future write-side switch.) Do NOT warn here — it would fire for 100%
879
- // of runs on every load and flood the UI on startup.
880
- return parsed as TeamTaskState[];
881
- }
882
- // v1+ envelope: { schemaVersion, tasks }.
883
- if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
884
- const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
885
- const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
886
- if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
887
- console.warn(
888
- `[state-store] tasks.json schemaVersion mismatch: expected ${CURRENT_TASKS_SCHEMA_VERSION}, got ${detected}. Run ${runId} may be incompatible.`,
889
- );
890
- }
891
- }
892
- return extractTaskArray(parsed);
893
- }
894
- export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
895
- let content: string;
896
- try {
897
- content = fs.readFileSync(tasksPath, "utf-8");
898
- } catch {
899
- // ENOENT / ENOTDIR / other read errors → empty (retry loop handles
900
- // transient instability; ENOENT is a legitimate empty run).
901
- return [];
902
- }
903
- let parsed: unknown;
904
- try {
905
- parsed = JSON.parse(content);
906
- } catch {
907
- // SyntaxError — corrupt file.
908
- quarantineCorruptFile(tasksPath);
909
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
910
- if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
911
- return reconstructed;
912
- }
913
- if (!isRecognizableTasksPayload(parsed)) {
914
- // Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
915
- quarantineCorruptFile(tasksPath);
916
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
917
- if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
918
- return reconstructed;
919
- }
920
- return migrateTasksFile(parsed, runId);
921
- }
922
-
923
- /**
924
- * STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes:
925
- * - ENOENT / read error → undefined (legitimate missing run — NOT quarantined).
926
- * - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-<ts>` + log + undefined.
927
- * Manifest CANNOT be reconstructed from events.jsonl (run.created only carries
928
- * {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt
929
- * reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest
930
- * from silently making a run invisible (STATE-3).
931
- */
932
- export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined {
933
- let content: string;
934
- try {
935
- content = fs.readFileSync(manifestPath, "utf-8");
936
- } catch {
937
- // ENOENT / ENOTDIR / other read error → legitimate missing run.
938
- return undefined;
939
- }
940
- try {
941
- return JSON.parse(content) as TeamRunManifest;
942
- } catch {
943
- // SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
944
- // then treat as missing. Do NOT reconstruct (infeasible from events).
945
- quarantineCorruptFile(manifestPath);
946
- console.error(
947
- `[state-store] 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.`,
948
- );
949
- return undefined;
950
- }
951
- }
952
-
953
- /**
954
- * ST-4: async twin of {@link loadTasksWithRecovery}.
955
- */
956
- async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string, runId: string): Promise<TeamTaskState[]> {
957
- let content: string;
958
- try {
959
- content = await fs.promises.readFile(tasksPath, "utf-8");
960
- } catch {
961
- return [];
962
- }
963
- let parsed: unknown;
964
- try {
965
- parsed = JSON.parse(content);
966
- } catch {
967
- quarantineCorruptFile(tasksPath);
968
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
969
- if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
970
- return reconstructed;
971
- }
972
- if (!isRecognizableTasksPayload(parsed)) {
973
- quarantineCorruptFile(tasksPath);
974
- const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
975
- if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
976
- return reconstructed;
977
- }
978
- return migrateTasksFile(parsed, runId);
979
- }
980
-
981
798
  /**
982
799
  * Load a run manifest and its tasks by runId.
983
800
  * WARNING: This function provides best-effort consistency only. The sentinel-based
@@ -1094,8 +911,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1094
911
  // S-01: warn (do not throw) on schemaVersion mismatch — future version
1095
912
  // bumps will add migration logic here.
1096
913
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1097
- console.warn(
1098
- `[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
914
+ logInternalError(
915
+ "state-store",
916
+ new Error(
917
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
918
+ ),
919
+ undefined,
920
+ "warn",
1099
921
  );
1100
922
  }
1101
923
  // STATE-3: readJsonFile returns undefined for BOTH missing (ENOENT) and corrupt
@@ -1105,8 +927,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1105
927
  // infeasible (run.created lacks manifest fields), so quarantine+log is the recovery.
1106
928
  if (!manifest && fs.existsSync(manifestPath)) {
1107
929
  quarantineCorruptFile(manifestPath);
1108
- console.error(
1109
- `[state-store] STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
930
+ logInternalError(
931
+ "state-store",
932
+ new Error(
933
+ `STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
934
+ ),
935
+ undefined,
936
+ "error",
1110
937
  );
1111
938
  return undefined;
1112
939
  }
@@ -1231,8 +1058,13 @@ export async function loadRunManifestByIdAsync(
1231
1058
  // S-01: warn (do not throw) on schemaVersion mismatch — future version
1232
1059
  // bumps will add migration logic here.
1233
1060
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1234
- console.warn(
1235
- `[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1061
+ logInternalError(
1062
+ "state-store",
1063
+ new Error(
1064
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1065
+ ),
1066
+ undefined,
1067
+ "warn",
1236
1068
  );
1237
1069
  }
1238
1070
  // STATE-3 (async twin): readJsonFileAsync returns undefined for BOTH missing (ENOENT)
@@ -1242,8 +1074,13 @@ export async function loadRunManifestByIdAsync(
1242
1074
  // lacks manifest fields), so quarantine+log is the recovery.
1243
1075
  if (!manifest && fs.existsSync(manifestPath)) {
1244
1076
  quarantineCorruptFile(manifestPath);
1245
- console.error(
1246
- `[state-store] STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1077
+ logInternalError(
1078
+ "state-store",
1079
+ new Error(
1080
+ `STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
1081
+ ),
1082
+ undefined,
1083
+ "error",
1247
1084
  );
1248
1085
  return undefined;
1249
1086
  }