taskplane 0.29.2 → 0.30.0

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 (41) 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/extensions/reviewer-extension.ts +17 -11
  5. package/extensions/taskplane/abort.ts +50 -18
  6. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  7. package/extensions/taskplane/agent-host.ts +224 -97
  8. package/extensions/taskplane/cleanup.ts +71 -42
  9. package/extensions/taskplane/config-loader.ts +142 -58
  10. package/extensions/taskplane/config-schema.ts +6 -13
  11. package/extensions/taskplane/config.ts +10 -2
  12. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  13. package/extensions/taskplane/diagnostics.ts +13 -13
  14. package/extensions/taskplane/discovery.ts +35 -61
  15. package/extensions/taskplane/engine-worker.ts +53 -46
  16. package/extensions/taskplane/engine.ts +1760 -602
  17. package/extensions/taskplane/execution.ts +426 -206
  18. package/extensions/taskplane/extension.ts +1073 -598
  19. package/extensions/taskplane/formatting.ts +136 -124
  20. package/extensions/taskplane/git.ts +0 -2
  21. package/extensions/taskplane/lane-runner.ts +542 -311
  22. package/extensions/taskplane/mailbox.ts +57 -49
  23. package/extensions/taskplane/merge.ts +662 -383
  24. package/extensions/taskplane/messages.ts +109 -51
  25. package/extensions/taskplane/migrations.ts +1 -1
  26. package/extensions/taskplane/path-resolver.ts +8 -9
  27. package/extensions/taskplane/persistence.ts +425 -262
  28. package/extensions/taskplane/process-registry.ts +36 -7
  29. package/extensions/taskplane/quality-gate.ts +107 -55
  30. package/extensions/taskplane/resume.ts +774 -267
  31. package/extensions/taskplane/sessions.ts +1 -1
  32. package/extensions/taskplane/settings-tui.ts +505 -164
  33. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  34. package/extensions/taskplane/supervisor.ts +477 -270
  35. package/extensions/taskplane/task-executor-core.ts +178 -53
  36. package/extensions/taskplane/types.ts +186 -108
  37. package/extensions/taskplane/verification.ts +27 -22
  38. package/extensions/taskplane/waves.ts +59 -43
  39. package/extensions/taskplane/workspace.ts +14 -12
  40. package/extensions/taskplane/worktree.ts +218 -196
  41. 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,
