pi-crew 0.11.21 → 0.11.23

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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.mjs +542 -531
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool/respond.ts +8 -6
  16. package/src/extension/team-tool/run.ts +4 -4
  17. package/src/extension/team-tool.ts +24 -2
  18. package/src/prompt/inbox-poll.ts +77 -25
  19. package/src/prompt/message-tool.ts +62 -4
  20. package/src/prompt/prompt-runtime.ts +82 -32
  21. package/src/prompt/surface-worker.ts +13 -1
  22. package/src/runtime/broker/crew-broker-client.ts +74 -16
  23. package/src/runtime/broker/crew-broker.ts +120 -97
  24. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  25. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  26. package/src/runtime/broker/wait-status-cache.ts +14 -5
  27. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  28. package/src/runtime/child-pi/child-pi.ts +20 -2
  29. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  30. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  31. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  32. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  33. package/src/runtime/crew-agent-records.ts +32 -14
  34. package/src/runtime/delivery-coordinator.ts +30 -0
  35. package/src/runtime/dispatch-batch.ts +9 -6
  36. package/src/runtime/dwf-state-store.ts +41 -7
  37. package/src/runtime/finalize-run.ts +13 -2
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  39. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  41. package/src/runtime/live-session/embedded-tier.ts +3 -1
  42. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  43. package/src/runtime/model/model-fallback.ts +23 -1
  44. package/src/runtime/output/pi-json-output.ts +26 -1
  45. package/src/runtime/policy-engine.ts +18 -0
  46. package/src/runtime/process/zombie-scanner.ts +84 -16
  47. package/src/runtime/process-status.ts +6 -1
  48. package/src/runtime/recovery/crash-recovery.ts +170 -28
  49. package/src/runtime/rpc/rpc-worker.ts +44 -1
  50. package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
  51. package/src/runtime/scheduling/scheduler.ts +74 -11
  52. package/src/runtime/scratchpad/engine.ts +26 -3
  53. package/src/runtime/settings-store.ts +22 -2
  54. package/src/runtime/stale-reconciler.ts +4 -1
  55. package/src/runtime/surface/tmux-provider.ts +24 -3
  56. package/src/runtime/task-health.ts +16 -1
  57. package/src/runtime/task-output-context.ts +143 -71
  58. package/src/runtime/task-packet.ts +70 -17
  59. package/src/runtime/task-runner/child-executor.ts +27 -0
  60. package/src/runtime/task-runner/output-splitter.ts +79 -16
  61. package/src/runtime/task-runner/post-execution.ts +75 -15
  62. package/src/runtime/task-runner/state-helpers.ts +92 -2
  63. package/src/runtime/usage-tracker.ts +11 -0
  64. package/src/runtime/verification/green-contract.ts +17 -0
  65. package/src/runtime/verification/verification-gates.ts +24 -6
  66. package/src/runtime/workspace-lock.ts +53 -0
  67. package/src/schema/team-tool-schema.ts +10 -4
  68. package/src/state/coordination/mailbox.ts +133 -38
  69. package/src/state/decision-ledger.ts +56 -8
  70. package/src/state/event-log/cursor.ts +14 -2
  71. package/src/state/event-log/event-log-rotation.ts +83 -5
  72. package/src/state/event-log/event-log.ts +181 -12
  73. package/src/state/stores/active-run-registry.ts +10 -3
  74. package/src/state/types.ts +9 -1
  75. package/src/ui/agents-jobs-browser.ts +22 -8
  76. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  77. package/src/ui/events-state-source.ts +67 -28
  78. package/src/ui/inline-panel/agent-pane.ts +13 -6
  79. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  80. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  81. package/src/ui/inline-panel/index.ts +21 -1
  82. package/src/ui/live-run-sidebar.ts +14 -3
  83. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  84. package/src/ui/powerbar-publisher.ts +6 -3
  85. package/src/ui/rail.ts +7 -1
  86. package/src/ui/render-scheduler.ts +53 -1
  87. package/src/ui/run-action-dispatcher.ts +68 -20
  88. package/src/ui/status-colors.ts +9 -0
  89. package/src/utils/env-filter.ts +8 -0
