taskplane 0.28.4 → 0.28.6

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,1360 +1,1383 @@
1
- /**
2
- * Lane Runner — Headless per-lane execution for Runtime V2
3
- *
4
- * Replaces the legacy TMUX-backed lane execution path with a
5
- * deterministic Node process that owns:
6
- * - worker iteration loops
7
- * - STATUS.md progression
8
- * - .DONE creation detection
9
- * - reviewer orchestration (future)
10
- * - lane snapshot emission
11
- *
12
- * No Pi extension dependency. No TMUX. No TASK_AUTOSTART.
13
- *
14
- * @module taskplane/lane-runner
15
- * @since TP-105
16
- */
17
-
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "fs";
19
- import { join, dirname, resolve, basename } from "path";
20
- import { execSync } from "child_process";
21
- import { fileURLToPath } from "url";
22
-
23
- import {
24
- parsePromptMd,
25
- parseStatusMd,
26
- generateStatusMd,
27
- updateStatusField,
28
- updateStepStatus,
29
- logExecution,
30
- isStepComplete,
31
- type StepInfo,
32
- type CoreParsedTask,
33
- } from "./task-executor-core.ts";
34
-
35
- import { spawnAgent, type AgentHostOptions, type AgentHostResult } from "./agent-host.ts";
36
- import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
37
-
38
- import {
39
- appendAgentEvent,
40
- writeLaneSnapshot,
41
- } from "./process-registry.ts";
42
-
43
- import {
44
- readOutbox,
45
- readInbox,
46
- ackMessage,
47
- sessionInboxDir,
48
- ackOutboxMessage,
49
- appendMailboxAuditEvent,
50
- } from "./mailbox.ts";
51
-
52
- import {
53
- resolvePacketPaths,
54
- buildRuntimeAgentId,
55
- runtimeAgentEventsPath,
56
- type ExecutionUnit,
57
- type RuntimeAgentId,
58
- type RuntimeLaneSnapshot,
59
- type RuntimeAgentTelemetrySnapshot,
60
- type RuntimeTaskProgress,
61
- type RuntimeAgentStatus,
62
- type PacketPaths,
63
- type LaneTaskOutcome,
64
- type LaneTaskStatus,
65
- type SupervisorAlertCallback,
66
- type StepSegmentMapping,
67
- } from "./types.ts";
68
-
69
- const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
70
-
71
- // ── Segment Scoping Helpers (Phase A, TP-174) ────────────────────────
72
-
73
- /**
74
- * Get the set of step numbers that have segments for a given repoId.
75
- *
76
- * Used to filter the "remaining steps" view so the worker only sees steps
77
- * that contain work for its repo.
78
- *
79
- * @param stepSegmentMap - Parsed step-segment mapping from PROMPT.md
80
- * @param repoId - Repo ID to filter by
81
- * @returns Set of step numbers that have at least one segment for this repoId
82
- * @since TP-174
83
- */
84
- export function getStepsForRepoId(
85
- stepSegmentMap: StepSegmentMapping[],
86
- repoId: string,
87
- ): Set<number> {
88
- const stepNumbers = new Set<number>();
89
- for (const step of stepSegmentMap) {
90
- if (step.segments.some(seg => seg.repoId === repoId)) {
91
- stepNumbers.add(step.stepNumber);
92
- }
93
- }
94
- return stepNumbers;
95
- }
96
-
97
- /**
98
- * Extract a segment's checkbox block from STATUS.md content for a given step and repoId.
99
- *
100
- * Looks for `#### Segment: <repoId>` headers within `### Step N:` sections,
101
- * then returns the checkbox lines belonging to that segment block.
102
- *
103
- * @param statusContent - Raw STATUS.md content
104
- * @param stepNumber - Step number to look in
105
- * @param repoId - Repo ID of the segment
106
- * @returns Object with checked/unchecked counts, or null if no segment block found
107
- * @since TP-174
108
- */
109
- export function getSegmentCheckboxes(
110
- statusContent: string,
111
- stepNumber: number,
112
- repoId: string,
113
- ): { checked: number; unchecked: number; total: number; uncheckedTexts: string[] } | null {
114
- const text = statusContent.replace(/\r\n/g, "\n");
115
-
116
- // Find the step section
117
- const stepHeaderPattern = new RegExp(`^###\\s+Step\\s+${stepNumber}:`, "m");
118
- const stepMatch = text.match(stepHeaderPattern);
119
- if (!stepMatch || stepMatch.index === undefined) return null;
120
-
121
- // Find the end of this step section (next ### or end of file)
122
- const afterStep = text.slice(stepMatch.index + stepMatch[0].length);
123
- const nextStepMatch = afterStep.search(/^###\s+Step\s+\d+:/m);
124
- const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
125
-
126
- // Find the segment header within this step
127
- const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
128
- const segMatch = stepContent.match(segHeaderPattern);
129
- if (!segMatch || segMatch.index === undefined) return null;
130
-
131
- // Extract content from segment header to next #### header or ### header or ---
132
- const afterSeg = stepContent.slice(segMatch.index + segMatch[0].length);
133
- const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|---)/m);
134
- const segContent = nextSectionMatch !== -1 ? afterSeg.slice(0, nextSectionMatch) : afterSeg;
135
-
136
- // Count checkboxes
137
- let checked = 0;
138
- let unchecked = 0;
139
- const uncheckedTexts: string[] = [];
140
- const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
141
- let m;
142
- while ((m = cbRegex.exec(segContent)) !== null) {
143
- if (m[1].toLowerCase() === "x") {
144
- checked++;
145
- } else {
146
- unchecked++;
147
- uncheckedTexts.push(m[2].trim());
148
- }
149
- }
150
-
151
- return { checked, unchecked, total: checked + unchecked, uncheckedTexts };
152
- }
153
-
154
- /**
155
- * Check if all checkboxes in a segment block are checked.
156
- *
157
- * @param statusContent - Raw STATUS.md content
158
- * @param stepNumber - Step number to check
159
- * @param repoId - Repo ID of the segment
160
- * @returns true when all checkboxes in the segment block are checked
161
- * @since TP-174
162
- */
163
- export function isSegmentComplete(
164
- statusContent: string,
165
- stepNumber: number,
166
- repoId: string,
167
- ): boolean {
168
- const result = getSegmentCheckboxes(statusContent, stepNumber, repoId);
169
- if (!result) return false;
170
- if (result.total === 0) return false;
171
- return result.unchecked === 0;
172
- }
173
-
174
- // ── Types ────────────────────────────────────────────────────────────
175
-
176
- /**
177
- * Configuration for a lane-runner execution.
178
- *
179
- * @since TP-105
180
- */
181
- export interface LaneRunnerConfig {
182
- /** Batch ID */
183
- batchId: string;
184
- /** Operator prefix for agent IDs (e.g., "orch-henrylach") */
185
- agentIdPrefix: string;
186
- /** Lane number (1-indexed) */
187
- laneNumber: number;
188
- /** Absolute path to the lane worktree */
189
- worktreePath: string;
190
- /** Git branch checked out in the worktree */
191
- branch: string;
192
- /** Repo ID */
193
- repoId: string;
194
- /** State root for runtime artifacts (workspace root or repo root) */
195
- stateRoot: string;
196
- /** Worker model (empty string = inherit from session) */
197
- workerModel: string;
198
- /** Worker tools */
199
- workerTools: string;
200
- /** Worker thinking mode */
201
- workerThinking: string;
202
- /** Worker system prompt (full-task mode) */
203
- workerSystemPrompt: string;
204
- /** Worker system prompt for segment-scoped mode (appended to base) */
205
- workerSegmentPrompt: string;
206
- /**
207
- * Reviewer model (empty string = inherit session default).
208
- * Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
209
- * @since TP-160
210
- */
211
- reviewerModel: string;
212
- /**
213
- * Reviewer thinking mode (empty string = inherit).
214
- * @since TP-160
215
- */
216
- reviewerThinking: string;
217
- /**
218
- * Reviewer tool allowlist (comma-separated).
219
- * @since TP-160
220
- */
221
- reviewerTools: string;
222
- /** Supervisor autonomy level for bridge-tool guards. */
223
- supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
224
- /** Project name (for review request context) */
225
- projectName?: string;
226
- /** Package specifiers to exclude from worker extension forwarding (exact match). @since TP-180 */
227
- workerExcludeExtensions?: string[];
228
- /** Package specifiers to exclude from reviewer extension forwarding (exact match). @since TP-180 */
229
- reviewerExcludeExtensions?: string[];
230
- /** Max worker iterations before giving up */
231
- maxIterations: number;
232
- /** No-progress stall limit */
233
- noProgressLimit: number;
234
- /** Max worker time in minutes per iteration */
235
- maxWorkerMinutes: number;
236
- /** Context pressure warn threshold (0-100) */
237
- warnPercent: number;
238
- /** Context pressure kill threshold (0-100) */
239
- killPercent: number;
240
- /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
241
- onSupervisorAlert?: SupervisorAlertCallback;
242
- }
243
-
244
- /**
245
- * Result of executing one task through the lane-runner.
246
- *
247
- * @since TP-105
248
- */
249
- export interface LaneRunnerTaskResult {
250
- /** Standard lane task outcome compatible with the engine */
251
- outcome: LaneTaskOutcome;
252
- /** Total worker iterations consumed */
253
- iterations: number;
254
- /** Cumulative worker cost in USD */
255
- costUsd: number;
256
- /** Total tokens used */
257
- totalTokens: number;
258
- }
259
-
260
- // ── Core Execution ───────────────────────────────────────────────────
261
-
262
- /**
263
- * Execute a single task in a lane using the Runtime V2 headless backend.
264
- *
265
- * This is the core function that replaces the legacy TMUX-backed
266
- * `executeLane()` `spawnLaneSession()` → `task-runner TASK_AUTOSTART`
267
- * path with direct child-process hosting.
268
- *
269
- * Execution loop:
270
- * 1. Parse task and ensure STATUS.md exists
271
- * 2. For each iteration:
272
- * a. Determine remaining steps
273
- * b. Spawn worker agent via agent-host
274
- * c. Wait for worker to exit
275
- * d. Check progress (checkboxes)
276
- * e. If all steps complete success
277
- * f. If no progress → increment stall counter
278
- * g. If stall limit or iteration limit hit → fail
279
- * 3. If all steps complete, check for .DONE
280
- * 4. Return LaneTaskOutcome
281
- *
282
- * @since TP-105
283
- */
284
- export async function executeTaskV2(
285
- unit: ExecutionUnit,
286
- config: LaneRunnerConfig,
287
- pauseSignal: { paused: boolean },
288
- ): Promise<LaneRunnerTaskResult> {
289
- const startTime = Date.now();
290
- const statusPath = unit.packet.statusPath;
291
- const donePath = unit.packet.donePath;
292
- const promptPath = unit.packet.promptPath;
293
- const taskFolder = unit.packet.taskFolder;
294
- const reviewerStatePath = join(taskFolder, ".reviewer-state.json");
295
- const taskId = unit.taskId;
296
- const segmentId = unit.segmentId;
297
- const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker");
298
-
299
- // ── 1. Ensure STATUS.md exists ──────────────────────────────────
300
- if (!existsSync(statusPath)) {
301
- const content = readFileSync(promptPath, "utf-8");
302
- const parsed = parsePromptMd(content, promptPath);
303
- writeFileSync(statusPath, generateStatusMd(parsed));
304
- }
305
-
306
- updateStatusField(statusPath, "Status", "🟡 In Progress");
307
- updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
308
- logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
309
-
310
- // Pre-segment guard: remove any stale .DONE from a prior segment or prior run.
311
- // This closes the race window where the monitor sees .DONE before lane-runner
312
- // can suppress it at segment end. For non-final segments, .DONE must not exist
313
- // at any point during execution.
314
- const isNonFinalAtStart = segmentId != null
315
- && Array.isArray(unit.task.segmentIds)
316
- && unit.task.segmentIds.length > 1
317
- && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
318
- if (isNonFinalAtStart && existsSync(donePath)) {
319
- try { unlinkSync(donePath); } catch { /* best effort */ }
320
- logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
321
- }
322
-
323
- // ── 2. Iteration loop ───────────────────────────────────────────
324
- let noProgressCount = 0;
325
- let totalIterations = 0;
326
- let cumulativeCostUsd = 0;
327
- let cumulativeTokens = 0;
328
- // TP-115: carry latest worker telemetry across iterations and into post-loop terminal snapshots
329
- let lastTelemetry: Partial<AgentHostResult> = {};
330
-
331
- // TP-174: Build segment context once for emitSnapshot calls.
332
- // Available outside the loop so it can be passed to makeResult too.
333
- const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
334
- (segmentId && unit.task.stepSegmentMap && config.repoId)
335
- ? (() => {
336
- const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
337
- return repoSteps.size > 0
338
- ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
339
- : null;
340
- })()
341
- : null;
342
-
343
- for (let iter = 0; iter < config.maxIterations; iter++) {
344
- if (pauseSignal.paused) {
345
- logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
346
- return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime,
347
- "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx);
348
- }
349
-
350
- // Determine remaining steps
351
- const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
352
- const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
353
-
354
- // TP-174: Resolve segment-scoped step filtering.
355
- // Use config.repoId (structured identity) instead of parsing opaque segmentId.
356
- const stepSegmentMap = unit.task.stepSegmentMap;
357
- const currentRepoId = segmentId ? config.repoId : null;
358
- const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
359
- ? getStepsForRepoId(stepSegmentMap, currentRepoId)
360
- : null;
361
- // TP-174 legacy fallback: If no steps have segments for this repoId
362
- // (multi-segment task without explicit markers, where all checkboxes
363
- // are assigned to the fallback/packet repo), disable segment filtering.
364
- const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
365
- ? rawRepoStepNumbers
366
- : null;
367
-
368
- // TP-174: Read STATUS.md content once for segment-scoped checks
369
- const iterStatusContent = readFileSync(statusPath, "utf-8");
370
-
371
- const remainingSteps = parsed.steps.filter(step => {
372
- // TP-174: When segment-scoped, only show steps that have work for this repoId
373
- if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
374
- // TP-174: Use segment-scoped completion check in segment mode
375
- if (repoStepNumbers && currentRepoId) {
376
- return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
377
- }
378
- const ss = currentStatus.steps.find(s => s.number === step.number);
379
- return !isStepComplete(ss);
380
- });
381
-
382
- if (remainingSteps.length === 0) break; // All done
383
-
384
- totalIterations++;
385
- updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
386
- updateStatusField(statusPath, "Iteration", `${totalIterations}`);
387
-
388
- // Mark first incomplete step as in-progress
389
- const firstStep = remainingSteps[0];
390
- const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
391
- if (firstStepStatus?.status !== "in-progress") {
392
- updateStepStatus(statusPath, firstStep.number, "in-progress");
393
- logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
394
- }
395
-
396
- // Count checkboxes before worker runs
397
- // TP-174: When segment-scoped, count only this segment's checkboxes
398
- let prevTotalChecked: number;
399
- if (repoStepNumbers && currentRepoId) {
400
- const preStatusContent = readFileSync(statusPath, "utf-8");
401
- const segCbs = getSegmentCheckboxes(preStatusContent, firstStep.number, currentRepoId);
402
- prevTotalChecked = segCbs ? segCbs.checked : 0;
403
- } else {
404
- prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
405
- }
406
-
407
- // ── Build worker prompt ─────────────────────────────────────
408
- const wrapUpFile = join(taskFolder, ".task-wrap-up");
409
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
410
-
411
- // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
412
- const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers
413
- && remainingSteps.length > 0
414
- && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number)
415
- ?.segments.find(seg => seg.repoId === currentRepoId));
416
-
417
- const promptLines = [
418
- `Read your task instructions at: ${promptPath}`,
419
- `Read your execution state at: ${statusPath}`,
420
- ``,
421
- `Task: ${taskId}`,
422
- `Task folder: ${taskFolder}/`,
423
- `Iteration: ${totalIterations}`,
424
- `Wrap-up signal file: ${wrapUpFile}`,
425
- ``,
426
- `Execution repo context:`,
427
- `- Execution repo ID: ${unit.executionRepoId}`,
428
- `- Execution worktree (worker cwd): ${unit.worktreePath}`,
429
- `- Lane repo ID: ${config.repoId}`,
430
- // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
431
- // workers incorrectly self-scoping based on segment metadata.
432
- ...(isSegmentScoped
433
- ? [`- Active segment ID: ${segmentId}`]
434
- : []),
435
- ``,
436
- `Packet home context:`,
437
- `- Packet home repo ID: ${unit.packetHomeRepoId}`,
438
- `- Packet task folder: ${taskFolder}`,
439
- `- Packet PROMPT path: ${promptPath}`,
440
- `- Packet STATUS path: ${statusPath}`,
441
- `- Packet .DONE path: ${donePath}`,
442
- `- Packet .reviews path: ${unit.packet.reviewsDir}`,
443
- ``,
444
- `⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. The orchestrator handles post-merge archival.`,
445
- ``,
446
- `⚠️ 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.`,
447
- ];
448
-
449
- // Only show segment DAG in segment-scoped mode
450
- const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
451
- if (segmentDag && segmentDag.repoIds.length > 0) {
452
- const edgeSummary = segmentDag.edges.length > 0
453
- ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
454
- : "(no explicit edges)";
455
- promptLines.push(
456
- ``,
457
- `Segment DAG context (from PROMPT metadata):`,
458
- `- Repos: ${segmentDag.repoIds.join(", ")}`,
459
- `- Edges: ${edgeSummary}`,
460
- );
461
- }
462
-
463
- // Segment scope mode is determined by which system prompt was loaded.
464
- // No SegmentScopeMode line needed — the prompt IS the mode.
465
-
466
- // TP-174: Segment-scoped prompt — show only this segment's checkboxes
467
- if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
468
- const currentStepNum = remainingSteps[0].number;
469
- const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
470
- const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
471
-
472
- // Only inject segment-scoped prompt when the current step has an explicit
473
- // segment for this repoId. If mySegment is missing (legacy task without
474
- // markers, or step has no work for this repo), skip and preserve legacy behavior.
475
- if (currentStepMapping && mySegment) {
476
- const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
477
-
478
- // Count total segments for this repo across all steps
479
- const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
480
- const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
481
- const totalSegmentsInStep = currentStepMapping.segments.length;
482
-
483
- promptLines.push(
484
- ``,
485
- `Segment-scoped context (Phase A):`,
486
- `Active segment: ${segmentId} (Step ${currentStepNum}, segment ${segmentIndexInStep} of ${totalSegmentsInStep})`,
487
- `Your repo: ${currentRepoId}`,
488
- ``,
489
- );
490
-
491
- if (mySegment && mySegment.checkboxes.length > 0) {
492
- promptLines.push(`Your checkboxes for this step:`);
493
- for (const cb of mySegment.checkboxes) {
494
- promptLines.push(` ${cb}`);
495
- }
496
- }
497
-
498
- if (otherSegments.length > 0) {
499
- promptLines.push(``);
500
- promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
501
- for (const seg of otherSegments) {
502
- promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
503
- }
504
- }
505
-
506
- // List completed steps for this repo
507
- const completedForRepo = parsed.steps.filter(step => {
508
- if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
509
- const ss = currentStatus.steps.find(s => s.number === step.number);
510
- return isStepComplete(ss);
511
- });
512
- if (completedForRepo.length > 0) {
513
- promptLines.push(``);
514
- promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
515
- }
516
-
517
- promptLines.push(
518
- ``,
519
- `When all YOUR checkboxes are checked, your segment is done — exit successfully.`,
520
- `Do NOT attempt work in other repos.`,
521
- );
522
- }
523
- }
524
-
525
- if (totalIterations > 1 && remainingSteps.length > 0) {
526
- const remainingSet = new Set(remainingSteps.map(s => s.number));
527
- const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
528
- promptLines.push(
529
- ``,
530
- `IMPORTANT: You exited previously without completing all steps.`,
531
- `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
532
- `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
533
- );
534
-
535
- // If the worker exited without checking any boxes, add a corrective directive
536
- if (noProgressCount > 0) {
537
- promptLines.push(
538
- ``,
539
- `🚨 CRITICAL: You have exited ${noProgressCount} time(s) without completing work.`,
540
- `Your previous exit was premature. You said something like "Now let me fix this"`,
541
- `and then STOPPED instead of actually making the edit.`,
542
- ``,
543
- `DO NOT DO THIS AGAIN. When you know what to edit, call the edit tool IMMEDIATELY.`,
544
- `Do not produce a text message describing what you plan to do. Just do it.`,
545
- `Work continuously through ALL remaining checkboxes until the task is DONE.`,
546
- `Do not exit between checkboxes or steps.`,
547
- );
548
- }
549
- }
550
-
551
- // ── Spawn worker ────────────────────────────────────────────
552
- const eventsPath = runtimeAgentEventsPath(config.stateRoot, config.batchId, workerAgentId);
553
-
554
- const mailboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId);
555
- mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
556
-
557
- const steeringPendingPath = join(taskFolder, ".steering-pending");
558
-
559
- // TP-106: Bridge extension wiring for agent-side reply/escalate tools
560
- const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
561
- const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
562
-
563
- // TP-180: Forward user-installed extensions to worker agent
564
- const allPackages = loadPiSettingsPackages(config.stateRoot);
565
- const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []);
566
-
567
- const hostOpts: AgentHostOptions = {
568
- agentId: workerAgentId,
569
- role: "worker",
570
- batchId: config.batchId,
571
- laneNumber: config.laneNumber,
572
- taskId,
573
- repoId: config.repoId,
574
- cwd: unit.worktreePath,
575
- prompt: promptLines.join("\n"),
576
- systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
577
- ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
578
- : config.workerSystemPrompt) || undefined,
579
- model: config.workerModel || undefined,
580
- tools: config.workerTools || "read,write,edit,bash,grep,find,ls",
581
- thinking: config.workerThinking || undefined,
582
- mailboxDir,
583
- steeringPendingPath,
584
- eventsPath,
585
- exitSummaryPath: eventsPath.replace(/\.jsonl$/, "-exit.json"),
586
- timeoutMs: config.maxWorkerMinutes * 60_000,
587
- stateRoot: config.stateRoot,
588
- packet: unit.packet,
589
- extensions: [bridgeExtensionPath, ...workerPackages],
590
- env: {
591
- TASKPLANE_OUTBOX_DIR: outboxDir,
592
- TASKPLANE_AGENT_ID: workerAgentId,
593
- TASKPLANE_TASK_FOLDER: taskFolder,
594
- TASKPLANE_STATUS_PATH: statusPath,
595
- TASKPLANE_PROMPT_PATH: promptPath,
596
- TASKPLANE_REVIEWS_DIR: unit.packet.reviewsDir,
597
- TASKPLANE_REVIEWER_STATE_PATH: reviewerStatePath,
598
- TASKPLANE_PROJECT_NAME: config.projectName || "project",
599
- TASKPLANE_TASK_ID: taskId,
600
- // Hard-set segment env vars based on mode. In FULL_TASK mode,
601
- // explicitly clear them to prevent env inheritance leaking segment cues.
602
- TASKPLANE_ACTIVE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
603
- TASKPLANE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
604
- TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
605
- ORCH_BATCH_ID: config.batchId,
606
- ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
607
- ...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
608
- ...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
609
- // TP-180: Pass state root and reviewer exclusions for extension forwarding
610
- TASKPLANE_STATE_ROOT: config.stateRoot,
611
- ...(config.reviewerExcludeExtensions && config.reviewerExcludeExtensions.length > 0
612
- ? { TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS: JSON.stringify(config.reviewerExcludeExtensions) }
613
- : {}),
614
- },
615
- // TP-172: Exit interception callback — escalate to supervisor when worker
616
- // exits without making visible progress (no checkboxes, no blocker logged).
617
- onPrematureExit: config.onSupervisorAlert
618
- ? async (assistantMessage: string): Promise<string | null> => {
619
- // Check if the worker made visible progress during this turn:
620
- // 1. Checkbox progress (more items checked)
621
- // 2. Blocker logged (non-empty Blockers section)
622
- try {
623
- const statusContent = readFileSync(statusPath, "utf-8");
624
- // TP-174: Use same scope as prevTotalChecked (segment or global)
625
- let midTotalChecked: number;
626
- if (repoStepNumbers && currentRepoId) {
627
- const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
628
- midTotalChecked = segCbs ? segCbs.checked : 0;
629
- } else {
630
- const midStatus = parseStatusMd(statusContent);
631
- midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
632
- }
633
- if (midTotalChecked > prevTotalChecked) {
634
- // Worker checked off checkboxes let it exit normally
635
- return null;
636
- }
637
- // Check for blocker entries: extract Blockers section and see if non-empty
638
- const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
639
- if (blockerMatch) {
640
- const blockerContent = blockerMatch[1].trim();
641
- // If blockers section has real content (not just "*None*" or empty)
642
- if (blockerContent && blockerContent !== "*None*") {
643
- // Worker logged a blocker — let it exit normally
644
- return null;
645
- }
646
- }
647
- } catch { /* If we can't read STATUS.md, proceed with escalation */ }
648
-
649
- // No visible progress — compose escalation message
650
- const truncatedMsg = assistantMessage.slice(0, 500);
651
- const uncheckedItems: string[] = [];
652
- try {
653
- const statusContent = readFileSync(statusPath, "utf-8");
654
- // TP-174: When segment-scoped, report only this segment's unchecked items
655
- if (repoStepNumbers && currentRepoId) {
656
- const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
657
- if (segCbs) {
658
- for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
659
- uncheckedItems.push(text);
660
- }
661
- }
662
- } else {
663
- const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
664
- if (uncheckedMatches) {
665
- for (const item of uncheckedMatches.slice(0, 5)) {
666
- uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
667
- }
668
- }
669
- }
670
- } catch { /* best effort */ }
671
-
672
- const currentStepInfo = remainingSteps.length > 0
673
- ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
674
- : "Unknown";
675
-
676
- // Fire supervisor alert
677
- try {
678
- config.onSupervisorAlert!({
679
- category: "worker-exit-intercept",
680
- summary:
681
- `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
682
- ` Task: ${taskId}\n` +
683
- ` Current step: ${currentStepInfo}\n` +
684
- ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
685
- ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
686
- ` Worker said: "${truncatedMsg}"\n` +
687
- `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
688
- ` or reply "skip" / "let it fail" to close the session.`,
689
- context: {
690
- taskId,
691
- laneId: `lane-${config.laneNumber}`,
692
- laneNumber: config.laneNumber,
693
- agentId: workerAgentId,
694
- exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
695
- },
696
- });
697
- } catch { /* best effort don't block on alert failure */ }
698
-
699
- // Poll worker mailbox inbox for supervisor reply (60s timeout)
700
- const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
701
- const POLL_INTERVAL_MS = 2_000;
702
- const escalationTimestamp = Date.now();
703
- const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
704
-
705
- const supervisorReply = await new Promise<string | null>((resolve) => {
706
- const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
707
- const poll = () => {
708
- if (Date.now() >= deadline) {
709
- resolve(null); // Timeout fall back to corrective re-spawn
710
- return;
711
- }
712
- try {
713
- const messages = readInbox(inboxDir, config.batchId);
714
- // Only accept messages newer than escalation timestamp
715
- for (const { filename, message } of messages) {
716
- if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
717
- // Consume the message
718
- const ackDir = join(dirname(inboxDir), "ack");
719
- try { ackMessage(inboxDir, filename); } catch { /* best effort */ }
720
- resolve(message.content);
721
- return;
722
- }
723
- }
724
- } catch { /* inbox not ready yet */ }
725
- setTimeout(poll, POLL_INTERVAL_MS);
726
- };
727
- poll();
728
- });
729
-
730
- if (!supervisorReply) {
731
- // Timeout — let the session close, corrective re-spawn will handle it
732
- logExecution(statusPath, "Exit intercept timeout",
733
- `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`);
734
- return null;
735
- }
736
-
737
- // Interpret supervisor reply: close directives vs instructional content
738
- const normalizedReply = supervisorReply.trim().toLowerCase();
739
- const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
740
- // Only short messages (< 30 chars) can be close directives.
741
- // Longer messages are always instructions even if they start with "stop".
742
- const isShortEnoughForDirective = normalizedReply.length < 30;
743
- if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d =>
744
- normalizedReply === d ||
745
- normalizedReply.startsWith(d + ":") ||
746
- normalizedReply.startsWith(d + " ") ||
747
- normalizedReply.startsWith(d + ".") ||
748
- normalizedReply.startsWith(d + " -")
749
- )) {
750
- logExecution(statusPath, "Exit intercept close",
751
- `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`);
752
- return null;
753
- }
754
-
755
- // Instructional reply — return as new prompt for the worker
756
- logExecution(statusPath, "Exit intercept reprompt",
757
- `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`);
758
- return supervisorReply;
759
- }
760
- : undefined,
761
- };
762
-
763
- // Context pressure: write wrap-up signal before kill
764
- let workerKillReason: "context" | "timer" | null = null;
765
- let iterationTelemetry: Partial<AgentHostResult> = {};
766
-
767
- const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
768
- try {
769
- // Context pressure check
770
- if (telemetry.contextUsage) {
771
- const pct = telemetry.contextUsage.percent;
772
- if (pct >= config.warnPercent) {
773
- const msg = `Wrap up (context ${Math.round(pct)}%)`;
774
- if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
775
- }
776
- if (pct >= config.killPercent) {
777
- workerKillReason = "context";
778
- spawned.kill();
779
- }
780
- }
781
-
782
- iterationTelemetry = telemetry;
783
- lastTelemetry = telemetry;
784
- // Emit lane snapshot
785
- emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
786
- } catch { /* non-fatal: telemetry callback must never crash the engine */ }
787
- });
788
-
789
- // Reviewer telemetry is written by the worker bridge during review_step.
790
- // Poll snapshot refresh independently from worker message_end cadence so
791
- // the dashboard sees reviewer activity while tool calls are in-flight.
792
- let reviewerSnapshotFailures = 0;
793
- const reviewerRefreshFailureThreshold = 5;
794
- const reviewerRefresh = setInterval(() => {
795
- const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
796
- if (ok) {
797
- reviewerSnapshotFailures = 0;
798
- return;
799
- }
800
-
801
- reviewerSnapshotFailures += 1;
802
- if (reviewerSnapshotFailures >= reviewerRefreshFailureThreshold) {
803
- clearInterval(reviewerRefresh);
804
- logExecution(
805
- statusPath,
806
- "Snapshot refresh disabled",
807
- `Lane ${config.laneNumber}, task ${taskId}: ${reviewerSnapshotFailures} consecutive emitSnapshot failures`,
808
- );
809
- }
810
- }, 1000);
811
-
812
- let workerResult: AgentHostResult;
813
- try {
814
- workerResult = await spawned.promise;
815
- } finally {
816
- clearInterval(reviewerRefresh);
817
- }
818
-
819
- // TP-115: Update lastTelemetry with definitive final values from AgentHostResult
820
- lastTelemetry = workerResult;
821
-
822
- // Clean up wrap-up signal
823
- if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
824
-
825
- // Accumulate costs
826
- cumulativeCostUsd += workerResult.costUsd;
827
- cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
828
- workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
829
-
830
- // ── TP-106: Poll worker outbox for replies/escalations ─────
831
- try {
832
- const outboxMessages = readOutbox(config.stateRoot, config.batchId, workerAgentId);
833
- for (const msg of outboxMessages) {
834
- const sanitized = msg.content.replace(/\r?\n/g, " / ").slice(0, 200);
835
- logExecution(statusPath, `Agent ${msg.type}`, sanitized);
836
-
837
- if (msg.type === "reply" || msg.type === "escalate") {
838
- appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
839
- batchId: config.batchId,
840
- agentId: workerAgentId,
841
- role: "worker",
842
- laneNumber: config.laneNumber,
843
- taskId,
844
- repoId: config.repoId,
845
- ts: Date.now(),
846
- type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
847
- payload: {
848
- messageId: msg.id,
849
- replyTo: msg.replyTo ?? null,
850
- content: sanitized,
851
- },
852
- });
853
-
854
- appendMailboxAuditEvent(config.stateRoot, config.batchId, {
855
- type: msg.type === "reply" ? "message_replied" : "message_escalated",
856
- from: workerAgentId,
857
- to: "supervisor",
858
- messageId: msg.id,
859
- messageType: msg.type,
860
- contentPreview: sanitized,
861
- });
862
-
863
- if (config.onSupervisorAlert) {
864
- const isEscalation = msg.type === "escalate";
865
- try {
866
- config.onSupervisorAlert({
867
- category: "agent-message",
868
- summary:
869
- `${isEscalation ? "🚨" : "📨"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
870
- ` Task: ${taskId}\n` +
871
- ` Lane: lane-${config.laneNumber}\n` +
872
- ` Message: ${sanitized}`,
873
- context: {
874
- taskId,
875
- laneId: `lane-${config.laneNumber}`,
876
- laneNumber: config.laneNumber,
877
- agentId: workerAgentId,
878
- messageId: msg.id,
879
- exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
880
- },
881
- });
882
- } catch { /* best effort */ }
883
- }
884
- }
885
-
886
- // Consume outbox message to prevent duplicate processing in later iterations.
887
- ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
888
- }
889
- } catch { /* best effort */ }
890
-
891
- // ── Steering annotation ─────────────────────────────────────
892
- try {
893
- if (existsSync(steeringPendingPath)) {
894
- const raw = readFileSync(steeringPendingPath, "utf-8");
895
- for (const line of raw.split("\n").filter(l => l.trim())) {
896
- try {
897
- const entry = JSON.parse(line) as { ts: number; content: string; id: string };
898
- const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
899
- const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
900
- logExecution(statusPath, "⚠️ Steering", sanitized);
901
- } catch { /* skip malformed */ }
902
- }
903
- unlinkSync(steeringPendingPath);
904
- }
905
- } catch { /* non-fatal */ }
906
-
907
- // Log iteration result
908
- const statusMsg = workerResult.killed
909
- ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
910
- : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`);
911
- logExecution(statusPath, `Worker iter ${totalIterations}`,
912
- `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
913
-
914
- // ── Check progress ──────────────────────────────────────────
915
- const afterStatusContent = readFileSync(statusPath, "utf-8");
916
- const afterStatus = parseStatusMd(afterStatusContent);
917
- // TP-174: Segment-scoped progress delta
918
- let afterTotalChecked: number;
919
- if (repoStepNumbers && currentRepoId) {
920
- const segCbs = getSegmentCheckboxes(afterStatusContent, firstStep.number, currentRepoId);
921
- afterTotalChecked = segCbs ? segCbs.checked : 0;
922
- } else {
923
- afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
924
- }
925
- const progressDelta = afterTotalChecked - prevTotalChecked;
926
-
927
- if (progressDelta <= 0) {
928
- // Check for soft progress: uncommitted changes in the worktree
929
- // indicate the worker is actively editing code even if no checkbox
930
- // was checked yet. This avoids false stall detection on complex
931
- // steps where analysis + editing spans multiple tool calls.
932
- let hasSoftProgress = false;
933
- try {
934
- const diffOutput = execSync("git diff --stat HEAD", {
935
- cwd: unit.worktreePath,
936
- timeout: 5000,
937
- encoding: "utf-8",
938
- stdio: ["pipe", "pipe", "pipe"],
939
- }).trim();
940
- // Only count source file changes as soft progress, not just STATUS.md
941
- const changedFiles = diffOutput.split("\n").filter(l => l.includes("|"));
942
- const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering"));
943
- hasSoftProgress = sourceChanges.length > 0;
944
- } catch { /* git not available or timeout — treat as no soft progress */ }
945
-
946
- if (hasSoftProgress) {
947
- // Worker has uncommitted code changes — don't count toward stall.
948
- // Reset the counter since the worker is actively editing.
949
- logExecution(statusPath, "Soft progress",
950
- `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`);
951
- noProgressCount = 0;
952
- } else {
953
- noProgressCount++;
954
- logExecution(statusPath, "No progress",
955
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
956
- if (noProgressCount >= config.noProgressLimit) {
957
- logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
958
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
959
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
960
- }
961
- }
962
- } else {
963
- noProgressCount = 0;
964
- }
965
-
966
- // Mark completed steps
967
- // TP-174: When segment-scoped, mark step complete when the segment's
968
- // checkboxes are all checked (not the full step which may have other segments).
969
- if (repoStepNumbers && currentRepoId) {
970
- for (const stepNum of repoStepNumbers) {
971
- if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
972
- // Only mark step complete in STATUS.md if ALL segments in that step
973
- // are complete (not just ours). But for loop exit, we only care about ours.
974
- const ss = afterStatus.steps.find(s => s.number === stepNum);
975
- if (isStepComplete(ss)) {
976
- updateStepStatus(statusPath, stepNum, "complete");
977
- }
978
- }
979
- }
980
- } else {
981
- for (const step of parsed.steps) {
982
- const ss = afterStatus.steps.find(s => s.number === step.number);
983
- if (isStepComplete(ss)) {
984
- updateStepStatus(statusPath, step.number, "complete");
985
- }
986
- }
987
- }
988
-
989
- // Check if all steps are now complete
990
- // TP-174: When segment-scoped, exit when all steps for this repoId
991
- // have their segment checkboxes complete.
992
- let allComplete: boolean;
993
- if (repoStepNumbers && currentRepoId) {
994
- allComplete = [...repoStepNumbers].every(stepNum =>
995
- isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
996
- );
997
- } else {
998
- allComplete = parsed.steps.every(step => {
999
- const ss = afterStatus.steps.find(s => s.number === step.number);
1000
- return isStepComplete(ss);
1001
- });
1002
- }
1003
- if (allComplete) break;
1004
- }
1005
-
1006
- // ── 3. Post-loop completion check ───────────────────────────────
1007
- const finalStatusContent = readFileSync(statusPath, "utf-8");
1008
- const finalStatus = parseStatusMd(finalStatusContent);
1009
- const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
1010
-
1011
- // TP-174: Segment-scoped post-loop check. Re-derive repo scoping since
1012
- // the iteration loop variables are out of scope here.
1013
- const postLoopRepoId = segmentId ? config.repoId : null;
1014
- const postLoopStepSegMap = unit.task.stepSegmentMap;
1015
- const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId)
1016
- ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1017
- : null;
1018
- const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0)
1019
- ? postLoopRepoSteps
1020
- : null;
1021
-
1022
- let allStepsComplete: boolean;
1023
- if (effectivePostLoopRepoSteps && postLoopRepoId) {
1024
- allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1025
- isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1026
- );
1027
- } else {
1028
- allStepsComplete = parsed.steps.every(step => {
1029
- const ss = finalStatus.steps.find(s => s.number === step.number);
1030
- return isStepComplete(ss);
1031
- });
1032
- }
1033
-
1034
- if (!allStepsComplete) {
1035
- let incomplete: string;
1036
- if (effectivePostLoopRepoSteps && postLoopRepoId) {
1037
- incomplete = [...effectivePostLoopRepoSteps]
1038
- .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1039
- .map(n => `Step ${n}`)
1040
- .join(", ");
1041
- } else {
1042
- incomplete = parsed.steps
1043
- .filter(step => {
1044
- const ss = finalStatus.steps.find(s => s.number === step.number);
1045
- return !isStepComplete(ss);
1046
- })
1047
- .map(s => `Step ${s.number}`)
1048
- .join(", ");
1049
- }
1050
- logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
1051
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1052
- `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
1053
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1054
- }
1055
-
1056
- // TP-145: Determine if this is a non-final segment of a multi-segment task.
1057
- // If more segments remain after this one, suppress .DONE creation so that
1058
- // the engine can advance the segment frontier and execute subsequent segments.
1059
- // .DONE must only exist when ALL segments of a multi-segment task are complete.
1060
- const isNonFinalSegment = segmentId != null
1061
- && Array.isArray(unit.task.segmentIds)
1062
- && unit.task.segmentIds.length > 1
1063
- && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
1064
-
1065
- // TP-165: Check for pending expansion requests in the worker's outbox.
1066
- // If the worker filed expansion requests, more segments may be added by the
1067
- // engine at the segment boundary .DONE must not be created even if this
1068
- // appears to be the final segment based on the static segmentIds list.
1069
- const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles(
1070
- config.stateRoot, config.batchId, workerAgentId,
1071
- );
1072
-
1073
- if (isNonFinalSegment || hasPendingExpansionRequests) {
1074
- // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
1075
- // The engine will advance the frontier and dispatch the next segment.
1076
- // Also delete any .DONE the worker may have created directly (workers have
1077
- // write access and sometimes create .DONE on their own, bypassing this gate).
1078
- if (existsSync(donePath)) {
1079
- let deleted = false;
1080
- try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
1081
- if (deleted) {
1082
- logExecution(statusPath, "Segment complete",
1083
- `Segment ${segmentId} succeeded (non-final removed premature worker-created .DONE)`);
1084
- } else {
1085
- logExecution(statusPath, "Segment complete",
1086
- `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE downstream segments may be skipped`);
1087
- }
1088
- } else {
1089
- logExecution(statusPath, "Segment complete",
1090
- `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
1091
- }
1092
- const suppressionReason = isNonFinalSegment
1093
- ? "non-final"
1094
- : "pending expansion requests";
1095
- return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1096
- `Segment completed (${suppressionReason} .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1097
- }
1098
-
1099
- // Create .DONE if not already present (final segment or single-segment/whole-task execution)
1100
- if (!existsSync(donePath)) {
1101
- writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
1102
- }
1103
- updateStatusField(statusPath, "Status", "✅ Complete");
1104
- logExecution(statusPath, "Task complete", ".DONE created");
1105
-
1106
- return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1107
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1108
- }
1109
-
1110
- // ── Helpers ──────────────────────────────────────────────────────────
1111
-
1112
- /**
1113
- * TP-165: Check if the worker's outbox contains pending segment expansion requests.
1114
- *
1115
- * Pending expansion request files match `segment-expansion-*.json` (not renamed
1116
- * to `.processed`, `.rejected`, etc.). If any exist, the engine will process them
1117
- * at the segment boundary — and may add more segments to the task.
1118
- *
1119
- * @returns true if at least one pending expansion request file exists
1120
- */
1121
- export function hasPendingExpansionRequestFiles(
1122
- stateRoot: string,
1123
- batchId: string,
1124
- agentId: string,
1125
- ): boolean {
1126
- const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox");
1127
- if (!existsSync(outboxDir)) return false;
1128
- try {
1129
- const entries = readdirSync(outboxDir);
1130
- return entries.some((entry) => /^segment-expansion-.+\.json$/.test(entry));
1131
- } catch {
1132
- return false;
1133
- }
1134
- }
1135
-
1136
- export function mapLaneTaskStatusToTerminalSnapshotStatus(
1137
- status: LaneTaskStatus,
1138
- ): "idle" | "complete" | "failed" {
1139
- if (status === "succeeded") return "complete";
1140
- if (status === "skipped") return "idle";
1141
- return "failed";
1142
- }
1143
-
1144
- export function mapLaneSnapshotStatusToWorkerStatus(
1145
- status: "running" | "idle" | "complete" | "failed",
1146
- ): RuntimeAgentStatus {
1147
- if (status === "running") return "running";
1148
- if (status === "complete") return "exited";
1149
- if (status === "idle") return "wrapping_up";
1150
- return "crashed";
1151
- }
1152
-
1153
- function makeResult(
1154
- taskId: string,
1155
- segmentId: string | null,
1156
- sessionName: string,
1157
- status: LaneTaskStatus,
1158
- startTime: number,
1159
- exitReason: string,
1160
- doneFileFound: boolean,
1161
- iterations: number,
1162
- costUsd: number,
1163
- totalTokens: number,
1164
- config?: LaneRunnerConfig,
1165
- statusPath?: string,
1166
- reviewerStatePath?: string,
1167
- finalTelemetry?: Partial<AgentHostResult>,
1168
- /** TP-174: Segment context for segment-scoped snapshot progress */
1169
- segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1170
- ): LaneRunnerTaskResult {
1171
- const telemetry = status === "skipped"
1172
- ? undefined
1173
- : {
1174
- inputTokens: finalTelemetry?.inputTokens ?? 0,
1175
- outputTokens: finalTelemetry?.outputTokens ?? 0,
1176
- cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
1177
- cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
1178
- costUsd: finalTelemetry?.costUsd ?? 0,
1179
- toolCalls: finalTelemetry?.toolCalls ?? 0,
1180
- durationMs: finalTelemetry?.durationMs ?? 0,
1181
- };
1182
-
1183
- const result: LaneRunnerTaskResult = {
1184
- outcome: {
1185
- taskId,
1186
- status,
1187
- segmentId,
1188
- startTime,
1189
- endTime: Date.now(),
1190
- exitReason,
1191
- sessionName,
1192
- doneFileFound,
1193
- laneNumber: config?.laneNumber,
1194
- telemetry,
1195
- },
1196
- iterations,
1197
- costUsd,
1198
- totalTokens,
1199
- };
1200
-
1201
- // TP-115: Emit terminal snapshot with real telemetry from agent-host result
1202
- if (config && statusPath && reviewerStatePath) {
1203
- const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
1204
- emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
1205
- }
1206
-
1207
- return result;
1208
- }
1209
-
1210
- /** Max age for reviewer state file before it's considered stale (2 minutes). */
1211
- const REVIEWER_STATE_STALE_MS = 120_000;
1212
-
1213
- export function readReviewerTelemetrySnapshot(
1214
- config: LaneRunnerConfig,
1215
- reviewerStatePathOrStatusPath: string,
1216
- ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
1217
- const reviewerPath = basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1218
- ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1219
- : reviewerStatePathOrStatusPath;
1220
- if (!existsSync(reviewerPath)) return null;
1221
-
1222
- try {
1223
- const raw = readFileSync(reviewerPath, "utf-8");
1224
- const parsed = JSON.parse(raw) as Partial<{
1225
- status: string;
1226
- elapsedMs: number;
1227
- toolCalls: number;
1228
- contextPct: number;
1229
- costUsd: number;
1230
- lastTool: string;
1231
- inputTokens: number;
1232
- outputTokens: number;
1233
- cacheReadTokens: number;
1234
- cacheWriteTokens: number;
1235
- updatedAt: number;
1236
- reviewType: string;
1237
- reviewStep: number;
1238
- }>;
1239
-
1240
- if (parsed.status !== "running") return null;
1241
-
1242
- // Stale guard: if updatedAt is present and older than threshold, ignore
1243
- if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null;
1244
-
1245
- return {
1246
- agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
1247
- status: "running",
1248
- elapsedMs: Number.isFinite(parsed.elapsedMs) ? Number(parsed.elapsedMs) : 0,
1249
- toolCalls: Number.isFinite(parsed.toolCalls) ? Number(parsed.toolCalls) : 0,
1250
- contextPct: Number.isFinite(parsed.contextPct) ? Number(parsed.contextPct) : 0,
1251
- costUsd: Number.isFinite(parsed.costUsd) ? Number(parsed.costUsd) : 0,
1252
- lastTool: typeof parsed.lastTool === "string" ? parsed.lastTool : "",
1253
- inputTokens: Number.isFinite(parsed.inputTokens) ? Number(parsed.inputTokens) : 0,
1254
- outputTokens: Number.isFinite(parsed.outputTokens) ? Number(parsed.outputTokens) : 0,
1255
- cacheReadTokens: Number.isFinite(parsed.cacheReadTokens) ? Number(parsed.cacheReadTokens) : 0,
1256
- cacheWriteTokens: Number.isFinite(parsed.cacheWriteTokens) ? Number(parsed.cacheWriteTokens) : 0,
1257
- reviewType: typeof parsed.reviewType === "string" ? parsed.reviewType : undefined,
1258
- reviewStep: Number.isFinite(parsed.reviewStep) ? Number(parsed.reviewStep) : undefined,
1259
- };
1260
- } catch {
1261
- return null;
1262
- }
1263
- }
1264
-
1265
- /**
1266
- * Emit a lane snapshot to disk. NON-THROWING by contract all errors are
1267
- * caught and logged. This function is called from setInterval callbacks
1268
- * and onTelemetry callbacks where an unhandled throw would trigger
1269
- * uncaughtException and crash the engine-worker process.
1270
- *
1271
- * @returns true when snapshot write succeeds, false when it fails.
1272
- */
1273
- function emitSnapshot(
1274
- config: LaneRunnerConfig,
1275
- taskId: string,
1276
- segmentId: string | null,
1277
- status: "running" | "idle" | "complete" | "failed",
1278
- telemetry: Partial<AgentHostResult>,
1279
- statusPath: string,
1280
- reviewerStatePath: string,
1281
- /** TP-174: Optional segment context for segment-scoped progress reporting */
1282
- segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1283
- ): boolean {
1284
- try {
1285
- // Parse progress from STATUS.md
1286
- let progress: RuntimeTaskProgress | null = null;
1287
- try {
1288
- const content = readFileSync(statusPath, "utf-8");
1289
- const parsed = parseStatusMd(content);
1290
- const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
1291
-
1292
- // TP-174: Segment-scoped progress when segment markers are present.
1293
- // Only count checkboxes from steps that belong to this segment's repoId.
1294
- let checked: number;
1295
- let total: number;
1296
- if (segmentContext) {
1297
- const { stepSegmentMap, repoId } = segmentContext;
1298
- const repoSteps = getStepsForRepoId(stepSegmentMap, repoId);
1299
- let segChecked = 0;
1300
- let segTotal = 0;
1301
- for (const stepNum of repoSteps) {
1302
- const segCbs = getSegmentCheckboxes(content, stepNum, repoId);
1303
- if (segCbs) {
1304
- segChecked += segCbs.checked;
1305
- segTotal += segCbs.total;
1306
- }
1307
- }
1308
- checked = segChecked;
1309
- total = segTotal;
1310
- } else {
1311
- checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
1312
- total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1313
- }
1314
-
1315
- progress = {
1316
- currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
1317
- checked,
1318
- total,
1319
- iteration: parsed.iteration,
1320
- reviews: parsed.reviewCounter,
1321
- };
1322
- } catch { /* best effort */ }
1323
-
1324
- const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath);
1325
-
1326
- const snapshot: RuntimeLaneSnapshot = {
1327
- batchId: config.batchId,
1328
- laneNumber: config.laneNumber,
1329
- laneId: `lane-${config.laneNumber}`,
1330
- repoId: config.repoId,
1331
- taskId,
1332
- segmentId,
1333
- status,
1334
- worker: {
1335
- agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
1336
- status: mapLaneSnapshotStatusToWorkerStatus(status),
1337
- elapsedMs: telemetry.durationMs ?? 0,
1338
- toolCalls: telemetry.toolCalls ?? 0,
1339
- contextPct: telemetry.contextUsage?.percent ?? 0,
1340
- costUsd: telemetry.costUsd ?? 0,
1341
- lastTool: telemetry.lastTool ?? "",
1342
- inputTokens: telemetry.inputTokens ?? 0,
1343
- outputTokens: telemetry.outputTokens ?? 0,
1344
- cacheReadTokens: telemetry.cacheReadTokens ?? 0,
1345
- cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
1346
- },
1347
- reviewer: reviewerSnapshot,
1348
- progress,
1349
- updatedAt: Date.now(),
1350
- };
1351
-
1352
- writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
1353
- return true;
1354
- } catch {
1355
- // Non-fatal: snapshot is telemetry, not execution-critical.
1356
- // Swallow to prevent uncaughtException crash in setInterval/callback contexts.
1357
- return false;
1358
- }
1359
- }
1360
-
1
+ /**
2
+ * Lane Runner — Headless per-lane execution for Runtime V2
3
+ *
4
+ * Replaces the legacy TMUX-backed lane execution path with a
5
+ * deterministic Node process that owns:
6
+ * - worker iteration loops
7
+ * - STATUS.md progression
8
+ * - .DONE creation detection
9
+ * - reviewer orchestration (future)
10
+ * - lane snapshot emission
11
+ *
12
+ * No Pi extension dependency. No TMUX. No TASK_AUTOSTART.
13
+ *
14
+ * @module taskplane/lane-runner
15
+ * @since TP-105
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "fs";
19
+ import { join, dirname, resolve, basename } from "path";
20
+ import { execSync } from "child_process";
21
+ import { fileURLToPath } from "url";
22
+
23
+ import {
24
+ parsePromptMd,
25
+ parseStatusMd,
26
+ generateStatusMd,
27
+ updateStatusField,
28
+ updateStepStatus,
29
+ logExecution,
30
+ isStepComplete,
31
+ type StepInfo,
32
+ type CoreParsedTask,
33
+ } from "./task-executor-core.ts";
34
+
35
+ import {
36
+ spawnAgent,
37
+ buildWorkerToolsAllowlist,
38
+ ENGINE_BRIDGE_TOOLS,
39
+ type AgentHostOptions,
40
+ type AgentHostResult,
41
+ } from "./agent-host.ts";
42
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
43
+
44
+ import {
45
+ appendAgentEvent,
46
+ writeLaneSnapshot,
47
+ } from "./process-registry.ts";
48
+
49
+ import {
50
+ readOutbox,
51
+ readInbox,
52
+ ackMessage,
53
+ sessionInboxDir,
54
+ ackOutboxMessage,
55
+ appendMailboxAuditEvent,
56
+ } from "./mailbox.ts";
57
+
58
+ import {
59
+ resolvePacketPaths,
60
+ buildRuntimeAgentId,
61
+ runtimeAgentEventsPath,
62
+ type ExecutionUnit,
63
+ type RuntimeAgentId,
64
+ type RuntimeLaneSnapshot,
65
+ type RuntimeAgentTelemetrySnapshot,
66
+ type RuntimeTaskProgress,
67
+ type RuntimeAgentStatus,
68
+ type PacketPaths,
69
+ type LaneTaskOutcome,
70
+ type LaneTaskStatus,
71
+ type SupervisorAlertCallback,
72
+ type StepSegmentMapping,
73
+ } from "./types.ts";
74
+
75
+ const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
76
+
77
+ // ── Segment Scoping Helpers (Phase A, TP-174) ────────────────────────
78
+
79
+ /**
80
+ * Get the set of step numbers that have segments for a given repoId.
81
+ *
82
+ * Used to filter the "remaining steps" view so the worker only sees steps
83
+ * that contain work for its repo.
84
+ *
85
+ * @param stepSegmentMap - Parsed step-segment mapping from PROMPT.md
86
+ * @param repoId - Repo ID to filter by
87
+ * @returns Set of step numbers that have at least one segment for this repoId
88
+ * @since TP-174
89
+ */
90
+ export function getStepsForRepoId(
91
+ stepSegmentMap: StepSegmentMapping[],
92
+ repoId: string,
93
+ ): Set<number> {
94
+ const stepNumbers = new Set<number>();
95
+ for (const step of stepSegmentMap) {
96
+ if (step.segments.some(seg => seg.repoId === repoId)) {
97
+ stepNumbers.add(step.stepNumber);
98
+ }
99
+ }
100
+ return stepNumbers;
101
+ }
102
+
103
+ /**
104
+ * Extract a segment's checkbox block from STATUS.md content for a given step and repoId.
105
+ *
106
+ * Looks for `#### Segment: <repoId>` headers within `### Step N:` sections,
107
+ * then returns the checkbox lines belonging to that segment block.
108
+ *
109
+ * @param statusContent - Raw STATUS.md content
110
+ * @param stepNumber - Step number to look in
111
+ * @param repoId - Repo ID of the segment
112
+ * @returns Object with checked/unchecked counts, or null if no segment block found
113
+ * @since TP-174
114
+ */
115
+ export function getSegmentCheckboxes(
116
+ statusContent: string,
117
+ stepNumber: number,
118
+ repoId: string,
119
+ ): { checked: number; unchecked: number; total: number; uncheckedTexts: string[] } | null {
120
+ const text = statusContent.replace(/\r\n/g, "\n");
121
+
122
+ // Find the step section
123
+ const stepHeaderPattern = new RegExp(`^###\\s+Step\\s+${stepNumber}:`, "m");
124
+ const stepMatch = text.match(stepHeaderPattern);
125
+ if (!stepMatch || stepMatch.index === undefined) return null;
126
+
127
+ // Find the end of this step section (next ### or end of file)
128
+ const afterStep = text.slice(stepMatch.index + stepMatch[0].length);
129
+ const nextStepMatch = afterStep.search(/^###\s+Step\s+\d+:/m);
130
+ const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
131
+
132
+ // Find the segment header within this step
133
+ const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
134
+ const segMatch = stepContent.match(segHeaderPattern);
135
+ if (!segMatch || segMatch.index === undefined) return null;
136
+
137
+ // Extract content from segment header to next #### header or ### header or ---
138
+ const afterSeg = stepContent.slice(segMatch.index + segMatch[0].length);
139
+ const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|---)/m);
140
+ const segContent = nextSectionMatch !== -1 ? afterSeg.slice(0, nextSectionMatch) : afterSeg;
141
+
142
+ // Count checkboxes
143
+ let checked = 0;
144
+ let unchecked = 0;
145
+ const uncheckedTexts: string[] = [];
146
+ const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
147
+ let m;
148
+ while ((m = cbRegex.exec(segContent)) !== null) {
149
+ if (m[1].toLowerCase() === "x") {
150
+ checked++;
151
+ } else {
152
+ unchecked++;
153
+ uncheckedTexts.push(m[2].trim());
154
+ }
155
+ }
156
+
157
+ return { checked, unchecked, total: checked + unchecked, uncheckedTexts };
158
+ }
159
+
160
+ /**
161
+ * Check if all checkboxes in a segment block are checked.
162
+ *
163
+ * @param statusContent - Raw STATUS.md content
164
+ * @param stepNumber - Step number to check
165
+ * @param repoId - Repo ID of the segment
166
+ * @returns true when all checkboxes in the segment block are checked
167
+ * @since TP-174
168
+ */
169
+ export function isSegmentComplete(
170
+ statusContent: string,
171
+ stepNumber: number,
172
+ repoId: string,
173
+ ): boolean {
174
+ const result = getSegmentCheckboxes(statusContent, stepNumber, repoId);
175
+ if (!result) return false;
176
+ if (result.total === 0) return false;
177
+ return result.unchecked === 0;
178
+ }
179
+
180
+ // ── Types ────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Configuration for a lane-runner execution.
184
+ *
185
+ * @since TP-105
186
+ */
187
+ export interface LaneRunnerConfig {
188
+ /** Batch ID */
189
+ batchId: string;
190
+ /** Operator prefix for agent IDs (e.g., "orch-henrylach") */
191
+ agentIdPrefix: string;
192
+ /** Lane number (1-indexed) */
193
+ laneNumber: number;
194
+ /** Absolute path to the lane worktree */
195
+ worktreePath: string;
196
+ /** Git branch checked out in the worktree */
197
+ branch: string;
198
+ /** Repo ID */
199
+ repoId: string;
200
+ /** State root for runtime artifacts (workspace root or repo root) */
201
+ stateRoot: string;
202
+ /** Worker model (empty string = inherit from session) */
203
+ workerModel: string;
204
+ /** Worker tools */
205
+ workerTools: string;
206
+ /** Worker thinking mode */
207
+ workerThinking: string;
208
+ /** Worker system prompt (full-task mode) */
209
+ workerSystemPrompt: string;
210
+ /** Worker system prompt for segment-scoped mode (appended to base) */
211
+ workerSegmentPrompt: string;
212
+ /**
213
+ * Reviewer model (empty string = inherit session default).
214
+ * Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
215
+ * @since TP-160
216
+ */
217
+ reviewerModel: string;
218
+ /**
219
+ * Reviewer thinking mode (empty string = inherit).
220
+ * @since TP-160
221
+ */
222
+ reviewerThinking: string;
223
+ /**
224
+ * Reviewer tool allowlist (comma-separated).
225
+ * @since TP-160
226
+ */
227
+ reviewerTools: string;
228
+ /** Supervisor autonomy level for bridge-tool guards. */
229
+ supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
230
+ /** Project name (for review request context) */
231
+ projectName?: string;
232
+ /** Package specifiers to exclude from worker extension forwarding (exact match). @since TP-180 */
233
+ workerExcludeExtensions?: string[];
234
+ /** Package specifiers to exclude from reviewer extension forwarding (exact match). @since TP-180 */
235
+ reviewerExcludeExtensions?: string[];
236
+ /** Max worker iterations before giving up */
237
+ maxIterations: number;
238
+ /** No-progress stall limit */
239
+ noProgressLimit: number;
240
+ /** Max worker time in minutes per iteration */
241
+ maxWorkerMinutes: number;
242
+ /** Context pressure warn threshold (0-100) */
243
+ warnPercent: number;
244
+ /** Context pressure kill threshold (0-100) */
245
+ killPercent: number;
246
+ /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
247
+ onSupervisorAlert?: SupervisorAlertCallback;
248
+ }
249
+
250
+ /**
251
+ * Result of executing one task through the lane-runner.
252
+ *
253
+ * @since TP-105
254
+ */
255
+ export interface LaneRunnerTaskResult {
256
+ /** Standard lane task outcome compatible with the engine */
257
+ outcome: LaneTaskOutcome;
258
+ /** Total worker iterations consumed */
259
+ iterations: number;
260
+ /** Cumulative worker cost in USD */
261
+ costUsd: number;
262
+ /** Total tokens used */
263
+ totalTokens: number;
264
+ }
265
+
266
+ // ── Core Execution ───────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Execute a single task in a lane using the Runtime V2 headless backend.
270
+ *
271
+ * This is the core function that replaces the legacy TMUX-backed
272
+ * `executeLane()` `spawnLaneSession()` → `task-runner TASK_AUTOSTART`
273
+ * path with direct child-process hosting.
274
+ *
275
+ * Execution loop:
276
+ * 1. Parse task and ensure STATUS.md exists
277
+ * 2. For each iteration:
278
+ * a. Determine remaining steps
279
+ * b. Spawn worker agent via agent-host
280
+ * c. Wait for worker to exit
281
+ * d. Check progress (checkboxes)
282
+ * e. If all steps complete → success
283
+ * f. If no progress → increment stall counter
284
+ * g. If stall limit or iteration limit hit → fail
285
+ * 3. If all steps complete, check for .DONE
286
+ * 4. Return LaneTaskOutcome
287
+ *
288
+ * @since TP-105
289
+ */
290
+ export async function executeTaskV2(
291
+ unit: ExecutionUnit,
292
+ config: LaneRunnerConfig,
293
+ pauseSignal: { paused: boolean },
294
+ ): Promise<LaneRunnerTaskResult> {
295
+ const startTime = Date.now();
296
+ const statusPath = unit.packet.statusPath;
297
+ const donePath = unit.packet.donePath;
298
+ const promptPath = unit.packet.promptPath;
299
+ const taskFolder = unit.packet.taskFolder;
300
+ const reviewerStatePath = join(taskFolder, ".reviewer-state.json");
301
+ const taskId = unit.taskId;
302
+ const segmentId = unit.segmentId;
303
+ const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker");
304
+
305
+ // ── 1. Ensure STATUS.md exists ──────────────────────────────────
306
+ if (!existsSync(statusPath)) {
307
+ const content = readFileSync(promptPath, "utf-8");
308
+ const parsed = parsePromptMd(content, promptPath);
309
+ writeFileSync(statusPath, generateStatusMd(parsed));
310
+ }
311
+
312
+ updateStatusField(statusPath, "Status", "🟡 In Progress");
313
+ updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
314
+ logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
315
+
316
+ // Pre-segment guard: remove any stale .DONE from a prior segment or prior run.
317
+ // This closes the race window where the monitor sees .DONE before lane-runner
318
+ // can suppress it at segment end. For non-final segments, .DONE must not exist
319
+ // at any point during execution.
320
+ const isNonFinalAtStart = segmentId != null
321
+ && Array.isArray(unit.task.segmentIds)
322
+ && unit.task.segmentIds.length > 1
323
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
324
+ if (isNonFinalAtStart && existsSync(donePath)) {
325
+ try { unlinkSync(donePath); } catch { /* best effort */ }
326
+ logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
327
+ }
328
+
329
+ // ── 2. Iteration loop ───────────────────────────────────────────
330
+ let noProgressCount = 0;
331
+ let totalIterations = 0;
332
+ let cumulativeCostUsd = 0;
333
+ let cumulativeTokens = 0;
334
+ // TP-115: carry latest worker telemetry across iterations and into post-loop terminal snapshots
335
+ let lastTelemetry: Partial<AgentHostResult> = {};
336
+
337
+ // TP-174: Build segment context once for emitSnapshot calls.
338
+ // Available outside the loop so it can be passed to makeResult too.
339
+ const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
340
+ (segmentId && unit.task.stepSegmentMap && config.repoId)
341
+ ? (() => {
342
+ const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
343
+ return repoSteps.size > 0
344
+ ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
345
+ : null;
346
+ })()
347
+ : null;
348
+
349
+ for (let iter = 0; iter < config.maxIterations; iter++) {
350
+ if (pauseSignal.paused) {
351
+ logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
352
+ return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime,
353
+ "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx);
354
+ }
355
+
356
+ // Determine remaining steps
357
+ const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
358
+ const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
359
+
360
+ // TP-174: Resolve segment-scoped step filtering.
361
+ // Use config.repoId (structured identity) instead of parsing opaque segmentId.
362
+ const stepSegmentMap = unit.task.stepSegmentMap;
363
+ const currentRepoId = segmentId ? config.repoId : null;
364
+ const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
365
+ ? getStepsForRepoId(stepSegmentMap, currentRepoId)
366
+ : null;
367
+ // TP-174 legacy fallback: If no steps have segments for this repoId
368
+ // (multi-segment task without explicit markers, where all checkboxes
369
+ // are assigned to the fallback/packet repo), disable segment filtering.
370
+ const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
371
+ ? rawRepoStepNumbers
372
+ : null;
373
+
374
+ // TP-174: Read STATUS.md content once for segment-scoped checks
375
+ const iterStatusContent = readFileSync(statusPath, "utf-8");
376
+
377
+ const remainingSteps = parsed.steps.filter(step => {
378
+ // TP-174: When segment-scoped, only show steps that have work for this repoId
379
+ if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
380
+ // TP-174: Use segment-scoped completion check in segment mode
381
+ if (repoStepNumbers && currentRepoId) {
382
+ return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
383
+ }
384
+ const ss = currentStatus.steps.find(s => s.number === step.number);
385
+ return !isStepComplete(ss);
386
+ });
387
+
388
+ if (remainingSteps.length === 0) break; // All done
389
+
390
+ totalIterations++;
391
+ updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
392
+ updateStatusField(statusPath, "Iteration", `${totalIterations}`);
393
+
394
+ // Mark first incomplete step as in-progress
395
+ const firstStep = remainingSteps[0];
396
+ const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
397
+ if (firstStepStatus?.status !== "in-progress") {
398
+ updateStepStatus(statusPath, firstStep.number, "in-progress");
399
+ logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
400
+ }
401
+
402
+ // Count checkboxes before worker runs
403
+ // TP-174: When segment-scoped, count only this segment's checkboxes
404
+ let prevTotalChecked: number;
405
+ if (repoStepNumbers && currentRepoId) {
406
+ const preStatusContent = readFileSync(statusPath, "utf-8");
407
+ const segCbs = getSegmentCheckboxes(preStatusContent, firstStep.number, currentRepoId);
408
+ prevTotalChecked = segCbs ? segCbs.checked : 0;
409
+ } else {
410
+ prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
411
+ }
412
+
413
+ // ── Build worker prompt ─────────────────────────────────────
414
+ const wrapUpFile = join(taskFolder, ".task-wrap-up");
415
+ if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
416
+
417
+ // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
418
+ const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers
419
+ && remainingSteps.length > 0
420
+ && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number)
421
+ ?.segments.find(seg => seg.repoId === currentRepoId));
422
+
423
+ const promptLines = [
424
+ `Read your task instructions at: ${promptPath}`,
425
+ `Read your execution state at: ${statusPath}`,
426
+ ``,
427
+ `Task: ${taskId}`,
428
+ `Task folder: ${taskFolder}/`,
429
+ `Iteration: ${totalIterations}`,
430
+ `Wrap-up signal file: ${wrapUpFile}`,
431
+ ``,
432
+ `Execution repo context:`,
433
+ `- Execution repo ID: ${unit.executionRepoId}`,
434
+ `- Execution worktree (worker cwd): ${unit.worktreePath}`,
435
+ `- Lane repo ID: ${config.repoId}`,
436
+ // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
437
+ // workers incorrectly self-scoping based on segment metadata.
438
+ ...(isSegmentScoped
439
+ ? [`- Active segment ID: ${segmentId}`]
440
+ : []),
441
+ ``,
442
+ `Packet home context:`,
443
+ `- Packet home repo ID: ${unit.packetHomeRepoId}`,
444
+ `- Packet task folder: ${taskFolder}`,
445
+ `- Packet PROMPT path: ${promptPath}`,
446
+ `- Packet STATUS path: ${statusPath}`,
447
+ `- Packet .DONE path: ${donePath}`,
448
+ `- Packet .reviews path: ${unit.packet.reviewsDir}`,
449
+ ``,
450
+ `⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. The orchestrator handles post-merge archival.`,
451
+ ``,
452
+ `⚠️ 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.`,
453
+ ];
454
+
455
+ // Only show segment DAG in segment-scoped mode
456
+ const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
457
+ if (segmentDag && segmentDag.repoIds.length > 0) {
458
+ const edgeSummary = segmentDag.edges.length > 0
459
+ ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
460
+ : "(no explicit edges)";
461
+ promptLines.push(
462
+ ``,
463
+ `Segment DAG context (from PROMPT metadata):`,
464
+ `- Repos: ${segmentDag.repoIds.join(", ")}`,
465
+ `- Edges: ${edgeSummary}`,
466
+ );
467
+ }
468
+
469
+ // Segment scope mode is determined by which system prompt was loaded.
470
+ // No SegmentScopeMode line needed the prompt IS the mode.
471
+
472
+ // TP-174: Segment-scoped prompt show only this segment's checkboxes
473
+ if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
474
+ const currentStepNum = remainingSteps[0].number;
475
+ const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
476
+ const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
477
+
478
+ // Only inject segment-scoped prompt when the current step has an explicit
479
+ // segment for this repoId. If mySegment is missing (legacy task without
480
+ // markers, or step has no work for this repo), skip and preserve legacy behavior.
481
+ if (currentStepMapping && mySegment) {
482
+ const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
483
+
484
+ // Count total segments for this repo across all steps
485
+ const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
486
+ const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
487
+ const totalSegmentsInStep = currentStepMapping.segments.length;
488
+
489
+ promptLines.push(
490
+ ``,
491
+ `Segment-scoped context (Phase A):`,
492
+ `Active segment: ${segmentId} (Step ${currentStepNum}, segment ${segmentIndexInStep} of ${totalSegmentsInStep})`,
493
+ `Your repo: ${currentRepoId}`,
494
+ ``,
495
+ );
496
+
497
+ if (mySegment && mySegment.checkboxes.length > 0) {
498
+ promptLines.push(`Your checkboxes for this step:`);
499
+ for (const cb of mySegment.checkboxes) {
500
+ promptLines.push(` ${cb}`);
501
+ }
502
+ }
503
+
504
+ if (otherSegments.length > 0) {
505
+ promptLines.push(``);
506
+ promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
507
+ for (const seg of otherSegments) {
508
+ promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
509
+ }
510
+ }
511
+
512
+ // List completed steps for this repo
513
+ const completedForRepo = parsed.steps.filter(step => {
514
+ if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
515
+ const ss = currentStatus.steps.find(s => s.number === step.number);
516
+ return isStepComplete(ss);
517
+ });
518
+ if (completedForRepo.length > 0) {
519
+ promptLines.push(``);
520
+ promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
521
+ }
522
+
523
+ promptLines.push(
524
+ ``,
525
+ `When all YOUR checkboxes are checked, your segment is done — exit successfully.`,
526
+ `Do NOT attempt work in other repos.`,
527
+ );
528
+ }
529
+ }
530
+
531
+ if (totalIterations > 1 && remainingSteps.length > 0) {
532
+ const remainingSet = new Set(remainingSteps.map(s => s.number));
533
+ const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
534
+ promptLines.push(
535
+ ``,
536
+ `IMPORTANT: You exited previously without completing all steps.`,
537
+ `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
538
+ `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
539
+ );
540
+
541
+ // If the worker exited without checking any boxes, add a corrective directive
542
+ if (noProgressCount > 0) {
543
+ promptLines.push(
544
+ ``,
545
+ `🚨 CRITICAL: You have exited ${noProgressCount} time(s) without completing work.`,
546
+ `Your previous exit was premature. You said something like "Now let me fix this"`,
547
+ `and then STOPPED instead of actually making the edit.`,
548
+ ``,
549
+ `DO NOT DO THIS AGAIN. When you know what to edit, call the edit tool IMMEDIATELY.`,
550
+ `Do not produce a text message describing what you plan to do. Just do it.`,
551
+ `Work continuously through ALL remaining checkboxes until the task is DONE.`,
552
+ `Do not exit between checkboxes or steps.`,
553
+ );
554
+ }
555
+ }
556
+
557
+ // ── Spawn worker ────────────────────────────────────────────
558
+ const eventsPath = runtimeAgentEventsPath(config.stateRoot, config.batchId, workerAgentId);
559
+
560
+ const mailboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId);
561
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
562
+
563
+ const steeringPendingPath = join(taskFolder, ".steering-pending");
564
+
565
+ // TP-106: Bridge extension wiring for agent-side reply/escalate tools
566
+ const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
567
+ const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
568
+
569
+ // TP-180: Forward user-installed extensions to worker agent
570
+ const allPackages = loadPiSettingsPackages(config.stateRoot);
571
+ const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []);
572
+
573
+ const hostOpts: AgentHostOptions = {
574
+ agentId: workerAgentId,
575
+ role: "worker",
576
+ batchId: config.batchId,
577
+ laneNumber: config.laneNumber,
578
+ taskId,
579
+ repoId: config.repoId,
580
+ cwd: unit.worktreePath,
581
+ prompt: promptLines.join("\n"),
582
+ systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
583
+ ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
584
+ : config.workerSystemPrompt) || undefined,
585
+ model: config.workerModel || undefined,
586
+ // TP-184: buildWorkerToolsAllowlist always appends ENGINE_BRIDGE_TOOLS
587
+ // (review_step, notify_supervisor, request_segment_expansion) so that
588
+ // engine-internal coordination tools are present regardless of what the
589
+ // user configured for taskRunner.worker.tools. See issue #530.
590
+ tools: buildWorkerToolsAllowlist(config.workerTools),
591
+ thinking: config.workerThinking || undefined,
592
+ mailboxDir,
593
+ steeringPendingPath,
594
+ eventsPath,
595
+ exitSummaryPath: eventsPath.replace(/\.jsonl$/, "-exit.json"),
596
+ timeoutMs: config.maxWorkerMinutes * 60_000,
597
+ stateRoot: config.stateRoot,
598
+ packet: unit.packet,
599
+ extensions: [bridgeExtensionPath, ...workerPackages],
600
+ env: {
601
+ TASKPLANE_OUTBOX_DIR: outboxDir,
602
+ TASKPLANE_AGENT_ID: workerAgentId,
603
+ TASKPLANE_TASK_FOLDER: taskFolder,
604
+ TASKPLANE_STATUS_PATH: statusPath,
605
+ TASKPLANE_PROMPT_PATH: promptPath,
606
+ TASKPLANE_REVIEWS_DIR: unit.packet.reviewsDir,
607
+ TASKPLANE_REVIEWER_STATE_PATH: reviewerStatePath,
608
+ TASKPLANE_PROJECT_NAME: config.projectName || "project",
609
+ TASKPLANE_TASK_ID: taskId,
610
+ // Hard-set segment env vars based on mode. In FULL_TASK mode,
611
+ // explicitly clear them to prevent env inheritance leaking segment cues.
612
+ TASKPLANE_ACTIVE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
613
+ TASKPLANE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
614
+ TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
615
+ ORCH_BATCH_ID: config.batchId,
616
+ ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
617
+ ...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
618
+ ...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
619
+ // TP-180: Pass state root and reviewer exclusions for extension forwarding
620
+ TASKPLANE_STATE_ROOT: config.stateRoot,
621
+ ...(config.reviewerExcludeExtensions && config.reviewerExcludeExtensions.length > 0
622
+ ? { TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS: JSON.stringify(config.reviewerExcludeExtensions) }
623
+ : {}),
624
+ },
625
+ // TP-172: Exit interception callback — escalate to supervisor when worker
626
+ // exits without making visible progress (no checkboxes, no blocker logged).
627
+ onPrematureExit: config.onSupervisorAlert
628
+ ? async (assistantMessage: string): Promise<string | null> => {
629
+ // Check if the worker made visible progress during this turn:
630
+ // 1. Checkbox progress (more items checked)
631
+ // 2. Blocker logged (non-empty Blockers section)
632
+ try {
633
+ const statusContent = readFileSync(statusPath, "utf-8");
634
+ // TP-174: Use same scope as prevTotalChecked (segment or global)
635
+ let midTotalChecked: number;
636
+ if (repoStepNumbers && currentRepoId) {
637
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
638
+ midTotalChecked = segCbs ? segCbs.checked : 0;
639
+ } else {
640
+ const midStatus = parseStatusMd(statusContent);
641
+ midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
642
+ }
643
+ if (midTotalChecked > prevTotalChecked) {
644
+ // Worker checked off checkboxes — let it exit normally
645
+ return null;
646
+ }
647
+ // Check for blocker entries: extract Blockers section and see if non-empty
648
+ const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
649
+ if (blockerMatch) {
650
+ const blockerContent = blockerMatch[1].trim();
651
+ // If blockers section has real content (not just "*None*" or empty)
652
+ if (blockerContent && blockerContent !== "*None*") {
653
+ // Worker logged a blocker — let it exit normally
654
+ return null;
655
+ }
656
+ }
657
+ } catch { /* If we can't read STATUS.md, proceed with escalation */ }
658
+
659
+ // No visible progress — compose escalation message
660
+ const truncatedMsg = assistantMessage.slice(0, 500);
661
+ const uncheckedItems: string[] = [];
662
+ try {
663
+ const statusContent = readFileSync(statusPath, "utf-8");
664
+ // TP-174: When segment-scoped, report only this segment's unchecked items
665
+ if (repoStepNumbers && currentRepoId) {
666
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
667
+ if (segCbs) {
668
+ for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
669
+ uncheckedItems.push(text);
670
+ }
671
+ }
672
+ } else {
673
+ const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
674
+ if (uncheckedMatches) {
675
+ for (const item of uncheckedMatches.slice(0, 5)) {
676
+ uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
677
+ }
678
+ }
679
+ }
680
+ } catch { /* best effort */ }
681
+
682
+ const currentStepInfo = remainingSteps.length > 0
683
+ ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
684
+ : "Unknown";
685
+
686
+ // Fire supervisor alert
687
+ try {
688
+ config.onSupervisorAlert!({
689
+ category: "worker-exit-intercept",
690
+ summary:
691
+ `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
692
+ ` Task: ${taskId}\n` +
693
+ ` Current step: ${currentStepInfo}\n` +
694
+ ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
695
+ ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
696
+ ` Worker said: "${truncatedMsg}"\n` +
697
+ `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
698
+ ` or reply "skip" / "let it fail" to close the session.`,
699
+ context: {
700
+ taskId,
701
+ laneId: `lane-${config.laneNumber}`,
702
+ laneNumber: config.laneNumber,
703
+ agentId: workerAgentId,
704
+ exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
705
+ },
706
+ });
707
+ } catch { /* best effort — don't block on alert failure */ }
708
+
709
+ // Poll worker mailbox inbox for supervisor reply (60s timeout)
710
+ const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
711
+ const POLL_INTERVAL_MS = 2_000;
712
+ const escalationTimestamp = Date.now();
713
+ const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
714
+
715
+ const supervisorReply = await new Promise<string | null>((resolve) => {
716
+ const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
717
+ const poll = () => {
718
+ if (Date.now() >= deadline) {
719
+ resolve(null); // Timeout fall back to corrective re-spawn
720
+ return;
721
+ }
722
+ try {
723
+ const messages = readInbox(inboxDir, config.batchId);
724
+ // Only accept messages newer than escalation timestamp
725
+ for (const { filename, message } of messages) {
726
+ if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
727
+ // Consume the message
728
+ const ackDir = join(dirname(inboxDir), "ack");
729
+ try { ackMessage(inboxDir, filename); } catch { /* best effort */ }
730
+ resolve(message.content);
731
+ return;
732
+ }
733
+ }
734
+ } catch { /* inbox not ready yet */ }
735
+ setTimeout(poll, POLL_INTERVAL_MS);
736
+ };
737
+ poll();
738
+ });
739
+
740
+ if (!supervisorReply) {
741
+ // Timeout let the session close, corrective re-spawn will handle it
742
+ logExecution(statusPath, "Exit intercept timeout",
743
+ `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`);
744
+ return null;
745
+ }
746
+
747
+ // Interpret supervisor reply: close directives vs instructional content
748
+ const normalizedReply = supervisorReply.trim().toLowerCase();
749
+ const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
750
+ // Only short messages (< 30 chars) can be close directives.
751
+ // Longer messages are always instructions even if they start with "stop".
752
+ const isShortEnoughForDirective = normalizedReply.length < 30;
753
+ if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d =>
754
+ normalizedReply === d ||
755
+ normalizedReply.startsWith(d + ":") ||
756
+ normalizedReply.startsWith(d + " ") ||
757
+ normalizedReply.startsWith(d + ".") ||
758
+ normalizedReply.startsWith(d + " -")
759
+ )) {
760
+ logExecution(statusPath, "Exit intercept close",
761
+ `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`);
762
+ return null;
763
+ }
764
+
765
+ // Instructional reply return as new prompt for the worker
766
+ logExecution(statusPath, "Exit intercept reprompt",
767
+ `Supervisor provided instructions (${supervisorReply.length} chars) reprompting worker`);
768
+ return supervisorReply;
769
+ }
770
+ : undefined,
771
+ };
772
+
773
+ // TP-184: Defense-in-depth sanity check. Under normal operation,
774
+ // `buildWorkerToolsAllowlist()` guarantees ENGINE_BRIDGE_TOOLS are
775
+ // present in the allowlist. Warn (do NOT throw or block spawn) if any
776
+ // is missing — this catches future helper bugs or accidental bypasses.
777
+ // See issue #530 for what silently breaks when bridge tools are missing.
778
+ const toolsList = (hostOpts.tools ?? "").split(",").map((s) => s.trim()).filter(Boolean);
779
+ for (const bridgeTool of ENGINE_BRIDGE_TOOLS) {
780
+ if (!toolsList.includes(bridgeTool)) {
781
+ logExecution(statusPath, "WARN",
782
+ `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`);
783
+ }
784
+ }
785
+
786
+ // Context pressure: write wrap-up signal before kill
787
+ let workerKillReason: "context" | "timer" | null = null;
788
+ let iterationTelemetry: Partial<AgentHostResult> = {};
789
+
790
+ const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
791
+ try {
792
+ // Context pressure check
793
+ if (telemetry.contextUsage) {
794
+ const pct = telemetry.contextUsage.percent;
795
+ if (pct >= config.warnPercent) {
796
+ const msg = `Wrap up (context ${Math.round(pct)}%)`;
797
+ if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
798
+ }
799
+ if (pct >= config.killPercent) {
800
+ workerKillReason = "context";
801
+ spawned.kill();
802
+ }
803
+ }
804
+
805
+ iterationTelemetry = telemetry;
806
+ lastTelemetry = telemetry;
807
+ // Emit lane snapshot
808
+ emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
809
+ } catch { /* non-fatal: telemetry callback must never crash the engine */ }
810
+ });
811
+
812
+ // Reviewer telemetry is written by the worker bridge during review_step.
813
+ // Poll snapshot refresh independently from worker message_end cadence so
814
+ // the dashboard sees reviewer activity while tool calls are in-flight.
815
+ let reviewerSnapshotFailures = 0;
816
+ const reviewerRefreshFailureThreshold = 5;
817
+ const reviewerRefresh = setInterval(() => {
818
+ const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
819
+ if (ok) {
820
+ reviewerSnapshotFailures = 0;
821
+ return;
822
+ }
823
+
824
+ reviewerSnapshotFailures += 1;
825
+ if (reviewerSnapshotFailures >= reviewerRefreshFailureThreshold) {
826
+ clearInterval(reviewerRefresh);
827
+ logExecution(
828
+ statusPath,
829
+ "Snapshot refresh disabled",
830
+ `Lane ${config.laneNumber}, task ${taskId}: ${reviewerSnapshotFailures} consecutive emitSnapshot failures`,
831
+ );
832
+ }
833
+ }, 1000);
834
+
835
+ let workerResult: AgentHostResult;
836
+ try {
837
+ workerResult = await spawned.promise;
838
+ } finally {
839
+ clearInterval(reviewerRefresh);
840
+ }
841
+
842
+ // TP-115: Update lastTelemetry with definitive final values from AgentHostResult
843
+ lastTelemetry = workerResult;
844
+
845
+ // Clean up wrap-up signal
846
+ if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
847
+
848
+ // Accumulate costs
849
+ cumulativeCostUsd += workerResult.costUsd;
850
+ cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens +
851
+ workerResult.cacheReadTokens + workerResult.cacheWriteTokens;
852
+
853
+ // ── TP-106: Poll worker outbox for replies/escalations ─────
854
+ try {
855
+ const outboxMessages = readOutbox(config.stateRoot, config.batchId, workerAgentId);
856
+ for (const msg of outboxMessages) {
857
+ const sanitized = msg.content.replace(/\r?\n/g, " / ").slice(0, 200);
858
+ logExecution(statusPath, `Agent ${msg.type}`, sanitized);
859
+
860
+ if (msg.type === "reply" || msg.type === "escalate") {
861
+ appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
862
+ batchId: config.batchId,
863
+ agentId: workerAgentId,
864
+ role: "worker",
865
+ laneNumber: config.laneNumber,
866
+ taskId,
867
+ repoId: config.repoId,
868
+ ts: Date.now(),
869
+ type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
870
+ payload: {
871
+ messageId: msg.id,
872
+ replyTo: msg.replyTo ?? null,
873
+ content: sanitized,
874
+ },
875
+ });
876
+
877
+ appendMailboxAuditEvent(config.stateRoot, config.batchId, {
878
+ type: msg.type === "reply" ? "message_replied" : "message_escalated",
879
+ from: workerAgentId,
880
+ to: "supervisor",
881
+ messageId: msg.id,
882
+ messageType: msg.type,
883
+ contentPreview: sanitized,
884
+ });
885
+
886
+ if (config.onSupervisorAlert) {
887
+ const isEscalation = msg.type === "escalate";
888
+ try {
889
+ config.onSupervisorAlert({
890
+ category: "agent-message",
891
+ summary:
892
+ `${isEscalation ? "🚨" : "📨"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
893
+ ` Task: ${taskId}\n` +
894
+ ` Lane: lane-${config.laneNumber}\n` +
895
+ ` Message: ${sanitized}`,
896
+ context: {
897
+ taskId,
898
+ laneId: `lane-${config.laneNumber}`,
899
+ laneNumber: config.laneNumber,
900
+ agentId: workerAgentId,
901
+ messageId: msg.id,
902
+ exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
903
+ },
904
+ });
905
+ } catch { /* best effort */ }
906
+ }
907
+ }
908
+
909
+ // Consume outbox message to prevent duplicate processing in later iterations.
910
+ ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
911
+ }
912
+ } catch { /* best effort */ }
913
+
914
+ // ── Steering annotation ─────────────────────────────────────
915
+ try {
916
+ if (existsSync(steeringPendingPath)) {
917
+ const raw = readFileSync(steeringPendingPath, "utf-8");
918
+ for (const line of raw.split("\n").filter(l => l.trim())) {
919
+ try {
920
+ const entry = JSON.parse(line) as { ts: number; content: string; id: string };
921
+ const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
922
+ const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
923
+ logExecution(statusPath, "⚠️ Steering", sanitized);
924
+ } catch { /* skip malformed */ }
925
+ }
926
+ unlinkSync(steeringPendingPath);
927
+ }
928
+ } catch { /* non-fatal */ }
929
+
930
+ // Log iteration result
931
+ const statusMsg = workerResult.killed
932
+ ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
933
+ : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`);
934
+ logExecution(statusPath, `Worker iter ${totalIterations}`,
935
+ `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
936
+
937
+ // ── Check progress ──────────────────────────────────────────
938
+ const afterStatusContent = readFileSync(statusPath, "utf-8");
939
+ const afterStatus = parseStatusMd(afterStatusContent);
940
+ // TP-174: Segment-scoped progress delta
941
+ let afterTotalChecked: number;
942
+ if (repoStepNumbers && currentRepoId) {
943
+ const segCbs = getSegmentCheckboxes(afterStatusContent, firstStep.number, currentRepoId);
944
+ afterTotalChecked = segCbs ? segCbs.checked : 0;
945
+ } else {
946
+ afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
947
+ }
948
+ const progressDelta = afterTotalChecked - prevTotalChecked;
949
+
950
+ if (progressDelta <= 0) {
951
+ // Check for soft progress: uncommitted changes in the worktree
952
+ // indicate the worker is actively editing code even if no checkbox
953
+ // was checked yet. This avoids false stall detection on complex
954
+ // steps where analysis + editing spans multiple tool calls.
955
+ let hasSoftProgress = false;
956
+ try {
957
+ const diffOutput = execSync("git diff --stat HEAD", {
958
+ cwd: unit.worktreePath,
959
+ timeout: 5000,
960
+ encoding: "utf-8",
961
+ stdio: ["pipe", "pipe", "pipe"],
962
+ }).trim();
963
+ // Only count source file changes as soft progress, not just STATUS.md
964
+ const changedFiles = diffOutput.split("\n").filter(l => l.includes("|"));
965
+ const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering"));
966
+ hasSoftProgress = sourceChanges.length > 0;
967
+ } catch { /* git not available or timeout — treat as no soft progress */ }
968
+
969
+ if (hasSoftProgress) {
970
+ // Worker has uncommitted code changes — don't count toward stall.
971
+ // Reset the counter since the worker is actively editing.
972
+ logExecution(statusPath, "Soft progress",
973
+ `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected not counting as stall`);
974
+ noProgressCount = 0;
975
+ } else {
976
+ noProgressCount++;
977
+ logExecution(statusPath, "No progress",
978
+ `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
979
+ if (noProgressCount >= config.noProgressLimit) {
980
+ logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
981
+ return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
982
+ `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
983
+ }
984
+ }
985
+ } else {
986
+ noProgressCount = 0;
987
+ }
988
+
989
+ // Mark completed steps
990
+ // TP-174: When segment-scoped, mark step complete when the segment's
991
+ // checkboxes are all checked (not the full step which may have other segments).
992
+ if (repoStepNumbers && currentRepoId) {
993
+ for (const stepNum of repoStepNumbers) {
994
+ if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
995
+ // Only mark step complete in STATUS.md if ALL segments in that step
996
+ // are complete (not just ours). But for loop exit, we only care about ours.
997
+ const ss = afterStatus.steps.find(s => s.number === stepNum);
998
+ if (isStepComplete(ss)) {
999
+ updateStepStatus(statusPath, stepNum, "complete");
1000
+ }
1001
+ }
1002
+ }
1003
+ } else {
1004
+ for (const step of parsed.steps) {
1005
+ const ss = afterStatus.steps.find(s => s.number === step.number);
1006
+ if (isStepComplete(ss)) {
1007
+ updateStepStatus(statusPath, step.number, "complete");
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ // Check if all steps are now complete
1013
+ // TP-174: When segment-scoped, exit when all steps for this repoId
1014
+ // have their segment checkboxes complete.
1015
+ let allComplete: boolean;
1016
+ if (repoStepNumbers && currentRepoId) {
1017
+ allComplete = [...repoStepNumbers].every(stepNum =>
1018
+ isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
1019
+ );
1020
+ } else {
1021
+ allComplete = parsed.steps.every(step => {
1022
+ const ss = afterStatus.steps.find(s => s.number === step.number);
1023
+ return isStepComplete(ss);
1024
+ });
1025
+ }
1026
+ if (allComplete) break;
1027
+ }
1028
+
1029
+ // ── 3. Post-loop completion check ───────────────────────────────
1030
+ const finalStatusContent = readFileSync(statusPath, "utf-8");
1031
+ const finalStatus = parseStatusMd(finalStatusContent);
1032
+ const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
1033
+
1034
+ // TP-174: Segment-scoped post-loop check. Re-derive repo scoping since
1035
+ // the iteration loop variables are out of scope here.
1036
+ const postLoopRepoId = segmentId ? config.repoId : null;
1037
+ const postLoopStepSegMap = unit.task.stepSegmentMap;
1038
+ const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId)
1039
+ ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1040
+ : null;
1041
+ const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0)
1042
+ ? postLoopRepoSteps
1043
+ : null;
1044
+
1045
+ let allStepsComplete: boolean;
1046
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1047
+ allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1048
+ isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1049
+ );
1050
+ } else {
1051
+ allStepsComplete = parsed.steps.every(step => {
1052
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1053
+ return isStepComplete(ss);
1054
+ });
1055
+ }
1056
+
1057
+ if (!allStepsComplete) {
1058
+ let incomplete: string;
1059
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1060
+ incomplete = [...effectivePostLoopRepoSteps]
1061
+ .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1062
+ .map(n => `Step ${n}`)
1063
+ .join(", ");
1064
+ } else {
1065
+ incomplete = parsed.steps
1066
+ .filter(step => {
1067
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1068
+ return !isStepComplete(ss);
1069
+ })
1070
+ .map(s => `Step ${s.number}`)
1071
+ .join(", ");
1072
+ }
1073
+ logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
1074
+ return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
1075
+ `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
1076
+ false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1077
+ }
1078
+
1079
+ // TP-145: Determine if this is a non-final segment of a multi-segment task.
1080
+ // If more segments remain after this one, suppress .DONE creation so that
1081
+ // the engine can advance the segment frontier and execute subsequent segments.
1082
+ // .DONE must only exist when ALL segments of a multi-segment task are complete.
1083
+ const isNonFinalSegment = segmentId != null
1084
+ && Array.isArray(unit.task.segmentIds)
1085
+ && unit.task.segmentIds.length > 1
1086
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
1087
+
1088
+ // TP-165: Check for pending expansion requests in the worker's outbox.
1089
+ // If the worker filed expansion requests, more segments may be added by the
1090
+ // engine at the segment boundary — .DONE must not be created even if this
1091
+ // appears to be the final segment based on the static segmentIds list.
1092
+ const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles(
1093
+ config.stateRoot, config.batchId, workerAgentId,
1094
+ );
1095
+
1096
+ if (isNonFinalSegment || hasPendingExpansionRequests) {
1097
+ // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
1098
+ // The engine will advance the frontier and dispatch the next segment.
1099
+ // Also delete any .DONE the worker may have created directly (workers have
1100
+ // write access and sometimes create .DONE on their own, bypassing this gate).
1101
+ if (existsSync(donePath)) {
1102
+ let deleted = false;
1103
+ try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
1104
+ if (deleted) {
1105
+ logExecution(statusPath, "Segment complete",
1106
+ `Segment ${segmentId} succeeded (non-final removed premature worker-created .DONE)`);
1107
+ } else {
1108
+ logExecution(statusPath, "Segment complete",
1109
+ `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`);
1110
+ }
1111
+ } else {
1112
+ logExecution(statusPath, "Segment complete",
1113
+ `Segment ${segmentId} succeeded (not final .DONE suppressed)`);
1114
+ }
1115
+ const suppressionReason = isNonFinalSegment
1116
+ ? "non-final"
1117
+ : "pending expansion requests";
1118
+ return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1119
+ `Segment completed (${suppressionReason} .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1120
+ }
1121
+
1122
+ // Create .DONE if not already present (final segment or single-segment/whole-task execution)
1123
+ if (!existsSync(donePath)) {
1124
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
1125
+ }
1126
+ updateStatusField(statusPath, "Status", " Complete");
1127
+ logExecution(statusPath, "Task complete", ".DONE created");
1128
+
1129
+ return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
1130
+ ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
1131
+ }
1132
+
1133
+ // ── Helpers ──────────────────────────────────────────────────────────
1134
+
1135
+ /**
1136
+ * TP-165: Check if the worker's outbox contains pending segment expansion requests.
1137
+ *
1138
+ * Pending expansion request files match `segment-expansion-*.json` (not renamed
1139
+ * to `.processed`, `.rejected`, etc.). If any exist, the engine will process them
1140
+ * at the segment boundary — and may add more segments to the task.
1141
+ *
1142
+ * @returns true if at least one pending expansion request file exists
1143
+ */
1144
+ export function hasPendingExpansionRequestFiles(
1145
+ stateRoot: string,
1146
+ batchId: string,
1147
+ agentId: string,
1148
+ ): boolean {
1149
+ const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox");
1150
+ if (!existsSync(outboxDir)) return false;
1151
+ try {
1152
+ const entries = readdirSync(outboxDir);
1153
+ return entries.some((entry) => /^segment-expansion-.+\.json$/.test(entry));
1154
+ } catch {
1155
+ return false;
1156
+ }
1157
+ }
1158
+
1159
+ export function mapLaneTaskStatusToTerminalSnapshotStatus(
1160
+ status: LaneTaskStatus,
1161
+ ): "idle" | "complete" | "failed" {
1162
+ if (status === "succeeded") return "complete";
1163
+ if (status === "skipped") return "idle";
1164
+ return "failed";
1165
+ }
1166
+
1167
+ export function mapLaneSnapshotStatusToWorkerStatus(
1168
+ status: "running" | "idle" | "complete" | "failed",
1169
+ ): RuntimeAgentStatus {
1170
+ if (status === "running") return "running";
1171
+ if (status === "complete") return "exited";
1172
+ if (status === "idle") return "wrapping_up";
1173
+ return "crashed";
1174
+ }
1175
+
1176
+ function makeResult(
1177
+ taskId: string,
1178
+ segmentId: string | null,
1179
+ sessionName: string,
1180
+ status: LaneTaskStatus,
1181
+ startTime: number,
1182
+ exitReason: string,
1183
+ doneFileFound: boolean,
1184
+ iterations: number,
1185
+ costUsd: number,
1186
+ totalTokens: number,
1187
+ config?: LaneRunnerConfig,
1188
+ statusPath?: string,
1189
+ reviewerStatePath?: string,
1190
+ finalTelemetry?: Partial<AgentHostResult>,
1191
+ /** TP-174: Segment context for segment-scoped snapshot progress */
1192
+ segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1193
+ ): LaneRunnerTaskResult {
1194
+ const telemetry = status === "skipped"
1195
+ ? undefined
1196
+ : {
1197
+ inputTokens: finalTelemetry?.inputTokens ?? 0,
1198
+ outputTokens: finalTelemetry?.outputTokens ?? 0,
1199
+ cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
1200
+ cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
1201
+ costUsd: finalTelemetry?.costUsd ?? 0,
1202
+ toolCalls: finalTelemetry?.toolCalls ?? 0,
1203
+ durationMs: finalTelemetry?.durationMs ?? 0,
1204
+ };
1205
+
1206
+ const result: LaneRunnerTaskResult = {
1207
+ outcome: {
1208
+ taskId,
1209
+ status,
1210
+ segmentId,
1211
+ startTime,
1212
+ endTime: Date.now(),
1213
+ exitReason,
1214
+ sessionName,
1215
+ doneFileFound,
1216
+ laneNumber: config?.laneNumber,
1217
+ telemetry,
1218
+ },
1219
+ iterations,
1220
+ costUsd,
1221
+ totalTokens,
1222
+ };
1223
+
1224
+ // TP-115: Emit terminal snapshot with real telemetry from agent-host result
1225
+ if (config && statusPath && reviewerStatePath) {
1226
+ const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
1227
+ emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
1228
+ }
1229
+
1230
+ return result;
1231
+ }
1232
+
1233
+ /** Max age for reviewer state file before it's considered stale (2 minutes). */
1234
+ const REVIEWER_STATE_STALE_MS = 120_000;
1235
+
1236
+ export function readReviewerTelemetrySnapshot(
1237
+ config: LaneRunnerConfig,
1238
+ reviewerStatePathOrStatusPath: string,
1239
+ ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
1240
+ const reviewerPath = basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
1241
+ ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
1242
+ : reviewerStatePathOrStatusPath;
1243
+ if (!existsSync(reviewerPath)) return null;
1244
+
1245
+ try {
1246
+ const raw = readFileSync(reviewerPath, "utf-8");
1247
+ const parsed = JSON.parse(raw) as Partial<{
1248
+ status: string;
1249
+ elapsedMs: number;
1250
+ toolCalls: number;
1251
+ contextPct: number;
1252
+ costUsd: number;
1253
+ lastTool: string;
1254
+ inputTokens: number;
1255
+ outputTokens: number;
1256
+ cacheReadTokens: number;
1257
+ cacheWriteTokens: number;
1258
+ updatedAt: number;
1259
+ reviewType: string;
1260
+ reviewStep: number;
1261
+ }>;
1262
+
1263
+ if (parsed.status !== "running") return null;
1264
+
1265
+ // Stale guard: if updatedAt is present and older than threshold, ignore
1266
+ if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null;
1267
+
1268
+ return {
1269
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
1270
+ status: "running",
1271
+ elapsedMs: Number.isFinite(parsed.elapsedMs) ? Number(parsed.elapsedMs) : 0,
1272
+ toolCalls: Number.isFinite(parsed.toolCalls) ? Number(parsed.toolCalls) : 0,
1273
+ contextPct: Number.isFinite(parsed.contextPct) ? Number(parsed.contextPct) : 0,
1274
+ costUsd: Number.isFinite(parsed.costUsd) ? Number(parsed.costUsd) : 0,
1275
+ lastTool: typeof parsed.lastTool === "string" ? parsed.lastTool : "",
1276
+ inputTokens: Number.isFinite(parsed.inputTokens) ? Number(parsed.inputTokens) : 0,
1277
+ outputTokens: Number.isFinite(parsed.outputTokens) ? Number(parsed.outputTokens) : 0,
1278
+ cacheReadTokens: Number.isFinite(parsed.cacheReadTokens) ? Number(parsed.cacheReadTokens) : 0,
1279
+ cacheWriteTokens: Number.isFinite(parsed.cacheWriteTokens) ? Number(parsed.cacheWriteTokens) : 0,
1280
+ reviewType: typeof parsed.reviewType === "string" ? parsed.reviewType : undefined,
1281
+ reviewStep: Number.isFinite(parsed.reviewStep) ? Number(parsed.reviewStep) : undefined,
1282
+ };
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Emit a lane snapshot to disk. NON-THROWING by contract — all errors are
1290
+ * caught and logged. This function is called from setInterval callbacks
1291
+ * and onTelemetry callbacks where an unhandled throw would trigger
1292
+ * uncaughtException and crash the engine-worker process.
1293
+ *
1294
+ * @returns true when snapshot write succeeds, false when it fails.
1295
+ */
1296
+ function emitSnapshot(
1297
+ config: LaneRunnerConfig,
1298
+ taskId: string,
1299
+ segmentId: string | null,
1300
+ status: "running" | "idle" | "complete" | "failed",
1301
+ telemetry: Partial<AgentHostResult>,
1302
+ statusPath: string,
1303
+ reviewerStatePath: string,
1304
+ /** TP-174: Optional segment context for segment-scoped progress reporting */
1305
+ segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
1306
+ ): boolean {
1307
+ try {
1308
+ // Parse progress from STATUS.md
1309
+ let progress: RuntimeTaskProgress | null = null;
1310
+ try {
1311
+ const content = readFileSync(statusPath, "utf-8");
1312
+ const parsed = parseStatusMd(content);
1313
+ const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
1314
+
1315
+ // TP-174: Segment-scoped progress when segment markers are present.
1316
+ // Only count checkboxes from steps that belong to this segment's repoId.
1317
+ let checked: number;
1318
+ let total: number;
1319
+ if (segmentContext) {
1320
+ const { stepSegmentMap, repoId } = segmentContext;
1321
+ const repoSteps = getStepsForRepoId(stepSegmentMap, repoId);
1322
+ let segChecked = 0;
1323
+ let segTotal = 0;
1324
+ for (const stepNum of repoSteps) {
1325
+ const segCbs = getSegmentCheckboxes(content, stepNum, repoId);
1326
+ if (segCbs) {
1327
+ segChecked += segCbs.checked;
1328
+ segTotal += segCbs.total;
1329
+ }
1330
+ }
1331
+ checked = segChecked;
1332
+ total = segTotal;
1333
+ } else {
1334
+ checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
1335
+ total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1336
+ }
1337
+
1338
+ progress = {
1339
+ currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
1340
+ checked,
1341
+ total,
1342
+ iteration: parsed.iteration,
1343
+ reviews: parsed.reviewCounter,
1344
+ };
1345
+ } catch { /* best effort */ }
1346
+
1347
+ const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath);
1348
+
1349
+ const snapshot: RuntimeLaneSnapshot = {
1350
+ batchId: config.batchId,
1351
+ laneNumber: config.laneNumber,
1352
+ laneId: `lane-${config.laneNumber}`,
1353
+ repoId: config.repoId,
1354
+ taskId,
1355
+ segmentId,
1356
+ status,
1357
+ worker: {
1358
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
1359
+ status: mapLaneSnapshotStatusToWorkerStatus(status),
1360
+ elapsedMs: telemetry.durationMs ?? 0,
1361
+ toolCalls: telemetry.toolCalls ?? 0,
1362
+ contextPct: telemetry.contextUsage?.percent ?? 0,
1363
+ costUsd: telemetry.costUsd ?? 0,
1364
+ lastTool: telemetry.lastTool ?? "",
1365
+ inputTokens: telemetry.inputTokens ?? 0,
1366
+ outputTokens: telemetry.outputTokens ?? 0,
1367
+ cacheReadTokens: telemetry.cacheReadTokens ?? 0,
1368
+ cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
1369
+ },
1370
+ reviewer: reviewerSnapshot,
1371
+ progress,
1372
+ updatedAt: Date.now(),
1373
+ };
1374
+
1375
+ writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
1376
+ return true;
1377
+ } catch {
1378
+ // Non-fatal: snapshot is telemetry, not execution-critical.
1379
+ // Swallow to prevent uncaughtException crash in setInterval/callback contexts.
1380
+ return false;
1381
+ }
1382
+ }
1383
+