pi-crew 0.11.21 → 0.11.22

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 (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.mjs +537 -526
  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.ts +24 -2
  16. package/src/prompt/inbox-poll.ts +77 -25
  17. package/src/prompt/message-tool.ts +62 -4
  18. package/src/prompt/prompt-runtime.ts +82 -32
  19. package/src/runtime/broker/crew-broker-client.ts +74 -16
  20. package/src/runtime/broker/crew-broker.ts +111 -77
  21. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  22. package/src/runtime/broker/wait-status-cache.ts +14 -5
  23. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  24. package/src/runtime/child-pi/child-pi.ts +20 -2
  25. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  26. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  27. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  28. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  29. package/src/runtime/crew-agent-records.ts +7 -1
  30. package/src/runtime/delivery-coordinator.ts +30 -0
  31. package/src/runtime/dwf-state-store.ts +41 -7
  32. package/src/runtime/finalize-run.ts +13 -2
  33. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  34. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  35. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  36. package/src/runtime/live-session/embedded-tier.ts +3 -1
  37. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  38. package/src/runtime/model/model-fallback.ts +23 -1
  39. package/src/runtime/output/pi-json-output.ts +26 -1
  40. package/src/runtime/policy-engine.ts +18 -0
  41. package/src/runtime/process/zombie-scanner.ts +78 -14
  42. package/src/runtime/process-status.ts +6 -1
  43. package/src/runtime/recovery/crash-recovery.ts +170 -28
  44. package/src/runtime/rpc/rpc-worker.ts +44 -1
  45. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  46. package/src/runtime/scheduling/scheduler.ts +74 -11
  47. package/src/runtime/scratchpad/engine.ts +26 -3
  48. package/src/runtime/settings-store.ts +22 -2
  49. package/src/runtime/surface/tmux-provider.ts +24 -3
  50. package/src/runtime/task-health.ts +16 -1
  51. package/src/runtime/task-output-context.ts +143 -71
  52. package/src/runtime/task-packet.ts +70 -17
  53. package/src/runtime/task-runner/child-executor.ts +27 -0
  54. package/src/runtime/task-runner/output-splitter.ts +79 -16
  55. package/src/runtime/task-runner/post-execution.ts +75 -15
  56. package/src/runtime/task-runner/state-helpers.ts +92 -2
  57. package/src/runtime/usage-tracker.ts +11 -0
  58. package/src/runtime/verification/green-contract.ts +17 -0
  59. package/src/runtime/verification/verification-gates.ts +24 -6
  60. package/src/runtime/workspace-lock.ts +53 -0
  61. package/src/schema/team-tool-schema.ts +10 -4
  62. package/src/state/coordination/mailbox.ts +133 -38
  63. package/src/state/decision-ledger.ts +56 -8
  64. package/src/state/event-log/event-log-rotation.ts +83 -5
  65. package/src/state/event-log/event-log.ts +181 -12
  66. package/src/state/stores/active-run-registry.ts +10 -3
  67. package/src/state/types.ts +9 -1
  68. package/src/ui/agents-jobs-browser.ts +22 -8
  69. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  70. package/src/ui/events-state-source.ts +67 -28
  71. package/src/ui/inline-panel/agent-pane.ts +13 -6
  72. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  73. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  74. package/src/ui/inline-panel/index.ts +21 -1
  75. package/src/ui/live-run-sidebar.ts +14 -3
  76. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  77. package/src/ui/powerbar-publisher.ts +6 -3
  78. package/src/ui/rail.ts +7 -1
  79. package/src/ui/render-scheduler.ts +53 -1
  80. package/src/ui/run-action-dispatcher.ts +68 -20
  81. package/src/ui/status-colors.ts +9 -0
  82. package/src/utils/env-filter.ts +8 -0
@@ -20,7 +20,55 @@
20
20
  * "compacted ... chars, head+tail preserved") and `readIfSmall` (marker
21
21
  * "truncated ... bytes, head+tail preserved") backward-compatible when no
22
22
  * important lines are present.