@@ -33,6 +33,18 @@ export function inferGreenLevelFromTask(success: boolean, contract: Verification
33
33
  return contract.allowManualEvidence ? contract.requiredGreenLevel : "targeted";
34
34
  }
35
35
 
36
+ /** R4-5 (DR-02 wave 2): true when the contract REQUIRES a green level but
37
+ * configures zero verification commands — the default contract shape for
38
+ * every `verify: true` step (task-packet defaultVerificationContract).
39
+ * With no commands to run, satisfaction can only ever come from
40
+ * self-attestation (allowManualEvidence → inferGreenLevelFromTask returns
41
+ * the required level), which silently reports e.g. "targeted/targeted".
42
+ * Surface that as `decorative: true` on the evidence instead of changing
43
+ * the satisfied logic or the defaults (detection-first, no behavior flip). */
44
+ export function isDecorativeVerification(contract: VerificationContract): boolean {
45
+ return contract.requiredGreenLevel !== "none" && contract.commands.length === 0;
46
+ }
47
+
36
48
  export function createVerificationEvidence(contract: VerificationContract, success: boolean, notes: string): VerificationEvidence {
37
49
  const observedGreenLevel = inferGreenLevelFromTask(success, contract);
38
50
  const outcome = evaluateGreenContract(contract, {
@@ -42,6 +54,10 @@ export function createVerificationEvidence(contract: VerificationContract, succe
42
54
  commands: [],
43
55
  notes,
44
56
  });
57
+ // R4-5: stamp the decorative flag so consumers (post-execution evidence
58
+ // builder, policy engine, UI) can tell self-attested satisfaction from
59
+ // command-backed satisfaction. Satisfied logic is untouched.
60
+ const decorative = isDecorativeVerification(contract);
45
61
  return {
46
62
  requiredGreenLevel: contract.requiredGreenLevel,
47
63
  observedGreenLevel,
@@ -51,5 +67,6 @@ export function createVerificationEvidence(contract: VerificationContract, succe
51
67
  status: "not_run" as const,
52
68
  })),
53
69
  notes,
70
+ ...(decorative ? { decorative: true } : {}),
54
71
  };
55
72
  }