@@ -94,7 +91,7 @@ export function getStepsForRepoId(
94
91
  ): Set<number> {
95
92
  const stepNumbers = new Set<number>();
96
93
  for (const step of stepSegmentMap) {
97
- if (step.segments.some(seg => seg.repoId === repoId)) {
94
+ if (step.segments.some((seg) => seg.repoId === repoId)) {
98
95
  stepNumbers.add(step.stepNumber);
99
96
  }
100
97
  }
@@ -131,7 +128,10 @@ export function getSegmentCheckboxes(
131
128
  const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
132
129
 
133
130
  // Find the segment header within this step
134
- const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
131
+ const segHeaderPattern = new RegExp(
132
+ `^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
133
+ "m",
134
+ );
135
135
  const segMatch = stepContent.match(segHeaderPattern);
136
136
  if (!segMatch || segMatch.index === undefined) return null;
137
137
 
@@ -145,7 +145,7 @@ export function getSegmentCheckboxes(
145
145
  let unchecked = 0;
146
146
  const uncheckedTexts: string[] = [];
147
147
  const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
148
- let m;
148
+ let m: RegExpExecArray | null;
149
149
  while ((m = cbRegex.exec(segContent)) !== null) {
150
150
  if (m[1].toLowerCase() === "x") {
151
151
  checked++;
@@ -326,13 +326,22 @@ export async function executeTaskV2(
326
326
  // This closes the race window where the monitor sees .DONE before lane-runner
327
327
  // can suppress it at segment end. For non-final segments, .DONE must not exist
328
328
  // 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;
329
+ const isNonFinalAtStart =
330
+ segmentId != null &&
331
+ Array.isArray(unit.task.segmentIds) &&
332
+ unit.task.segmentIds.length > 1 &&
333
+ unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
333
334
  if (isNonFinalAtStart && existsSync(donePath)) {
334
- try { unlinkSync(donePath); } catch { /* best effort */ }
335
- logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
335
+ try {
336
+ unlinkSync(donePath);
337
+ } catch {
338
+ /* best effort */
339
+ }
340
+ logExecution(
341
+ statusPath,
342
+ "Segment start",
343
+ `Removed stale .DONE before non-final segment ${segmentId}`,
344
+ );
336
345
  }
337
346
 
338
347
  // ── 2. Iteration loop ───────────────────────────────────────────
@@ -346,20 +355,35 @@ export async function executeTaskV2(
346
355
  // TP-174: Build segment context once for emitSnapshot calls.
347
356
  // Available outside the loop so it can be passed to makeResult too.
348
357
  const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
349
- (segmentId && unit.task.stepSegmentMap && config.repoId)
358
+ segmentId && unit.task.stepSegmentMap && config.repoId
350
359
  ? (() => {
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
- })()
360
+ const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
361
+ return repoSteps.size > 0
362
+ ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
363
+ : null;
364
+ })()
356
365
  : null;
357
366
 
358
367
  for (let iter = 0; iter < config.maxIterations; iter++) {
359
368
  if (pauseSignal.paused) {
360
369
  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);
370
+ return makeResult(
371
+ taskId,
372
+ segmentId,
373
+ workerAgentId,
374
+ "skipped",
375
+ startTime,
376
+ "Paused by user",
377
+ false,
378
+ totalIterations,
379
+ cumulativeCostUsd,
380
+ cumulativeTokens,
381
+ config,
382
+ statusPath,
383
+ reviewerStatePath,
384
+ undefined,
385
+ snapshotSegmentCtx,
386
+ );
363
387
  }
364
388
 
365
389
  // Determine remaining steps
@@ -370,39 +394,41 @@ export async function executeTaskV2(
370
394
  // Use config.repoId (structured identity) instead of parsing opaque segmentId.
371
395
  const stepSegmentMap = unit.task.stepSegmentMap;
372
396
  const currentRepoId = segmentId ? config.repoId : null;
373
- const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
374
- ? getStepsForRepoId(stepSegmentMap, currentRepoId)
375
- : null;
397
+ const rawRepoStepNumbers =
398
+ stepSegmentMap && currentRepoId ? getStepsForRepoId(stepSegmentMap, currentRepoId) : null;
376
399
  // TP-174 legacy fallback: If no steps have segments for this repoId
377
400
  // (multi-segment task without explicit markers, where all checkboxes
378
401
  // are assigned to the fallback/packet repo), disable segment filtering.
379
- const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
380
- ? rawRepoStepNumbers
381
- : null;
402
+ const repoStepNumbers =
403
+ rawRepoStepNumbers && rawRepoStepNumbers.size > 0 ? rawRepoStepNumbers : null;
382
404
 
383
405
  // TP-174: Read STATUS.md content once for segment-scoped checks
384
406
  const iterStatusContent = readFileSync(statusPath, "utf-8");
385
407
 
386
- const remainingSteps = parsed.steps.filter(step => {
408
+ const remainingSteps = parsed.steps.filter((step) => {
387
409
  // TP-174: When segment-scoped, only show steps that have work for this repoId
388
410
  if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
389
411
  // TP-174: Use segment-scoped completion check in segment mode
390
412
  if (repoStepNumbers && currentRepoId) {
391
413
  return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
392
414
  }
393
- const ss = currentStatus.steps.find(s => s.number === step.number);
415
+ const ss = currentStatus.steps.find((s) => s.number === step.number);
394
416
  return !isStepComplete(ss);
395
417
  });
396
418
 
397
419
  if (remainingSteps.length === 0) break; // All done
398
420
 
399
421
  totalIterations++;
400
- updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
422
+ updateStatusField(
423
+ statusPath,
424
+ "Current Step",
425
+ `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`,
426
+ );
401
427
  updateStatusField(statusPath, "Iteration", `${totalIterations}`);
402
428
 
403
429
  // Mark first incomplete step as in-progress
404
430
  const firstStep = remainingSteps[0];
405
- const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
431
+ const firstStepStatus = currentStatus.steps.find((s) => s.number === firstStep.number);
406
432
  if (firstStepStatus?.status !== "in-progress") {
407
433
  updateStepStatus(statusPath, firstStep.number, "in-progress");
408
434
  logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
@@ -421,13 +447,23 @@ export async function executeTaskV2(
421
447
 
422
448
  // ── Build worker prompt ─────────────────────────────────────
423
449
  const wrapUpFile = join(taskFolder, ".task-wrap-up");
424
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
450
+ if (existsSync(wrapUpFile))
451
+ try {
452
+ unlinkSync(wrapUpFile);
453
+ } catch {
454
+ /* ignore */
455
+ }
425
456
 
426
457
  // 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));
458
+ const isSegmentScoped = !!(
459
+ stepSegmentMap &&
460
+ currentRepoId &&
461
+ repoStepNumbers &&
462
+ remainingSteps.length > 0 &&
463
+ stepSegmentMap
464
+ .find((s) => s.stepNumber === remainingSteps[0].number)
465
+ ?.segments.find((seg) => seg.repoId === currentRepoId)
466
+ );
431
467
 
432
468
  const promptLines = [
433
469
  `Read your task instructions at: ${promptPath}`,
@@ -444,9 +480,7 @@ export async function executeTaskV2(
444
480
  `- Lane repo ID: ${config.repoId}`,
445
481
  // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
446
482
  // workers incorrectly self-scoping based on segment metadata.
447
- ...(isSegmentScoped
448
- ? [`- Active segment ID: ${segmentId}`]
449
- : []),
483
+ ...(isSegmentScoped ? [`- Active segment ID: ${segmentId}`] : []),
450
484
  ``,
451
485
  `Packet home context:`,
452
486
  `- Packet home repo ID: ${unit.packetHomeRepoId}`,
@@ -464,9 +498,10 @@ export async function executeTaskV2(
464
498
  // Only show segment DAG in segment-scoped mode
465
499
  const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
466
500
  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)";
501
+ const edgeSummary =
502
+ segmentDag.edges.length > 0
503
+ ? segmentDag.edges.map((edge) => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
504
+ : "(no explicit edges)";
470
505
  promptLines.push(
471
506
  ``,
472
507
  `Segment DAG context (from PROMPT metadata):`,
@@ -481,18 +516,19 @@ export async function executeTaskV2(
481
516
  // TP-174: Segment-scoped prompt — show only this segment's checkboxes
482
517
  if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
483
518
  const currentStepNum = remainingSteps[0].number;
484
- const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
485
- const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
519
+ const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNum);
520
+ const mySegment = currentStepMapping?.segments.find((seg) => seg.repoId === currentRepoId);
486
521
 
487
522
  // Only inject segment-scoped prompt when the current step has an explicit
488
523
  // segment for this repoId. If mySegment is missing (legacy task without
489
524
  // markers, or step has no work for this repo), skip and preserve legacy behavior.
490
525
  if (currentStepMapping && mySegment) {
491
- const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
526
+ const otherSegments = currentStepMapping.segments.filter((seg) => seg.repoId !== currentRepoId);
492
527
 
493
528
  // Count total segments for this repo across all steps
494
529
  const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
495
- const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
530
+ const segmentIndexInStep =
531
+ currentStepMapping.segments.findIndex((seg) => seg.repoId === currentRepoId) + 1;
496
532
  const totalSegmentsInStep = currentStepMapping.segments.length;
497
533
 
498
534
  promptLines.push(
@@ -514,19 +550,23 @@ export async function executeTaskV2(
514
550
  promptLines.push(``);
515
551
  promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
516
552
  for (const seg of otherSegments) {
517
- promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
553
+ promptLines.push(
554
+ ` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`,
555
+ );
518
556
  }
519
557
  }
520
558
 
521
559
  // List completed steps for this repo
522
- const completedForRepo = parsed.steps.filter(step => {
560
+ const completedForRepo = parsed.steps.filter((step) => {
523
561
  if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
524
- const ss = currentStatus.steps.find(s => s.number === step.number);
562
+ const ss = currentStatus.steps.find((s) => s.number === step.number);
525
563
  return isStepComplete(ss);
526
564
  });
527
565
  if (completedForRepo.length > 0) {
528
566
  promptLines.push(``);
529
- promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
567
+ promptLines.push(
568
+ `Prior steps completed: ${completedForRepo.map((s) => `Step ${s.number} (${s.name})`).join(", ")}`,
569
+ );
530
570
  }
531
571
 
532
572
  promptLines.push(
@@ -538,13 +578,13 @@ export async function executeTaskV2(
538
578
  }
539
579
 
540
580
  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));
581
+ const remainingSet = new Set(remainingSteps.map((s) => s.number));
582
+ const completedSteps = parsed.steps.filter((s) => !remainingSet.has(s.number));
543
583
  promptLines.push(
544
584
  ``,
545
585
  `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(", ")}`,
586
+ `Completed (do not redo): ${completedSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
587
+ `Remaining (focus here): ${remainingSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ")}`,
548
588
  );
549
589
 
550
590
  // If the worker exited without checking any boxes, add a corrective directive
@@ -572,12 +612,22 @@ export async function executeTaskV2(
572
612
  const steeringPendingPath = join(taskFolder, ".steering-pending");
573
613
 
574
614
  // TP-106: Bridge extension wiring for agent-side reply/escalate tools
575
- const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
615
+ const outboxDir = join(
616
+ config.stateRoot,
617
+ ".pi",
618
+ "mailbox",
619
+ config.batchId,
620
+ workerAgentId,
621
+ "outbox",
622
+ );
576
623
  const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
577
624
 
578
625
  // TP-180: Forward user-installed extensions to worker agent
579
626
  const allPackages = loadPiSettingsPackages(config.stateRoot);
580
- const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []);
627
+ const workerPackages = filterExcludedExtensions(
628
+ allPackages,
629
+ config.workerExcludeExtensions ?? [],
630
+ );
581
631
 
582
632
  const hostOpts: AgentHostOptions = {
583
633
  agentId: workerAgentId,
@@ -588,9 +638,10 @@ export async function executeTaskV2(
588
638
  repoId: config.repoId,
589
639
  cwd: unit.worktreePath,
590
640
  prompt: promptLines.join("\n"),
591
- systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
592
- ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
593
- : config.workerSystemPrompt) || undefined,
641
+ systemPrompt:
642
+ (isSegmentScoped && config.workerSegmentPrompt
643
+ ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
644
+ : config.workerSystemPrompt) || undefined,
594
645
  model: config.workerModel || undefined,
595
646
  // TP-184: buildWorkerToolsAllowlist always appends ENGINE_BRIDGE_TOOLS
596
647
  // (review_step, notify_supervisor, request_segment_expansion) so that
@@ -635,185 +686,217 @@ export async function executeTaskV2(
635
686
  // exits without making visible progress (no checkboxes, no blocker logged).
636
687
  onPrematureExit: config.onSupervisorAlert
637
688
  ? 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
689
+ // Check if the worker made visible progress during this turn:
690
+ // 1. Checkbox progress (more items checked)
691
+ // 2. Blocker logged (non-empty Blockers section)
692
+ try {
693
+ const statusContent = readFileSync(statusPath, "utf-8");
694
+ // TP-174: Use same scope as prevTotalChecked (segment or global)
695
+ let midTotalChecked: number;
696
+ if (repoStepNumbers && currentRepoId) {
697
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
698
+ midTotalChecked = segCbs ? segCbs.checked : 0;
699
+ } else {
700
+ const midStatus = parseStatusMd(statusContent);
701
+ midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
702
+ }
703
+ if (midTotalChecked > prevTotalChecked) {
704
+ // Worker checked off checkboxes — let it exit normally
663
705
  return null;
664
706
  }
707
+ // Check for blocker entries: extract Blockers section and see if non-empty
708
+ const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
709
+ if (blockerMatch) {
710
+ const blockerContent = blockerMatch[1].trim();
711
+ // If blockers section has real content (not just "*None*" or empty)
712
+ if (blockerContent && blockerContent !== "*None*") {
713
+ // Worker logged a blocker — let it exit normally
714
+ return null;
715
+ }
716
+ }
717
+ } catch {
718
+ /* If we can't read STATUS.md, proceed with escalation */
665
719
  }
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;
720
+
721
+ // No visible progress — compose escalation message.
722
+ // TP-187 (#540): when the worker exits silently, fall back to the most
723
+ // recent `assistant_message` event in events.jsonl so the supervisor
724
+ // has SOMETHING to act on instead of `Worker said: ""`.
725
+ let workerSaid = (assistantMessage ?? "").trim();
726
+ let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" =
727
+ "current-turn";
728
+ if (!workerSaid) {
729
+ workerSaidSource = "empty-sentinel";
730
+ try {
731
+ const raw = readFileSync(eventsPath, "utf-8");
732
+ const lines = raw.split("\n");
733
+ // Walk backward to find the most recent assistant_message with non-empty text.
734
+ for (let i = lines.length - 1; i >= 0; i--) {
735
+ const line = lines[i].trim();
736
+ if (!line) continue;
737
+ try {
738
+ const evt = JSON.parse(line) as Record<string, unknown>;
739
+ if (evt.type === "assistant_message") {
740
+ const payload = evt.payload as Record<string, unknown> | undefined;
741
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
742
+ if (text) {
743
+ workerSaid = text;
744
+ workerSaidSource = "events-jsonl-fallback";
745
+ break;
746
+ }
692
747
  }
748
+ } catch {
749
+ /* skip malformed line */
693
750
  }
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
751
  }
752
+ } catch {
753
+ /* events.jsonl unreadable; sentinel will be used */
713
754
  }
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());
755
+ }
756
+ if (!workerSaid) {
757
+ workerSaid =
758
+ "(no assistant message captured — worker exited without producing visible output)";
759
+ workerSaidSource = "empty-sentinel";
760
+ }
761
+ const truncatedMsg = workerSaid.slice(0, 500);
762
+ const uncheckedItems: string[] = [];
763
+ try {
764
+ const statusContent = readFileSync(statusPath, "utf-8");
765
+ // TP-174: When segment-scoped, report only this segment's unchecked items
766
+ if (repoStepNumbers && currentRepoId) {
767
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
768
+ if (segCbs) {
769
+ for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
770
+ uncheckedItems.push(text);
771
+ }
772
+ }
773
+ } else {
774
+ const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
775
+ if (uncheckedMatches) {
776
+ for (const item of uncheckedMatches.slice(0, 5)) {
777
+ uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
778
+ }
719
779
  }
720
780
  }
781
+ } catch {
782
+ /* best effort */
721
783
  }
722
- } catch { /* best effort */ }
723
784
 
724
- const currentStepInfo = remainingSteps.length > 0
725
- ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
726
- : "Unknown";
785
+ const currentStepInfo =
786
+ remainingSteps.length > 0
787
+ ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
788
+ : "Unknown";
727
789
 
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;
790
+ // Fire supervisor alert
791
+ try {
792
+ config.onSupervisorAlert!({
793
+ category: "worker-exit-intercept",
794
+ summary:
795
+ `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
796
+ ` Task: ${taskId}\n` +
797
+ ` Current step: ${currentStepInfo}\n` +
798
+ ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
799
+ ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
800
+ ` Worker said: "${truncatedMsg}"` +
801
+ (workerSaidSource === "events-jsonl-fallback"
802
+ ? ` (fallback: most-recent assistant_message from events.jsonl)\n`
803
+ : workerSaidSource === "empty-sentinel"
804
+ ? ` (no assistant message captured this iteration)\n`
805
+ : "\n") +
806
+ `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
807
+ ` or reply "skip" / "let it fail" to close the session.`,
808
+ context: {
809
+ taskId,
810
+ laneId: `lane-${config.laneNumber}`,
811
+ laneNumber: config.laneNumber,
812
+ agentId: workerAgentId,
813
+ exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
814
+ },
815
+ });
816
+ } catch {
817
+ /* best effort — don't block on alert failure */
818
+ }
819
+
820
+ // Poll worker mailbox inbox for supervisor reply (60s timeout)
821
+ const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
822
+ const POLL_INTERVAL_MS = 2_000;
823
+ const escalationTimestamp = Date.now();
824
+ const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
825
+
826
+ const supervisorReply = await new Promise<string | null>((resolve) => {
827
+ const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
828
+ const poll = () => {
829
+ if (Date.now() >= deadline) {
830
+ resolve(null); // Timeout — fall back to corrective re-spawn
831
+ return;
832
+ }
833
+ try {
834
+ const messages = readInbox(inboxDir, config.batchId);
835
+ // Only accept messages newer than escalation timestamp
836
+ for (const { filename, message } of messages) {
837
+ if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
838
+ // Consume the message
839
+ const ackDir = join(dirname(inboxDir), "ack");
840
+ try {
841
+ ackMessage(inboxDir, filename);
842
+ } catch {
843
+ /* best effort */
844
+ }
845
+ resolve(message.content);
846
+ return;
847
+ }
779
848
  }
849
+ } catch {
850
+ /* inbox not ready yet */
780
851
  }
781
- } catch { /* inbox not ready yet */ }
782
- setTimeout(poll, POLL_INTERVAL_MS);
783
- };
784
- poll();
785
- });
852
+ setTimeout(poll, POLL_INTERVAL_MS);
853
+ };
854
+ poll();
855
+ });
786
856
 
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
- }
857
+ if (!supervisorReply) {
858
+ // Timeout — let the session close, corrective re-spawn will handle it
859
+ logExecution(
860
+ statusPath,
861
+ "Exit intercept timeout",
862
+ `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`,
863
+ );
864
+ return null;
865
+ }
793
866
 
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
- }
867
+ // Interpret supervisor reply: close directives vs instructional content
868
+ const normalizedReply = supervisorReply.trim().toLowerCase();
869
+ const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
870
+ // Only short messages (< 30 chars) can be close directives.
871
+ // Longer messages are always instructions even if they start with "stop".
872
+ const isShortEnoughForDirective = normalizedReply.length < 30;
873
+ if (
874
+ isShortEnoughForDirective &&
875
+ CLOSE_DIRECTIVES.some(
876
+ (d) =>
877
+ normalizedReply === d ||
878
+ normalizedReply.startsWith(d + ":") ||
879
+ normalizedReply.startsWith(d + " ") ||
880
+ normalizedReply.startsWith(d + ".") ||
881
+ normalizedReply.startsWith(d + " -"),
882
+ )
883
+ ) {
884
+ logExecution(
885
+ statusPath,
886
+ "Exit intercept close",
887
+ `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`,
888
+ );
889
+ return null;
890
+ }
811
891
 
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
- }
892
+ // Instructional reply — return as new prompt for the worker
893
+ logExecution(
894
+ statusPath,
895
+ "Exit intercept reprompt",
896
+ `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`,
897
+ );
898
+ return supervisorReply;
899
+ }
817
900
  : undefined,
