taskplane 0.26.1 → 0.28.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.
@@ -15,8 +15,9 @@
15
15
  * @since TP-105
16
16
  */
17
17
 
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "fs";
19
19
  import { join, dirname, resolve, basename } from "path";
20
+ import { execSync } from "child_process";
20
21
  import { fileURLToPath } from "url";
21
22
 
22
23
  import {
@@ -40,6 +41,9 @@ import {
40
41
 
41
42
  import {
42
43
  readOutbox,
44
+ readInbox,
45
+ ackMessage,
46
+ sessionInboxDir,
43
47
  ackOutboxMessage,
44
48
  appendMailboxAuditEvent,
45
49
  } from "./mailbox.ts";
@@ -58,10 +62,114 @@ import {
58
62
  type LaneTaskOutcome,
59
63
  type LaneTaskStatus,
60
64
  type SupervisorAlertCallback,
65
+ type StepSegmentMapping,
61
66
  } from "./types.ts";
62
67
 
63
68
  const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
64
69
 
70
+ // ── Segment Scoping Helpers (Phase A, TP-174) ────────────────────────
71
+
72
+ /**
73
+ * Get the set of step numbers that have segments for a given repoId.
74
+ *
75
+ * Used to filter the "remaining steps" view so the worker only sees steps
76
+ * that contain work for its repo.
77
+ *
78
+ * @param stepSegmentMap - Parsed step-segment mapping from PROMPT.md
79
+ * @param repoId - Repo ID to filter by
80
+ * @returns Set of step numbers that have at least one segment for this repoId
81
+ * @since TP-174
82
+ */
83
+ export function getStepsForRepoId(
84
+ stepSegmentMap: StepSegmentMapping[],
85
+ repoId: string,
86
+ ): Set<number> {
87
+ const stepNumbers = new Set<number>();
88
+ for (const step of stepSegmentMap) {
89
+ if (step.segments.some(seg => seg.repoId === repoId)) {
90
+ stepNumbers.add(step.stepNumber);
91
+ }
92
+ }
93
+ return stepNumbers;
94
+ }
95
+
96
+ /**
97
+ * Extract a segment's checkbox block from STATUS.md content for a given step and repoId.
98
+ *
99
+ * Looks for `#### Segment: <repoId>` headers within `### Step N:` sections,
100
+ * then returns the checkbox lines belonging to that segment block.
101
+ *
102
+ * @param statusContent - Raw STATUS.md content
103
+ * @param stepNumber - Step number to look in
104
+ * @param repoId - Repo ID of the segment
105
+ * @returns Object with checked/unchecked counts, or null if no segment block found
106
+ * @since TP-174
107
+ */
108
+ export function getSegmentCheckboxes(
109
+ statusContent: string,
110
+ stepNumber: number,
111
+ repoId: string,
112
+ ): { checked: number; unchecked: number; total: number; uncheckedTexts: string[] } | null {
113
+ const text = statusContent.replace(/\r\n/g, "\n");
114
+
115
+ // Find the step section
116
+ const stepHeaderPattern = new RegExp(`^###\\s+Step\\s+${stepNumber}:`, "m");
117
+ const stepMatch = text.match(stepHeaderPattern);
118
+ if (!stepMatch || stepMatch.index === undefined) return null;
119
+
120
+ // Find the end of this step section (next ### or end of file)
121
+ const afterStep = text.slice(stepMatch.index + stepMatch[0].length);
122
+ const nextStepMatch = afterStep.search(/^###\s+Step\s+\d+:/m);
123
+ const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
124
+
125
+ // Find the segment header within this step
126
+ const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
127
+ const segMatch = stepContent.match(segHeaderPattern);
128
+ if (!segMatch || segMatch.index === undefined) return null;
129
+
130
+ // Extract content from segment header to next #### header or ### header or ---
131
+ const afterSeg = stepContent.slice(segMatch.index + segMatch[0].length);
132
+ const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|---)/m);
133
+ const segContent = nextSectionMatch !== -1 ? afterSeg.slice(0, nextSectionMatch) : afterSeg;
134
+
135
+ // Count checkboxes
136
+ let checked = 0;
137
+ let unchecked = 0;
138
+ const uncheckedTexts: string[] = [];
139
+ const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
140
+ let m;
141
+ while ((m = cbRegex.exec(segContent)) !== null) {
142
+ if (m[1].toLowerCase() === "x") {
143
+ checked++;
144
+ } else {
145
+ unchecked++;
146
+ uncheckedTexts.push(m[2].trim());
147
+ }
148
+ }
149
+
150
+ return { checked, unchecked, total: checked + unchecked, uncheckedTexts };
151
+ }
152
+
153
+ /**
154
+ * Check if all checkboxes in a segment block are checked.
155
+ *
156
+ * @param statusContent - Raw STATUS.md content
157
+ * @param stepNumber - Step number to check
158
+ * @param repoId - Repo ID of the segment
159
+ * @returns true when all checkboxes in the segment block are checked
160
+ * @since TP-174
161
+ */
162
+ export function isSegmentComplete(
163
+ statusContent: string,
164
+ stepNumber: number,
165
+ repoId: string,
166
+ ): boolean {
167
+ const result = getSegmentCheckboxes(statusContent, stepNumber, repoId);
168
+ if (!result) return false;
169
+ if (result.total === 0) return false;
170
+ return result.unchecked === 0;
171
+ }
172
+
65
173
  // ── Types ────────────────────────────────────────────────────────────
66
174
 
67
175
  /**
@@ -90,8 +198,10 @@ export interface LaneRunnerConfig {
90
198
  workerTools: string;
91
199
  /** Worker thinking mode */
92
200
  workerThinking: string;
93
- /** Worker system prompt */
201
+ /** Worker system prompt (full-task mode) */
94
202
  workerSystemPrompt: string;
203
+ /** Worker system prompt for segment-scoped mode (appended to base) */
204
+ workerSegmentPrompt: string;
95
205
  /**
96
206
  * Reviewer model (empty string = inherit session default).
97
207
  * Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
@@ -213,17 +323,53 @@ export async function executeTaskV2(
213
323
  // TP-115: carry latest worker telemetry across iterations and into post-loop terminal snapshots
214
324
  let lastTelemetry: Partial<AgentHostResult> = {};
215
325
 
326
+ // TP-174: Build segment context once for emitSnapshot calls.
327
+ // Available outside the loop so it can be passed to makeResult too.
328
+ const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
329
+ (segmentId && unit.task.stepSegmentMap && config.repoId)
330
+ ? (() => {
331
+ const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
332
+ return repoSteps.size > 0
333
+ ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
334
+ : null;
335
+ })()
336
+ : null;
337
+
216
338
  for (let iter = 0; iter < config.maxIterations; iter++) {
217
339
  if (pauseSignal.paused) {
218
340
  logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
219
341
  return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime,
220
- "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath);
342
+ "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx);
221
343
  }
222
344
 
223
345
  // Determine remaining steps
224
346
  const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
225
347
  const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
348
+
349
+ // TP-174: Resolve segment-scoped step filtering.
350
+ // Use config.repoId (structured identity) instead of parsing opaque segmentId.
351
+ const stepSegmentMap = unit.task.stepSegmentMap;
352
+ const currentRepoId = segmentId ? config.repoId : null;
353
+ const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
354
+ ? getStepsForRepoId(stepSegmentMap, currentRepoId)
355
+ : null;
356
+ // TP-174 legacy fallback: If no steps have segments for this repoId
357
+ // (multi-segment task without explicit markers, where all checkboxes
358
+ // are assigned to the fallback/packet repo), disable segment filtering.
359
+ const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
360
+ ? rawRepoStepNumbers
361
+ : null;
362
+
363
+ // TP-174: Read STATUS.md content once for segment-scoped checks
364
+ const iterStatusContent = readFileSync(statusPath, "utf-8");
365
+
226
366
  const remainingSteps = parsed.steps.filter(step => {
367
+ // TP-174: When segment-scoped, only show steps that have work for this repoId
368
+ if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
369
+ // TP-174: Use segment-scoped completion check in segment mode
370
+ if (repoStepNumbers && currentRepoId) {
371
+ return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
372
+ }
227
373
  const ss = currentStatus.steps.find(s => s.number === step.number);
228
374
  return !isStepComplete(ss);
229
375
  });
@@ -243,12 +389,26 @@ export async function executeTaskV2(
243
389
  }
244
390
 
245
391
  // Count checkboxes before worker runs
246
- const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
392
+ // TP-174: When segment-scoped, count only this segment's checkboxes
393
+ let prevTotalChecked: number;
394
+ if (repoStepNumbers && currentRepoId) {
395
+ const preStatusContent = readFileSync(statusPath, "utf-8");
396
+ const segCbs = getSegmentCheckboxes(preStatusContent, firstStep.number, currentRepoId);
397
+ prevTotalChecked = segCbs ? segCbs.checked : 0;
398
+ } else {
399
+ prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
400
+ }
247
401
 
248
402
  // ── Build worker prompt ─────────────────────────────────────
249
403
  const wrapUpFile = join(taskFolder, ".task-wrap-up");
250
404
  if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
251
405
 
406
+ // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
407
+ const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers
408
+ && remainingSteps.length > 0
409
+ && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number)
410
+ ?.segments.find(seg => seg.repoId === currentRepoId));
411
+
252
412
  const promptLines = [
253
413
  `Read your task instructions at: ${promptPath}`,
254
414
  `Read your execution state at: ${statusPath}`,
@@ -262,7 +422,11 @@ export async function executeTaskV2(
262
422
  `- Execution repo ID: ${unit.executionRepoId}`,
263
423
  `- Execution worktree (worker cwd): ${unit.worktreePath}`,
264
424
  `- Lane repo ID: ${config.repoId}`,
265
- `- Active segment ID: ${segmentId ?? "(none / whole-task execution)"}`,
425
+ // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
426
+ // workers incorrectly self-scoping based on segment metadata.
427
+ ...(isSegmentScoped
428
+ ? [`- Active segment ID: ${segmentId}`]
429
+ : []),
266
430
  ``,
267
431
  `Packet home context:`,
268
432
  `- Packet home repo ID: ${unit.packetHomeRepoId}`,
@@ -277,7 +441,8 @@ export async function executeTaskV2(
277
441
  `⚠️ CHECKPOINT RULE: After completing EACH checkbox item, immediately edit STATUS.md to check it off (- [ ] → - [x]) BEFORE starting the next item. Do NOT batch checkbox updates at the end of a step.`,
278
442
  ];
279
443
 
280
- const segmentDag = unit.task.explicitSegmentDag;
444
+ // Only show segment DAG in segment-scoped mode
445
+ const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
281
446
  if (segmentDag && segmentDag.repoIds.length > 0) {
282
447
  const edgeSummary = segmentDag.edges.length > 0
283
448
  ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
@@ -290,6 +455,68 @@ export async function executeTaskV2(
290
455
  );
291
456
  }
292
457
 
458
+ // Segment scope mode is determined by which system prompt was loaded.
459
+ // No SegmentScopeMode line needed — the prompt IS the mode.
460
+
461
+ // TP-174: Segment-scoped prompt — show only this segment's checkboxes
462
+ if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
463
+ const currentStepNum = remainingSteps[0].number;
464
+ const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
465
+ const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
466
+
467
+ // Only inject segment-scoped prompt when the current step has an explicit
468
+ // segment for this repoId. If mySegment is missing (legacy task without
469
+ // markers, or step has no work for this repo), skip and preserve legacy behavior.
470
+ if (currentStepMapping && mySegment) {
471
+ const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
472
+
473
+ // Count total segments for this repo across all steps
474
+ const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
475
+ const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
476
+ const totalSegmentsInStep = currentStepMapping.segments.length;
477
+
478
+ promptLines.push(
479
+ ``,
480
+ `Segment-scoped context (Phase A):`,
481
+ `Active segment: ${segmentId} (Step ${currentStepNum}, segment ${segmentIndexInStep} of ${totalSegmentsInStep})`,
482
+ `Your repo: ${currentRepoId}`,
483
+ ``,
484
+ );
485
+
486
+ if (mySegment && mySegment.checkboxes.length > 0) {
487
+ promptLines.push(`Your checkboxes for this step:`);
488
+ for (const cb of mySegment.checkboxes) {
489
+ promptLines.push(` ${cb}`);
490
+ }
491
+ }
492
+
493
+ if (otherSegments.length > 0) {
494
+ promptLines.push(``);
495
+ promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
496
+ for (const seg of otherSegments) {
497
+ promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
498
+ }
499
+ }
500
+
501
+ // List completed steps for this repo
502
+ const completedForRepo = parsed.steps.filter(step => {
503
+ if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
504
+ const ss = currentStatus.steps.find(s => s.number === step.number);
505
+ return isStepComplete(ss);
506
+ });
507
+ if (completedForRepo.length > 0) {
508
+ promptLines.push(``);
509
+ promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
510
+ }
511
+
512
+ promptLines.push(
513
+ ``,
514
+ `When all YOUR checkboxes are checked, your segment is done — exit successfully.`,
515
+ `Do NOT attempt work in other repos.`,
516
+ );
517
+ }
518
+ }
519
+
293
520
  if (totalIterations > 1 && remainingSteps.length > 0) {
294
521
  const remainingSet = new Set(remainingSteps.map(s => s.number));
295
522
  const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
@@ -299,6 +526,21 @@ export async function executeTaskV2(
299
526
  `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
300
527
  `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
301
528
  );
529
+
530
+ // If the worker exited without checking any boxes, add a corrective directive
531
+ if (noProgressCount > 0) {
532
+ promptLines.push(
533
+ ``,
534
+ `🚨 CRITICAL: You have exited ${noProgressCount} time(s) without completing work.`,
535
+ `Your previous exit was premature. You said something like "Now let me fix this"`,
536
+ `and then STOPPED instead of actually making the edit.`,
537
+ ``,
538
+ `DO NOT DO THIS AGAIN. When you know what to edit, call the edit tool IMMEDIATELY.`,
539
+ `Do not produce a text message describing what you plan to do. Just do it.`,
540
+ `Work continuously through ALL remaining checkboxes until the task is DONE.`,
541
+ `Do not exit between checkboxes or steps.`,
542
+ );
543
+ }
302
544
  }
303
545
 
304
546
  // ── Spawn worker ────────────────────────────────────────────
@@ -322,7 +564,9 @@ export async function executeTaskV2(
322
564
  repoId: config.repoId,
323
565
  cwd: unit.worktreePath,
324
566
  prompt: promptLines.join("\n"),
325
- systemPrompt: config.workerSystemPrompt || undefined,
567
+ systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
568
+ ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
569
+ : config.workerSystemPrompt) || undefined,
326
570
  model: config.workerModel || undefined,
327
571
  tools: config.workerTools || "read,write,edit,bash,grep,find,ls",
328
572
  thinking: config.workerThinking || undefined,
@@ -344,13 +588,162 @@ export async function executeTaskV2(
344
588
  TASKPLANE_REVIEWER_STATE_PATH: reviewerStatePath,
345
589
  TASKPLANE_PROJECT_NAME: config.projectName || "project",
346
590
  TASKPLANE_TASK_ID: taskId,
347
- TASKPLANE_ACTIVE_SEGMENT_ID: segmentId ?? "",
591
+ // Hard-set segment env vars based on mode. In FULL_TASK mode,
592
+ // explicitly clear them to prevent env inheritance leaking segment cues.
593
+ TASKPLANE_ACTIVE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
594
+ TASKPLANE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
348
595
  TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
349
596
  ORCH_BATCH_ID: config.batchId,
350
597
  ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
351
598
  ...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
352
599
  ...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
353
600
  },
601
+ // TP-172: Exit interception callback — escalate to supervisor when worker
602
+ // exits without making visible progress (no checkboxes, no blocker logged).
603
+ onPrematureExit: config.onSupervisorAlert
604
+ ? async (assistantMessage: string): Promise<string | null> => {
605
+ // Check if the worker made visible progress during this turn:
606
+ // 1. Checkbox progress (more items checked)
607
+ // 2. Blocker logged (non-empty Blockers section)
608
+ try {
609
+ const statusContent = readFileSync(statusPath, "utf-8");
610
+ // TP-174: Use same scope as prevTotalChecked (segment or global)
611
+ let midTotalChecked: number;
612
+ if (repoStepNumbers && currentRepoId) {
613
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
614
+ midTotalChecked = segCbs ? segCbs.checked : 0;
615
+ } else {
616
+ const midStatus = parseStatusMd(statusContent);
617
+ midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
618
+ }
619
+ if (midTotalChecked > prevTotalChecked) {
620
+ // Worker checked off checkboxes — let it exit normally
621
+ return null;
622
+ }
623
+ // Check for blocker entries: extract Blockers section and see if non-empty
624
+ const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
625
+ if (blockerMatch) {
626
+ const blockerContent = blockerMatch[1].trim();
627
+ // If blockers section has real content (not just "*None*" or empty)
628
+ if (blockerContent && blockerContent !== "*None*") {
629
+ // Worker logged a blocker — let it exit normally
630
+ return null;
631
+ }
632
+ }
633
+ } catch { /* If we can't read STATUS.md, proceed with escalation */ }
634
+
635
+ // No visible progress — compose escalation message
636
+ const truncatedMsg = assistantMessage.slice(0, 500);
637
+ const uncheckedItems: string[] = [];
638
+ try {
639
+ const statusContent = readFileSync(statusPath, "utf-8");
640
+ // TP-174: When segment-scoped, report only this segment's unchecked items
641
+ if (repoStepNumbers && currentRepoId) {
642
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
643
+ if (segCbs) {
644
+ for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
645
+ uncheckedItems.push(text);
646
+ }
647
+ }
648
+ } else {
649
+ const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
650
+ if (uncheckedMatches) {
651
+ for (const item of uncheckedMatches.slice(0, 5)) {
652
+ uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
653
+ }
654
+ }
655
+ }
656
+ } catch { /* best effort */ }
657
+
658
+ const currentStepInfo = remainingSteps.length > 0
659
+ ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
660
+ : "Unknown";
661
+
662
+ // Fire supervisor alert
663
+ try {
664
+ config.onSupervisorAlert!({
665
+ category: "worker-exit-intercept",
666
+ summary:
667
+ `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
668
+ ` Task: ${taskId}\n` +
669
+ ` Current step: ${currentStepInfo}\n` +
670
+ ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
671
+ ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
672
+ ` Worker said: "${truncatedMsg}"\n` +
673
+ `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
674
+ ` or reply "skip" / "let it fail" to close the session.`,
675
+ context: {
676
+ taskId,
677
+ laneId: `lane-${config.laneNumber}`,
678
+ laneNumber: config.laneNumber,
679
+ agentId: workerAgentId,
680
+ exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
681
+ },
682
+ });
683
+ } catch { /* best effort — don't block on alert failure */ }
684
+
685
+ // Poll worker mailbox inbox for supervisor reply (60s timeout)
686
+ const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
687
+ const POLL_INTERVAL_MS = 2_000;
688
+ const escalationTimestamp = Date.now();
689
+ const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
690
+
691
+ const supervisorReply = await new Promise<string | null>((resolve) => {
692
+ const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
693
+ const poll = () => {
694
+ if (Date.now() >= deadline) {
695
+ resolve(null); // Timeout — fall back to corrective re-spawn
696
+ return;
697
+ }
698
+ try {
699
+ const messages = readInbox(inboxDir, config.batchId);
700
+ // Only accept messages newer than escalation timestamp
701
+ for (const { filename, message } of messages) {
702
+ if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
703
+ // Consume the message
704
+ const ackDir = join(dirname(inboxDir), "ack");
705
+ try { ackMessage(inboxDir, filename); } catch { /* best effort */ }
706
+ resolve(message.content);
707
+ return;
708
+ }
709
+ }
710
+ } catch { /* inbox not ready yet */ }
711
+ setTimeout(poll, POLL_INTERVAL_MS);
712
+ };
713
+ poll();
714
+ });
715
+
716
+ if (!supervisorReply) {
717
+ // Timeout — let the session close, corrective re-spawn will handle it
718
+ logExecution(statusPath, "Exit intercept timeout",
719
+ `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`);
720
+ return null;
721
+ }
722
+
723
+ // Interpret supervisor reply: close directives vs instructional content
724
+ const normalizedReply = supervisorReply.trim().toLowerCase();
725
+ const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
726
+ // Only short messages (< 30 chars) can be close directives.
727
+ // Longer messages are always instructions even if they start with "stop".
728
+ const isShortEnoughForDirective = normalizedReply.length < 30;
729
+ if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d =>
730
+ normalizedReply === d ||
731
+ normalizedReply.startsWith(d + ":") ||
732
+ normalizedReply.startsWith(d + " ") ||
733
+ normalizedReply.startsWith(d + ".") ||
734
+ normalizedReply.startsWith(d + " -")
735
+ )) {
736
+ logExecution(statusPath, "Exit intercept close",
737
+ `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`);
738
+ return null;
739
+ }
740
+
741
+ // Instructional reply — return as new prompt for the worker
742
+ logExecution(statusPath, "Exit intercept reprompt",
743
+ `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`);
744
+ return supervisorReply;
745
+ }
746
+ : undefined,
354
747
  };