@@ -144,6 +144,16 @@ export const CARGO_RUST_GATES: Array<{
144
144
  /**
145
145
  * Execute a single command and capture output.
146
146
  */
147
+ /**
148
+ * P-4 (DR-04 round-7): accumulation cap while STREAMING child output. The
149
+ * final `slice(-100000)` already bounds the RESULT, but the live `output`
150
+ * buffer used to concatenate every chunk until close/timeout — a command
151
+ * emitting gigabytes held them ALL in memory (plus O(n²) string concat).
152
+ * Past this cap we stop concatenating and pin a "[truncated]" marker in the
153
+ * buffer; the final tail slice is unchanged.
154
+ */
155
+ const OUTPUT_ACCUMULATION_CAP_CHARS = 200 * 1024;
156
+ const OUTPUT_TRUNCATION_MARKER = "\n[truncated]";
147
157
  /** Characters/patterns that indicate dangerous shell metacharacters. */
148
158
  // Round 25 (VULN-3/VULN-4): also block raw newlines (sh -c treats \n as a
149
159
  // command separator -> injection) and bare $VARNAME references (can exfiltrate
@@ -253,8 +263,20 @@ export async function executeCommand(
253
263
 
254
264
  const start = Date.now();
255
265
  let output = "";
266
+ let capped = false;
256
267
  let exitCode: number | null = null;
257
268
 
269
+ // P-4: single capped appender for BOTH streams — once the cap trips, later
270
+ // chunks are dropped (marker stays in the buffer; final slice keeps it).
271
+ const appendCapped = (chunk: Buffer): void => {
272
+ if (capped) return;
273
+ output += chunk.toString();
274
+ if (output.length > OUTPUT_ACCUMULATION_CAP_CHARS) {
275
+ capped = true;
276
+ output += OUTPUT_TRUNCATION_MARKER;
277
+ }
278
+ };
279
+
258
280
  return new Promise((resolve) => {
259
281
  const shell = spawn("sh", ["-c", command], {
260
282
  cwd,
@@ -263,13 +285,9 @@ export async function executeCommand(
263
285
  detached: true, // CORE-10: own process group so group-wide SIGKILL reaches children
264
286
  });
265
287
 
266
- shell.stdout?.on("data", (data) => {
267
- output += data.toString();
268
- });
288
+ shell.stdout?.on("data", appendCapped);
269
289
 
270
- shell.stderr?.on("data", (data) => {
271
- output += data.toString();
272
- });
290
+ shell.stderr?.on("data", appendCapped);
273
291
 
274
292
  // M-9 fix (code-review 2026-06-23): store the timeout handle so it can be
275
293
  // cleared when the process exits normally, and unref it so it does not
@@ -28,6 +28,22 @@
28
28
  * Granularity: per-goal, held for the goal's lifetime (release() on goal end).
29
29
  * Contention: default QUEUE (poll until released or stale);
30
30
  * opts.failOnWorkspaceBusy:true → THROW instead of queue.
31
+ *
32
+ * ─── HEARTBEAT REFRESH CONTRACT (WI-2 / DR-02 R4-3, 2026-10-15) ───
33
+ * The heartbeat is written ONCE at acquire. Clause (b) above marks a lock
34
+ * stale after HEARTBEAT_STALE_MS REGARDLESS of holder pid liveness — so a
35
+ * live goal whose loop runs longer than 60s would be force-stolen by a
36
+ * second goal on the same cwd (multi-goal clobber, audit R4-3). Holders that
37
+ * stay in a long-running loop MUST keep the heartbeat fresh via
38
+ * refreshWorkspaceLockHeartbeat(lockPath, goalId) — the goal-loop runner does
39
+ * this on a 15s unref-ed timer (startWorkspaceLockHeartbeatRefresh in
40
+ * goal-loop-runner.ts), stopped in the same finally that releases the lock.
41
+ * The refresh re-stamps ONLY `heartbeat` (all other fields + file format
42
+ * preserved) and ONLY while the lock still belongs to (goalId, pid) — a lock
43
+ * reclaimed by another goal is never clobbered. Decision note: the interval
44
+ * refresh was chosen over "drop clause (b) / PID-liveness-only staleness"
45
+ * because clause (b) is the only crash signal for kill -9'd holders on
46
+ * non-Linux platforms where /proc startTime is unavailable.
31
47
  */
32
48
 
33
49
  import { createHash } from "node:crypto";
@@ -149,6 +165,43 @@ function writeLock(lockPath: string, contents: WorkspaceLockContents): void {
149
165
  atomicWriteJson(lockPath, contents);
150
166
  }
151
167
 
168
+ export interface RefreshWorkspaceLockHeartbeatOptions {
169
+ /** Test injection: override current time (ms). Default Date.now(). */
170
+ now?: () => number;
171
+ /** Test injection: override the current pid. Default process.pid. */
172
+ pid?: number;
173
+ }
174
+
175
+ /**
176
+ * Refresh the heartbeat of a lock this goal still owns (WI-2 / DR-02 R4-3).
177
+ *
178
+ * Reads the lockfile and rewrites it with `heartbeat: now()` — preserving
179
+ * every other field (pid, startTime, goalId, acquiredAt) and the file format
180
+ * — ONLY when the lock still belongs to (goalId, pid). Returns false —
181
+ * leaving the file untouched — when the lockfile is missing, was reclaimed /
182
+ * re-acquired by another goal, or the write failed: refreshing must NEVER
183
+ * clobber a lock that someone else owns (same ownership guard philosophy as
184
+ * safeRelease). This is what keeps a >60s-running goal's lock from being
185
+ * force-stolen via isLockStale clause (b) despite a live pid.
186
+ *
187
+ * Non-throwing by contract: failures surface as false, never as an exception
188
+ * into the caller's loop (the goal-loop still wraps calls in try/catch →
189
+ * logInternalError warn as belt-and-braces, see goal-loop-runner.ts).
190
+ */
191
+ export function refreshWorkspaceLockHeartbeat(lockPath: string, goalId: string, opts: RefreshWorkspaceLockHeartbeatOptions = {}): boolean {
192
+ try {
193
+ const current = readLock(lockPath);
194
+ if (!current) return false;
195
+ const pid = opts.pid ?? process.pid;
196
+ if (current.goalId !== goalId || current.pid !== pid) return false;
197
+ const now = opts.now ?? Date.now;
198
+ writeLock(lockPath, { ...current, heartbeat: now() });
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
152
205
  /**
153
206
  * CLAIM the lockfile atomically via O_EXCL (cold-review #3 NIT #N1 fix).
154
207
  * The previous claim used temp+rename (writeLock), which is NOT cross-process atomic —
@@ -74,7 +74,7 @@ export function buildStringEnum(values: readonly string[], description: string,
74
74
  }
75
75
 
76
76
  // ───────────────────────────────────────────────────────────────────────────
77
- // API-5 facade split: the 54-action mega-tool schema is split into 5 domain
77
+ // API-5 facade split: the 57-action mega-tool schema is split into 5 domain
78
78
  // schemas. Each domain owns a subset of the action union; all parameter fields
79
79
  // are shared across every variant so Phase 1 validation is identical to the
80
80
  // pre-split flat Object (additionalProperties: true → no tightening).
@@ -226,14 +226,20 @@ const sharedFields = {
226
226
  // Also accept stringified numbers ("123456") — pi-ai coercion stringifies
227
227
  // numeric values when a Union has a string-literal branch. handleTeamTool
228
228
  // coerces strings back to numbers before handlers run.
229
+ // R7-5 (docs-truth): this value is ADVISORY metadata — the enforced
230
+ // deadline is the broker-clamped task.waiting.deadline, not this field.
229
231
  Type.Union(
230
232
  [
231
233
  Type.Literal(""),
232
- Type.Integer({ description: "Ms epoch deadline for a reply." }),
234
+ Type.Integer({
235
+ description:
236
+ "Advisory metadata only. The enforced deadline is auto-clamped by the broker (task.waiting.deadline); this value is recorded, not enforced.",
237
+ }),
233
238
  Type.String({ pattern: NUMERIC_STRING_RE }),
234
239
  ],
235
240
  {
236
- description: "Ms epoch deadline for a reply.",
241
+ description:
242
+ "Advisory metadata only. The enforced deadline is auto-clamped by the broker (task.waiting.deadline); this value is recorded, not enforced.",
237
243
  },
238
244
  ),
239
245
  ),
@@ -589,7 +595,7 @@ export interface TeamToolParamsValue {
589
595
  replyTo?: string;
590
596
  /** Task ID sending the reply. */
591
597
  replyFrom?: string;
592
- /** Ms epoch deadline for a reply. */
598
+ /** Advisory metadata only. The enforced deadline is auto-clamped by the broker (task.waiting.deadline); this value is recorded, not enforced. */
593
599
  replyDeadline?: number;
594
600
  /** Path to a markdown plan document for orchestration. */
595
601
  planPath?: string;
@@ -375,9 +375,27 @@ function cachedMailboxRead(filePath: string, direction: MailboxDirection): Mailb
375
375
  return messages.slice();
376
376
  }
377
377
 
378
+ /** R7-3 (2026-10-16): rotated archive siblings of a live mailbox file,
379
+ * sorted lexicographically. The archive name embeds an ISO timestamp, so
380
+ * lexicographic order ≈ chronological (oldest first). */
381
+ function listMailboxArchives(filePath: string): string[] {
382
+ try {
383
+ const dir = path.dirname(filePath);
384
+ const base = path.basename(filePath);
385
+ return fs
386
+ .readdirSync(dir)
387
+ .filter((entry) => entry.startsWith(`${base}.`) && entry.endsWith(".archive.jsonl"))
388
+ .sort()
389
+ .map((entry) => path.join(dir, entry));
390
+ } catch {
391
+ // Directory missing — nothing to read.
392
+ return [];
393
+ }
394
+ }
395
+
378
396
  function safeReadMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
379
397
  // PERF: stat-gated primary read — ENOENT is handled inside cachedMailboxRead.
380
- const messages = cachedMailboxRead(filePath, direction);
398
+ const live = cachedMailboxRead(filePath, direction);
381
399
  // 3.3 — also include any rotated archive files alongside the live file.
382
400
  // Archive naming: `<filename>.<isoTimestamp>.archive.jsonl`.
383
401
  // PERF: archives are immutable once written (rotation only ever creates
@@ -385,18 +403,37 @@ function safeReadMailboxFile(filePath: string, direction: MailboxDirection): Mai
385
403
  // sequence), so they route through cachedMailboxRead too and permanently
386
404
  // hit after the first parse. pruneOldMailboxArchives deleting an archive
387
405
  // is covered by cachedMailboxRead's ENOENT → cache-delete branch.
388
- try {
389
- const dir = path.dirname(filePath);
390
- const base = path.basename(filePath);
391
- for (const entry of fs.readdirSync(dir)) {
392
- if (!entry.startsWith(`${base}.`) || !entry.endsWith(".archive.jsonl")) continue;
393
- const archivePath = path.join(dir, entry);
394
- messages.push(...cachedMailboxRead(archivePath, direction));
406
+ const archives = listMailboxArchives(filePath);
407
+ if (archives.length === 0) return live;
408
+ // R7-3 (2026-10-16) — same-id LAST-WINS merge, live file applied LAST.
409
+ //
410
+ // DESIGN: rotation moves a message into an archive and recreates an empty
411
+ // live file, so a message normally exists in exactly ONE place and a plain
412
+ // concat was duplicate-free. The exception is updateMailboxMessageReply:
413
+ // archives are IMMUTABLE history and are never rewritten, so when the
414
+ // original message lives only in an archive the reply is recorded as a
415
+ // COMPENSATING record — same id, reply metadata set — appended to the LIVE
416
+ // file. Merging by id (archives oldest→newest first, live file last)
417
+ // collapses the pair into a single reply-marked message, so readMailbox /
418
+ // readMailboxMessage expose ONE copy with repliedAt set; consumers
419
+ // (replayPendingMailboxMessages, listMailboxByKind, …) never see the
420
+ // duplicate reply-less archived original.
421
+ const merged: MailboxMessage[] = [];
422
+ const indexById = new Map<string, number>();
423
+ const upsert = (message: MailboxMessage): void => {
424
+ const existing = indexById.get(message.id);
425
+ if (existing === undefined) {
426
+ indexById.set(message.id, merged.length);
427
+ merged.push(message);
428
+ } else {
429
+ merged[existing] = message;
395
430
  }
396
- } catch {
397
- // Directory missing — nothing to read.
431
+ };
432
+ for (const archivePath of archives) {
433
+ for (const message of cachedMailboxRead(archivePath, direction)) upsert(message);
398
434
  }
399
- return messages;
435
+ for (const message of live) upsert(message);
436
+ return merged;
400
437
  }
401
438
 
402
439
  /**
@@ -1138,6 +1175,15 @@ export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId:
1138
1175
  * Update an original mailbox message with reply metadata.
1139
1176
  * Rewrites the mailbox file line containing the original message
1140
1177
  * to include `repliedAt` and `replyContent`.
1178
+ *
1179
+ * R7-3 (2026-10-16): the original may have been ROTATED out of the live file
1180
+ * into a `*.archive.jsonl` sibling (rotateMailboxFileIfNeeded). Archives are
1181
+ * IMMUTABLE history — they are never rewritten here. When the original is
1182
+ * found only in an archive, a COMPENSATING record (same id + reply metadata)
1183
+ * is appended to the LIVE file instead; safeReadMailboxFile merges by id
1184
+ * last-wins (live overrides archive), so every reader sees the reply-marked
1185
+ * message. Global run mailboxes AND every task mailbox (inbox + outbox, live
1186
+ * + archives) are scanned.
1141
1187
  */
1142
1188
  export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMessageId: string, replyContent: string): void {
1143
1189
  const directions: MailboxDirection[] = ["inbox", "outbox"];
@@ -1167,35 +1213,64 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
1167
1213
  }
1168
1214
 
1169
1215
  for (const { filePath, direction } of filesToSearch) {
1170
- if (!fs.existsSync(filePath)) continue;
1171
- // FIX: Wrap read-modify-write in withFileLockSync to prevent concurrent
1172
- // updates from clobbering each other (each reply rewrites the whole file).
1173
- const found = withFileLockSync(filePath, () => {
1174
- const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
1175
- let localFound = false;
1176
- const updatedLines: string[] = [];
1177
- for (const line of lines) {
1178
- try {
1179
- const parsed = JSON.parse(line) as unknown;
1180
- const msg = parseMailboxMessage(parsed, direction);
1181
- if (msg && msg.id === originalMessageId) {
1182
- msg.repliedAt = new Date().toISOString();
1183
- msg.replyContent = replyContent;
1184
- updatedLines.push(JSON.stringify(redactSecrets(msg)));
1185
- localFound = true;
1186
- } else {
1216
+ const archives = listMailboxArchives(filePath);
1217
+ if (fs.existsSync(filePath)) {
1218
+ // Path 1 (original behavior): the message still lives in the LIVE
1219
+ // file — rewrite its line in place.
1220
+ // FIX: Wrap read-modify-write in withFileLockSync to prevent concurrent
1221
+ // updates from clobbering each other (each reply rewrites the whole file).
1222
+ const found = withFileLockSync(filePath, () => {
1223
+ const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
1224
+ let localFound = false;
1225
+ const updatedLines: string[] = [];
1226
+ for (const line of lines) {
1227
+ try {
1228
+ const parsed = JSON.parse(line) as unknown;
1229
+ const msg = parseMailboxMessage(parsed, direction);
1230
+ if (msg && msg.id === originalMessageId) {
1231
+ msg.repliedAt = new Date().toISOString();
1232
+ msg.replyContent = replyContent;
1233
+ updatedLines.push(JSON.stringify(redactSecrets(msg)));
1234
+ localFound = true;
1235
+ } else {
1236
+ updatedLines.push(line);
1237
+ }
1238
+ } catch {
1187
1239
  updatedLines.push(line);
1188
1240
  }
1189
- } catch {
1190
- updatedLines.push(line);
1191
1241
  }
1192
- }
1193
- if (localFound) {
1194
- atomicWriteFile(filePath, `${updatedLines.join("\n")}\n`);
1195
- }
1196
- return localFound;
1242
+ if (localFound) {
1243
+ atomicWriteFile(filePath, `${updatedLines.join("\n")}\n`);
1244
+ }
1245
+ return localFound;
1246
+ });
1247
+ if (found) return;
1248
+ }
1249
+ if (archives.length === 0) continue;
1250
+ // Path 2 (R7-3): the original was rotated into an archive. Archives are
1251
+ // immutable (see safeReadMailboxFile) — do NOT rewrite them; append a
1252
+ // compensating record carrying the reply metadata to the LIVE file.
1253
+ // The id-based last-wins merge makes it win over the archived original.
1254
+ const archived = archives
1255
+ .flatMap((archivePath) => cachedMailboxRead(archivePath, direction))
1256
+ .find((message) => message.id === originalMessageId);
1257
+ if (!archived) continue;
1258
+ const compensating: MailboxMessage = {
1259
+ ...archived,
1260
+ repliedAt: new Date().toISOString(),
1261
+ replyContent,
1262
+ };
1263
+ withFileLockSync(filePath, () => {
1264
+ // Recreate the live file if it went missing — the archives in this
1265
+ // directory prove the mailbox itself is valid.
1266
+ if (!fs.existsSync(filePath)) atomicWriteFile(filePath, "");
1267
+ fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(compensating))}\n`, "utf-8");
1268
+ // The append may itself trigger rotation — safe: the compensating
1269
+ // record then lands in the NEWEST archive, which sorts after the
1270
+ // original's archive in the merge order, so it still wins by id.
1271
+ rotateMailboxFileIfNeeded(filePath);
1197
1272
  });
1198
- if (found) return;
1273
+ return;
1199
1274
  }
1200
1275
  // Not finding the original is non-fatal; the reply is still delivered.
1201
1276
  }
@@ -1224,9 +1299,27 @@ export function validateMailbox(
1224
1299
  ensureRunMailbox(manifest);
1225
1300
  const issues: MailboxValidationIssue[] = [];
1226
1301
  const repaired: string[] = [];
1302
+ // L-9 (2026-10-16): validate + repair the TASK mailboxes
1303
+ // (`mailbox/tasks/<id>/<direction>.jsonl`) with the same line-parse
1304
+ // semantics as the run-level files — enumerate the tasks dir the same way
1305
+ // updateMailboxMessageReply does. Rotated `*.archive.jsonl` files are
1306
+ // immutable history and are deliberately NOT repaired.
1307
+ const filesToValidate: Array<{ filePath: string; direction: MailboxDirection }> = [];
1227
1308
  for (const direction of ["inbox", "outbox"] as const) {
1309
+ filesToValidate.push({ filePath: mailboxFile(manifest, direction), direction });
1310
+ }
1311
+ const tasksDir = safeMailboxTasksRoot(manifest);
1312
+ if (fs.existsSync(tasksDir)) {
1313
+ for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
1314
+ if (!entry.isDirectory()) continue;
1315
+ for (const direction of ["inbox", "outbox"] as const) {
1316
+ filesToValidate.push({ filePath: mailboxFile(manifest, direction, entry.name), direction });
1317
+ }
1318
+ }
1319
+ }
1320
+ for (const { filePath, direction } of filesToValidate) {
1228
1321
  if (options.signal?.aborted) break;
1229
- const filePath = mailboxFile(manifest, direction);
1322
+ if (!fs.existsSync(filePath)) continue;
1230
1323
  // FIX: Wrap read + optional repair in withFileLockSync so concurrent appends
1231
1324
  // don't race with the read-modify-write. Mailbox files are capped at 10MB
1232
1325
  // (MAILBOX_ARCHIVE_THRESHOLD_BYTES), so the per-call memory is bounded.
@@ -1254,7 +1347,9 @@ export function validateMailbox(
1254
1347
  });
1255
1348
  }
1256
1349
  const delivery = readDeliveryState(manifest);
1257
- const allMessages = readMailbox(manifest);
1350
+ // L-9: delivery-entry coverage now includes task mailbox messages
1351
+ // (readAllMessages walks the global files AND every task mailbox).
1352
+ const allMessages = [...readAllMessages(manifest, "inbox"), ...readAllMessages(manifest, "outbox")];
1258
1353
  for (const message of allMessages) {
1259
1354
  if (options.signal?.aborted) break;
1260
1355
  if (!delivery.messages[message.id])
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { logInternalError } from "../utils/internal-error.ts";
3
4
  import { projectCrewRoot } from "../utils/paths.ts";
4
5
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
6
  import { withFileLockSync } from "./coordination/locks.ts";
@@ -145,6 +146,12 @@ export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
145
146
 
146
147
  /**
147
148
  * Read all entries from the decision ledger.
149
+ *
150
+ * P-3 (DR-04 round-7): tolerant parse — a corrupt line (partial write, manual
151
+ * edit, disk garbage) is SKIPPED, not thrown: one bad tail line must not take
152
+ * down every reader of the ledger. Unparseable lines are logged ONCE per read
153
+ * (with the count) via logInternalError; valid lines are preserved. The reader
154
+ * never throws.
148
155
  */
149
156
  export function getLedger(runId: string): RolloutEntry[] {
150
157
  assertSafePathId("runId", runId);
@@ -154,15 +161,47 @@ export function getLedger(runId: string): RolloutEntry[] {
154
161
  return [];
155
162
  }
156
163
 
157
- const content = readFileSync(ledgerPath, "utf-8");
164
+ let content: string;
165
+ try {
166
+ content = readFileSync(ledgerPath, "utf-8");
167
+ } catch (error) {
168
+ // P-3: read failure (EACCES, transient EAGAIN, …) — warn and treat as
169
+ // empty rather than throwing out of a status/summary reader.
170
+ logInternalError("decision-ledger.getLedger", error, `run=${runId}`, "warn");
171
+ return [];
172
+ }
158
173
  if (!content.trim()) {
159
174
  return [];
160
175
  }
161
176
 
162
- return content
163
- .split("\n")
164
- .filter((line) => line.trim())
165
- .map((line) => JSON.parse(line) as RolloutEntry);
177
+ return parseLedgerLines(content, runId, "decision-ledger.getLedger");
178
+ }
179
+
180
+ /**
181
+ * P-3 (DR-04 round-7): parse JSONL ledger lines tolerantly — valid lines are
182
+ * kept, unparseable lines are skipped and reported ONCE per call with a count
183
+ * (plus a bounded preview) so a corrupt tail is visible without flooding.
184
+ */
185
+ function parseLedgerLines(content: string, runId: string, scope: string): RolloutEntry[] {
186
+ const entries: RolloutEntry[] = [];
187
+ const skipped: string[] = [];
188
+ for (const line of content.split("\n")) {
189
+ if (!line.trim()) continue;
190
+ try {
191
+ entries.push(JSON.parse(line) as RolloutEntry);
192
+ } catch {
193
+ skipped.push(line.slice(0, 80));
194
+ }
195
+ }
196
+ if (skipped.length > 0) {
197
+ logInternalError(
198
+ scope,
199
+ new Error(`skipped ${skipped.length} unparseable decision-ledger line(s)`),
200
+ `run=${runId} lines=${JSON.stringify(skipped)}`,
201
+ "warn",
202
+ );
203
+ }
204
+ return entries;
166
205
  }
167
206
 
168
207
  /**
@@ -181,13 +220,22 @@ function readTailEntries(runId: string, n: number = 10): RolloutEntry[] {
181
220
  return [];
182
221
  }
183
222
 
184
- const content = readFileSync(ledgerPath, "utf-8");
223
+ let content: string;
224
+ try {
225
+ content = readFileSync(ledgerPath, "utf-8");
226
+ } catch (error) {
227
+ // P-3: same tolerant-read contract as getLedger — the append path must
228
+ // not throw out of the file-lock callback because the tail was unreadable.
229
+ logInternalError("decision-ledger.readTailEntries", error, `run=${runId}`, "warn");
230
+ return [];
231
+ }
185
232
  if (!content.trim()) {
186
233
  return [];
187
234
  }
188
235
 
189
- const lines = content.split("\n").filter((line) => line.trim());
190
- return lines.slice(-n).map((line) => JSON.parse(line) as RolloutEntry);
236
+ // P-3: skip unparseable lines first, THEN take the tail — otherwise a
237
+ // single corrupt tail line would slice garbage into the coherence window.
238
+ return parseLedgerLines(content, runId, "decision-ledger.readTailEntries").slice(-n);
191
239
  }
192
240
 
193
241
  /**
@@ -522,7 +522,15 @@ function cursorResultFromEvents(
522
522
  // streaming consumers. No-rotation case: no archives exist → behavior is
523
523
  // byte-identical to before (mergeArchiveTailEvents returns liveEvents).
524
524
  const merged = mergeArchiveTailEvents(readArchiveTailEvents(eventsPath, sinceSeq), filtered);
525
- const events = limit !== undefined ? merged.slice(0, limit) : merged;
525
+ // FS3 (fix): seq-sort before the limit slice. File-order slicing on a
526
+ // documented non-monotonic log can deliver an early high seq inside the
527
+ // first `limit` events; nextSeq=max-delivered then permanently skips every
528
+ // undelivered lower-seq event for paging consumers resuming from nextSeq.
529
+ // Sort ONLY under a limit: the no-limit path delivers every event (nextSeq
530
+ // = true max ⇒ nothing skippable) and keeps its historical FILE-order
531
+ // contract (pinned by event-log-torn-poison.test.ts AC7, U22 Phase 1).
532
+ const events =
533
+ limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
526
534
  const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
527
535
  return {
528
536
  events,
@@ -782,7 +790,11 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
782
790
  const filtered = safeItems.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
783
791
  const merged = mergeArchiveTailEvents(drainedArchiveEvents, filtered);
784
792
  const limit = positiveInteger(options.limit);
785
- const events = limit !== undefined ? merged.slice(0, limit) : merged;
793
+ // FS3 (fix): seq-sort before the limit slice — see cursorResultFromEvents.
794
+ // Sort ONLY under a limit (no-limit keeps the historical file-order
795
+ // contract; nothing is skippable when everything is delivered).
796
+ const events =
797
+ limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
786
798
  const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
787
799
  // BR-08: `read.state.byteOffset` sits past the ENTIRE delta read above —
788
800
  // including the events the `limit` slice just dropped. Handing that back