818
901
  };
819
902
 
@@ -822,11 +905,17 @@ export async function executeTaskV2(
822
905
  // present in the allowlist. Warn (do NOT throw or block spawn) if any
823
906
  // is missing — this catches future helper bugs or accidental bypasses.
824
907
  // See issue #530 for what silently breaks when bridge tools are missing.
825
- const toolsList = (hostOpts.tools ?? "").split(",").map((s) => s.trim()).filter(Boolean);
908
+ const toolsList = (hostOpts.tools ?? "")
909
+ .split(",")
910
+ .map((s) => s.trim())
911
+ .filter(Boolean);
826
912
  for (const bridgeTool of ENGINE_BRIDGE_TOOLS) {
827
913
  if (!toolsList.includes(bridgeTool)) {
828
- logExecution(statusPath, "WARN",
829
- `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`);
914
+ logExecution(
915
+ statusPath,
916
+ "WARN",
917
+ `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`,
918
+ );
830
919
  }
831
920
  }
832
921
 
@@ -852,8 +941,19 @@ export async function executeTaskV2(
852
941
  iterationTelemetry = telemetry;
853
942
  lastTelemetry = telemetry;
854
943
  // Emit lane snapshot
855
- emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
856
- } catch { /* non-fatal: telemetry callback must never crash the engine */ }
944
+ emitSnapshot(
945
+ config,
946
+ taskId,
947
+ segmentId,
948
+ "running",
949
+ telemetry,
950
+ statusPath,
951
+ reviewerStatePath,
952
+ snapshotSegmentCtx,
953
+ );
954
+ } catch {
955
+ /* non-fatal: telemetry callback must never crash the engine */
956
+ }
857
957
  });
