taskplane 0.29.2 → 0.30.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 (43) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/dashboard/public/app.js +124 -15
  5. package/dashboard/public/style.css +83 -2
  6. package/extensions/reviewer-extension.ts +17 -11
  7. package/extensions/taskplane/abort.ts +50 -18
  8. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  9. package/extensions/taskplane/agent-host.ts +224 -97
  10. package/extensions/taskplane/cleanup.ts +71 -42
  11. package/extensions/taskplane/config-loader.ts +142 -58
  12. package/extensions/taskplane/config-schema.ts +6 -13
  13. package/extensions/taskplane/config.ts +10 -2
  14. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  15. package/extensions/taskplane/diagnostics.ts +13 -13
  16. package/extensions/taskplane/discovery.ts +78 -63
  17. package/extensions/taskplane/engine-worker.ts +53 -46
  18. package/extensions/taskplane/engine.ts +1760 -602
  19. package/extensions/taskplane/execution.ts +469 -207
  20. package/extensions/taskplane/extension.ts +1073 -598
  21. package/extensions/taskplane/formatting.ts +136 -124
  22. package/extensions/taskplane/git.ts +0 -2
  23. package/extensions/taskplane/lane-runner.ts +652 -319
  24. package/extensions/taskplane/mailbox.ts +57 -49
  25. package/extensions/taskplane/merge.ts +662 -383
  26. package/extensions/taskplane/messages.ts +109 -51
  27. package/extensions/taskplane/migrations.ts +1 -1
  28. package/extensions/taskplane/path-resolver.ts +8 -9
  29. package/extensions/taskplane/persistence.ts +425 -262
  30. package/extensions/taskplane/process-registry.ts +36 -7
  31. package/extensions/taskplane/quality-gate.ts +107 -55
  32. package/extensions/taskplane/resume.ts +832 -280
  33. package/extensions/taskplane/sessions.ts +1 -1
  34. package/extensions/taskplane/settings-tui.ts +505 -164
  35. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  36. package/extensions/taskplane/supervisor.ts +477 -270
  37. package/extensions/taskplane/task-executor-core.ts +178 -53
  38. package/extensions/taskplane/types.ts +209 -108
  39. package/extensions/taskplane/verification.ts +27 -22
  40. package/extensions/taskplane/waves.ts +59 -43
  41. package/extensions/taskplane/workspace.ts +14 -12
  42. package/extensions/taskplane/worktree.ts +218 -196
  43. package/package.json +14 -2
@@ -41,10 +41,7 @@ import {
41
41
  } from "./agent-host.ts";
42
42
  import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
43
43
 
44
- import {
45
- appendAgentEvent,
46
- writeLaneSnapshot,
47
- } from "./process-registry.ts";
44
+ import { appendAgentEvent, writeLaneSnapshot } from "./process-registry.ts";
48
45
 
