pi-crew 0.9.5 → 0.9.8

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 (37) hide show
  1. package/CHANGELOG.md +556 -0
  2. package/README.md +10 -3
  3. package/docs/HARNESS_BACKLOG.md +51 -3
  4. package/docs/dynamic-workflows.md +315 -2
  5. package/docs/fix-plan-disabletools-exit-null.md +219 -0
  6. package/docs/troubleshooting.md +76 -0
  7. package/package.json +10 -3
  8. package/src/config/defaults.ts +8 -4
  9. package/src/extension/team-tool/doctor.ts +14 -0
  10. package/src/extension/team-tool/run.ts +2 -0
  11. package/src/runtime/background-runner.ts +1 -1
  12. package/src/runtime/capability-inventory.ts +20 -1
  13. package/src/runtime/child-pi.ts +109 -11
  14. package/src/runtime/deterministic-ast.ts +161 -0
  15. package/src/runtime/dwf-state-store.ts +97 -0
  16. package/src/runtime/dynamic-workflow-context.ts +381 -7
  17. package/src/runtime/dynamic-workflow-runner.ts +93 -2
  18. package/src/runtime/pi-args.ts +11 -0
  19. package/src/runtime/result-extractor.ts +72 -7
  20. package/src/runtime/task-output-context.ts +25 -9
  21. package/src/runtime/team-runner.ts +8 -3
  22. package/src/runtime/zombie-scanner.ts +297 -0
  23. package/src/schema/team-tool-schema.ts +28 -0
  24. package/src/skills/discover-skills.ts +61 -8
  25. package/src/skills/validate.ts +267 -0
  26. package/src/state/contracts.ts +1 -0
  27. package/src/state/state-store.ts +3 -0
  28. package/src/state/types.ts +9 -0
  29. package/src/ui/dashboard-panes/progress-pane.ts +5 -0
  30. package/src/ui/dwf-phase-display.ts +151 -0
  31. package/src/ui/keybinding-map.ts +128 -41
  32. package/src/ui/run-event-bus.ts +83 -0
  33. package/src/ui/run-snapshot-cache.ts +4 -0
  34. package/src/ui/snapshot-types.ts +3 -0
  35. package/src/workflows/workflow-config.ts +3 -0
  36. package/src/worktree/worktree-manager.ts +94 -0
  37. package/types/dwf.d.ts +187 -0
@@ -23,7 +23,9 @@ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
23
23
  import { appendEvent } from "../state/event-log.ts";
24
24
  import { writeArtifact } from "../state/artifact-store.ts";
25
25
  import { logInternalError } from "../utils/internal-error.ts";
26
- import { makeWorkflowCtx, getWorkflowFinalResult } from "./dynamic-workflow-context.ts";
26
+ import { makeWorkflowCtx, getWorkflowFinalResult, getWorkflowPhaseState } from "./dynamic-workflow-context.ts";
27
+ import { DwfStore } from "./dwf-state-store.ts";
28
+ import { assertDeterministicScript, isDeterminismCheckEnabled } from "./deterministic-ast.ts";
27
29
  import { projectCrewRoot, userPiRoot, packageRoot } from "../utils/paths.ts";
28
30
  import type { DynamicWorkflowConfig } from "../workflows/workflow-config.ts";
29
31
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -36,6 +38,8 @@ export interface RunDynamicWorkflowInput {
36
38
  signal: AbortSignal;
37
39
  concurrency?: number;
38
40
  modelOverride?: string;
41
+ /** round-14 P1-2: per-workflow token budget. Overrides workflow.maxTokenBudget. */
42
+ tokenBudget?: number;
39
43
  }
40
44
 