858
958
 
859
959
  // Reviewer telemetry is written by the worker bridge during review_step.
@@ -862,7 +962,16 @@ export async function executeTaskV2(
862
962
  let reviewerSnapshotFailures = 0;
863
963
  const reviewerRefreshFailureThreshold = 5;
864
964
  const reviewerRefresh = setInterval(() => {
865
- const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
965
+ const ok = emitSnapshot(
966
+ config,
967
+ taskId,
968
+ segmentId,
969
+ "running",
970
+ iterationTelemetry,
971
+ statusPath,
972
+ reviewerStatePath,
973
+ snapshotSegmentCtx,
974
+ );
866
975
  if (ok) {
867
976
  reviewerSnapshotFailures = 0;
868
977
  return;
@@ -890,12 +999,20 @@ export async function executeTaskV2(
890
999
  lastTelemetry = workerResult;
891
1000
 
892
1001
  // Clean up wrap-up signal
893
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
1002
+ if (existsSync(wrapUpFile))
1003
+ try {
1004
+ unlinkSync(wrapUpFile);
1005
+ } catch {
1006
+ /* ignore */
1007
+ }
894
1008
 
895
1009
  // Accumulate costs
896
1010
  cumulativeCostUsd += workerResult.costUsd;
897
- cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
898
- workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
1011
+ cumulativeTokens +=
1012
+ workerResult.inputTokens +
1013
+ workerResult.outputTokens +
1014
+ workerResult.cacheReadTokens +
1015
+ workerResult.cacheWriteTokens;
899
1016
 
900
1017
  // ── TP-106: Poll worker outbox for replies/escalations ─────
901
1018
  try {
@@ -949,37 +1066,50 @@ export async function executeTaskV2(
949
1066
  exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
950
1067
  },
951
1068
  });
952
- } catch { /* best effort */ }
1069
+ } catch {
1070
+ /* best effort */
1071
+ }
953
1072
  }
954
1073
  }
