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