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