955
1074
 
956
1075
  // Consume outbox message to prevent duplicate processing in later iterations.
957
1076
  ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
958
1077
  }
959
- } catch { /* best effort */ }
1078
+ } catch {
1079
+ /* best effort */
1080
+ }
960
1081
 
961
1082
  // ── Steering annotation ─────────────────────────────────────
962
1083
  try {
963
1084
  if (existsSync(steeringPendingPath)) {
964
1085
  const raw = readFileSync(steeringPendingPath, "utf-8");
965
- for (const line of raw.split("\n").filter(l => l.trim())) {
1086
+ for (const line of raw.split("\n").filter((l) => l.trim())) {
966
1087
  try {
967
1088
  const entry = JSON.parse(line) as { ts: number; content: string; id: string };
968
1089
  const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
969
1090
  const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
970
1091
  logExecution(statusPath, "⚠️ Steering", sanitized);
971
- } catch { /* skip malformed */ }
1092
+ } catch {
1093
+ /* skip malformed */
1094
+ }
972
1095
  }
973
1096
  unlinkSync(steeringPendingPath);
974
1097
  }
975
- } catch { /* non-fatal */ }
1098
+ } catch {
1099
+ /* non-fatal */
1100
+ }
976
1101
 
977
1102
  // Log iteration result