355
748
 
356
749
  // Context pressure: write wrap-up signal before kill
@@ -375,7 +768,7 @@ export async function executeTaskV2(
375
768
  iterationTelemetry = telemetry;
376
769
  lastTelemetry = telemetry;
377
770
  // Emit lane snapshot
378
- emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath);
771
+ emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
379
772
  } catch { /* non-fatal: telemetry callback must never crash the engine */ }
380
773
  });
381
774
 
@@ -385,7 +778,7 @@ export async function executeTaskV2(
385
778
  let reviewerSnapshotFailures = 0;
386
779
  const reviewerRefreshFailureThreshold = 5;
387
780
  const reviewerRefresh = setInterval(() => {
388
- const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath);
781
+ const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
389
782
  if (ok) {
390
783
  reviewerSnapshotFailures = 0;
391
784
  return;
@@ -505,59 +898,145 @@ export async function executeTaskV2(
505
898
  `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
506
899
 
507
900
  // ── Check progress ──────────────────────────────────────────
508
- const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
509
- const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
901
+ const afterStatusContent = readFileSync(statusPath, "utf-8");
902
+ const afterStatus = parseStatusMd(afterStatusContent);
903
+ // TP-174: Segment-scoped progress delta
904
+ let afterTotalChecked: number;
905
+ if (repoStepNumbers && currentRepoId) {
906
+ const segCbs = getSegmentCheckboxes(afterStatusContent, firstStep.number, currentRepoId);
907
+ afterTotalChecked = segCbs ? segCbs.checked : 0;
908
+ } else {
909
+ afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
910
+ }
510
911
  const progressDelta = afterTotalChecked - prevTotalChecked;
511
912
 
512
913
  if (progressDelta <= 0) {
513
- noProgressCount++;
514
- logExecution(statusPath, "No progress",
515
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
516
- if (noProgressCount >= config.noProgressLimit) {
517
- logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
518
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
519
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
914
+ // Check for soft progress: uncommitted changes in the worktree
915
+ // indicate the worker is actively editing code even if no checkbox
916
+ // was checked yet. This avoids false stall detection on complex
917
+ // steps where analysis + editing spans multiple tool calls.
918
+ let hasSoftProgress = false;
919
+ try {
920
+ const diffOutput = execSync("git diff --stat HEAD", {
921
+ cwd: unit.worktreePath,
922
+ timeout: 5000,
923
+ encoding: "utf-8",
924
+ stdio: ["pipe", "pipe", "pipe"],
925
+ }).trim();
926
+ // Only count source file changes as soft progress, not just STATUS.md
927
+ const changedFiles = diffOutput.split("\n").filter(l => l.includes("|"));
928
+ const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering"));
929
+ hasSoftProgress = sourceChanges.length > 0;
930
+ } catch { /* git not available or timeout — treat as no soft progress */ }
931
+
932
+ if (hasSoftProgress) {
933
+ // Worker has uncommitted code changes — don't count toward stall.
934
+ // Reset the counter since the worker is actively editing.
935
+ logExecution(statusPath, "Soft progress",
936
+ `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`);
937
+ noProgressCount = 0;
938
+ } else {
939
+ noProgressCount++;
940
+ logExecution(statusPath, "No progress",
941
+ `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
942
+ if (noProgressCount >= config.noProgressLimit) {
943
+ logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
944
+ return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
945
+ `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
946
+ }
520
947
  }
521
948
  } else {
522
949
  noProgressCount = 0;
523
950
  }
524
951
 
525
952
  // Mark completed steps
526
- for (const step of parsed.steps) {
527
- const ss = afterStatus.steps.find(s => s.number === step.number);
528
- if (isStepComplete(ss)) {
529
- updateStepStatus(statusPath, step.number, "complete");
953
+ // TP-174: When segment-scoped, mark step complete when the segment's
954
+ // checkboxes are all checked (not the full step which may have other segments).
955
+ if (repoStepNumbers && currentRepoId) {
956
+ for (const stepNum of repoStepNumbers) {
957
+ if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
958
+ // Only mark step complete in STATUS.md if ALL segments in that step
959
+ // are complete (not just ours). But for loop exit, we only care about ours.
960
+ const ss = afterStatus.steps.find(s => s.number === stepNum);
961
+ if (isStepComplete(ss)) {
962
+ updateStepStatus(statusPath, stepNum, "complete");
963
+ }
964
+ }
965
+ }
966
+ } else {
967
+ for (const step of parsed.steps) {
968
+ const ss = afterStatus.steps.find(s => s.number === step.number);
969
+ if (isStepComplete(ss)) {
970
+ updateStepStatus(statusPath, step.number, "complete");
971
+ }
530
972
  }
531
973
  }
532
974
 
533
975
  // Check if all steps are now complete
534
- const allComplete = parsed.steps.every(step => {
535
- const ss = afterStatus.steps.find(s => s.number === step.number);
536
- return isStepComplete(ss);
537
- });
976
+ // TP-174: When segment-scoped, exit when all steps for this repoId
977
+ // have their segment checkboxes complete.
978
+ let allComplete: boolean;
979
+ if (repoStepNumbers && currentRepoId) {
980
+ allComplete = [...repoStepNumbers].every(stepNum =>
981
+ isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
982
+ );
983
+ } else {
984
+ allComplete = parsed.steps.every(step => {
985
+ const ss = afterStatus.steps.find(s => s.number === step.number);
986
+ return isStepComplete(ss);
987
+ });
988
+ }
538
989
  if (allComplete) break;
539
990
  }
540
991
 
541
992
  // ── 3. Post-loop completion check ───────────────────────────────
542
- const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
993
+ const finalStatusContent = readFileSync(statusPath, "utf-8");
994
+ const finalStatus = parseStatusMd(finalStatusContent);
543
995
  const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
544
- const allStepsComplete = parsed.steps.every(step => {
545
- const ss = finalStatus.steps.find(s => s.number === step.number);
546
- return isStepComplete(ss);
547
- });
996
+
997
+ // TP-174: Segment-scoped post-loop check. Re-derive repo scoping since
998
+ // the iteration loop variables are out of scope here.
999
+ const postLoopRepoId = segmentId ? config.repoId : null;
1000
+ const postLoopStepSegMap = unit.task.stepSegmentMap;
1001
+ const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId)
1002
+ ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1003
+ : null;
1004
+ const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0)
1005
+ ? postLoopRepoSteps
1006
+ : null;
1007
+
1008
+ let allStepsComplete: boolean;
1009
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1010
+ allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1011
+ isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1012
+ );
1013
+ } else {
1014
+ allStepsComplete = parsed.steps.every(step => {
1015
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1016
+ return isStepComplete(ss);
1017
+ });
1018
+ }
548
1019
 
549
1020
  if (!allStepsComplete) {
550
- const incomplete = parsed.steps
551
- .filter(step => {
552
- const ss = finalStatus.steps.find(s => s.number === step.number);
553
- return !isStepComplete(ss);
554
- })
555
- .map(s => `Step ${s.number}`)
556
- .join(", ");
1021
+ let incomplete: string;
1022
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1023
+ incomplete = [...effectivePostLoopRepoSteps]
1024
+ .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1025
+ .map(n => `Step ${n}`)
1026
+ .join(", ");
1027
+ } else {
1028
+ incomplete = parsed.steps
1029
+ .filter(step => {
1030
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1031
+ return !isStepComplete(ss);
1032
+ })
1033
+ .map(s => `Step ${s.number}`)
1034
+ .join(", ");
1035
+ }
557
1036
  logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
558
1037
  return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
559
1038
  `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
560
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1039
+ false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
561
1040
  }
562
1041
 
563
1042
  // TP-145: Determine if this is a non-final segment of a multi-segment task.
@@ -569,7 +1048,15 @@ export async function executeTaskV2(
569
1048
  && unit.task.segmentIds.length > 1
570
1049
  && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
571
1050
 
572
- if (isNonFinalSegment) {
1051
+ // TP-165: Check for pending expansion requests in the worker's outbox.
1052
+ // If the worker filed expansion requests, more segments may be added by the
1053
+ // engine at the segment boundary — .DONE must not be created even if this
1054
+ // appears to be the final segment based on the static segmentIds list.
1055
+ const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles(
1056
+ config.stateRoot, config.batchId, workerAgentId,
1057
+ );
1058
+
1059
+ if (isNonFinalSegment || hasPendingExpansionRequests) {
573
1060
  // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
574
1061
  // The engine will advance the frontier and dispatch the next segment.
575
1062
  // Also delete any .DONE the worker may have created directly (workers have
@@ -588,8 +1075,11 @@ export async function executeTaskV2(
588
1075
  logExecution(statusPath, "Segment complete",
589
1076
  `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
590
1077
  }
1078
+ const suppressionReason = isNonFinalSegment
1079
+ ? "non-final"
1080
+ : "pending expansion requests";
591
1081
  return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
592
- "Segment completed (non-final — .DONE suppressed)", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1082
+ `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
593
1083
  }
594
1084
 
595
1085
  // Create .DONE if not already present (final segment or single-segment/whole-task execution)
@@ -600,11 +1090,35 @@ export async function executeTaskV2(
600
1090
  logExecution(statusPath, "Task complete", ".DONE created");
601
1091
 
602
1092
  return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
603
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1093
+ ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
604
1094
  }
605
1095
 
606
1096
  // ── Helpers ──────────────────────────────────────────────────────────
607
1097
 
1098
+ /**
1099
+ * TP-165: Check if the worker's outbox contains pending segment expansion requests.
1100
+ *
1101
+ * Pending expansion request files match `segment-expansion-*.json` (not renamed
1102
+ * to `.processed`, `.rejected`, etc.). If any exist, the engine will process them
1103
+ * at the segment boundary — and may add more segments to the task.
1104
+ *
1105
+ * @returns true if at least one pending expansion request file exists
1106
+ */
1107
+ export function hasPendingExpansionRequestFiles(
1108
+ stateRoot: string,
1109
+ batchId: string,
1110
+ agentId: string,
1111
+ ): boolean {
1112
+ const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox");
1113
+ if (!existsSync(outboxDir)) return false;
1114
+ try {
1115
+ const entries = readdirSync(outboxDir);
1116
+ return entries.some((entry) => /^segment-expansion-.+\.json$/.test(entry));
1117
+ } catch {
1118
+ return false;
1119
+ }
1120
+ }
1121
+
608
1122
  export function mapLaneTaskStatusToTerminalSnapshotStatus(
609
1123
  status: LaneTaskStatus,
610
1124
  ): "idle" | "complete" | "failed" {
@@ -637,6 +1151,8 @@ function makeResult(
637
1151
  statusPath?: string,
638
1152
  reviewerStatePath?: string,
639
1153
  finalTelemetry?: Partial<AgentHostResult>,
1154
+ /** TP-174: Segment context for segment-scoped snapshot progress */
1155
+ segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
640
1156
  ): LaneRunnerTaskResult {
641
1157
  const telemetry = status === "skipped"
642
1158
  ? undefined
@@ -671,7 +1187,7 @@ function makeResult(
671
1187
  // TP-115: Emit terminal snapshot with real telemetry from agent-host result
672
1188
  if (config && statusPath && reviewerStatePath) {
673
1189
  const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
674
- emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath);
1190
+ emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
675
1191
  }
676
1192
 
677
1193
  return result;
@@ -748,6 +1264,8 @@ function emitSnapshot(
748
1264
  telemetry: Partial<AgentHostResult>,
749
1265
  statusPath: string,
750
1266
  reviewerStatePath: string,
1267
+ /** TP-174: Optional segment context for segment-scoped progress reporting */
1268
+ segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
751
1269
  ): boolean {
752
1270
  try {
753
1271
  // Parse progress from STATUS.md
@@ -756,8 +1274,30 @@ function emitSnapshot(
756
1274
  const content = readFileSync(statusPath, "utf-8");
757
1275
  const parsed = parseStatusMd(content);
758
1276
  const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
759
- const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
760
- const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1277
+
1278
+ // TP-174: Segment-scoped progress when segment markers are present.
1279
+ // Only count checkboxes from steps that belong to this segment's repoId.
1280
+ let checked: number;
1281
+ let total: number;
1282
+ if (segmentContext) {
1283
+ const { stepSegmentMap, repoId } = segmentContext;
1284
+ const repoSteps = getStepsForRepoId(stepSegmentMap, repoId);
1285
+ let segChecked = 0;
1286
+ let segTotal = 0;
1287
+ for (const stepNum of repoSteps) {
1288
+ const segCbs = getSegmentCheckboxes(content, stepNum, repoId);
1289
+ if (segCbs) {
1290
+ segChecked += segCbs.checked;
1291
+ segTotal += segCbs.total;
1292
+ }
1293
+ }
1294
+ checked = segChecked;
1295
+ total = segTotal;
1296
+ } else {
1297
+ checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
1298
+ total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1299
+ }
1300
+
761
1301
  progress = {
762
1302
  currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
763
1303
  checked,