41
45
  export interface RunDynamicWorkflowResult {
@@ -46,6 +50,27 @@ export interface RunDynamicWorkflowResult {
46
50
  /** The signature a .dwf.ts default export must satisfy. */
47
51
  export type DynamicWorkflowScript = (ctx: import("./dynamic-workflow-context.ts").WorkflowCtx) => Promise<void> | void;
48
52
 
53
+ /**
54
+ * round-12 P0-4: defensive structured-clone guard at the runner boundary.
55
+ *
56
+ * Today this is mostly future-proofing: a DWF script's `setResult()` path
57
+ * reads an artifact file as a string, and strings are always structured-
58
+ * cloneable. But if a future code path produces a non-cloneable value
59
+ * (e.g. a Worker postMessage payload that wraps a Symbol or function), we
60
+ * want a clear, actionable error here — not a cryptic `DataCloneError`
61
+ * from deep inside the artifact store. The error message also nudges
62
+ * users toward the most common cause: forgetting `await` on ctx.agent()
63
+ * or ctx.review() in their script.
64
+ */
65
+ function assertStructuredCloneable(value: unknown, name: string): void {
66
+ try {
67
+ structuredClone(value);
68
+ } catch (error) {
69
+ const detail = error instanceof Error ? error.message : String(error);
70
+ throw new Error(`${name} must be structured-cloneable; did you forget to await ctx.agent() or ctx.review()? ${detail}`);
71
+ }
72
+ }
73
+
49
74
  /**
50
75
  * Resolve + validate the script path against the allowlist of workflow dirs (§0c C5).
51
76
  * Returns the real contained path or throws.
@@ -79,8 +104,19 @@ function resolveScriptPath(workflow: DynamicWorkflowConfig, cwd: string): string
79
104
  /**
80
105
  * Transpile + load the .dwf.ts default export. Uses jiti (already a dep) for TS→JS.
81
106
  * Returns the default export function or throws.
107
+ *
108
+ * Round-13 P0-2: after reading the script source, run `assertDeterministicScript`
109
+ * to reject non-deterministic calls (Date.now()/Math.random()/new Date()) BEFORE
110
+ * jiti executes the module. The check is opt-out via PI_CREW_DWF_SKIP_DETERMINISM_CHECK=1.
82
111
  */
83
112
  async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowScript> {
113
+ // Round-13 P0-2: read source first so we can AST-scan before execution.
114
+ // jiti does not surface the transpiled source back to us, so we read the
115
+ // raw .dwf.ts file. This is the same source jiti will execute.
116
+ const scriptSource = readFileSync(scriptPath, "utf-8");
117
+ if (isDeterminismCheckEnabled()) {
118
+ assertDeterministicScript(scriptSource);
119
+ }
84
120
  // jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
85
121
  // lazily so this module stays importable in environments without jiti (type-only consumers).
86
122
  // Fix round-4: use createRequire(import.meta.url) so `require` works under the strip-types
@@ -110,11 +146,37 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
110
146
 
111
147
  appendEvent(eventsPath, { type: "dwf.started", runId: manifest.runId, data: { workflow: workflow.name, script: scriptPath } });
112
148
 
149
+ // round-18 P2-3: resume/checkpoint. Load any existing checkpoint for this run's stateRoot.
150
+ // stateRoot is already <crewRoot>/state/runs/<runId>, so the checkpoint lands at
151
+ // <stateRoot>/dwf-checkpoint.json (no double-nesting). A missing checkpoint (fresh run)
152
+ // yields undefined — makeWorkflowCtx starts with empty defaults (backward compatible).
153
+ const dwfStore = new DwfStore(manifest.stateRoot);
154
+ const resumedState = dwfStore.load();
155
+ if (resumedState) {
156
+ appendEvent(eventsPath, {
157
+ type: "dwf.resumed",
158
+ runId: manifest.runId,
159
+ data: { agentCount: resumedState.agentCount, phases: resumedState.phases, currentPhase: resumedState.currentPhase },
160
+ });
161
+ }
162
+
113
163
  const ctx = makeWorkflowCtx(manifest, {
114
164
  concurrency: input.concurrency ?? workflow.maxConcurrency ?? 4,
115
165
  signal,
116
166
  team: input.team,
117
167
  modelOverride: input.modelOverride,
168
+ tokenBudget: input.tokenBudget ?? workflow.maxTokenBudget,
169
+ args: manifest.args,
170
+ resumedState,
171
+ // round-18 P2-3: checkpoint after each ctx.agent() call so a crash between calls
172
+ // leaves durable state. onCheckpoint captures the closure values at call time.
173
+ onCheckpoint: (state) => {
174
+ try {
175
+ dwfStore.save(state);
176
+ } catch (error) {
177
+ logInternalError("dynamic-workflow-runner.checkpoint-save", error, `runId=${manifest.runId}`);
178
+ }
179
+ },
118
180
  });
119
181
 
120
182
  // Freeze the ctx so the script cannot add/override capability methods (§0c C4).
@@ -151,6 +213,12 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
151
213
  const final = getWorkflowFinalResult(ctx);
152
214
  const finalText = final ? readFinalArtifact(final.artifactPath) : `(dynamic workflow '${workflow.name}' completed without calling ctx.setResult())`;
153
215
 
216
+ // round-12 P0-4: fail fast on unawaited Promise returns BEFORE we try to
217
+ // write a 2 KB blob that contains a Promise reference. structuredClone on
218
+ // a string always succeeds; if it doesn't, the script returned something
219
+ // uncloneable (most often an unawaited Promise) and we want a clear error.
220
+ assertStructuredCloneable(finalText, "final artifact content (set via ctx.setResult)");
221
+
154
222
  // Write a summary artifact mirroring the static-workflow summary.md contract (run.ts reads this).
155
223
  const summary = writeArtifact(manifest.artifactsRoot, {
156
224
  kind: "result",
@@ -159,12 +227,35 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
159
227
  producer: "dynamic-workflow",
160
228
  });
161
229
 
230
+ // round-12 P0-1: safety net — if a script never explicitly closes its
231
+ // final phase before returning, the runner emits a closing event so the
232
+ // last open phase is always terminated before dwf.completed.
233
+ const phaseState = getWorkflowPhaseState(ctx);
234
+ if (phaseState?.currentPhase !== undefined) {
235
+ appendEvent(eventsPath, {
236
+ type: "dwf.phase_completed",
237
+ runId: manifest.runId,
238
+ data: { phase: phaseState.currentPhase },
239
+ });
240
+ phaseState.currentPhase = undefined;
241
+ }
242
+
162
243
  appendEvent(eventsPath, { type: "dwf.completed", runId: manifest.runId, data: { workflow: workflow.name, summaryArtifact: summary.path } });
163
244
 
245
+ // round-18 P2-3: the run completed cleanly — delete the checkpoint so a fresh re-run
246
+ // (same runId) starts from scratch rather than resuming stale state.
247
+ dwfStore.delete();
248
+
249
+ // round-12 P0-4: also guard the manifest.summary slice (the value is
250
+ // written into JSON-serialized manifest state — a Promise here would also
251
+ // crash later in the run-event-bus emitter).
252
+ const summaryText = finalText.slice(0, 2000);
253
+ assertStructuredCloneable(summaryText, "manifest.summary (derived from final result)");
254
+
164
255
  const updatedManifest: TeamRunManifest = {
165
256
  ...manifest,
166
257
  status: "completed",
167
- summary: finalText.slice(0, 2000),
258
+ summary: summaryText,
168
259
  updatedAt: new Date().toISOString(),
169
260
  artifacts: [...manifest.artifacts, summary],
170
261
  };
@@ -243,6 +243,12 @@ export function createSafeTempDir(base: string, prefix: string): string {
243
243
  }
244
244
 
245
245
  export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerArgsResult {
246
+ // NOTE: do NOT add an argv flag like `--crew-subagent` here. Pi uses a strict
247
+ // option parser and REJECTS unknown flags with a non-zero exit, which would
248
+ // break every ctx.agent() call. The authoritative sub-agent identity signal
249
+ // is the PI_CREW_KIND=subagent ENV var (set below) — the zombie scanner and
250
+ // doctor --zombies read it from /proc/<pid>/environ. The user's main session
251
+ // never sets it, so it can never be matched as a sub-agent.
246
252
  const args = ["--mode", "json", "-p"];
247
253
  if (input.sessionEnabled === false) args.push("--no-session");
248
254
 
@@ -327,6 +333,11 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
327
333
  return {
328
334
  args,
329
335
  env: {
336
+ // PI_CREW_KIND is the authoritative machine-readable sub-agent marker. It is always
337
+ // present on a child-pi process and NEVER present on a user's interactive main session.
338
+ // doctor --zombies uses it to safely list orphaned sub-agents without ever matching a
339
+ // main session (the lesson from an accidental `kill` of a live main session).
340
+ PI_CREW_KIND: "subagent",
330
341
  PI_CREW_INHERIT_PROJECT_CONTEXT: input.agent.inheritProjectContext ? "1" : "0",
331
342
  PI_CREW_INHERIT_SKILLS: input.agent.inheritSkills ? "1" : "0",
332
343
  PI_CREW_DEPTH: String(parentDepth + 1),
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Structured Result Extractor — attempts to extract structured data from worker output.
3
3
  * Tries multiple extraction strategies before falling back to raw text.
4
+ *
5
+ * Round-13 P0-3: optional `schema` (TypeBox `TSchema`) — when provided, extracted
6
+ * data is validated against the schema via `Value.Check`. On mismatch, the result
7
+ * is `structured:false` with an explanatory `error`. Backward compatible: when
8
+ * schema is undefined, behavior is identical to the previous regex-based extractor.
4
9
  */
10
+ import type { TSchema } from "@sinclair/typebox";
11
+ import { Value } from "@sinclair/typebox/value";
12
+
5
13
  export interface ExtractedResult {
6
14
  /** Whether structured data was successfully extracted */
7
15
  structured: boolean;
8
- /** Parsed structured data (if structured=true) */
16
+ /** Parsed structured data (if structured=true AND validated against schema if provided) */
9
17
  data: unknown;
10
18
  /** Raw text output (always available) */
11
19
  rawText: string;
@@ -15,9 +23,13 @@ export interface ExtractedResult {
15
23
 
16
24
  /**
17
25
  * Extract structured result from raw worker output text.
18
- * Tries strategies in order: direct JSON, fenced JSON, key-value markers.
26
+ * Tries strategies in order: direct JSON, fenced JSON, key-value markers, scan.
27
+ *
28
+ * @param raw - the raw text output from a worker
29
+ * @param schema - optional TypeBox schema. When provided, the extracted value is
30
+ * validated; mismatch produces `{structured:false, error:...}`.
19
31
  */
20
- export function extractStructuredResult(raw: string, _schema?: Record<string, unknown>): ExtractedResult {
32
+ export function extractStructuredResult(raw: string, schema?: TSchema): ExtractedResult {
21
33
  const trimmed = raw.trim();
22
34
  if (!trimmed) {
23
35
  return { structured: false, data: null, rawText: raw };
@@ -26,19 +38,19 @@ export function extractStructuredResult(raw: string, _schema?: Record<string, un
26
38
  // Strategy 1: Direct JSON parse (entire output is JSON)
27
39
  const directResult = tryDirectJson(trimmed);
28
40
  if (directResult !== undefined) {
29
- return { structured: true, data: directResult, rawText: raw };
41
+ return finalize(directResult, raw, schema);
30
42
  }
31
43
 
32
44
  // Strategy 2: Extract from ```json ... ``` fence
33
45
  const fencedResult = tryFencedJson(trimmed);
34
46
  if (fencedResult !== undefined) {
35
- return { structured: true, data: fencedResult, rawText: raw };
47
+ return finalize(fencedResult, raw, schema);
36
48
  }
37
49
 
38
50
  // Strategy 3: Extract from markers like "RESULT:" or "OUTPUT:"
39
51
  const markerResult = tryMarkerExtraction(trimmed);
40
52
  if (markerResult !== undefined) {
41
- return { structured: true, data: markerResult, rawText: raw };
53
+ return finalize(markerResult, raw, schema);
42
54
  }
43
55
 
44
56
  // Strategy 4: Scan for the first JSON object/array anywhere in text.
@@ -46,12 +58,65 @@ export function extractStructuredResult(raw: string, _schema?: Record<string, un
46
58
  // around the JSON. This catches JSON embedded in sentences, lists, or prose.
47
59
  const scannedResult = tryScanJson(trimmed);
48
60
  if (scannedResult !== undefined) {
49
- return { structured: true, data: scannedResult, rawText: raw };
61
+ return finalize(scannedResult, raw, schema);
50
62
  }
51
63
 
52
64
  return { structured: false, data: null, rawText: raw };
53
65
  }
54
66
 
67
+ /**
68
+ * After extracting a candidate object, validate it against the optional TypeBox schema.
69
+ * When no schema is given, behavior is the legacy "structured:true" path.
70
+ * When a schema is given and validation fails, return structured:false with a
71
+ * clear error message (caller can surface this in the AgentResult).
72
+ *
73
+ * NOTE: TypeBox 0.34.49's `Value.Check` returns a boolean and does not expose
74
+ * per-error paths in its public API. We use the boolean + a fallback "type mismatch"
75
+ * description. Scripts that need detailed diagnostics can wrap their own validator.
76
+ */
77
+ function finalize(candidate: unknown, raw: string, schema: TSchema | undefined): ExtractedResult {
78
+ if (!schema) {
79
+ return { structured: true, data: candidate, rawText: raw };
80
+ }
81
+ const ok = Value.Check(schema, candidate);
82
+ if (ok) {
83
+ return { structured: true, data: candidate, rawText: raw };
84
+ }
85
+ return {
86
+ structured: false,
87
+ data: null,
88
+ rawText: raw,
89
+ error: `structured output does not match schema: expected shape ${describeSchemaShape(schema)}, got ${describeValue(candidate)}`,
90
+ };
91
+ }
92
+
93
+ function describeValue(value: unknown): string {
94
+ try {
95
+ const json = JSON.stringify(value);
96
+ return json.length > 200 ? `${json.slice(0, 200)}…` : json;
97
+ } catch {
98
+ return typeof value;
99
+ }
100
+ }
101
+
102
+ function describeSchemaShape(schema: unknown): string {
103
+ if (!schema || typeof schema !== "object") return "any";
104
+ const obj = schema as Record<string, unknown>;
105
+ const kind = obj.kind as string | undefined;
106
+ const type = obj.type as string | undefined;
107
+ if (kind === "object" || type === "object") {
108
+ const properties = obj.properties;
109
+ if (!properties || typeof properties !== "object") return "object";
110
+ return `object<${Object.keys(properties as Record<string, unknown>).join(",")}>`;
111
+ }
112
+ if (kind === "array" || type === "array") return "array";
113
+ if (type === "string") return "string";
114
+ if (type === "number" || type === "integer") return "number";
115
+ if (type === "boolean") return "boolean";
116
+ if (Array.isArray(obj.anyOf) || Array.isArray(obj.oneOf)) return "union";
117
+ return "any";
118
+ }
119
+
55
120
  function tryDirectJson(text: string): unknown | undefined {
56
121
  if (!text.startsWith("{") && !text.startsWith("[")) return undefined;
57
122
  try {
@@ -30,20 +30,36 @@ function containedExists(filePath: string, baseDir?: string): boolean {
30
30
  }
31
31
  }
32
32
 
33
- function readIfSmall(filePath: string, maxBytes = 24_000, baseDir?: string): string | undefined {
33
+ /**
34
+ * L4 output-handling: single consistent threshold for all artifact reads.
35
+ * Sized from real data (27 result artifacts: max 9226 bytes; 100% < 16KB).
36
+ * 32KB gives 2x headroom over the largest observed real output while still
37
+ * bounding memory. Larger than the old inconsistent per-call-site values
38
+ * (24K/40K/80K) which truncated the same artifact differently depending on
39
+ * which code path read it.
40
+ */
41
+ const MAX_RESULT_INLINE_BYTES = 32_000;
42
+
43
+ function readIfSmall(filePath: string, baseDir?: string): string | undefined {
44
+ const maxBytes = MAX_RESULT_INLINE_BYTES;
34
45
  try {
35
46
  const safePath = baseDir ? resolveRealContainedPath(baseDir, filePath) : filePath;
36
47
  const stat = fs.statSync(safePath);
37
48
  if (stat.size > maxBytes) {
38
- // Use bounded read to avoid loading entire file into memory
39
- const buf = Buffer.alloc(maxBytes);
49
+ // L4: head + tail instead of head-only. Keeps closing markdown
50
+ // structure (code fences, headings) instead of leaving them truncated.
51
+ const head = Math.floor(maxBytes * 0.75);
52
+ const tail = maxBytes - head;
53
+ const headBuf = Buffer.alloc(head);
54
+ const tailBuf = Buffer.alloc(tail);
40
55
  const fd = fs.openSync(safePath, "r");
41
56
  try {
42
- fs.readSync(fd, buf, 0, maxBytes, 0);
57
+ fs.readSync(fd, headBuf, 0, head, 0);
58
+ fs.readSync(fd, tailBuf, 0, tail, stat.size - tail);
43
59
  } finally {
44
60
  fs.closeSync(fd);
45
61
  }
46
- return `${buf.toString("utf-8")}\n\n...(truncated ${stat.size - maxBytes} bytes)`;
62
+ return `${headBuf.toString("utf-8")}\n\n...[pi-crew truncated ${stat.size - maxBytes} bytes, head+tail preserved]...\n${tailBuf.toString("utf-8")}`;
47
63
  }
48
64
  return fs.readFileSync(safePath, "utf-8");
49
65
  } catch {
@@ -99,7 +115,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
99
115
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
100
116
  const byId = new Map(tasks.map((item) => [item.id, item]));
101
117
  const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
102
- const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, 24_000, manifest.artifactsRoot) : undefined;
118
+ const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
103
119
  return {
104
120
  taskId: item.id,
105
121
  role: item.role,
@@ -113,7 +129,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
113
129
  });
114
130
  const sharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
115
131
  const filePath = sharedPath(manifest, name);
116
- return { name, path: filePath, content: readIfSmall(filePath, 24_000, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
132
+ return { name, path: filePath, content: readIfSmall(filePath, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
117
133
  }).filter((item) => item.content.trim().length > 0);
118
134
  return { dependencies, sharedReads };
119
135
  }
@@ -139,7 +155,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
139
155
  export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
140
156
  if (step.output === false) return undefined;
141
157
  const name = safeSharedName(step.output || `${task.id}.md`);
142
- const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path, 80_000, manifest.artifactsRoot) : undefined;
158
+ const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest.artifactsRoot) : undefined;
143
159
  if (!source) return undefined;
144
160
  return writeArtifact(manifest.artifactsRoot, {
145
161
  kind: "metadata",
@@ -160,7 +176,7 @@ export function writeTaskInputsArtifact(manifest: TeamRunManifest, task: TeamTas
160
176
 
161
177
  export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest): string {
162
178
  return tasks.map((task, index) => {
163
- const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, 40_000, manifest?.artifactsRoot) : undefined;
179
+ const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest?.artifactsRoot) : undefined;
164
180
  const hasBody = Boolean(body?.trim());
165
181
  const expectedMissing = task.resultArtifact && !containedExists(task.resultArtifact.path, manifest?.artifactsRoot);
166
182
  const status = task.status === "skipped"
@@ -63,16 +63,21 @@ builtInRegistry.register(VitePlugin);
63
63
  * executing. The team-runner has no periodic heartbeat today, so any
64
64
  * team run lasting >5min is at risk.
65
65
  */
66
- function startTeamRunHeartbeat(stateRoot: string, runId: string, lastTaskUpdateAt?: string): () => void {
66
+ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
67
67
  const heartbeatPath = path.join(stateRoot, "heartbeat.json");
68
68
  const writeHeartbeat = (): void => {
69
69
  try {
70
+ // lastTaskUpdateAt is written fresh on each tick so the heartbeat
71
+ // never carries a stale creation-time timestamp. Previously this
72
+ // captured manifest.updatedAt once at startup, making the value
73
+ // permanently stale throughout the run.
74
+ const now = new Date().toISOString();
70
75
  fs.writeFileSync(heartbeatPath, JSON.stringify({
71
76
  pid: process.pid,
72
77
  at: Date.now(),
73
78
  runId,
74
79
  kind: "team-runner",
75
- lastTaskUpdateAt,
80
+ lastTaskUpdateAt: now,
76
81
  }), { encoding: "utf-8", mode: 0o600 });
77
82
  } catch {
78
83
  // best-effort
@@ -439,7 +444,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
439
444
  // (NO_PID_HEARTBEAT_STALE_MS). Previously only sub-task runners wrote
440
445
  // heartbeats; the team-level run had no heartbeat, so any multi-phase
441
446
  // workflow lasting >5min was marked stale and cancelled.
442
- const stopTeamHeartbeat = startTeamRunHeartbeat(manifest.stateRoot, manifest.runId, manifest.updatedAt);
447
+ const stopTeamHeartbeat = startTeamRunHeartbeat(manifest.stateRoot, manifest.runId);
443
448
 
444
449
  const cleanupUsage = (): void => {
445
450
  for (const task of input.tasks) clearTrackedTaskUsage(task.id);