978
1103
  const statusMsg = workerResult.killed
979
1104
  ? `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}`);
1105
+ : workerResult.exitCode === 0
1106
+ ? "done"
1107
+ : `error (code ${workerResult.exitCode})`;
1108
+ logExecution(
1109
+ statusPath,
1110
+ `Worker iter ${totalIterations}`,
1111
+ `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`,
1112
+ );
983
1113
 
984
1114
  // ── Check progress ──────────────────────────────────────────
985
1115
  const afterStatusContent = readFileSync(statusPath, "utf-8");
@@ -1008,21 +1138,31 @@ export async function executeTaskV2(
1008
1138
  stdio: ["pipe", "pipe", "pipe"],
1009
1139
  }).trim();
1010
1140
  // 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"));
1141
+ const changedFiles = diffOutput.split("\n").filter((l) => l.includes("|"));
1142
+ const sourceChanges = changedFiles.filter(
1143
+ (l) => !l.includes("STATUS.md") && !l.includes(".steering"),
1144
+ );
1013
1145
  hasSoftProgress = sourceChanges.length > 0;
1014
- } catch { /* git not available or timeout — treat as no soft progress */ }
1146
+ } catch {
1147
+ /* git not available or timeout — treat as no soft progress */
1148
+ }
1015
1149
 
1016
1150
  if (hasSoftProgress) {
1017
1151
  // Worker has uncommitted code changes — don't count toward stall.
1018
1152
  // 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`);
1153
+ logExecution(
1154
+ statusPath,
1155
+ "Soft progress",
1156
+ `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`,
1157
+ );
1021
1158
  noProgressCount = 0;
1022
1159
  } else {
1023
1160
  noProgressCount++;
1024
- logExecution(statusPath, "No progress",
1025
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
1161
+ logExecution(
1162
+ statusPath,
1163
+ "No progress",
1164
+ `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`,
1165
+ );
1026
1166
  if (noProgressCount >= config.noProgressLimit) {
1027
1167
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
1028
1168
  // TP-187 (#538): synchronous outbox drain at lane-termination decision
@@ -1032,10 +1172,15 @@ export async function executeTaskV2(
1032
1172
  try {
1033
1173
  const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
1034
1174
  if (drained > 0) {
1035
- logExecution(statusPath, "Outbox drained",
1036
- `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`);
1175
+ logExecution(
1176
+ statusPath,
1177
+ "Outbox drained",
1178
+ `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`,
1179
+ );
1037
1180
  }
1038
- } catch { /* best effort — do not block termination */ }
1181
+ } catch {
1182
+ /* best effort — do not block termination */
1183
+ }
1039
1184
  // TP-187 (#538): notify the supervisor process so it can suppress any
1040
1185
  // further alerts queued for this lane (zombie-alert filter).
1041
1186
  if (config.onLaneTerminated) {
@@ -1047,10 +1192,27 @@ export async function executeTaskV2(
1047
1192
  terminatedAt: Date.now(),
1048
1193
  reason: "no-progress-kill",
1049
1194
  });
1050
- } catch { /* best effort */ }
1195
+ } catch {
1196
+ /* best effort */
1197
+ }
1051
1198
  }
1052
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1053
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1199
+ return makeResult(
1200
+ taskId,
1201
+ segmentId,
1202
+ workerAgentId,
1203
+ "failed",
1204
+ startTime,
1205
+ `No progress after ${noProgressCount} iterations`,
1206
+ false,
1207
+ totalIterations,
1208
+ cumulativeCostUsd,
1209
+ cumulativeTokens,
1210
+ config,
1211
+ statusPath,
1212
+ reviewerStatePath,
1213
+ lastTelemetry,
1214
+ snapshotSegmentCtx,
1215
+ );
1054
1216
  }
1055
1217
  }