23
+ *
24
+ * K6/K7 (DR-03 round-6):
25
+ * - `reserveChars` lets the caller (TruncationStage) subtract the composed
26
+ * marker + separator + joined-important-line overhead from the head+tail
27
+ * budget, so the FINAL composed result fits `maxChars` + small constant
28
+ * instead of stacking unbudgeted overhead on top (~1.15×maxChars).
29
+ * - Head/tail/middle boundaries are surrogate-pair-safe: a code-unit index
30
+ * that would split an astral pair (emoji, CJK extensions) is backed off
31
+ * so every emitted part contains only whole code points.
32
+ */
33
+
34
+ /** Default fraction of `maxChars` available for important-line content. */
35
+ export const DEFAULT_SLACK_FACTOR = 0.15;
36
+ /** Default cap on candidate lines before slack-budget selection. */
37
+ export const DEFAULT_MAX_IMPORTANT_LINES = 30;
38
+
39
+ function isHighSurrogateUnit(codeUnit: number): boolean {
40
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
41
+ }
42
+
43
+ function isLowSurrogateUnit(codeUnit: number): boolean {
44
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
45
+ }
46
+
47
+ /**
48
+ * K7: back a head-end boundary off while it would split a surrogate pair.
49
+ * `value.slice(0, boundary)` must never end on a lone high surrogate — if the
50
+ * code unit at `boundary - 1` is a high surrogate, its low partner lives at
51
+ * `boundary` and slicing there would orphan both halves; the boundary walks
52
+ * back so the whole pair falls into the (dropped) middle instead.
23
53
  */
54
+ export function safeHeadBoundary(value: string, end: number): number {
55
+ let boundary = Math.max(0, Math.min(end, value.length));
56
+ while (boundary > 0 && isHighSurrogateUnit(value.charCodeAt(boundary - 1))) boundary -= 1;
57
+ return boundary;
58
+ }
59
+
60
+ /**
61
+ * K7: advance a tail-start boundary while it would split a surrogate pair.
62
+ * `value.slice(boundary)` must never start on a lone low surrogate — if the
63
+ * code unit at `boundary` is a low surrogate, its high partner lives at
64
+ * `boundary - 1` and slicing there would orphan both halves; the boundary
65
+ * walks forward so the whole pair stays in the (dropped) middle instead.
66
+ */
67
+ export function safeTailBoundary(value: string, start: number): number {
68
+ let boundary = Math.max(0, Math.min(start, value.length));
69
+ while (boundary < value.length && isLowSurrogateUnit(value.charCodeAt(boundary))) boundary += 1;
70
+ return boundary;
71
+ }
24
72
 
25
73
  /** Diagnostic patterns. Anchored where safe to avoid matching noise. */