49
46
  import {
50
47
  readOutbox,
@@ -71,6 +68,7 @@ import {
71
68
  type LaneTaskStatus,
72
69
  type SupervisorAlertCallback,
73
70
  type StepSegmentMapping,
71
+ type SegmentScopeMode,
74
72
  } from "./types.ts";
75
73
 
76
74
  const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
@@ -94,7 +92,7 @@ export function getStepsForRepoId(
94
92
  ): Set<number> {
95
93
  const stepNumbers = new Set<number>();
96
94
  for (const step of stepSegmentMap) {
97
- if (step.segments.some(seg => seg.repoId === repoId)) {
95
+ if (step.segments.some((seg) => seg.repoId === repoId)) {
98
96
  stepNumbers.add(step.stepNumber);
99
97
  }
100
98
  }
@@ -131,7 +129,10 @@ export function getSegmentCheckboxes(
131
129
  const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
132
130
 
133
131
  // Find the segment header within this step
134
- const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
132
+ const segHeaderPattern = new RegExp(
133
+ `^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
134
+ "m",
135
+ );
135
136
  const segMatch = stepContent.match(segHeaderPattern);
136
137
  if (!segMatch || segMatch.index === undefined) return null;
137
138
 
@@ -145,7 +146,7 @@ export function getSegmentCheckboxes(
145
146
  let unchecked = 0;
146
147
  const uncheckedTexts: string[] = [];
147
148
  const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
148
- let m;
149
+ let m: RegExpExecArray | null;
149
150
  while ((m = cbRegex.exec(segContent)) !== null) {
150
151
  if (m[1].toLowerCase() === "x") {
151
152
  checked++;
@@ -178,6 +179,75 @@ export function isSegmentComplete(
178
179
  return result.unchecked === 0;
179
180
  }
180
181
 
182
+ /**
183
+ * Compute the authoritative `SegmentScopeMode` for one worker iteration.
184
+ *
185
+ * This is the single source of truth for the FULL_TASK vs SEGMENT_SCOPED
186
+ * decision (TP-196 / #502). All segment-related side-effects (env vars,
187
+ * system-prompt overlay, prompt content, tool registration) should derive
188
+ * their behaviour from this mode rather than re-evaluating the underlying
189
+ * boolean conditions in isolation, which is what created the drift risk
190
+ * documented in #502.
191
+ *
192
+ * Returns `SEGMENT_SCOPED` iff ALL of the following hold:
193
+ * - The task has a non-empty `stepSegmentMap` (parsed from PROMPT.md markers).
194
+ * - The lane has an associated `currentRepoId` (segmentId set, so we know
195
+ * which repo this lane is iterating).
196
+ * - The (legacy-fallback-filtered) `repoStepNumbers` set is non-null (the
197
+ * repo has at least one step with explicit segment markers).
198
+ * - A `currentStepNumber` is provided (there is a step to evaluate).
199
+ * - The current step's segment mapping contains an entry for `currentRepoId`
200
+ * (the worker actually has segment-scoped work in the current step).
201
+ *
202
+ * In any other case the mode is `FULL_TASK`.
203
+ *
204
+ * @since TP-196
205
+ */
206
+ export function computeSegmentScopeMode(
207
+ stepSegmentMap: StepSegmentMapping[] | undefined | null,
208
+ repoStepNumbers: Set<number> | null,
209
+ currentRepoId: string | null,
210
+ currentStepNumber: number | null,
211
+ ): SegmentScopeMode {
212
+ if (!stepSegmentMap || !currentRepoId || !repoStepNumbers) return "FULL_TASK";
213
+ if (currentStepNumber === null) return "FULL_TASK";
214
+ const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNumber);
215
+ if (!currentStepMapping) return "FULL_TASK";
216
+ const mySegment = currentStepMapping.segments.find((seg) => seg.repoId === currentRepoId);
217
+ return mySegment ? "SEGMENT_SCOPED" : "FULL_TASK";
218
+ }
219
+
220
+ /**
221
+ * Pre-spawn segment-completion check (TP-196 / #508).
222
+ *
223
+ * Returns `true` when the lane-runner iteration loop should SKIP spawning
224
+ * a worker because all of the segment's checkboxes for this repo are
225
+ * already complete. The lane should `break` out of its iteration loop and
226
+ * fall through to post-loop completion handling.
227
+ *
228
+ * Contract:
229
+ * - Returns `false` for FULL_TASK iterations (`currentRepoId === null` or
230
+ * `repoStepNumbers === null` or empty). Those rely on the existing
231
+ * `remainingSteps.length === 0` exit, not this check.
232
+ * - Returns `true` iff EVERY step in `repoStepNumbers` is
233
+ * `isSegmentComplete(statusContent, stepNum, currentRepoId)`.
234
+ *
235
+ * Pure function: no filesystem access, no global state. The caller reads
236
+ * the STATUS.md content once per iteration and passes it in.
237
+ *
238
+ * @since TP-196
239
+ */
240
+ export function shouldSkipSpawnForCompleteSegment(
241
+ statusContent: string,
242
+ repoStepNumbers: Set<number> | null,
243
+ currentRepoId: string | null,
244
+ ): boolean {
245
+ if (!repoStepNumbers || !currentRepoId || repoStepNumbers.size === 0) return false;
246
+ return [...repoStepNumbers].every((stepNum) =>
247
+ isSegmentComplete(statusContent, stepNum, currentRepoId),
248
+ );
249
+ }
250
+
181
251
  // ── Types ────────────────────────────────────────────────────────────
182
252
 
183
253
  /**
@@ -326,13 +396,22 @@ export async function executeTaskV2(
326
396
  // This closes the race window where the monitor sees .DONE before lane-runner
327
397
  // can suppress it at segment end. For non-final segments, .DONE must not exist
328
398
  // at any point during execution.
329
- const isNonFinalAtStart = segmentId != null
330
- && Array.isArray(unit.task.segmentIds)
331
- && unit.task.segmentIds.length > 1
332
- && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
399
+ const isNonFinalAtStart =
400
+ segmentId != null &&
401
+ Array.isArray(unit.task.segmentIds) &&
402
+ unit.task.segmentIds.length > 1 &&
403
+ unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
333
404
  if (isNonFinalAtStart && existsSync(donePath)) {
334
- try { unlinkSync(donePath); } catch { /* best effort */ }
335
- logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
405
+ try {
406
+ unlinkSync(donePath);
407
+ } catch {
408
+ /* best effort */
409
+ }
410
+ logExecution(
411
+ statusPath,
412
+ "Segment start",
413
+ `Removed stale .DONE before non-final segment ${segmentId}`,
414
+ );
336
415
  }
337
416
 
338
417
  // ── 2. Iteration loop ───────────────────────────────────────────
@@ -346,20 +425,35 @@ export async function executeTaskV2(
346
425
  // TP-174: Build segment context once for emitSnapshot calls.
347
426
  // Available outside the loop so it can be passed to makeResult too.
348
427
  const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
349
- (segmentId && unit.task.stepSegmentMap && config.repoId)
428
+ segmentId && unit.task.stepSegmentMap && config.repoId
350
429
  ? (() => {
351
- const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
352
- return repoSteps.size > 0
353
- ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
354
- : null;
355
- })()
430
+ const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
431
+ return repoSteps.size > 0
432
+ ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
433
+ : null;
434
+ })()
356
435
  : null;
357
436
 
358
437
  for (let iter = 0; iter < config.maxIterations; iter++) {
359
438
  if (pauseSignal.paused) {
360
439
  logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
361
- return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime,
362
- "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx);
440
+ return makeResult(
441
+ taskId,
442
+ segmentId,
443
+ workerAgentId,
444
+ "skipped",
445
+ startTime,
446
+ "Paused by user",
447
+ false,
448
+ totalIterations,
449
+ cumulativeCostUsd,
450
+ cumulativeTokens,
451
+ config,
452
+ statusPath,
453
+ reviewerStatePath,
454
+ undefined,
455
+ snapshotSegmentCtx,
456
+ );
363
457
  }
364
458
 
365
459
  // Determine remaining steps
@@ -370,39 +464,62 @@ export async function executeTaskV2(
370
464
  // Use config.repoId (structured identity) instead of parsing opaque segmentId.
371
465
  const stepSegmentMap = unit.task.stepSegmentMap;
372
466
  const currentRepoId = segmentId ? config.repoId : null;
373
- const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
374
- ? getStepsForRepoId(stepSegmentMap, currentRepoId)
375
- : null;
467
+ const rawRepoStepNumbers =
468
+ stepSegmentMap && currentRepoId ? getStepsForRepoId(stepSegmentMap, currentRepoId) : null;
376
469
  // TP-174 legacy fallback: If no steps have segments for this repoId
377
470
  // (multi-segment task without explicit markers, where all checkboxes
378
471
  // are assigned to the fallback/packet repo), disable segment filtering.
379
- const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
380
- ? rawRepoStepNumbers
381
- : null;
472
+ const repoStepNumbers =
473
+ rawRepoStepNumbers && rawRepoStepNumbers.size > 0 ? rawRepoStepNumbers : null;
382
474
 
383
475
  // TP-174: Read STATUS.md content once for segment-scoped checks
384
476
  const iterStatusContent = readFileSync(statusPath, "utf-8");
385
477
 
386
- const remainingSteps = parsed.steps.filter(step => {
478
+ const remainingSteps = parsed.steps.filter((step) => {
387
479
  // TP-174: When segment-scoped, only show steps that have work for this repoId
388
480
  if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
389
481
  // TP-174: Use segment-scoped completion check in segment mode
390
482
  if (repoStepNumbers && currentRepoId) {
391
483
  return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
392
484
  }
393
- const ss = currentStatus.steps.find(s => s.number === step.number);
485
+ const ss = currentStatus.steps.find((s) => s.number === step.number);
394
486
  return !isStepComplete(ss);
395
487
  });
396
488
 
397
489
  if (remainingSteps.length === 0) break; // All done
398
490
 
491
+ // TP-196 / #508: Pre-spawn segment-completion check.
492
+ //
493
+ // When the lane is iterating a segment-scoped task, verify that NOT ALL
494
+ // `repoStepNumbers` are segment-complete before incurring the cost of
495
+ // spawning a worker. The `remainingSteps` filter above already enforces
496
+ // this implicitly (via `isSegmentComplete`), but expressing the check
497
+ // explicitly at the spawn boundary:
498
+ // 1. Makes the wasted-iteration prevention contract visible.
499
+ // 2. Provides a defensive backstop for cases where `parsed.steps` and
500
+ // `repoStepNumbers` diverge (e.g., legacy/partial-marker tasks).
501
+ // 3. Gives behavioural tests a clean assertion target (via the pure
502
+ // helper `shouldSkipSpawnForCompleteSegment`).
503
+ if (shouldSkipSpawnForCompleteSegment(iterStatusContent, repoStepNumbers, currentRepoId)) {
504
+ logExecution(
505
+ statusPath,
506
+ "Pre-spawn segment-completion check",
507
+ `all segment checkboxes already complete for repo '${currentRepoId}' — skipping worker spawn (#508)`,
508
+ );
509
+ break;
510
+ }
511
+
399
512
  totalIterations++;
400
- updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
513
+ updateStatusField(
514
+ statusPath,
515
+ "Current Step",
516
+ `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`,
517
+ );
401
518
  updateStatusField(statusPath, "Iteration", `${totalIterations}`);
402
519
 
403
520
  // Mark first incomplete step as in-progress
404
521
  const firstStep = remainingSteps[0];
405
- const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
522
+ const firstStepStatus = currentStatus.steps.find((s) => s.number === firstStep.number);
406
523
  if (firstStepStatus?.status !== "in-progress") {
407
524
  updateStepStatus(statusPath, firstStep.number, "in-progress");
408
525
  logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
@@ -421,13 +538,23 @@ export async function executeTaskV2(
421
538
 
422
539
  // ── Build worker prompt ─────────────────────────────────────
423
540
  const wrapUpFile = join(taskFolder, ".task-wrap-up");
424
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
541
+ if (existsSync(wrapUpFile))
542
+ try {
543
+ unlinkSync(wrapUpFile);
544
+ } catch {
545
+ /* ignore */
546
+ }
425
547
 
426
- // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
427
- const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers
428
- && remainingSteps.length > 0
429
- && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number)
430
- ?.segments.find(seg => seg.repoId === currentRepoId));
548
+ // TP-174/TP-501/TP-196: Compute segment scope mode BEFORE building prompt.
549
+ // `segmentScopeMode` is the authoritative TP-196 flag; `isSegmentScoped` is
550
+ // preserved as a boolean alias for ergonomics at the many existing call sites.
551
+ const segmentScopeMode: SegmentScopeMode = computeSegmentScopeMode(
552
+ stepSegmentMap,
553
+ repoStepNumbers,
554
+ currentRepoId,
555
+ remainingSteps.length > 0 ? remainingSteps[0].number : null,
556
+ );
557
+ const isSegmentScoped = segmentScopeMode === "SEGMENT_SCOPED";
431
558
 
432
559
  const promptLines = [
433
560
  `Read your task instructions at: ${promptPath}`,
@@ -444,9 +571,7 @@ export async function executeTaskV2(
444
571
  `- Lane repo ID: ${config.repoId}`,
445
572
  // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
446
573
  // workers incorrectly self-scoping based on segment metadata.
447
- ...(isSegmentScoped
448
- ? [`- Active segment ID: ${segmentId}`]
449
- : []),
574
+ ...(isSegmentScoped ? [`- Active segment ID: ${segmentId}`] : []),
450
575
  ``,
451
576
  `Packet home context:`,
452
577
  `- Packet home repo ID: ${unit.packetHomeRepoId}`,
@@ -464,9 +589,10 @@ export async function executeTaskV2(
464
589
  // Only show segment DAG in segment-scoped mode
465
590
  const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
466
591
  if (segmentDag && segmentDag.repoIds.length > 0) {
467
- const edgeSummary = segmentDag.edges.length > 0
468
- ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
469
- : "(no explicit edges)";
592
+ const edgeSummary =
593
+ segmentDag.edges.length > 0
594
+ ? segmentDag.edges.map((edge) => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
595
+ : "(no explicit edges)";
470
596
  promptLines.push(
471
597
  ``,
472
598
  `Segment DAG context (from PROMPT metadata):`,
@@ -478,21 +604,33 @@ export async function executeTaskV2(
478
604
  // Segment scope mode is determined by which system prompt was loaded.
479
605
  // No SegmentScopeMode line needed — the prompt IS the mode.
480
606
 
481
- // TP-174: Segment-scoped prompt — show only this segment's checkboxes
482
- if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
607
+ // TP-174/TP-196: Segment-scoped prompt — show only this segment's checkboxes.
608
+ // Gated on the authoritative `isSegmentScoped` (derived from `segmentScopeMode`)
609
+ // rather than the raw composite condition, so the prompt branch can't drift
610
+ // from the mode decision (TP-196 / #502).
611
+ if (isSegmentScoped) {
483
612
  const currentStepNum = remainingSteps[0].number;
484
- const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
485
- const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
486
-
487
- // Only inject segment-scoped prompt when the current step has an explicit
488
- // segment for this repoId. If mySegment is missing (legacy task without
489
- // markers, or step has no work for this repo), skip and preserve legacy behavior.
490
- if (currentStepMapping && mySegment) {
491
- const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
613
+ // Defensive guards: when `isSegmentScoped === true`, `computeSegmentScopeMode`
614
+ // has already verified `stepSegmentMap`, `currentRepoId`, and that the
615
+ // current step's mapping contains an entry for the active repo. We re-fetch
616
+ // the structures here for clarity. If any are missing we log and skip the
617
+ // segment block (defense-in-depth should never trip in practice).
618
+ const currentStepMapping = stepSegmentMap?.find((s) => s.stepNumber === currentStepNum);
619
+ const mySegment = currentStepMapping?.segments.find((seg) => seg.repoId === currentRepoId);
620
+
621
+ if (!currentStepMapping || !mySegment) {
622
+ logExecution(
623
+ statusPath,
624
+ "WARN",
625
+ `segmentScopeMode === SEGMENT_SCOPED but current step mapping missing — skipping segment prompt block (currentRepoId=${currentRepoId}, stepNum=${currentStepNum})`,
626
+ );
627
+ } else {
628
+ const otherSegments = currentStepMapping.segments.filter((seg) => seg.repoId !== currentRepoId);
492
629
 
493
630
  // Count total segments for this repo across all steps
494
631
  const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
495
- const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
632
+ const segmentIndexInStep =
633
+ currentStepMapping.segments.findIndex((seg) => seg.repoId === currentRepoId) + 1;
496
634
  const totalSegmentsInStep = currentStepMapping.segments.length;
497
635
 
498
636
  promptLines.push(
@@ -514,19 +652,23 @@ export async function executeTaskV2(
514
652
  promptLines.push(``);
515
653
  promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
516
654
  for (const seg of otherSegments) {
517
- promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
655
+ promptLines.push(
656
+ ` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`,
657
+ );
518
658
  }
519
659
  }
520
660
 
521
661
  // List completed steps for this repo
522
- const completedForRepo = parsed.steps.filter(step => {
662
+ const completedForRepo = parsed.steps.filter((step) => {
523
663
  if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
524
- const ss = currentStatus.steps.find(s => s.number === step.number);
664
+ const ss = currentStatus.steps.find((s) => s.number === step.number);
525
665
  return isStepComplete(ss);
526
666
  });
527
667
  if (completedForRepo.length > 0) {
528
668
  promptLines.push(``);
529
- promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
669
+ promptLines.push(
670
+ `Prior steps completed: ${completedForRepo.map((s) => `Step ${s.number} (${s.name})`).join(", ")}`,
671
+ );
530
672
  }
531
673
 
532
674
  promptLines.push(
@@ -538,13 +680,13 @@ export async function executeTaskV2(
538
680
  }
539
681
 
540
682
  if (totalIterations > 1 && remainingSteps.length > 0) {
541
- const remainingSet = new Set(remainingSteps.map(s => s.number));
542
- const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
683
+ const remainingSet = new Set(remainingSteps.map((s) => s.number));
684
+ const completedSteps = parsed.steps.filter((s) => !remainingSet.has(s.number));
543
685
  promptLines.push(
544
686
  ``,
545
687
  `IMPORTANT: You exited previously without completing all steps.`,
546
- `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
547
- `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
688
+ `Completed (do not redo): ${completedSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
689
+ `Remaining (focus here): ${remainingSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ")}`,
548
690
  );
549
691
 
550
692
  // If the worker exited without checking any boxes, add a corrective directive
@@ -572,12 +714,22 @@ export async function executeTaskV2(
572
714
  const steeringPendingPath = join(taskFolder, ".steering-pending");
573
715
 
574
716
  // TP-106: Bridge extension wiring for agent-side reply/escalate tools
575
- const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
717
+ const outboxDir = join(
718
+ config.stateRoot,
719
+ ".pi",
720
+ "mailbox",
721
+ config.batchId,
722
+ workerAgentId,
723
+ "outbox",
724
+ );
576
725
  const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
577
726
 
578
727
  // TP-180: Forward user-installed extensions to worker agent
579
728
  const allPackages = loadPiSettingsPackages(config.stateRoot);
580
- const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []);
729
+ const workerPackages = filterExcludedExtensions(
730
+ allPackages,
731
+ config.workerExcludeExtensions ?? [],
732
+ );
581
733
 
582
734
  const hostOpts: AgentHostOptions = {
583
735
  agentId: workerAgentId,
@@ -588,9 +740,10 @@ export async function executeTaskV2(
588
740
  repoId: config.repoId,
589
741
  cwd: unit.worktreePath,
590
742
  prompt: promptLines.join("\n"),
591
- systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
592
- ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
593
- : config.workerSystemPrompt) || undefined,
743
+ systemPrompt:
744
+ (isSegmentScoped && config.workerSegmentPrompt
745
+ ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
746
+ : config.workerSystemPrompt) || undefined,
594
747
  model: config.workerModel || undefined,
595
748
  // TP-184: buildWorkerToolsAllowlist always appends ENGINE_BRIDGE_TOOLS
596
749
  // (review_step, notify_supervisor, request_segment_expansion) so that
@@ -635,185 +788,217 @@ export async function executeTaskV2(
635
788
  // exits without making visible progress (no checkboxes, no blocker logged).
636
789
  onPrematureExit: config.onSupervisorAlert
637
790
  ? async (assistantMessage: string): Promise<string | null> => {
638
- // Check if the worker made visible progress during this turn:
639
- // 1. Checkbox progress (more items checked)
640
- // 2. Blocker logged (non-empty Blockers section)
641
- try {
642
- const statusContent = readFileSync(statusPath, "utf-8");
643
- // TP-174: Use same scope as prevTotalChecked (segment or global)
644
- let midTotalChecked: number;
645
- if (repoStepNumbers && currentRepoId) {
646
- const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
647
- midTotalChecked = segCbs ? segCbs.checked : 0;
648
- } else {
649
- const midStatus = parseStatusMd(statusContent);
650
- midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
651
- }
652
- if (midTotalChecked > prevTotalChecked) {
653
- // Worker checked off checkboxes — let it exit normally
654
- return null;
655
- }
656
- // Check for blocker entries: extract Blockers section and see if non-empty
657
- const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
658
- if (blockerMatch) {
659
- const blockerContent = blockerMatch[1].trim();
660
- // If blockers section has real content (not just "*None*" or empty)
661
- if (blockerContent && blockerContent !== "*None*") {
662
- // Worker logged a blocker — let it exit normally
791
+ // Check if the worker made visible progress during this turn:
792
+ // 1. Checkbox progress (more items checked)
793
+ // 2. Blocker logged (non-empty Blockers section)
794
+ try {
795
+ const statusContent = readFileSync(statusPath, "utf-8");
796
+ // TP-174: Use same scope as prevTotalChecked (segment or global)
797
+ let midTotalChecked: number;
798
+ if (repoStepNumbers && currentRepoId) {
799
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
800
+ midTotalChecked = segCbs ? segCbs.checked : 0;
801
+ } else {
802
+ const midStatus = parseStatusMd(statusContent);
803
+ midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
804
+ }
805
+ if (midTotalChecked > prevTotalChecked) {
806
+ // Worker checked off checkboxes — let it exit normally
663
807
  return null;
664
808
  }
809
+ // Check for blocker entries: extract Blockers section and see if non-empty
810
+ const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
811
+ if (blockerMatch) {
812
+ const blockerContent = blockerMatch[1].trim();
813
+ // If blockers section has real content (not just "*None*" or empty)
814
+ if (blockerContent && blockerContent !== "*None*") {
815
+ // Worker logged a blocker — let it exit normally
816
+ return null;
817
+ }
818
+ }
819
+ } catch {
820
+ /* If we can't read STATUS.md, proceed with escalation */
665
821
  }
666
- } catch { /* If we can't read STATUS.md, proceed with escalation */ }
667
-
668
- // No visible progress compose escalation message.
669
- // TP-187 (#540): when the worker exits silently, fall back to the most
670
- // recent `assistant_message` event in events.jsonl so the supervisor
671
- // has SOMETHING to act on instead of `Worker said: ""`.
672
- let workerSaid = (assistantMessage ?? "").trim();
673
- let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = "current-turn";
674
- if (!workerSaid) {
675
- workerSaidSource = "empty-sentinel";
676
- try {
677
- const raw = readFileSync(eventsPath, "utf-8");
678
- const lines = raw.split("\n");
679
- // Walk backward to find the most recent assistant_message with non-empty text.
680
- for (let i = lines.length - 1; i >= 0; i--) {
681
- const line = lines[i].trim();
682
- if (!line) continue;
683
- try {
684
- const evt = JSON.parse(line) as Record<string, unknown>;
685
- if (evt.type === "assistant_message") {
686
- const payload = evt.payload as Record<string, unknown> | undefined;
687
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
688
- if (text) {
689
- workerSaid = text;
690
- workerSaidSource = "events-jsonl-fallback";
691
- break;
822
+
823
+ // No visible progress — compose escalation message.
824
+ // TP-187 (#540): when the worker exits silently, fall back to the most
825
+ // recent `assistant_message` event in events.jsonl so the supervisor
826
+ // has SOMETHING to act on instead of `Worker said: ""`.
827
+ let workerSaid = (assistantMessage ?? "").trim();
828
+ let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" =
829
+ "current-turn";
830
+ if (!workerSaid) {
831
+ workerSaidSource = "empty-sentinel";
832
+ try {
833
+ const raw = readFileSync(eventsPath, "utf-8");
834
+ const lines = raw.split("\n");
835
+ // Walk backward to find the most recent assistant_message with non-empty text.
836
+ for (let i = lines.length - 1; i >= 0; i--) {
837
+ const line = lines[i].trim();
838
+ if (!line) continue;
839
+ try {
840
+ const evt = JSON.parse(line) as Record<string, unknown>;
841
+ if (evt.type === "assistant_message") {
842
+ const payload = evt.payload as Record<string, unknown> | undefined;
843
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
844
+ if (text) {
845
+ workerSaid = text;
846
+ workerSaidSource = "events-jsonl-fallback";
847
+ break;
848
+ }
692
849
  }
850
+ } catch {
851
+ /* skip malformed line */
693
852
  }
694
- } catch { /* skip malformed line */ }
695
- }
696
- } catch { /* events.jsonl unreadable; sentinel will be used */ }
697
- }
698
- if (!workerSaid) {
699
- workerSaid = "(no assistant message captured — worker exited without producing visible output)";
700
- workerSaidSource = "empty-sentinel";
701
- }
702
- const truncatedMsg = workerSaid.slice(0, 500);
703
- const uncheckedItems: string[] = [];
704
- try {
705
- const statusContent = readFileSync(statusPath, "utf-8");
706
- // TP-174: When segment-scoped, report only this segment's unchecked items
707
- if (repoStepNumbers && currentRepoId) {
708
- const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
709
- if (segCbs) {
710
- for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
711
- uncheckedItems.push(text);
712
853
  }
854
+ } catch {
855
+ /* events.jsonl unreadable; sentinel will be used */
713
856
  }
714
- } else {
715
- const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
716
- if (uncheckedMatches) {
717
- for (const item of uncheckedMatches.slice(0, 5)) {
718
- uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
857
+ }
858
+ if (!workerSaid) {
859
+ workerSaid =
860
+ "(no assistant message captured — worker exited without producing visible output)";
861
+ workerSaidSource = "empty-sentinel";
862
+ }
863
+ const truncatedMsg = workerSaid.slice(0, 500);
864
+ const uncheckedItems: string[] = [];
865
+ try {
866
+ const statusContent = readFileSync(statusPath, "utf-8");
867
+ // TP-174: When segment-scoped, report only this segment's unchecked items
868
+ if (repoStepNumbers && currentRepoId) {
869
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
870
+ if (segCbs) {
871
+ for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
872
+ uncheckedItems.push(text);
873
+ }
874
+ }
875
+ } else {
876
+ const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
877
+ if (uncheckedMatches) {
878
+ for (const item of uncheckedMatches.slice(0, 5)) {
879
+ uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
880
+ }
719
881
  }
720
882
  }
883
+ } catch {
884
+ /* best effort */
721
885
  }
722
- } catch { /* best effort */ }
723
886
 
724
- const currentStepInfo = remainingSteps.length > 0
725
- ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
726
- : "Unknown";
887
+ const currentStepInfo =
888
+ remainingSteps.length > 0
889
+ ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
890
+ : "Unknown";
727
891
 
728
- // Fire supervisor alert
729
- try {
730
- config.onSupervisorAlert!({
731
- category: "worker-exit-intercept",
732
- summary:
733
- `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
734
- ` Task: ${taskId}\n` +
735
- ` Current step: ${currentStepInfo}\n` +
736
- ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
737
- ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
738
- ` Worker said: "${truncatedMsg}"` +
739
- (workerSaidSource === "events-jsonl-fallback"
740
- ? ` (fallback: most-recent assistant_message from events.jsonl)\n`
741
- : workerSaidSource === "empty-sentinel"
742
- ? ` (no assistant message captured this iteration)\n`
743
- : "\n") +
744
- `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
745
- ` or reply "skip" / "let it fail" to close the session.`,
746
- context: {
747
- taskId,
748
- laneId: `lane-${config.laneNumber}`,
749
- laneNumber: config.laneNumber,
750
- agentId: workerAgentId,
751
- exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
752
- },
753
- });
754
- } catch { /* best effort — don't block on alert failure */ }
755
-
756
- // Poll worker mailbox inbox for supervisor reply (60s timeout)
757
- const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
758
- const POLL_INTERVAL_MS = 2_000;
759
- const escalationTimestamp = Date.now();
760
- const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
761
-
762
- const supervisorReply = await new Promise<string | null>((resolve) => {
763
- const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
764
- const poll = () => {
765
- if (Date.now() >= deadline) {
766
- resolve(null); // Timeout fall back to corrective re-spawn
767
- return;
768
- }
769
- try {
770
- const messages = readInbox(inboxDir, config.batchId);
771
- // Only accept messages newer than escalation timestamp
772
- for (const { filename, message } of messages) {
773
- if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
774
- // Consume the message
775
- const ackDir = join(dirname(inboxDir), "ack");
776
- try { ackMessage(inboxDir, filename); } catch { /* best effort */ }
777
- resolve(message.content);
778
- return;
892
+ // Fire supervisor alert
893
+ try {
894
+ config.onSupervisorAlert!({
895
+ category: "worker-exit-intercept",
896
+ summary:
897
+ `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
898
+ ` Task: ${taskId}\n` +
899
+ ` Current step: ${currentStepInfo}\n` +
900
+ ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
901
+ ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
902
+ ` Worker said: "${truncatedMsg}"` +
903
+ (workerSaidSource === "events-jsonl-fallback"
904
+ ? ` (fallback: most-recent assistant_message from events.jsonl)\n`
905
+ : workerSaidSource === "empty-sentinel"
906
+ ? ` (no assistant message captured this iteration)\n`
907
+ : "\n") +
908
+ `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
909
+ ` or reply "skip" / "let it fail" to close the session.`,
910
+ context: {
911
+ taskId,
912
+ laneId: `lane-${config.laneNumber}`,
913
+ laneNumber: config.laneNumber,
914
+ agentId: workerAgentId,
915
+ exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
916
+ },
917
+ });
918
+ } catch {
919
+ /* best effort — don't block on alert failure */
920
+ }
921
+
922
+ // Poll worker mailbox inbox for supervisor reply (60s timeout)
923
+ const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
924
+ const POLL_INTERVAL_MS = 2_000;
925
+ const escalationTimestamp = Date.now();
926
+ const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
927
+
928
+ const supervisorReply = await new Promise<string | null>((resolve) => {
929
+ const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
930
+ const poll = () => {
931
+ if (Date.now() >= deadline) {
932
+ resolve(null); // Timeout — fall back to corrective re-spawn
933
+ return;
934
+ }
935
+ try {
936
+ const messages = readInbox(inboxDir, config.batchId);
937
+ // Only accept messages newer than escalation timestamp
938
+ for (const { filename, message } of messages) {
939
+ if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
940
+ // Consume the message
941
+ const ackDir = join(dirname(inboxDir), "ack");
942
+ try {
943
+ ackMessage(inboxDir, filename);
944
+ } catch {
945
+ /* best effort */
946
+ }
947
+ resolve(message.content);
948
+ return;
949
+ }
779
950
  }
951
+ } catch {
952
+ /* inbox not ready yet */
780
953
  }
781
- } catch { /* inbox not ready yet */ }
782
- setTimeout(poll, POLL_INTERVAL_MS);
783
- };
784
- poll();
785
- });
954
+ setTimeout(poll, POLL_INTERVAL_MS);
955
+ };
956
+ poll();
957
+ });
786
958
 
787
- if (!supervisorReply) {
788
- // Timeout — let the session close, corrective re-spawn will handle it
789
- logExecution(statusPath, "Exit intercept timeout",
790
- `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`);
791
- return null;
792
- }
959
+ if (!supervisorReply) {
960
+ // Timeout — let the session close, corrective re-spawn will handle it
961
+ logExecution(
962
+ statusPath,
963
+ "Exit intercept timeout",
964
+ `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`,
965
+ );
966
+ return null;
967
+ }
793
968
 
794
- // Interpret supervisor reply: close directives vs instructional content
795
- const normalizedReply = supervisorReply.trim().toLowerCase();
796
- const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
797
- // Only short messages (< 30 chars) can be close directives.
798
- // Longer messages are always instructions even if they start with "stop".
799
- const isShortEnoughForDirective = normalizedReply.length < 30;
800
- if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d =>
801
- normalizedReply === d ||
802
- normalizedReply.startsWith(d + ":") ||
803
- normalizedReply.startsWith(d + " ") ||
804
- normalizedReply.startsWith(d + ".") ||
805
- normalizedReply.startsWith(d + " -")
806
- )) {
807
- logExecution(statusPath, "Exit intercept close",
808
- `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`);
809
- return null;
810
- }
969
+ // Interpret supervisor reply: close directives vs instructional content
970
+ const normalizedReply = supervisorReply.trim().toLowerCase();
971
+ const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
972
+ // Only short messages (< 30 chars) can be close directives.
973
+ // Longer messages are always instructions even if they start with "stop".
974
+ const isShortEnoughForDirective = normalizedReply.length < 30;
975
+ if (
976
+ isShortEnoughForDirective &&
977
+ CLOSE_DIRECTIVES.some(
978
+ (d) =>
979
+ normalizedReply === d ||
980
+ normalizedReply.startsWith(d + ":") ||
981
+ normalizedReply.startsWith(d + " ") ||
982
+ normalizedReply.startsWith(d + ".") ||
983
+ normalizedReply.startsWith(d + " -"),
984
+ )
985
+ ) {
986
+ logExecution(
987
+ statusPath,
988
+ "Exit intercept close",
989
+ `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`,
990
+ );
991
+ return null;
992
+ }
811
993
 
812
- // Instructional reply — return as new prompt for the worker
813
- logExecution(statusPath, "Exit intercept reprompt",
814
- `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`);
815
- return supervisorReply;
816
- }
994
+ // Instructional reply — return as new prompt for the worker
995
+ logExecution(
996
+ statusPath,
997
+ "Exit intercept reprompt",
998
+ `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`,
999
+ );
1000
+ return supervisorReply;
1001
+ }
817
1002
  : undefined,
818
1003
  };
819
1004
 
@@ -822,11 +1007,17 @@ export async function executeTaskV2(
822
1007
  // present in the allowlist. Warn (do NOT throw or block spawn) if any
823
1008
  // is missing — this catches future helper bugs or accidental bypasses.
824
1009
  // See issue #530 for what silently breaks when bridge tools are missing.
825
- const toolsList = (hostOpts.tools ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1010
+ const toolsList = (hostOpts.tools ?? "")
1011
+ .split(",")
1012
+ .map((s) => s.trim())
1013
+ .filter(Boolean);
826
1014
  for (const bridgeTool of ENGINE_BRIDGE_TOOLS) {
827
1015
  if (!toolsList.includes(bridgeTool)) {
828
- logExecution(statusPath, "WARN",
829
- `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`);
1016
+ logExecution(
1017
+ statusPath,
1018
+ "WARN",
1019
+ `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`,
1020
+ );
830
1021
  }
831
1022
  }
832
1023
 
@@ -852,8 +1043,19 @@ export async function executeTaskV2(
852
1043
  iterationTelemetry = telemetry;
853
1044
  lastTelemetry = telemetry;
854
1045
  // Emit lane snapshot
855
- emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
856
- } catch { /* non-fatal: telemetry callback must never crash the engine */ }
1046
+ emitSnapshot(
1047
+ config,
1048
+ taskId,
1049
+ segmentId,
1050
+ "running",
1051
+ telemetry,
1052
+ statusPath,
1053
+ reviewerStatePath,
1054
+ snapshotSegmentCtx,
1055
+ );
1056
+ } catch {
1057
+ /* non-fatal: telemetry callback must never crash the engine */
1058
+ }
857
1059
  });
858
1060
 
859
1061
  // Reviewer telemetry is written by the worker bridge during review_step.
@@ -862,7 +1064,16 @@ export async function executeTaskV2(
862
1064
  let reviewerSnapshotFailures = 0;
863
1065
  const reviewerRefreshFailureThreshold = 5;
864
1066
  const reviewerRefresh = setInterval(() => {
865
- const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
1067
+ const ok = emitSnapshot(
1068
+ config,
1069
+ taskId,
1070
+ segmentId,
1071
+ "running",
1072
+ iterationTelemetry,
1073
+ statusPath,
1074
+ reviewerStatePath,
1075
+ snapshotSegmentCtx,
1076
+ );
866
1077
  if (ok) {
867
1078
  reviewerSnapshotFailures = 0;
868
1079
  return;
@@ -890,12 +1101,20 @@ export async function executeTaskV2(
890
1101
  lastTelemetry = workerResult;
891
1102
 
892
1103
  // Clean up wrap-up signal
893
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
1104
+ if (existsSync(wrapUpFile))
1105
+ try {
1106
+ unlinkSync(wrapUpFile);
1107
+ } catch {
1108
+ /* ignore */
1109
+ }
894
1110
 
895
1111
  // Accumulate costs
896
1112
  cumulativeCostUsd += workerResult.costUsd;
897
- cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
898
- workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
1113
+ cumulativeTokens +=
1114
+ workerResult.inputTokens +
1115
+ workerResult.outputTokens +
1116
+ workerResult.cacheReadTokens +
1117
+ workerResult.cacheWriteTokens;
899
1118
 
900
1119
  // ── TP-106: Poll worker outbox for replies/escalations ─────
901
1120
  try {
@@ -949,37 +1168,50 @@ export async function executeTaskV2(
949
1168
  exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
950
1169
  },
951
1170
  });
952
- } catch { /* best effort */ }
1171
+ } catch {
1172
+ /* best effort */
1173
+ }
953
1174
  }
954
1175
  }
955
1176
 
956
1177
  // Consume outbox message to prevent duplicate processing in later iterations.
957
1178
  ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
958
1179
  }
959
- } catch { /* best effort */ }
1180
+ } catch {
1181
+ /* best effort */
1182
+ }
960
1183
 
961
1184
  // ── Steering annotation ─────────────────────────────────────
962
1185
  try {
963
1186
  if (existsSync(steeringPendingPath)) {
964
1187
  const raw = readFileSync(steeringPendingPath, "utf-8");
965
- for (const line of raw.split("\n").filter(l => l.trim())) {
1188
+ for (const line of raw.split("\n").filter((l) => l.trim())) {
966
1189
  try {
967
1190
  const entry = JSON.parse(line) as { ts: number; content: string; id: string };
968
1191
  const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
969
1192
  const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
970
1193
  logExecution(statusPath, "⚠️ Steering", sanitized);
971
- } catch { /* skip malformed */ }
1194
+ } catch {
1195
+ /* skip malformed */
1196
+ }
972
1197
  }
973
1198
  unlinkSync(steeringPendingPath);
974
1199
  }
975
- } catch { /* non-fatal */ }
1200
+ } catch {
1201
+ /* non-fatal */
1202
+ }
976
1203
 
977
1204
  // Log iteration result
978
1205
  const statusMsg = workerResult.killed
979
1206
  ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
980
- : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`);
981
- logExecution(statusPath, `Worker iter ${totalIterations}`,
982
- `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
1207
+ : workerResult.exitCode === 0
1208
+ ? "done"
1209
+ : `error (code ${workerResult.exitCode})`;
1210
+ logExecution(
1211
+ statusPath,
1212
+ `Worker iter ${totalIterations}`,
1213
+ `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`,
1214
+ );
983
1215
 
984
1216
  // ── Check progress ──────────────────────────────────────────
985
1217
  const afterStatusContent = readFileSync(statusPath, "utf-8");
@@ -1008,21 +1240,31 @@ export async function executeTaskV2(
1008
1240
  stdio: ["pipe", "pipe", "pipe"],
1009
1241
  }).trim();
1010
1242
  // Only count source file changes as soft progress, not just STATUS.md
1011
- const changedFiles = diffOutput.split("\n").filter(l => l.includes("|"));
1012
- const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering"));
1243
+ const changedFiles = diffOutput.split("\n").filter((l) => l.includes("|"));
1244
+ const sourceChanges = changedFiles.filter(
1245
+ (l) => !l.includes("STATUS.md") && !l.includes(".steering"),
1246
+ );
1013
1247
  hasSoftProgress = sourceChanges.length > 0;
1014
- } catch { /* git not available or timeout — treat as no soft progress */ }
1248
+ } catch {
1249
+ /* git not available or timeout — treat as no soft progress */
1250
+ }
1015
1251
 
1016
1252
  if (hasSoftProgress) {
1017
1253
  // Worker has uncommitted code changes — don't count toward stall.
1018
1254
  // Reset the counter since the worker is actively editing.
1019
- logExecution(statusPath, "Soft progress",
1020
- `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`);
1255
+ logExecution(
1256
+ statusPath,
1257
+ "Soft progress",
1258
+ `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`,
1259
+ );
1021
1260
  noProgressCount = 0;
1022
1261
  } else {
1023
1262
  noProgressCount++;
1024
- logExecution(statusPath, "No progress",
1025
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
1263
+ logExecution(
1264
+ statusPath,
1265
+ "No progress",
1266
+ `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`,
1267
+ );
1026
1268
  if (noProgressCount >= config.noProgressLimit) {
1027
1269
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
1028
1270
  // TP-187 (#538): synchronous outbox drain at lane-termination decision
@@ -1032,10 +1274,15 @@ export async function executeTaskV2(
1032
1274
  try {
1033
1275
  const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
1034
1276
  if (drained > 0) {
1035
- logExecution(statusPath, "Outbox drained",
1036
- `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`);
1277
+ logExecution(
1278
+ statusPath,
1279
+ "Outbox drained",
1280
+ `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`,
1281
+ );
1037
1282
  }
1038
- } catch { /* best effort — do not block termination */ }
1283
+ } catch {
1284
+ /* best effort — do not block termination */
1285
+ }
1039
1286
  // TP-187 (#538): notify the supervisor process so it can suppress any
1040
1287
  // further alerts queued for this lane (zombie-alert filter).
1041
1288
  if (config.onLaneTerminated) {
@@ -1047,10 +1294,27 @@ export async function executeTaskV2(
1047
1294
  terminatedAt: Date.now(),
1048
1295
  reason: "no-progress-kill",
1049
1296
  });
1050
- } catch { /* best effort */ }
1297
+ } catch {
1298
+ /* best effort */
1299
+ }
1051
1300
  }
1052
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1053
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1301
+ return makeResult(
1302
+ taskId,
1303
+ segmentId,
1304
+ workerAgentId,
1305
+ "failed",
1306
+ startTime,
1307
+ `No progress after ${noProgressCount} iterations`,
1308
+ false,
1309
+ totalIterations,
1310
+ cumulativeCostUsd,
1311
+ cumulativeTokens,
1312
+ config,
1313
+ statusPath,
1314
+ reviewerStatePath,
1315
+ lastTelemetry,
1316
+ snapshotSegmentCtx,
1317
+ );
1054
1318
  }
1055
1319
  }
1056
1320
  } else {
@@ -1065,7 +1329,7 @@ export async function executeTaskV2(
1065
1329
  if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
1066
1330
  // Only mark step complete in STATUS.md if ALL segments in that step
1067
1331
  // are complete (not just ours). But for loop exit, we only care about ours.
1068
- const ss = afterStatus.steps.find(s => s.number === stepNum);
1332
+ const ss = afterStatus.steps.find((s) => s.number === stepNum);
1069
1333
  if (isStepComplete(ss)) {
1070
1334
  updateStepStatus(statusPath, stepNum, "complete");
1071
1335
  }
@@ -1073,7 +1337,7 @@ export async function executeTaskV2(
1073
1337
  }
1074
1338
  } else {
1075
1339
  for (const step of parsed.steps) {
1076
- const ss = afterStatus.steps.find(s => s.number === step.number);
1340
+ const ss = afterStatus.steps.find((s) => s.number === step.number);
1077
1341
  if (isStepComplete(ss)) {
1078
1342
  updateStepStatus(statusPath, step.number, "complete");
1079
1343
  }
@@ -1085,12 +1349,12 @@ export async function executeTaskV2(
1085
1349
  // have their segment checkboxes complete.
1086
1350
  let allComplete: boolean;
1087
1351
  if (repoStepNumbers && currentRepoId) {
1088
- allComplete = [...repoStepNumbers].every(stepNum =>
1352
+ allComplete = [...repoStepNumbers].every((stepNum) =>
1089
1353
  isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
1090
1354
  );
1091
1355
  } else {
1092
- allComplete = parsed.steps.every(step => {
1093
- const ss = afterStatus.steps.find(s => s.number === step.number);
1356
+ allComplete = parsed.steps.every((step) => {
1357
+ const ss = afterStatus.steps.find((s) => s.number === step.number);
1094
1358
  return isStepComplete(ss);
1095
1359
  });
1096
1360
  }
@@ -1106,21 +1370,21 @@ export async function executeTaskV2(
1106
1370
  // the iteration loop variables are out of scope here.
1107
1371
  const postLoopRepoId = segmentId ? config.repoId : null;
1108
1372
  const postLoopStepSegMap = unit.task.stepSegmentMap;
1109
- const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId)
1110
- ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1111
- : null;
1112
- const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0)
1113
- ? postLoopRepoSteps
1114
- : null;
1373
+ const postLoopRepoSteps =
1374
+ postLoopStepSegMap && postLoopRepoId
1375
+ ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1376
+ : null;
1377
+ const effectivePostLoopRepoSteps =
1378
+ postLoopRepoSteps && postLoopRepoSteps.size > 0 ? postLoopRepoSteps : null;
1115
1379
 
1116
1380
  let allStepsComplete: boolean;
1117
1381
  if (effectivePostLoopRepoSteps && postLoopRepoId) {
1118
- allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1382
+ allStepsComplete = [...effectivePostLoopRepoSteps].every((stepNum) =>
1119
1383
  isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1120
1384
  );
1121
1385
  } else {
1122
- allStepsComplete = parsed.steps.every(step => {
1123
- const ss = finalStatus.steps.find(s => s.number === step.number);
1386
+ allStepsComplete = parsed.steps.every((step) => {
1387
+ const ss = finalStatus.steps.find((s) => s.number === step.number);
1124
1388
  return isStepComplete(ss);
1125
1389
  });
1126
1390
  }
@@ -1129,40 +1393,55 @@ export async function executeTaskV2(
1129
1393
  let incomplete: string;
1130
1394
  if (effectivePostLoopRepoSteps && postLoopRepoId) {
1131
1395
  incomplete = [...effectivePostLoopRepoSteps]
1132
- .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1133
- .map(n => `Step ${n}`)
1396
+ .filter((stepNum) => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1397
+ .map((n) => `Step ${n}`)
1134
1398
  .join(", ");
1135
1399
  } else {
1136
1400
  incomplete = parsed.steps
1137
- .filter(step => {
1138
- const ss = finalStatus.steps.find(s => s.number === step.number);
1401
+ .filter((step) => {
1402
+ const ss = finalStatus.steps.find((s) => s.number === step.number);
1139
1403
  return !isStepComplete(ss);
1140
1404
  })
1141
- .map(s => `Step ${s.number}`)
1405
+ .map((s) => `Step ${s.number}`)
1142
1406
  .join(", ");
1143
1407
  }
1144
1408
  logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
1145
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1409
+ return makeResult(
1410
+ taskId,
1411
+ segmentId,
1412
+ workerAgentId,
1413
+ "failed",
1414
+ startTime,
1146
1415
  `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
1147
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1416
+ false,
1417
+ totalIterations,
1418
+ cumulativeCostUsd,
1419
+ cumulativeTokens,
1420
+ config,
1421
+ statusPath,
1422
+ reviewerStatePath,
1423
+ lastTelemetry,
1424
+ snapshotSegmentCtx,
1425
+ );
1148
1426
  }
1149
1427
 
1150
1428
  // TP-145: Determine if this is a non-final segment of a multi-segment task.
1151
1429
  // If more segments remain after this one, suppress .DONE creation so that
1152
1430
  // the engine can advance the segment frontier and execute subsequent segments.
1153
1431
  // .DONE must only exist when ALL segments of a multi-segment task are complete.
1154
- const isNonFinalSegment = segmentId != null
1155
- && Array.isArray(unit.task.segmentIds)
1156
- && unit.task.segmentIds.length > 1
1157
- && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
1432
+ const isNonFinalSegment =
1433
+ segmentId != null &&
1434
+ Array.isArray(unit.task.segmentIds) &&
1435
+ unit.task.segmentIds.length > 1 &&
1436
+ unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
1158
1437
 
1159
1438
  // TP-165: Check for pending expansion requests in the worker's outbox.
1160
1439
  // If the worker filed expansion requests, more segments may be added by the
1161
1440
  // engine at the segment boundary — .DONE must not be created even if this
1162
1441
  // appears to be the final segment based on the static segmentIds list.
1163
- const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles(
1164
- config.stateRoot, config.batchId, workerAgentId,
1165
- );
1442
+ const hasPendingExpansionRequests =
1443
+ segmentId != null &&
1444
+ hasPendingExpansionRequestFiles(config.stateRoot, config.batchId, workerAgentId);
1166
1445
 
1167
1446
  if (isNonFinalSegment || hasPendingExpansionRequests) {
1168
1447
  // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
@@ -1171,23 +1450,50 @@ export async function executeTaskV2(
1171
1450
  // write access and sometimes create .DONE on their own, bypassing this gate).
1172
1451
  if (existsSync(donePath)) {
1173
1452
  let deleted = false;
1174
- try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
1453
+ try {
1454
+ unlinkSync(donePath);
1455
+ deleted = true;
1456
+ } catch {
1457
+ /* best effort */
1458
+ }
1175
1459
  if (deleted) {
1176
- logExecution(statusPath, "Segment complete",
1177
- `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`);
1460
+ logExecution(
1461
+ statusPath,
1462
+ "Segment complete",
1463
+ `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`,
1464
+ );
1178
1465
  } else {
1179
- logExecution(statusPath, "Segment complete",
1180
- `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`);
1466
+ logExecution(
1467
+ statusPath,
1468
+ "Segment complete",
1469
+ `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`,
1470
+ );
1181
1471
  }
1182
1472
  } else {
1183
- logExecution(statusPath, "Segment complete",
1184
- `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
1473
+ logExecution(
1474
+ statusPath,
1475
+ "Segment complete",
1476
+ `Segment ${segmentId} succeeded (not final — .DONE suppressed)`,
1477
+ );
1185
1478
  }
1186
- const suppressionReason = isNonFinalSegment
1187
- ? "non-final"
1188
- : "pending expansion requests";
1189
- return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1190
- `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1479
+ const suppressionReason = isNonFinalSegment ? "non-final" : "pending expansion requests";
1480
+ return makeResult(
1481
+ taskId,
1482
+ segmentId,
1483
+ workerAgentId,
1484
+ "succeeded",
1485
+ startTime,
1486
+ `Segment completed (${suppressionReason} — .DONE suppressed)`,
1487
+ false,
1488
+ totalIterations,
1489
+ cumulativeCostUsd,
1490
+ cumulativeTokens,
1491
+ config,
1492
+ statusPath,
1493
+ reviewerStatePath,
1494
+ lastTelemetry,
1495
+ snapshotSegmentCtx,
1496
+ );
1191
1497
  }
1192
1498
 
1193
1499
  // Create .DONE if not already present (final segment or single-segment/whole-task execution)
@@ -1197,8 +1503,23 @@ export async function executeTaskV2(
1197
1503
  updateStatusField(statusPath, "Status", "✅ Complete");
1198
1504
  logExecution(statusPath, "Task complete", ".DONE created");
1199
1505
 
1200
- return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1201
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1506
+ return makeResult(
1507
+ taskId,
1508
+ segmentId,
1509
+ workerAgentId,
1510
+ "succeeded",
1511
+ startTime,
1512
+ ".DONE file created by lane-runner",
1513
+ true,
1514
+ totalIterations,
1515
+ cumulativeCostUsd,
1516
+ cumulativeTokens,
1517
+ config,
1518
+ statusPath,
1519
+ reviewerStatePath,
1520
+ lastTelemetry,
1521
+ snapshotSegmentCtx,
1522
+ );
1202
1523
  }
1203
1524
 
1204
1525
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -1262,17 +1583,18 @@ function makeResult(
1262
1583
  /** TP-174: Segment context for segment-scoped snapshot progress */
1263
1584
  segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1264
1585
  ): LaneRunnerTaskResult {
1265
- const telemetry = status === "skipped"
1266
- ? undefined
1267
- : {
1268
- inputTokens: finalTelemetry?.inputTokens ?? 0,
1269
- outputTokens: finalTelemetry?.outputTokens ?? 0,
1270
- cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
1271
- cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
1272
- costUsd: finalTelemetry?.costUsd ?? 0,
1273
- toolCalls: finalTelemetry?.toolCalls ?? 0,
1274
- durationMs: finalTelemetry?.durationMs ?? 0,
1275
- };
1586
+ const telemetry =
1587
+ status === "skipped"
1588
+ ? undefined
1589
+ : {
1590
+ inputTokens: finalTelemetry?.inputTokens ?? 0,
1591
+ outputTokens: finalTelemetry?.outputTokens ?? 0,
1592
+ cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
1593
+ cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
1594
+ costUsd: finalTelemetry?.costUsd ?? 0,
1595
+ toolCalls: finalTelemetry?.toolCalls ?? 0,
1596
+ durationMs: finalTelemetry?.durationMs ?? 0,
1597
+ };
1276
1598
 
1277
1599
  const result: LaneRunnerTaskResult = {
1278
1600
  outcome: {
@@ -1295,7 +1617,16 @@ function makeResult(
1295
1617
  // TP-115: Emit terminal snapshot with real telemetry from agent-host result
1296
1618
  if (config && statusPath && reviewerStatePath) {
1297
1619
  const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
1298
- emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
1620
+ emitSnapshot(
1621
+ config,
1622
+ taskId,
1623
+ segmentId,
1624
+ terminalStatus,
1625
+ finalTelemetry ?? {},
1626
+ statusPath,
1627
+ reviewerStatePath,
1628
+ segmentCtx,
1629
+ );
1299
1630
  }
1300
1631
 
1301
1632
  return result;
@@ -1308,9 +1639,10 @@ export function readReviewerTelemetrySnapshot(
1308
1639
  config: LaneRunnerConfig,
1309
1640
  reviewerStatePathOrStatusPath: string,
1310
1641
  ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
1311
- const reviewerPath = basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1312
- ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1313
- : reviewerStatePathOrStatusPath;
1642
+ const reviewerPath =
1643
+ basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1644
+ ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1645
+ : reviewerStatePathOrStatusPath;
1314
1646
  if (!existsSync(reviewerPath)) return null;
1315
1647
 
1316
1648
  try {
@@ -1334,7 +1666,7 @@ export function readReviewerTelemetrySnapshot(
1334
1666
  if (parsed.status !== "running") return null;
1335
1667
 
1336
1668
  // Stale guard: if updatedAt is present and older than threshold, ignore
1337
- if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null;
1669
+ if (parsed.updatedAt && Date.now() - parsed.updatedAt > REVIEWER_STATE_STALE_MS) return null;
1338
1670
 
1339
1671
  return {
1340
1672
  agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
@@ -1413,7 +1745,9 @@ function emitSnapshot(
1413
1745
  iteration: parsed.iteration,
1414
1746
  reviews: parsed.reviewCounter,
1415
1747
  };
1416
- } catch { /* best effort */ }
1748
+ } catch {
1749
+ /* best effort */
1750
+ }
1417
1751
 
1418
1752
  const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath);
1419
1753
 
@@ -1451,4 +1785,3 @@ function emitSnapshot(
1451
1785
  return false;
1452
1786
  }
1453
1787
  }
1454
-