1056
1218
  } else {
@@ -1065,7 +1227,7 @@ export async function executeTaskV2(
1065
1227
  if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
1066
1228
  // Only mark step complete in STATUS.md if ALL segments in that step
1067
1229
  // are complete (not just ours). But for loop exit, we only care about ours.
1068
- const ss = afterStatus.steps.find(s => s.number === stepNum);
1230
+ const ss = afterStatus.steps.find((s) => s.number === stepNum);
1069
1231
  if (isStepComplete(ss)) {
1070
1232
  updateStepStatus(statusPath, stepNum, "complete");
1071
1233
  }
@@ -1073,7 +1235,7 @@ export async function executeTaskV2(
1073
1235
  }
1074
1236
  } else {
1075
1237
  for (const step of parsed.steps) {
1076
- const ss = afterStatus.steps.find(s => s.number === step.number);
1238
+ const ss = afterStatus.steps.find((s) => s.number === step.number);
1077
1239
  if (isStepComplete(ss)) {
1078
1240
  updateStepStatus(statusPath, step.number, "complete");
1079
1241
  }
@@ -1085,12 +1247,12 @@ export async function executeTaskV2(
1085
1247
  // have their segment checkboxes complete.
1086
1248
  let allComplete: boolean;
1087
1249
  if (repoStepNumbers && currentRepoId) {
1088
- allComplete = [...repoStepNumbers].every(stepNum =>
1250
+ allComplete = [...repoStepNumbers].every((stepNum) =>
1089
1251
  isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
1090
1252
  );
1091
1253
  } else {
1092
- allComplete = parsed.steps.every(step => {
1093
- const ss = afterStatus.steps.find(s => s.number === step.number);
1254
+ allComplete = parsed.steps.every((step) => {
1255
+ const ss = afterStatus.steps.find((s) => s.number === step.number);
1094
1256
  return isStepComplete(ss);
1095
1257
  });
1096
1258
  }
@@ -1106,21 +1268,21 @@ export async function executeTaskV2(
1106
1268
  // the iteration loop variables are out of scope here.
1107
1269
  const postLoopRepoId = segmentId ? config.repoId : null;
1108
1270
  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;
1271
+ const postLoopRepoSteps =
1272
+ postLoopStepSegMap && postLoopRepoId
1273
+ ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1274
+ : null;
1275
+ const effectivePostLoopRepoSteps =
1276
+ postLoopRepoSteps && postLoopRepoSteps.size > 0 ? postLoopRepoSteps : null;
1115
1277
 
1116
1278
  let allStepsComplete: boolean;
1117
1279
  if (effectivePostLoopRepoSteps && postLoopRepoId) {
1118
- allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1280
+ allStepsComplete = [...effectivePostLoopRepoSteps].every((stepNum) =>
1119
1281
  isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1120
1282
  );
1121
1283
  } else {
1122
- allStepsComplete = parsed.steps.every(step => {
1123
- const ss = finalStatus.steps.find(s => s.number === step.number);
1284
+ allStepsComplete = parsed.steps.every((step) => {
1285
+ const ss = finalStatus.steps.find((s) => s.number === step.number);
1124
1286
  return isStepComplete(ss);
1125
1287
  });
1126
1288
  }
@@ -1129,40 +1291,55 @@ export async function executeTaskV2(
1129
1291
  let incomplete: string;
1130
1292
  if (effectivePostLoopRepoSteps && postLoopRepoId) {
1131
1293
  incomplete = [...effectivePostLoopRepoSteps]
1132
- .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1133
- .map(n => `Step ${n}`)
1294
+ .filter((stepNum) => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1295
+ .map((n) => `Step ${n}`)
1134
1296
  .join(", ");
1135
1297
  } else {
1136
1298
  incomplete = parsed.steps
1137
- .filter(step => {
1138
- const ss = finalStatus.steps.find(s => s.number === step.number);
1299
+ .filter((step) => {
1300
+ const ss = finalStatus.steps.find((s) => s.number === step.number);
1139
1301
  return !isStepComplete(ss);
1140
1302
  })
1141
- .map(s => `Step ${s.number}`)
1303
+ .map((s) => `Step ${s.number}`)
1142
1304
  .join(", ");
1143
1305
  }
1144
1306
  logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
1145
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1307
+ return makeResult(
1308
+ taskId,
1309
+ segmentId,
1310
+ workerAgentId,
1311
+ "failed",
1312
+ startTime,
1146
1313
  `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
1147
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1314
+ false,
1315
+ totalIterations,
1316
+ cumulativeCostUsd,
1317
+ cumulativeTokens,
1318
+ config,
1319
+ statusPath,
1320
+ reviewerStatePath,
1321
+ lastTelemetry,
1322
+ snapshotSegmentCtx,
1323
+ );
1148
1324
  }
1149
1325
 
1150
1326
  // TP-145: Determine if this is a non-final segment of a multi-segment task.
1151
1327
  // If more segments remain after this one, suppress .DONE creation so that
1152
1328
  // the engine can advance the segment frontier and execute subsequent segments.
1153
1329
  // .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;
1330
+ const isNonFinalSegment =
1331
+ segmentId != null &&
1332
+ Array.isArray(unit.task.segmentIds) &&
1333
+ unit.task.segmentIds.length > 1 &&
1334
+ unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
1158
1335
 
1159
1336
  // TP-165: Check for pending expansion requests in the worker's outbox.
1160
1337
  // If the worker filed expansion requests, more segments may be added by the
1161
1338
  // engine at the segment boundary — .DONE must not be created even if this
1162
1339
  // 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
- );
1340
+ const hasPendingExpansionRequests =
1341
+ segmentId != null &&
1342
+ hasPendingExpansionRequestFiles(config.stateRoot, config.batchId, workerAgentId);
1166
1343
 
1167
1344
  if (isNonFinalSegment || hasPendingExpansionRequests) {
1168
1345
  // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
@@ -1171,23 +1348,50 @@ export async function executeTaskV2(
1171
1348
  // write access and sometimes create .DONE on their own, bypassing this gate).
1172
1349
  if (existsSync(donePath)) {
1173
1350
  let deleted = false;
1174
- try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
1351
+ try {
1352
+ unlinkSync(donePath);
1353
+ deleted = true;
1354
+ } catch {
1355
+ /* best effort */
1356
+ }
1175
1357
  if (deleted) {
1176
- logExecution(statusPath, "Segment complete",
1177
- `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`);
1358
+ logExecution(
1359
+ statusPath,
1360
+ "Segment complete",
1361
+ `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`,
1362
+ );
1178
1363
  } else {
1179
- logExecution(statusPath, "Segment complete",
1180
- `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`);
1364
+ logExecution(
1365
+ statusPath,
1366
+ "Segment complete",
1367
+ `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`,
1368
+ );
1181
1369
  }
1182
1370
  } else {
1183
- logExecution(statusPath, "Segment complete",
1184
- `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
1371
+ logExecution(
1372
+ statusPath,
1373
+ "Segment complete",
1374
+ `Segment ${segmentId} succeeded (not final — .DONE suppressed)`,
1375
+ );
1185
1376
  }
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);
1377
+ const suppressionReason = isNonFinalSegment ? "non-final" : "pending expansion requests";
1378
+ return makeResult(
1379
+ taskId,
1380
+ segmentId,
1381
+ workerAgentId,
1382
+ "succeeded",
1383
+ startTime,
1384
+ `Segment completed (${suppressionReason} — .DONE suppressed)`,
1385
+ false,
1386
+ totalIterations,
1387
+ cumulativeCostUsd,
1388
+ cumulativeTokens,
1389
+ config,
1390
+ statusPath,
1391
+ reviewerStatePath,
1392
+ lastTelemetry,
1393
+ snapshotSegmentCtx,
1394
+ );
1191
1395
  }
1192
1396
 
1193
1397
  // Create .DONE if not already present (final segment or single-segment/whole-task execution)
@@ -1197,8 +1401,23 @@ export async function executeTaskV2(
1197
1401
  updateStatusField(statusPath, "Status", "✅ Complete");
1198
1402
  logExecution(statusPath, "Task complete", ".DONE created");
1199
1403
 
1200
- return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1201
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1404
+ return makeResult(
1405
+ taskId,
1406
+ segmentId,
1407
+ workerAgentId,
1408
+ "succeeded",
1409
+ startTime,
1410
+ ".DONE file created by lane-runner",
1411
+ true,
1412
+ totalIterations,
1413
+ cumulativeCostUsd,
1414
+ cumulativeTokens,
1415
+ config,
1416
+ statusPath,
1417
+ reviewerStatePath,
1418
+ lastTelemetry,
1419
+ snapshotSegmentCtx,
1420
+ );
1202
1421
  }
1203
1422
 
1204
1423
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -1262,17 +1481,18 @@ function makeResult(
1262
1481
  /** TP-174: Segment context for segment-scoped snapshot progress */
1263
1482
  segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1264
1483
  ): 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
- };
1484
+ const telemetry =
1485
+ status === "skipped"
1486
+ ? undefined
1487
+ : {
1488
+ inputTokens: finalTelemetry?.inputTokens ?? 0,
1489
+ outputTokens: finalTelemetry?.outputTokens ?? 0,
1490
+ cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
1491
+ cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
1492
+ costUsd: finalTelemetry?.costUsd ?? 0,
1493
+ toolCalls: finalTelemetry?.toolCalls ?? 0,
1494
+ durationMs: finalTelemetry?.durationMs ?? 0,
1495
+ };
1276
1496
 
1277
1497
  const result: LaneRunnerTaskResult = {
1278
1498
  outcome: {
@@ -1295,7 +1515,16 @@ function makeResult(
1295
1515
  // TP-115: Emit terminal snapshot with real telemetry from agent-host result
1296
1516
  if (config && statusPath && reviewerStatePath) {
1297
1517
  const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
1298
- emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
1518
+ emitSnapshot(
1519
+ config,
1520
+ taskId,
1521
+ segmentId,
1522
+ terminalStatus,
1523
+ finalTelemetry ?? {},
1524
+ statusPath,
1525
+ reviewerStatePath,
1526
+ segmentCtx,
1527
+ );
1299
1528
  }
1300
1529
 
1301
1530
  return result;
@@ -1308,9 +1537,10 @@ export function readReviewerTelemetrySnapshot(
1308
1537
  config: LaneRunnerConfig,
1309
1538
  reviewerStatePathOrStatusPath: string,
1310
1539
  ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
1311
- const reviewerPath = basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1312
- ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1313
- : reviewerStatePathOrStatusPath;
1540
+ const reviewerPath =
1541
+ basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1542
+ ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1543
+ : reviewerStatePathOrStatusPath;
1314
1544
  if (!existsSync(reviewerPath)) return null;
1315
1545
 
1316
1546
  try {
@@ -1334,7 +1564,7 @@ export function readReviewerTelemetrySnapshot(
1334
1564
  if (parsed.status !== "running") return null;
1335
1565
 
1336
1566
  // 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;
1567
+ if (parsed.updatedAt && Date.now() - parsed.updatedAt > REVIEWER_STATE_STALE_MS) return null;
1338
1568
 
1339
1569
  return {
1340
1570
  agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
@@ -1413,7 +1643,9 @@ function emitSnapshot(
1413
1643
  iteration: parsed.iteration,
1414
1644
  reviews: parsed.reviewCounter,
1415
1645
  };
1416
- } catch { /* best effort */ }
1646
+ } catch {
1647
+ /* best effort */
1648
+ }
1417
1649
 
1418
1650
  const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath);
1419
1651
 
@@ -1451,4 +1683,3 @@ function emitSnapshot(
1451
1683
  return false;
1452
1684
  }
1453
1685
  }
1454
-