26
74
  export const IMPORTANT_LINE_PATTERNS: readonly RegExp[] = [
@@ -75,7 +123,9 @@ export interface TruncationSplit {
75
123
  * fit the slack budget.
76
124
  */
77
125
  importantLines: string[];
78
- /** `value.length - maxChars` — chars dropped if no important lines preserved. */
126
+ /** `value.length - headTailBudget` — chars dropped if no important lines
127
+ * preserved. `headTailBudget` is `maxChars - reserveChars`; with no
128
+ * reserve (the default) this equals `value.length - maxChars`. */
79
129
  baseDropped: number;
80
130
  }
81
131
 
@@ -84,8 +134,19 @@ export interface SplitOptions {
84
134
  preserveImportant?: boolean;
85
135
  /** Hard cap on candidate lines before slack-budget selection. Default 30. */
86
136
  maxImportantLines?: number;
87
- /** Fraction of `maxChars` available for important-line content. Default 0.15. */
137
+ /**
138
+ * Fraction of `maxChars` available for important-line content. Default 0.15
139
+ * ({@link DEFAULT_SLACK_FACTOR}).
140
+ */
88
141
  slackFactor?: number;
142
+ /**
143
+ * K6: code units the CALLER will compose AROUND head+tail (marker text,
144
+ * separators, joined important lines). The head+tail budget shrinks by
145
+ * this reserve so the caller's final composed result fits `maxChars` +
146
+ * small constant instead of stacking unbudgeted overhead on top. Default
147
+ * 0 — legacy semantics where head+tail alone consume the full maxChars.
148
+ */
149
+ reserveChars?: number;
89
150
  }
90
151
 
91
152
  /**
@@ -101,24 +162,37 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
101
162
  if (value.length <= maxChars) {
102
163
  return { head: value, tail: "", importantLines: [], baseDropped: 0 };
103
164
  }
104
- const headLen = Math.floor(maxChars * 0.75);
105
- const tailLen = maxChars - headLen;
106
- const head = value.slice(0, headLen);
107
- const tail = value.slice(value.length - tailLen);
165
+ // K6: reserve the caller's composed overhead (marker + separators +
166
+ // joined important lines) out of the head+tail budget. Clamped so at
167
+ // least one code unit of budget survives even when the reserve exceeds
168
+ // maxChars (pathologically small budgets — the marker alone dominates).
169
+ const reserve = Math.max(0, Math.min(Math.floor(opts.reserveChars ?? 0), maxChars - 1));
170
+ const headTailBudget = Math.max(1, maxChars - reserve);
171
+ const headLen = Math.floor(headTailBudget * 0.75);
172
+ const tailLen = headTailBudget - headLen;
173
+ // K7: surrogate-safe boundaries. Raw code-unit slices previously split
174
+ // astral pairs at the 75% head boundary and the tail start, emitting
175
+ // lone surrogates (mojibake) into the compacted output. Back off while
176
+ // the boundary would split a pair — the whole pair then falls into the
177
+ // middle (dropped) region instead of being broken across head/tail.
178
+ const headEnd = safeHeadBoundary(value, headLen);
179
+ const tailStart = safeTailBoundary(value, value.length - tailLen);
180
+ const head = value.slice(0, headEnd);
181
+ const tail = value.slice(tailStart);
108
182
 
109
183
  if (opts.preserveImportant === false) {
110
184
  return {
111
185
  head,
112
186
  tail,
113
187
  importantLines: [],
114
- baseDropped: value.length - maxChars,
188
+ baseDropped: value.length - headTailBudget,
115
189
  };
116
190
  }
117
191
 
118
- const slackFactor = opts.slackFactor ?? 0.15;
192
+ const slackFactor = opts.slackFactor ?? DEFAULT_SLACK_FACTOR;
119
193
  const slackChars = Math.max(0, Math.floor(maxChars * slackFactor));
120
- const maxCandidates = opts.maxImportantLines ?? 30;
121
- const middle = value.slice(headLen, value.length - tailLen);
194
+ const maxCandidates = opts.maxImportantLines ?? DEFAULT_MAX_IMPORTANT_LINES;
195
+ const middle = value.slice(headEnd, tailStart);
122
196
  const candidates = extractImportantLines(middle, maxCandidates);
123
197
 
124
198
  // Greedily pick whole lines that fit in the slack budget.
@@ -135,6 +209,6 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
135
209
  head,
136
210
  tail,
137
211
  importantLines: chosen,
138
- baseDropped: value.length - maxChars,
212
+ baseDropped: value.length - headTailBudget,
139
213
  };
140
214
  }
@@ -39,6 +39,14 @@ export interface ToolResultEntry {
39
39
  content: string;
40
40
  /** Whether the tool result represents an error. */
41
41
  isError?: boolean;
42
+ /**
43
+ * K8: the tool's REAL exit code when the original result carries one
44
+ * (e.g. a bash exit status). Passed through to the digest verbatim
45
+ * instead of the historical `isError ? 1 : 0` guess — exit 127 digests
46
+ * as `exit=127`. Optional: callers whose source shape has no exit code
47
+ * omit it and keep the legacy guess.
48
+ */
49
+ exitCode?: number;
42
50
  }
43
51
 
44
52
  /** A file mutation event (edit/write) that can invalidate earlier reads. */
@@ -102,11 +110,23 @@ function estimateTokens(text: string): number {
102
110
 
103
111
  const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
104
112
 
105
- function firstErrorLine(text: string): string | undefined {
106
- return text
107
- .split(/\r?\n/)
108
- .find((line) => /error|failed|exception|panic/i.test(line))
109
- ?.trim();
113
+ function firstErrorLine(text: string, isError = false): string | undefined {
114
+ const lines = text.split(/\r?\n/);
115
+ const matched = lines.find((line) => /error|failed|exception|panic/i.test(line))?.trim();
116
+ if (matched !== undefined) return matched;
117
+ // K8: multi-line stderr/stack whose HEAD carries the diagnostic but no
118
+ // line matches the keyword patterns (localized messages, shell errors,
119
+ // stack frames without a keyword). Fall back to the first 3 non-empty
120
+ // lines of the content head — the head names the failing frame; the old
121
+ // digest kept only the TAIL line, which for a stack trace is the least
122
+ // informative frame. Gated on isError so success output never grows a
123
+ // bogus error= part.
124
+ if (!isError) return undefined;
125
+ const head = lines
126
+ .map((line) => line.trim())
127
+ .filter((line) => line.length > 0)
128
+ .slice(0, 3);
129
+ return head.length > 0 ? head.join(" | ") : undefined;
110
130
  }
111
131
 
112
132
  function truncateField(value: string, maxLength: number): string {
@@ -119,22 +139,26 @@ function truncateField(value: string, maxLength: number): string {
119
139
  * Generate a compact digest of a tool result for the digest notice.
120
140
  * Supports bash (exit code + tail line), grep/search (match/file counts),
121
141
  * and falls back to undefined for tools without a known digest format.
142
+ *
143
+ * K8: `exitCode` passes the REAL exit status through when the original
144
+ * result carries one (exit 127 → `exit=127`, not the hardcoded `exit=1`);
145
+ * when absent the legacy `isError ? 1 : 0` guess is kept.
122
146
  */
123
- export function resultDigest(toolName: string, content: string, isError?: boolean): string | undefined {
147
+ export function resultDigest(toolName: string, content: string, isError?: boolean, exitCode?: number): string | undefined {
124
148
  const name = toolName.toLowerCase();
125
149
  const text = content ?? "";
126
150
  if (name === "bash") {
127
- const exitCode = isError ? 1 : 0;
151
+ const exitCodeValue = typeof exitCode === "number" && Number.isFinite(exitCode) ? Math.trunc(exitCode) : isError ? 1 : 0;
128
152
  const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
129
- const error = firstErrorLine(text);
130
- return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
153
+ const error = firstErrorLine(text, isError);
154
+ return [`exit=${exitCodeValue}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
131
155
  .filter((part): part is string => part !== undefined)
132
156
  .join("; ");
133
157
  }
134
158
  if (name === "search" || name === "grep") {
135
159
  const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
136
160
  const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
137
- const error = firstErrorLine(text);
161
+ const error = firstErrorLine(text, isError);
138
162
  return (
139
163
  [match ? `matches=${match[1]}` : undefined, files ? `files=${files[1]}` : undefined, error ? `error=${error}` : undefined]
140
164
  .filter((part): part is string => part !== undefined)
@@ -146,7 +170,7 @@ export function resultDigest(toolName: string, content: string, isError?: boolea
146
170
 
147
171
  function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
148
172
  const generic = `[Output pruned — ${tokens} tokens]`;
149
- const digest = resultDigest(entry.toolName, entry.content, entry.isError);
173
+ const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode);
150
174
  if (!digest) return generic;
151
175
  const genericTokens = countTokens(generic);
152
176
  const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import {
5
+ atomicWriteFile,
5
6
  atomicWriteJson,
6
7
  atomicWriteJsonCoalesced,
7
8
  cancelPendingCoalescedWriteForPath,
@@ -686,7 +687,12 @@ function readSeqFromSidecar(filePath: string): number | undefined {
686
687
 
687
688
  function writeSeqToSidecar(filePath: string, seq: number): void {
688
689
  try {
689
- fs.writeFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq));
690
+ // D6 (DR-02): tmp+rename so a concurrent reader (nextAgentEventSeq on
691
+ // another thread/process) never observes a torn or empty .seq.
692
+ // best-effort durability per F4: the sidecar is only an O(1) lookup
693
+ // optimization — losing it on a crash costs the O(n) scan fallback,
694
+ // never correctness. Path and content format unchanged (String(seq)).
695
+ atomicWriteFile(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq), { durability: "best-effort" });
690
696
  } catch (error) {
691
697
  logInternalError("crew-agent-records.seq-sidecar", error, `filePath=${filePath}`);
692
698
  }
@@ -26,6 +26,12 @@ export interface DeliveryCoordinatorDeps {
26
26
  const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
27
27
 
28
28
  export class DeliveryCoordinator {
29
+ // DR-02 C3: the queue was TTL-bounded ONLY (24h) — an extension that stays
30
+ // sessionless while runs keep completing could grow `pending` unboundedly.
31
+ // Hard count cap with drop-oldest (oldest delivery is the least likely to
32
+ // still matter); the TTL eviction below stays unchanged. Static (not
33
+ // module-const) so tests can read/patch it like Semaphore.MAX_QUEUE.
34
+ static readonly MAX_PENDING = 1_000;
29
35
  private active = false;
30
36
  private generation = 0;
31
37
  private pending: PendingDelivery[] = [];
@@ -36,6 +42,9 @@ export class DeliveryCoordinator {
36
42
  /** The session id passed to the most recent activate(); used by flushQueuedResults
37
43
  * to park deliveries owned by a different session (vector #12). */
38
44
  private activeSessionId?: string;
45
+ /** DR-02 C3: set after the first warn of an overflow episode; cleared once
46
+ * the queue is back at/below the cap so the NEXT episode warns again. */
47
+ private pendingOverflowWarned = false;
39
48
 
40
49
  constructor(deps: DeliveryCoordinatorDeps) {
41
50
  this.deps = deps;
@@ -215,6 +224,27 @@ export class DeliveryCoordinator {
215
224
  this.ttlTimer.unref();
216
225
  }
217
226
  this.pending.push({ ...delivery, generation: this.generation });
227
+ // DR-02 C3: count cap — drop-oldest while over cap, warn ONCE per
228
+ // overflow episode (flag resets once the queue is back under cap, so a
229
+ // later separate overflow is visible again).
230
+ if (this.pending.length <= DeliveryCoordinator.MAX_PENDING) {
231
+ this.pendingOverflowWarned = false;
232
+ return;
233
+ }
234
+ let dropped = 0;
235
+ while (this.pending.length > DeliveryCoordinator.MAX_PENDING) {
236
+ if (this.pending.shift() === undefined) break;
237
+ dropped++;
238
+ }
239
+ if (dropped > 0 && !this.pendingOverflowWarned) {
240
+ this.pendingOverflowWarned = true;
241
+ logInternalError(
242
+ "delivery-coordinator.pending-overflow",
243
+ undefined,
244
+ `dropped=${dropped} max=${DeliveryCoordinator.MAX_PENDING} remaining=${this.pending.length} runId=${delivery.runId}`,
245
+ "warn",
246
+ );
247
+ }
218
248
  }
219
249
 
220
250
  private evictExpired(): void {
@@ -19,8 +19,21 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
19
19
  import { dirname } from "node:path";
20
20
  import { atomicWriteJson } from "../state/atomic-write.ts";
21
21
  import { logInternalError } from "../utils/internal-error.ts";
22
+ import type { AgentResult } from "./goal-workflow/dynamic-workflow-context.ts";
23
+
24
+ /** R6-12 (DR-03 round-6, 2026-10-16): current DWF checkpoint format version.
25
+ * Bumped whenever the persisted shape changes incompatibly — the loader
26
+ * rejects unknown FUTURE versions (see DwfStore.load) so a newer checkpoint
27
+ * is never silently mis-hydrated by an older binary. */
28
+ export const DWF_CHECKPOINT_VERSION = 1;
22
29
 
23
30
  export interface DwfCheckpointState {
31
+ /** R6-12: format version. Always DWF_CHECKPOINT_VERSION on write (save()
32
+ * stamps it like updatedAt); load() tolerates a missing version (legacy
33
+ * pre-R6-12 checkpoints written by the version-less code — otherwise an
34
+ * in-flight run would lose its resume state across an upgrade) but rejects
35
+ * any other value. */
36
+ version: typeof DWF_CHECKPOINT_VERSION;
24
37
  runId: string;
25
38
  vars: Record<string, unknown>;
26
39
  phases: string[];
@@ -28,11 +41,14 @@ export interface DwfCheckpointState {
28
41
  logs: string[]; // capped copy (≤1000); the events log (dwf.log) is the durable source of truth
29
42
  spent: number; // budget accumulator (round-14 P1-2)
30
43
  agentCount: number;
31
- /** PERS-1: per-agent-call idempotency cache. Maps a deterministic call ID to the
32
- * cached result so a DWF resume skips already-completed agent calls (avoids
33
- * duplicating artifacts/mailbox/tokens). Uses Record (not Map) for JSON
34
- * serialization safety. */
35
- completedAgentCalls?: Record<string, { text: string; usage?: { input: number; output: number } }>;
44
+ /** PERS-1: per-agent-call idempotency cache. Maps a deterministic call ID to
45
+ * the cached FULL AgentResult (R6-3: text + usage + structured + artifactPath
46
+ * + actual durationMs — previously only {text,usage}, so replayed steps lost
47
+ * structured output and artifact linkage) so a DWF resume skips
48
+ * already-completed agent calls (avoids duplicating artifacts/mailbox/tokens).
49
+ * Uses Record (not Map) for JSON serialization safety. Optional: legacy
50
+ * checkpoints (and fresh states) omit it. */
51
+ completedAgentCalls?: Record<string, AgentResult>;
36
52
  updatedAt: string;
37
53
  }
38
54
 
@@ -70,16 +86,34 @@ export class DwfStore {
70
86
  // Corrupt-guard: a valid checkpoint must be an object with a string runId
71
87
  // (mirrors GoalStore.load's typeof parsed.goalId !== "string" check).
72
88
  if (!parsed || typeof parsed !== "object" || typeof parsed.runId !== "string") return undefined;
89
+ // R6-12: version guard, mirroring the recovery/checkpoint.ts runId-guard
90
+ // style (typed mismatch → treat as not-loadable). A checkpoint from an
91
+ // unknown FUTURE version is REJECTED (→ undefined → fresh run) rather than
92
+ // mis-hydrated — a newer format's fields mean nothing to this binary.
93
+ // A MISSING version is accepted as legacy (written by the pre-R6-12 code,
94
+ // shape-identical otherwise) so an in-flight run survives an upgrade.
95
+ // Logged (not silent) so the quarantine is observable in diagnostics.
96
+ if (parsed.version !== undefined && parsed.version !== DWF_CHECKPOINT_VERSION) {
97
+ logInternalError(
98
+ "dwf-state-store.load",
99
+ new Error(
100
+ `unknown dwf-checkpoint version ${String(parsed.version)} (expected ${DWF_CHECKPOINT_VERSION}) — rejecting checkpoint, run restarts fresh`,
101
+ ),
102
+ `runId=${parsed.runId}`,
103
+ );
104
+ return undefined;
105
+ }
73
106
  return parsed as DwfCheckpointState;
74
107
  } catch {
75
108
  return undefined;
76
109
  }
77
110
  }
78
111
 
79
- /** Atomically persist a checkpoint state. Stamps `updatedAt` (callers need not set it). */
112
+ /** Atomically persist a checkpoint state. Stamps `version` (R6-12) and
113
+ * `updatedAt` (callers need not set either). */
80
114
  save(state: DwfCheckpointState): void {
81
115
  const path = this.path;
82
- const next = { ...state, updatedAt: new Date().toISOString() };
116
+ const next = { ...state, version: DWF_CHECKPOINT_VERSION, updatedAt: new Date().toISOString() };
83
117
  try {
84
118
  mkdirSync(dirname(path), { recursive: true });
85
119
  atomicWriteJson(path, next);
@@ -439,9 +439,20 @@ export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: Te
439
439
  // (No updateRunStatus call: from===to is a no-op, but the intent here is
440
440
  // explicitly "leave the earlier decision intact".)
441
441
  } else {
442
+ // R4-4 (DR-02 wave 2): needs_attention is terminal-without-result and the
443
+ // scheduler counts it as done, so the run still COMPLETES — but the green
444
+ // "Team workflow completed." summary must not hide that a task's result
445
+ // was never submitted. Detection-first surfacing only: status stays
446
+ // "completed" (no flip, no block); the policy engine emits a matching
447
+ // non-blocking notify decision listing the task ids.
448
+ const needsAttentionCount = tasks.filter((task) => task.status === "needs_attention").length;
442
449
  manifest = applyStatusInMemory(
443
450
  "completed",
444
- input.executeWorkers ? "Team workflow completed." : "Team workflow scaffold completed without launching child workers.",
451
+ needsAttentionCount > 0
452
+ ? `Team workflow completed with ${needsAttentionCount} task(s) needing attention (no result submitted).`
453
+ : input.executeWorkers
454
+ ? "Team workflow completed."
455
+ : "Team workflow scaffold completed without launching child workers.",
445
456
  );
446
457
  }
447
458
  manifest = writeProgress(manifest, tasks, "team-runner", input.executeWorkers, input.runtimeConfig);
@@ -553,7 +564,7 @@ export async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: Te
553
564
  const healthStore = new HealthStore(crewRoot);
554
565
  healthStore.saveSnapshot({
555
566
  runId: finalManifest.manifest.runId,
556
- tasks: finalTasks.map((t) => ({ id: t.id, status: t.status })),
567
+ tasks: finalTasks.map((t) => ({ id: t.id, status: t.status, heartbeat: t.heartbeat })),
557
568
  createdAt: finalManifest.manifest.createdAt,
558
569
  });
559
570
  ctx.manifest = manifest;