taskplane 0.29.2 → 0.30.1
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/dashboard/public/app.js +124 -15
- package/dashboard/public/style.css +83 -2
- 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 +78 -63
- package/extensions/taskplane/engine-worker.ts +53 -46
- package/extensions/taskplane/engine.ts +1760 -602
- package/extensions/taskplane/execution.ts +469 -207
- 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 +652 -319
- 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 +832 -280
- 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 +209 -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,
|
|
@@ -71,6 +68,7 @@ import {
|
|
|
71
68
|
type LaneTaskStatus,
|
|
72
69
|
type SupervisorAlertCallback,
|
|
73
70
|
type StepSegmentMapping,
|
|
71
|
+
type SegmentScopeMode,
|
|
74
72
|
} from "./types.ts";
|
|
75
73
|
|
|
76
74
|
const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -94,7 +92,7 @@ export function getStepsForRepoId(
|
|
|
94
92
|
): Set<number> {
|
|
95
93
|
const stepNumbers = new Set<number>();
|
|
96
94
|
for (const step of stepSegmentMap) {
|
|
97
|
-
if (step.segments.some(seg => seg.repoId === repoId)) {
|
|
95
|
+
if (step.segments.some((seg) => seg.repoId === repoId)) {
|
|
98
96
|
stepNumbers.add(step.stepNumber);
|
|
99
97
|
}
|
|
100
98
|
}
|
|
@@ -131,7 +129,10 @@ export function getSegmentCheckboxes(
|
|
|
131
129
|
const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
|
|
132
130
|
|
|
133
131
|
// Find the segment header within this step
|
|
134
|
-
const segHeaderPattern = new RegExp(
|
|
132
|
+
const segHeaderPattern = new RegExp(
|
|
133
|
+
`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
|
|
134
|
+
"m",
|
|
135
|
+
);
|
|
135
136
|
const segMatch = stepContent.match(segHeaderPattern);
|
|
136
137
|
if (!segMatch || segMatch.index === undefined) return null;
|
|
137
138
|
|
|
@@ -145,7 +146,7 @@ export function getSegmentCheckboxes(
|
|
|
145
146
|
let unchecked = 0;
|
|
146
147
|
const uncheckedTexts: string[] = [];
|
|
147
148
|
const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
|
|
148
|
-
let m;
|
|
149
|
+
let m: RegExpExecArray | null;
|
|
149
150
|
while ((m = cbRegex.exec(segContent)) !== null) {
|
|
150
151
|
if (m[1].toLowerCase() === "x") {
|
|
151
152
|
checked++;
|
|
@@ -178,6 +179,75 @@ export function isSegmentComplete(
|
|
|
178
179
|
return result.unchecked === 0;
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Compute the authoritative `SegmentScopeMode` for one worker iteration.
|
|
184
|
+
*
|
|
185
|
+
* This is the single source of truth for the FULL_TASK vs SEGMENT_SCOPED
|
|
186
|
+
* decision (TP-196 / #502). All segment-related side-effects (env vars,
|
|
187
|
+
* system-prompt overlay, prompt content, tool registration) should derive
|
|
188
|
+
* their behaviour from this mode rather than re-evaluating the underlying
|
|
189
|
+
* boolean conditions in isolation, which is what created the drift risk
|
|
190
|
+
* documented in #502.
|
|
191
|
+
*
|
|
192
|
+
* Returns `SEGMENT_SCOPED` iff ALL of the following hold:
|
|
193
|
+
* - The task has a non-empty `stepSegmentMap` (parsed from PROMPT.md markers).
|
|
194
|
+
* - The lane has an associated `currentRepoId` (segmentId set, so we know
|
|
195
|
+
* which repo this lane is iterating).
|
|
196
|
+
* - The (legacy-fallback-filtered) `repoStepNumbers` set is non-null (the
|
|
197
|
+
* repo has at least one step with explicit segment markers).
|
|
198
|
+
* - A `currentStepNumber` is provided (there is a step to evaluate).
|
|
199
|
+
* - The current step's segment mapping contains an entry for `currentRepoId`
|
|
200
|
+
* (the worker actually has segment-scoped work in the current step).
|
|
201
|
+
*
|
|
202
|
+
* In any other case the mode is `FULL_TASK`.
|
|
203
|
+
*
|
|
204
|
+
* @since TP-196
|
|
205
|
+
*/
|
|
206
|
+
export function computeSegmentScopeMode(
|
|
207
|
+
stepSegmentMap: StepSegmentMapping[] | undefined | null,
|
|
208
|
+
repoStepNumbers: Set<number> | null,
|
|
209
|
+
currentRepoId: string | null,
|
|
210
|
+
currentStepNumber: number | null,
|
|
211
|
+
): SegmentScopeMode {
|
|
212
|
+
if (!stepSegmentMap || !currentRepoId || !repoStepNumbers) return "FULL_TASK";
|
|
213
|
+
if (currentStepNumber === null) return "FULL_TASK";
|
|
214
|
+
const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNumber);
|
|
215
|
+
if (!currentStepMapping) return "FULL_TASK";
|
|
216
|
+
const mySegment = currentStepMapping.segments.find((seg) => seg.repoId === currentRepoId);
|
|
217
|
+
return mySegment ? "SEGMENT_SCOPED" : "FULL_TASK";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Pre-spawn segment-completion check (TP-196 / #508).
|
|
222
|
+
*
|
|
223
|
+
* Returns `true` when the lane-runner iteration loop should SKIP spawning
|
|
224
|
+
* a worker because all of the segment's checkboxes for this repo are
|
|
225
|
+
* already complete. The lane should `break` out of its iteration loop and
|
|
226
|
+
* fall through to post-loop completion handling.
|
|
227
|
+
*
|
|
228
|
+
* Contract:
|
|
229
|
+
* - Returns `false` for FULL_TASK iterations (`currentRepoId === null` or
|
|
230
|
+
* `repoStepNumbers === null` or empty). Those rely on the existing
|
|
231
|
+
* `remainingSteps.length === 0` exit, not this check.
|
|
232
|
+
* - Returns `true` iff EVERY step in `repoStepNumbers` is
|
|
233
|
+
* `isSegmentComplete(statusContent, stepNum, currentRepoId)`.
|
|
234
|
+
*
|
|
235
|
+
* Pure function: no filesystem access, no global state. The caller reads
|
|
236
|
+
* the STATUS.md content once per iteration and passes it in.
|
|
237
|
+
*
|
|
238
|
+
* @since TP-196
|
|
239
|
+
*/
|
|
240
|
+
export function shouldSkipSpawnForCompleteSegment(
|
|
241
|
+
statusContent: string,
|
|
242
|
+
repoStepNumbers: Set<number> | null,
|
|
243
|
+
currentRepoId: string | null,
|
|
244
|
+
): boolean {
|
|
245
|
+
if (!repoStepNumbers || !currentRepoId || repoStepNumbers.size === 0) return false;
|
|
246
|
+
return [...repoStepNumbers].every((stepNum) =>
|
|
247
|
+
isSegmentComplete(statusContent, stepNum, currentRepoId),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
181
251
|
// ── Types ────────────────────────────────────────────────────────────
|
|
182
252
|
|
|
183
253
|
/**
|
|
@@ -326,13 +396,22 @@ export async function executeTaskV2(
|
|
|
326
396
|
// This closes the race window where the monitor sees .DONE before lane-runner
|
|
327
397
|
// can suppress it at segment end. For non-final segments, .DONE must not exist
|
|
328
398
|
// at any point during execution.
|
|
329
|
-
const isNonFinalAtStart =
|
|
330
|
-
&&
|
|
331
|
-
|
|
332
|
-
|
|
399
|
+
const isNonFinalAtStart =
|
|
400
|
+
segmentId != null &&
|
|
401
|
+
Array.isArray(unit.task.segmentIds) &&
|
|
402
|
+
unit.task.segmentIds.length > 1 &&
|
|
403
|
+
unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
|
|
333
404
|
if (isNonFinalAtStart && existsSync(donePath)) {
|
|
334
|
-
try {
|
|
335
|
-
|
|
405
|
+
try {
|
|
406
|
+
unlinkSync(donePath);
|
|
407
|
+
} catch {
|
|
408
|
+
/* best effort */
|
|
409
|
+
}
|
|
410
|
+
logExecution(
|
|
411
|
+
statusPath,
|
|
412
|
+
"Segment start",
|
|
413
|
+
`Removed stale .DONE before non-final segment ${segmentId}`,
|
|
414
|
+
);
|
|
336
415
|
}
|
|
337
416
|
|
|
338
417
|
// ── 2. Iteration loop ───────────────────────────────────────────
|
|
@@ -346,20 +425,35 @@ export async function executeTaskV2(
|
|
|
346
425
|
// TP-174: Build segment context once for emitSnapshot calls.
|
|
347
426
|
// Available outside the loop so it can be passed to makeResult too.
|
|
348
427
|
const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
|
|
349
|
-
|
|
428
|
+
segmentId && unit.task.stepSegmentMap && config.repoId
|
|
350
429
|
? (() => {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
430
|
+
const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
|
|
431
|
+
return repoSteps.size > 0
|
|
432
|
+
? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
|
|
433
|
+
: null;
|
|
434
|
+
})()
|
|
356
435
|
: null;
|
|
357
436
|
|
|
358
437
|
for (let iter = 0; iter < config.maxIterations; iter++) {
|
|
359
438
|
if (pauseSignal.paused) {
|
|
360
439
|
logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
|
|
361
|
-
return makeResult(
|
|
362
|
-
|
|
440
|
+
return makeResult(
|
|
441
|
+
taskId,
|
|
442
|
+
segmentId,
|
|
443
|
+
workerAgentId,
|
|
444
|
+
"skipped",
|
|
445
|
+
startTime,
|
|
446
|
+
"Paused by user",
|
|
447
|
+
false,
|
|
448
|
+
totalIterations,
|
|
449
|
+
cumulativeCostUsd,
|
|
450
|
+
cumulativeTokens,
|
|
451
|
+
config,
|
|
452
|
+
statusPath,
|
|
453
|
+
reviewerStatePath,
|
|
454
|
+
undefined,
|
|
455
|
+
snapshotSegmentCtx,
|
|
456
|
+
);
|
|
363
457
|
}
|
|
364
458
|
|
|
365
459
|
// Determine remaining steps
|
|
@@ -370,39 +464,62 @@ export async function executeTaskV2(
|
|
|
370
464
|
// Use config.repoId (structured identity) instead of parsing opaque segmentId.
|
|
371
465
|
const stepSegmentMap = unit.task.stepSegmentMap;
|
|
372
466
|
const currentRepoId = segmentId ? config.repoId : null;
|
|
373
|
-
const rawRepoStepNumbers =
|
|
374
|
-
? getStepsForRepoId(stepSegmentMap, currentRepoId)
|
|
375
|
-
: null;
|
|
467
|
+
const rawRepoStepNumbers =
|
|
468
|
+
stepSegmentMap && currentRepoId ? getStepsForRepoId(stepSegmentMap, currentRepoId) : null;
|
|
376
469
|
// TP-174 legacy fallback: If no steps have segments for this repoId
|
|
377
470
|
// (multi-segment task without explicit markers, where all checkboxes
|
|
378
471
|
// are assigned to the fallback/packet repo), disable segment filtering.
|
|
379
|
-
const repoStepNumbers =
|
|
380
|
-
? rawRepoStepNumbers
|
|
381
|
-
: null;
|
|
472
|
+
const repoStepNumbers =
|
|
473
|
+
rawRepoStepNumbers && rawRepoStepNumbers.size > 0 ? rawRepoStepNumbers : null;
|
|
382
474
|
|
|
383
475
|
// TP-174: Read STATUS.md content once for segment-scoped checks
|
|
384
476
|
const iterStatusContent = readFileSync(statusPath, "utf-8");
|
|
385
477
|
|
|
386
|
-
const remainingSteps = parsed.steps.filter(step => {
|
|
478
|
+
const remainingSteps = parsed.steps.filter((step) => {
|
|
387
479
|
// TP-174: When segment-scoped, only show steps that have work for this repoId
|
|
388
480
|
if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
|
|
389
481
|
// TP-174: Use segment-scoped completion check in segment mode
|
|
390
482
|
if (repoStepNumbers && currentRepoId) {
|
|
391
483
|
return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
|
|
392
484
|
}
|
|
393
|
-
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
485
|
+
const ss = currentStatus.steps.find((s) => s.number === step.number);
|
|
394
486
|
return !isStepComplete(ss);
|
|
395
487
|
});
|
|
396
488
|
|
|
397
489
|
if (remainingSteps.length === 0) break; // All done
|
|
398
490
|
|
|
491
|
+
// TP-196 / #508: Pre-spawn segment-completion check.
|
|
492
|
+
//
|
|
493
|
+
// When the lane is iterating a segment-scoped task, verify that NOT ALL
|
|
494
|
+
// `repoStepNumbers` are segment-complete before incurring the cost of
|
|
495
|
+
// spawning a worker. The `remainingSteps` filter above already enforces
|
|
496
|
+
// this implicitly (via `isSegmentComplete`), but expressing the check
|
|
497
|
+
// explicitly at the spawn boundary:
|
|
498
|
+
// 1. Makes the wasted-iteration prevention contract visible.
|
|
499
|
+
// 2. Provides a defensive backstop for cases where `parsed.steps` and
|
|
500
|
+
// `repoStepNumbers` diverge (e.g., legacy/partial-marker tasks).
|
|
501
|
+
// 3. Gives behavioural tests a clean assertion target (via the pure
|
|
502
|
+
// helper `shouldSkipSpawnForCompleteSegment`).
|
|
503
|
+
if (shouldSkipSpawnForCompleteSegment(iterStatusContent, repoStepNumbers, currentRepoId)) {
|
|
504
|
+
logExecution(
|
|
505
|
+
statusPath,
|
|
506
|
+
"Pre-spawn segment-completion check",
|
|
507
|
+
`all segment checkboxes already complete for repo '${currentRepoId}' — skipping worker spawn (#508)`,
|
|
508
|
+
);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
|
|
399
512
|
totalIterations++;
|
|
400
|
-
updateStatusField(
|
|
513
|
+
updateStatusField(
|
|
514
|
+
statusPath,
|
|
515
|
+
"Current Step",
|
|
516
|
+
`Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`,
|
|
517
|
+
);
|
|
401
518
|
updateStatusField(statusPath, "Iteration", `${totalIterations}`);
|
|
402
519
|
|
|
403
520
|
// Mark first incomplete step as in-progress
|
|
404
521
|
const firstStep = remainingSteps[0];
|
|
405
|
-
const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number);
|
|
522
|
+
const firstStepStatus = currentStatus.steps.find((s) => s.number === firstStep.number);
|
|
406
523
|
if (firstStepStatus?.status !== "in-progress") {
|
|
407
524
|
updateStepStatus(statusPath, firstStep.number, "in-progress");
|
|
408
525
|
logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
|
|
@@ -421,13 +538,23 @@ export async function executeTaskV2(
|
|
|
421
538
|
|
|
422
539
|
// ── Build worker prompt ─────────────────────────────────────
|
|
423
540
|
const wrapUpFile = join(taskFolder, ".task-wrap-up");
|
|
424
|
-
if (existsSync(wrapUpFile))
|
|
541
|
+
if (existsSync(wrapUpFile))
|
|
542
|
+
try {
|
|
543
|
+
unlinkSync(wrapUpFile);
|
|
544
|
+
} catch {
|
|
545
|
+
/* ignore */
|
|
546
|
+
}
|
|
425
547
|
|
|
426
|
-
// TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
548
|
+
// TP-174/TP-501/TP-196: Compute segment scope mode BEFORE building prompt.
|
|
549
|
+
// `segmentScopeMode` is the authoritative TP-196 flag; `isSegmentScoped` is
|
|
550
|
+
// preserved as a boolean alias for ergonomics at the many existing call sites.
|
|
551
|
+
const segmentScopeMode: SegmentScopeMode = computeSegmentScopeMode(
|
|
552
|
+
stepSegmentMap,
|
|
553
|
+
repoStepNumbers,
|
|
554
|
+
currentRepoId,
|
|
555
|
+
remainingSteps.length > 0 ? remainingSteps[0].number : null,
|
|
556
|
+
);
|
|
557
|
+
const isSegmentScoped = segmentScopeMode === "SEGMENT_SCOPED";
|
|
431
558
|
|
|
432
559
|
const promptLines = [
|
|
433
560
|
`Read your task instructions at: ${promptPath}`,
|
|
@@ -444,9 +571,7 @@ export async function executeTaskV2(
|
|
|
444
571
|
`- Lane repo ID: ${config.repoId}`,
|
|
445
572
|
// Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
|
|
446
573
|
// workers incorrectly self-scoping based on segment metadata.
|
|
447
|
-
...(isSegmentScoped
|
|
448
|
-
? [`- Active segment ID: ${segmentId}`]
|
|
449
|
-
: []),
|
|
574
|
+
...(isSegmentScoped ? [`- Active segment ID: ${segmentId}`] : []),
|
|
450
575
|
``,
|
|
451
576
|
`Packet home context:`,
|
|
452
577
|
`- Packet home repo ID: ${unit.packetHomeRepoId}`,
|
|
@@ -464,9 +589,10 @@ export async function executeTaskV2(
|
|
|
464
589
|
// Only show segment DAG in segment-scoped mode
|
|
465
590
|
const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
|
|
466
591
|
if (segmentDag && segmentDag.repoIds.length > 0) {
|
|
467
|
-
const edgeSummary =
|
|
468
|
-
|
|
469
|
-
|
|
592
|
+
const edgeSummary =
|
|
593
|
+
segmentDag.edges.length > 0
|
|
594
|
+
? segmentDag.edges.map((edge) => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
|
|
595
|
+
: "(no explicit edges)";
|
|
470
596
|
promptLines.push(
|
|
471
597
|
``,
|
|
472
598
|
`Segment DAG context (from PROMPT metadata):`,
|
|
@@ -478,21 +604,33 @@ export async function executeTaskV2(
|
|
|
478
604
|
// Segment scope mode is determined by which system prompt was loaded.
|
|
479
605
|
// No SegmentScopeMode line needed — the prompt IS the mode.
|
|
480
606
|
|
|
481
|
-
// TP-174: Segment-scoped prompt — show only this segment's checkboxes
|
|
482
|
-
|
|
607
|
+
// TP-174/TP-196: Segment-scoped prompt — show only this segment's checkboxes.
|
|
608
|
+
// Gated on the authoritative `isSegmentScoped` (derived from `segmentScopeMode`)
|
|
609
|
+
// rather than the raw composite condition, so the prompt branch can't drift
|
|
610
|
+
// from the mode decision (TP-196 / #502).
|
|
611
|
+
if (isSegmentScoped) {
|
|
483
612
|
const currentStepNum = remainingSteps[0].number;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
// segment
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
613
|
+
// Defensive guards: when `isSegmentScoped === true`, `computeSegmentScopeMode`
|
|
614
|
+
// has already verified `stepSegmentMap`, `currentRepoId`, and that the
|
|
615
|
+
// current step's mapping contains an entry for the active repo. We re-fetch
|
|
616
|
+
// the structures here for clarity. If any are missing we log and skip the
|
|
617
|
+
// segment block (defense-in-depth — should never trip in practice).
|
|
618
|
+
const currentStepMapping = stepSegmentMap?.find((s) => s.stepNumber === currentStepNum);
|
|
619
|
+
const mySegment = currentStepMapping?.segments.find((seg) => seg.repoId === currentRepoId);
|
|
620
|
+
|
|
621
|
+
if (!currentStepMapping || !mySegment) {
|
|
622
|
+
logExecution(
|
|
623
|
+
statusPath,
|
|
624
|
+
"WARN",
|
|
625
|
+
`segmentScopeMode === SEGMENT_SCOPED but current step mapping missing — skipping segment prompt block (currentRepoId=${currentRepoId}, stepNum=${currentStepNum})`,
|
|
626
|
+
);
|
|
627
|
+
} else {
|
|
628
|
+
const otherSegments = currentStepMapping.segments.filter((seg) => seg.repoId !== currentRepoId);
|
|
492
629
|
|
|
493
630
|
// Count total segments for this repo across all steps
|
|
494
631
|
const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
|
|
495
|
-
const segmentIndexInStep =
|
|
632
|
+
const segmentIndexInStep =
|
|
633
|
+
currentStepMapping.segments.findIndex((seg) => seg.repoId === currentRepoId) + 1;
|
|
496
634
|
const totalSegmentsInStep = currentStepMapping.segments.length;
|
|
497
635
|
|
|
498
636
|
promptLines.push(
|
|
@@ -514,19 +652,23 @@ export async function executeTaskV2(
|
|
|
514
652
|
promptLines.push(``);
|
|
515
653
|
promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
|
|
516
654
|
for (const seg of otherSegments) {
|
|
517
|
-
promptLines.push(
|
|
655
|
+
promptLines.push(
|
|
656
|
+
` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`,
|
|
657
|
+
);
|
|
518
658
|
}
|
|
519
659
|
}
|
|
520
660
|
|
|
521
661
|
// List completed steps for this repo
|
|
522
|
-
const completedForRepo = parsed.steps.filter(step => {
|
|
662
|
+
const completedForRepo = parsed.steps.filter((step) => {
|
|
523
663
|
if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
|
|
524
|
-
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
664
|
+
const ss = currentStatus.steps.find((s) => s.number === step.number);
|
|
525
665
|
return isStepComplete(ss);
|
|
526
666
|
});
|
|
527
667
|
if (completedForRepo.length > 0) {
|
|
528
668
|
promptLines.push(``);
|
|
529
|
-
promptLines.push(
|
|
669
|
+
promptLines.push(
|
|
670
|
+
`Prior steps completed: ${completedForRepo.map((s) => `Step ${s.number} (${s.name})`).join(", ")}`,
|
|
671
|
+
);
|
|
530
672
|
}
|
|
531
673
|
|
|
532
674
|
promptLines.push(
|
|
@@ -538,13 +680,13 @@ export async function executeTaskV2(
|
|
|
538
680
|
}
|
|
539
681
|
|
|
540
682
|
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));
|
|
683
|
+
const remainingSet = new Set(remainingSteps.map((s) => s.number));
|
|
684
|
+
const completedSteps = parsed.steps.filter((s) => !remainingSet.has(s.number));
|
|
543
685
|
promptLines.push(
|
|
544
686
|
``,
|
|
545
687
|
`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(", ")}`,
|
|
688
|
+
`Completed (do not redo): ${completedSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
|
|
689
|
+
`Remaining (focus here): ${remainingSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ")}`,
|
|
548
690
|
);
|
|
549
691
|
|
|
550
692
|
// If the worker exited without checking any boxes, add a corrective directive
|
|
@@ -572,12 +714,22 @@ export async function executeTaskV2(
|
|
|
572
714
|
const steeringPendingPath = join(taskFolder, ".steering-pending");
|
|
573
715
|
|
|
574
716
|
// TP-106: Bridge extension wiring for agent-side reply/escalate tools
|
|
575
|
-
const outboxDir = join(
|
|
717
|
+
const outboxDir = join(
|
|
718
|
+
config.stateRoot,
|
|
719
|
+
".pi",
|
|
720
|
+
"mailbox",
|
|
721
|
+
config.batchId,
|
|
722
|
+
workerAgentId,
|
|
723
|
+
"outbox",
|
|
724
|
+
);
|
|
576
725
|
const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
|
|
577
726
|
|
|
578
727
|
// TP-180: Forward user-installed extensions to worker agent
|
|
579
728
|
const allPackages = loadPiSettingsPackages(config.stateRoot);
|
|
580
|
-
const workerPackages = filterExcludedExtensions(
|
|
729
|
+
const workerPackages = filterExcludedExtensions(
|
|
730
|
+
allPackages,
|
|
731
|
+
config.workerExcludeExtensions ?? [],
|
|
732
|
+
);
|
|
581
733
|
|
|
582
734
|
const hostOpts: AgentHostOptions = {
|
|
583
735
|
agentId: workerAgentId,
|
|
@@ -588,9 +740,10 @@ export async function executeTaskV2(
|
|
|
588
740
|
repoId: config.repoId,
|
|
589
741
|
cwd: unit.worktreePath,
|
|
590
742
|
prompt: promptLines.join("\n"),
|
|
591
|
-
systemPrompt:
|
|
592
|
-
|
|
593
|
-
|
|
743
|
+
systemPrompt:
|
|
744
|
+
(isSegmentScoped && config.workerSegmentPrompt
|
|
745
|
+
? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
|
|
746
|
+
: config.workerSystemPrompt) || undefined,
|
|
594
747
|
model: config.workerModel || undefined,
|
|
595
748
|
// TP-184: buildWorkerToolsAllowlist always appends ENGINE_BRIDGE_TOOLS
|
|
596
749
|
// (review_step, notify_supervisor, request_segment_expansion) so that
|
|
@@ -635,185 +788,217 @@ export async function executeTaskV2(
|
|
|
635
788
|
// exits without making visible progress (no checkboxes, no blocker logged).
|
|
636
789
|
onPrematureExit: config.onSupervisorAlert
|
|
637
790
|
? 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
|
|
791
|
+
// Check if the worker made visible progress during this turn:
|
|
792
|
+
// 1. Checkbox progress (more items checked)
|
|
793
|
+
// 2. Blocker logged (non-empty Blockers section)
|
|
794
|
+
try {
|
|
795
|
+
const statusContent = readFileSync(statusPath, "utf-8");
|
|
796
|
+
// TP-174: Use same scope as prevTotalChecked (segment or global)
|
|
797
|
+
let midTotalChecked: number;
|
|
798
|
+
if (repoStepNumbers && currentRepoId) {
|
|
799
|
+
const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
|
|
800
|
+
midTotalChecked = segCbs ? segCbs.checked : 0;
|
|
801
|
+
} else {
|
|
802
|
+
const midStatus = parseStatusMd(statusContent);
|
|
803
|
+
midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
|
|
804
|
+
}
|
|
805
|
+
if (midTotalChecked > prevTotalChecked) {
|
|
806
|
+
// Worker checked off checkboxes — let it exit normally
|
|
663
807
|
return null;
|
|
664
808
|
}
|
|
809
|
+
// Check for blocker entries: extract Blockers section and see if non-empty
|
|
810
|
+
const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
|
|
811
|
+
if (blockerMatch) {
|
|
812
|
+
const blockerContent = blockerMatch[1].trim();
|
|
813
|
+
// If blockers section has real content (not just "*None*" or empty)
|
|
814
|
+
if (blockerContent && blockerContent !== "*None*") {
|
|
815
|
+
// Worker logged a blocker — let it exit normally
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} catch {
|
|
820
|
+
/* If we can't read STATUS.md, proceed with escalation */
|
|
665
821
|
}
|
|
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
|
-
|
|
822
|
+
|
|
823
|
+
// No visible progress — compose escalation message.
|
|
824
|
+
// TP-187 (#540): when the worker exits silently, fall back to the most
|
|
825
|
+
// recent `assistant_message` event in events.jsonl so the supervisor
|
|
826
|
+
// has SOMETHING to act on instead of `Worker said: ""`.
|
|
827
|
+
let workerSaid = (assistantMessage ?? "").trim();
|
|
828
|
+
let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" =
|
|
829
|
+
"current-turn";
|
|
830
|
+
if (!workerSaid) {
|
|
831
|
+
workerSaidSource = "empty-sentinel";
|
|
832
|
+
try {
|
|
833
|
+
const raw = readFileSync(eventsPath, "utf-8");
|
|
834
|
+
const lines = raw.split("\n");
|
|
835
|
+
// Walk backward to find the most recent assistant_message with non-empty text.
|
|
836
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
837
|
+
const line = lines[i].trim();
|
|
838
|
+
if (!line) continue;
|
|
839
|
+
try {
|
|
840
|
+
const evt = JSON.parse(line) as Record<string, unknown>;
|
|
841
|
+
if (evt.type === "assistant_message") {
|
|
842
|
+
const payload = evt.payload as Record<string, unknown> | undefined;
|
|
843
|
+
const text = typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
844
|
+
if (text) {
|
|
845
|
+
workerSaid = text;
|
|
846
|
+
workerSaidSource = "events-jsonl-fallback";
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
692
849
|
}
|
|
850
|
+
} catch {
|
|
851
|
+
/* skip malformed line */
|
|
693
852
|
}
|
|
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
853
|
}
|
|
854
|
+
} catch {
|
|
855
|
+
/* events.jsonl unreadable; sentinel will be used */
|
|
713
856
|
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
857
|
+
}
|
|
858
|
+
if (!workerSaid) {
|
|
859
|
+
workerSaid =
|
|
860
|
+
"(no assistant message captured — worker exited without producing visible output)";
|
|
861
|
+
workerSaidSource = "empty-sentinel";
|
|
862
|
+
}
|
|
863
|
+
const truncatedMsg = workerSaid.slice(0, 500);
|
|
864
|
+
const uncheckedItems: string[] = [];
|
|
865
|
+
try {
|
|
866
|
+
const statusContent = readFileSync(statusPath, "utf-8");
|
|
867
|
+
// TP-174: When segment-scoped, report only this segment's unchecked items
|
|
868
|
+
if (repoStepNumbers && currentRepoId) {
|
|
869
|
+
const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
|
|
870
|
+
if (segCbs) {
|
|
871
|
+
for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
|
|
872
|
+
uncheckedItems.push(text);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
|
|
877
|
+
if (uncheckedMatches) {
|
|
878
|
+
for (const item of uncheckedMatches.slice(0, 5)) {
|
|
879
|
+
uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
|
|
880
|
+
}
|
|
719
881
|
}
|
|
720
882
|
}
|
|
883
|
+
} catch {
|
|
884
|
+
/* best effort */
|
|
721
885
|
}
|
|
722
|
-
} catch { /* best effort */ }
|
|
723
886
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
887
|
+
const currentStepInfo =
|
|
888
|
+
remainingSteps.length > 0
|
|
889
|
+
? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
|
|
890
|
+
: "Unknown";
|
|
727
891
|
|
|
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
|
-
|
|
892
|
+
// Fire supervisor alert
|
|
893
|
+
try {
|
|
894
|
+
config.onSupervisorAlert!({
|
|
895
|
+
category: "worker-exit-intercept",
|
|
896
|
+
summary:
|
|
897
|
+
`🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
|
|
898
|
+
` Task: ${taskId}\n` +
|
|
899
|
+
` Current step: ${currentStepInfo}\n` +
|
|
900
|
+
` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
|
|
901
|
+
` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
|
|
902
|
+
` Worker said: "${truncatedMsg}"` +
|
|
903
|
+
(workerSaidSource === "events-jsonl-fallback"
|
|
904
|
+
? ` (fallback: most-recent assistant_message from events.jsonl)\n`
|
|
905
|
+
: workerSaidSource === "empty-sentinel"
|
|
906
|
+
? ` (no assistant message captured this iteration)\n`
|
|
907
|
+
: "\n") +
|
|
908
|
+
`\nSend a steering message to ${workerAgentId} with targeted instructions,` +
|
|
909
|
+
` or reply "skip" / "let it fail" to close the session.`,
|
|
910
|
+
context: {
|
|
911
|
+
taskId,
|
|
912
|
+
laneId: `lane-${config.laneNumber}`,
|
|
913
|
+
laneNumber: config.laneNumber,
|
|
914
|
+
agentId: workerAgentId,
|
|
915
|
+
exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
} catch {
|
|
919
|
+
/* best effort — don't block on alert failure */
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Poll worker mailbox inbox for supervisor reply (60s timeout)
|
|
923
|
+
const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
|
|
924
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
925
|
+
const escalationTimestamp = Date.now();
|
|
926
|
+
const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
|
|
927
|
+
|
|
928
|
+
const supervisorReply = await new Promise<string | null>((resolve) => {
|
|
929
|
+
const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
|
|
930
|
+
const poll = () => {
|
|
931
|
+
if (Date.now() >= deadline) {
|
|
932
|
+
resolve(null); // Timeout — fall back to corrective re-spawn
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const messages = readInbox(inboxDir, config.batchId);
|
|
937
|
+
// Only accept messages newer than escalation timestamp
|
|
938
|
+
for (const { filename, message } of messages) {
|
|
939
|
+
if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
|
|
940
|
+
// Consume the message
|
|
941
|
+
const ackDir = join(dirname(inboxDir), "ack");
|
|
942
|
+
try {
|
|
943
|
+
ackMessage(inboxDir, filename);
|
|
944
|
+
} catch {
|
|
945
|
+
/* best effort */
|
|
946
|
+
}
|
|
947
|
+
resolve(message.content);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
779
950
|
}
|
|
951
|
+
} catch {
|
|
952
|
+
/* inbox not ready yet */
|
|
780
953
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
});
|
|
954
|
+
setTimeout(poll, POLL_INTERVAL_MS);
|
|
955
|
+
};
|
|
956
|
+
poll();
|
|
957
|
+
});
|
|
786
958
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
959
|
+
if (!supervisorReply) {
|
|
960
|
+
// Timeout — let the session close, corrective re-spawn will handle it
|
|
961
|
+
logExecution(
|
|
962
|
+
statusPath,
|
|
963
|
+
"Exit intercept timeout",
|
|
964
|
+
`Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`,
|
|
965
|
+
);
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
793
968
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
969
|
+
// Interpret supervisor reply: close directives vs instructional content
|
|
970
|
+
const normalizedReply = supervisorReply.trim().toLowerCase();
|
|
971
|
+
const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
|
|
972
|
+
// Only short messages (< 30 chars) can be close directives.
|
|
973
|
+
// Longer messages are always instructions even if they start with "stop".
|
|
974
|
+
const isShortEnoughForDirective = normalizedReply.length < 30;
|
|
975
|
+
if (
|
|
976
|
+
isShortEnoughForDirective &&
|
|
977
|
+
CLOSE_DIRECTIVES.some(
|
|
978
|
+
(d) =>
|
|
979
|
+
normalizedReply === d ||
|
|
980
|
+
normalizedReply.startsWith(d + ":") ||
|
|
981
|
+
normalizedReply.startsWith(d + " ") ||
|
|
982
|
+
normalizedReply.startsWith(d + ".") ||
|
|
983
|
+
normalizedReply.startsWith(d + " -"),
|
|
984
|
+
)
|
|
985
|
+
) {
|
|
986
|
+
logExecution(
|
|
987
|
+
statusPath,
|
|
988
|
+
"Exit intercept close",
|
|
989
|
+
`Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`,
|
|
990
|
+
);
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
811
993
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
994
|
+
// Instructional reply — return as new prompt for the worker
|
|
995
|
+
logExecution(
|
|
996
|
+
statusPath,
|
|
997
|
+
"Exit intercept reprompt",
|
|
998
|
+
`Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`,
|
|
999
|
+
);
|
|
1000
|
+
return supervisorReply;
|
|
1001
|
+
}
|
|
817
1002
|
: undefined,
|
|
818
1003
|
};
|
|
819
1004
|
|
|
@@ -822,11 +1007,17 @@ export async function executeTaskV2(
|
|
|
822
1007
|
// present in the allowlist. Warn (do NOT throw or block spawn) if any
|
|
823
1008
|
// is missing — this catches future helper bugs or accidental bypasses.
|
|
824
1009
|
// See issue #530 for what silently breaks when bridge tools are missing.
|
|
825
|
-
const toolsList = (hostOpts.tools ?? "")
|
|
1010
|
+
const toolsList = (hostOpts.tools ?? "")
|
|
1011
|
+
.split(",")
|
|
1012
|
+
.map((s) => s.trim())
|
|
1013
|
+
.filter(Boolean);
|
|
826
1014
|
for (const bridgeTool of ENGINE_BRIDGE_TOOLS) {
|
|
827
1015
|
if (!toolsList.includes(bridgeTool)) {
|
|
828
|
-
logExecution(
|
|
829
|
-
|
|
1016
|
+
logExecution(
|
|
1017
|
+
statusPath,
|
|
1018
|
+
"WARN",
|
|
1019
|
+
`workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`,
|
|
1020
|
+
);
|
|
830
1021
|
}
|
|
831
1022
|
}
|
|
832
1023
|
|
|
@@ -852,8 +1043,19 @@ export async function executeTaskV2(
|
|
|
852
1043
|
iterationTelemetry = telemetry;
|
|
853
1044
|
lastTelemetry = telemetry;
|
|
854
1045
|
// Emit lane snapshot
|
|
855
|
-
emitSnapshot(
|
|
856
|
-
|
|
1046
|
+
emitSnapshot(
|
|
1047
|
+
config,
|
|
1048
|
+
taskId,
|
|
1049
|
+
segmentId,
|
|
1050
|
+
"running",
|
|
1051
|
+
telemetry,
|
|
1052
|
+
statusPath,
|
|
1053
|
+
reviewerStatePath,
|
|
1054
|
+
snapshotSegmentCtx,
|
|
1055
|
+
);
|
|
1056
|
+
} catch {
|
|
1057
|
+
/* non-fatal: telemetry callback must never crash the engine */
|
|
1058
|
+
}
|
|
857
1059
|
});
|
|
858
1060
|
|
|
859
1061
|
// Reviewer telemetry is written by the worker bridge during review_step.
|
|
@@ -862,7 +1064,16 @@ export async function executeTaskV2(
|
|
|
862
1064
|
let reviewerSnapshotFailures = 0;
|
|
863
1065
|
const reviewerRefreshFailureThreshold = 5;
|
|
864
1066
|
const reviewerRefresh = setInterval(() => {
|
|
865
|
-
const ok = emitSnapshot(
|
|
1067
|
+
const ok = emitSnapshot(
|
|
1068
|
+
config,
|
|
1069
|
+
taskId,
|
|
1070
|
+
segmentId,
|
|
1071
|
+
"running",
|
|
1072
|
+
iterationTelemetry,
|
|
1073
|
+
statusPath,
|
|
1074
|
+
reviewerStatePath,
|
|
1075
|
+
snapshotSegmentCtx,
|
|
1076
|
+
);
|
|
866
1077
|
if (ok) {
|
|
867
1078
|
reviewerSnapshotFailures = 0;
|
|
868
1079
|
return;
|
|
@@ -890,12 +1101,20 @@ export async function executeTaskV2(
|
|
|
890
1101
|
lastTelemetry = workerResult;
|
|
891
1102
|
|
|
892
1103
|
// Clean up wrap-up signal
|
|
893
|
-
if (existsSync(wrapUpFile))
|
|
1104
|
+
if (existsSync(wrapUpFile))
|
|
1105
|
+
try {
|
|
1106
|
+
unlinkSync(wrapUpFile);
|
|
1107
|
+
} catch {
|
|
1108
|
+
/* ignore */
|
|
1109
|
+
}
|
|
894
1110
|
|
|
895
1111
|
// Accumulate costs
|
|
896
1112
|
cumulativeCostUsd += workerResult.costUsd;
|
|
897
|
-
cumulativeTokens +=
|
|
898
|
-
workerResult.
|
|
1113
|
+
cumulativeTokens +=
|
|
1114
|
+
workerResult.inputTokens +
|
|
1115
|
+
workerResult.outputTokens +
|
|
1116
|
+
workerResult.cacheReadTokens +
|
|
1117
|
+
workerResult.cacheWriteTokens;
|
|
899
1118
|
|
|
900
1119
|
// ── TP-106: Poll worker outbox for replies/escalations ─────
|
|
901
1120
|
try {
|
|
@@ -949,37 +1168,50 @@ export async function executeTaskV2(
|
|
|
949
1168
|
exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
|
|
950
1169
|
},
|
|
951
1170
|
});
|
|
952
|
-
} catch {
|
|
1171
|
+
} catch {
|
|
1172
|
+
/* best effort */
|
|
1173
|
+
}
|
|
953
1174
|
}
|
|
954
1175
|
}
|
|
955
1176
|
|
|
956
1177
|
// Consume outbox message to prevent duplicate processing in later iterations.
|
|
957
1178
|
ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
|
|
958
1179
|
}
|
|
959
|
-
} catch {
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* best effort */
|
|
1182
|
+
}
|
|
960
1183
|
|
|
961
1184
|
// ── Steering annotation ─────────────────────────────────────
|
|
962
1185
|
try {
|
|
963
1186
|
if (existsSync(steeringPendingPath)) {
|
|
964
1187
|
const raw = readFileSync(steeringPendingPath, "utf-8");
|
|
965
|
-
for (const line of raw.split("\n").filter(l => l.trim())) {
|
|
1188
|
+
for (const line of raw.split("\n").filter((l) => l.trim())) {
|
|
966
1189
|
try {
|
|
967
1190
|
const entry = JSON.parse(line) as { ts: number; content: string; id: string };
|
|
968
1191
|
const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
|
|
969
1192
|
const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
|
|
970
1193
|
logExecution(statusPath, "⚠️ Steering", sanitized);
|
|
971
|
-
} catch {
|
|
1194
|
+
} catch {
|
|
1195
|
+
/* skip malformed */
|
|
1196
|
+
}
|
|
972
1197
|
}
|
|
973
1198
|
unlinkSync(steeringPendingPath);
|
|
974
1199
|
}
|
|
975
|
-
} catch {
|
|
1200
|
+
} catch {
|
|
1201
|
+
/* non-fatal */
|
|
1202
|
+
}
|
|
976
1203
|
|
|
977
1204
|
// Log iteration result
|
|
978
1205
|
const statusMsg = workerResult.killed
|
|
979
1206
|
? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})`
|
|
980
|
-
:
|
|
981
|
-
|
|
982
|
-
|
|
1207
|
+
: workerResult.exitCode === 0
|
|
1208
|
+
? "done"
|
|
1209
|
+
: `error (code ${workerResult.exitCode})`;
|
|
1210
|
+
logExecution(
|
|
1211
|
+
statusPath,
|
|
1212
|
+
`Worker iter ${totalIterations}`,
|
|
1213
|
+
`${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`,
|
|
1214
|
+
);
|
|
983
1215
|
|
|
984
1216
|
// ── Check progress ──────────────────────────────────────────
|
|
985
1217
|
const afterStatusContent = readFileSync(statusPath, "utf-8");
|
|
@@ -1008,21 +1240,31 @@ export async function executeTaskV2(
|
|
|
1008
1240
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1009
1241
|
}).trim();
|
|
1010
1242
|
// 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(
|
|
1243
|
+
const changedFiles = diffOutput.split("\n").filter((l) => l.includes("|"));
|
|
1244
|
+
const sourceChanges = changedFiles.filter(
|
|
1245
|
+
(l) => !l.includes("STATUS.md") && !l.includes(".steering"),
|
|
1246
|
+
);
|
|
1013
1247
|
hasSoftProgress = sourceChanges.length > 0;
|
|
1014
|
-
} catch {
|
|
1248
|
+
} catch {
|
|
1249
|
+
/* git not available or timeout — treat as no soft progress */
|
|
1250
|
+
}
|
|
1015
1251
|
|
|
1016
1252
|
if (hasSoftProgress) {
|
|
1017
1253
|
// Worker has uncommitted code changes — don't count toward stall.
|
|
1018
1254
|
// Reset the counter since the worker is actively editing.
|
|
1019
|
-
logExecution(
|
|
1020
|
-
|
|
1255
|
+
logExecution(
|
|
1256
|
+
statusPath,
|
|
1257
|
+
"Soft progress",
|
|
1258
|
+
`Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`,
|
|
1259
|
+
);
|
|
1021
1260
|
noProgressCount = 0;
|
|
1022
1261
|
} else {
|
|
1023
1262
|
noProgressCount++;
|
|
1024
|
-
logExecution(
|
|
1025
|
-
|
|
1263
|
+
logExecution(
|
|
1264
|
+
statusPath,
|
|
1265
|
+
"No progress",
|
|
1266
|
+
`Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`,
|
|
1267
|
+
);
|
|
1026
1268
|
if (noProgressCount >= config.noProgressLimit) {
|
|
1027
1269
|
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
1028
1270
|
// TP-187 (#538): synchronous outbox drain at lane-termination decision
|
|
@@ -1032,10 +1274,15 @@ export async function executeTaskV2(
|
|
|
1032
1274
|
try {
|
|
1033
1275
|
const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
|
|
1034
1276
|
if (drained > 0) {
|
|
1035
|
-
logExecution(
|
|
1036
|
-
|
|
1277
|
+
logExecution(
|
|
1278
|
+
statusPath,
|
|
1279
|
+
"Outbox drained",
|
|
1280
|
+
`No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`,
|
|
1281
|
+
);
|
|
1037
1282
|
}
|
|
1038
|
-
} catch {
|
|
1283
|
+
} catch {
|
|
1284
|
+
/* best effort — do not block termination */
|
|
1285
|
+
}
|
|
1039
1286
|
// TP-187 (#538): notify the supervisor process so it can suppress any
|
|
1040
1287
|
// further alerts queued for this lane (zombie-alert filter).
|
|
1041
1288
|
if (config.onLaneTerminated) {
|
|
@@ -1047,10 +1294,27 @@ export async function executeTaskV2(
|
|
|
1047
1294
|
terminatedAt: Date.now(),
|
|
1048
1295
|
reason: "no-progress-kill",
|
|
1049
1296
|
});
|
|
1050
|
-
} catch {
|
|
1297
|
+
} catch {
|
|
1298
|
+
/* best effort */
|
|
1299
|
+
}
|
|
1051
1300
|
}
|
|
1052
|
-
return makeResult(
|
|
1053
|
-
|
|
1301
|
+
return makeResult(
|
|
1302
|
+
taskId,
|
|
1303
|
+
segmentId,
|
|
1304
|
+
workerAgentId,
|
|
1305
|
+
"failed",
|
|
1306
|
+
startTime,
|
|
1307
|
+
`No progress after ${noProgressCount} iterations`,
|
|
1308
|
+
false,
|
|
1309
|
+
totalIterations,
|
|
1310
|
+
cumulativeCostUsd,
|
|
1311
|
+
cumulativeTokens,
|
|
1312
|
+
config,
|
|
1313
|
+
statusPath,
|
|
1314
|
+
reviewerStatePath,
|
|
1315
|
+
lastTelemetry,
|
|
1316
|
+
snapshotSegmentCtx,
|
|
1317
|
+
);
|
|
1054
1318
|
}
|
|
1055
1319
|
}
|
|
1056
1320
|
} else {
|
|
@@ -1065,7 +1329,7 @@ export async function executeTaskV2(
|
|
|
1065
1329
|
if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
|
|
1066
1330
|
// Only mark step complete in STATUS.md if ALL segments in that step
|
|
1067
1331
|
// are complete (not just ours). But for loop exit, we only care about ours.
|
|
1068
|
-
const ss = afterStatus.steps.find(s => s.number === stepNum);
|
|
1332
|
+
const ss = afterStatus.steps.find((s) => s.number === stepNum);
|
|
1069
1333
|
if (isStepComplete(ss)) {
|
|
1070
1334
|
updateStepStatus(statusPath, stepNum, "complete");
|
|
1071
1335
|
}
|
|
@@ -1073,7 +1337,7 @@ export async function executeTaskV2(
|
|
|
1073
1337
|
}
|
|
1074
1338
|
} else {
|
|
1075
1339
|
for (const step of parsed.steps) {
|
|
1076
|
-
const ss = afterStatus.steps.find(s => s.number === step.number);
|
|
1340
|
+
const ss = afterStatus.steps.find((s) => s.number === step.number);
|
|
1077
1341
|
if (isStepComplete(ss)) {
|
|
1078
1342
|
updateStepStatus(statusPath, step.number, "complete");
|
|
1079
1343
|
}
|
|
@@ -1085,12 +1349,12 @@ export async function executeTaskV2(
|
|
|
1085
1349
|
// have their segment checkboxes complete.
|
|
1086
1350
|
let allComplete: boolean;
|
|
1087
1351
|
if (repoStepNumbers && currentRepoId) {
|
|
1088
|
-
allComplete = [...repoStepNumbers].every(stepNum =>
|
|
1352
|
+
allComplete = [...repoStepNumbers].every((stepNum) =>
|
|
1089
1353
|
isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
|
|
1090
1354
|
);
|
|
1091
1355
|
} else {
|
|
1092
|
-
allComplete = parsed.steps.every(step => {
|
|
1093
|
-
const ss = afterStatus.steps.find(s => s.number === step.number);
|
|
1356
|
+
allComplete = parsed.steps.every((step) => {
|
|
1357
|
+
const ss = afterStatus.steps.find((s) => s.number === step.number);
|
|
1094
1358
|
return isStepComplete(ss);
|
|
1095
1359
|
});
|
|
1096
1360
|
}
|
|
@@ -1106,21 +1370,21 @@ export async function executeTaskV2(
|
|
|
1106
1370
|
// the iteration loop variables are out of scope here.
|
|
1107
1371
|
const postLoopRepoId = segmentId ? config.repoId : null;
|
|
1108
1372
|
const postLoopStepSegMap = unit.task.stepSegmentMap;
|
|
1109
|
-
const postLoopRepoSteps =
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
: null;
|
|
1373
|
+
const postLoopRepoSteps =
|
|
1374
|
+
postLoopStepSegMap && postLoopRepoId
|
|
1375
|
+
? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
|
|
1376
|
+
: null;
|
|
1377
|
+
const effectivePostLoopRepoSteps =
|
|
1378
|
+
postLoopRepoSteps && postLoopRepoSteps.size > 0 ? postLoopRepoSteps : null;
|
|
1115
1379
|
|
|
1116
1380
|
let allStepsComplete: boolean;
|
|
1117
1381
|
if (effectivePostLoopRepoSteps && postLoopRepoId) {
|
|
1118
|
-
allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
|
|
1382
|
+
allStepsComplete = [...effectivePostLoopRepoSteps].every((stepNum) =>
|
|
1119
1383
|
isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
|
|
1120
1384
|
);
|
|
1121
1385
|
} else {
|
|
1122
|
-
allStepsComplete = parsed.steps.every(step => {
|
|
1123
|
-
const ss = finalStatus.steps.find(s => s.number === step.number);
|
|
1386
|
+
allStepsComplete = parsed.steps.every((step) => {
|
|
1387
|
+
const ss = finalStatus.steps.find((s) => s.number === step.number);
|
|
1124
1388
|
return isStepComplete(ss);
|
|
1125
1389
|
});
|
|
1126
1390
|
}
|
|
@@ -1129,40 +1393,55 @@ export async function executeTaskV2(
|
|
|
1129
1393
|
let incomplete: string;
|
|
1130
1394
|
if (effectivePostLoopRepoSteps && postLoopRepoId) {
|
|
1131
1395
|
incomplete = [...effectivePostLoopRepoSteps]
|
|
1132
|
-
.filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
|
|
1133
|
-
.map(n => `Step ${n}`)
|
|
1396
|
+
.filter((stepNum) => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
|
|
1397
|
+
.map((n) => `Step ${n}`)
|
|
1134
1398
|
.join(", ");
|
|
1135
1399
|
} else {
|
|
1136
1400
|
incomplete = parsed.steps
|
|
1137
|
-
.filter(step => {
|
|
1138
|
-
const ss = finalStatus.steps.find(s => s.number === step.number);
|
|
1401
|
+
.filter((step) => {
|
|
1402
|
+
const ss = finalStatus.steps.find((s) => s.number === step.number);
|
|
1139
1403
|
return !isStepComplete(ss);
|
|
1140
1404
|
})
|
|
1141
|
-
.map(s => `Step ${s.number}`)
|
|
1405
|
+
.map((s) => `Step ${s.number}`)
|
|
1142
1406
|
.join(", ");
|
|
1143
1407
|
}
|
|
1144
1408
|
logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
|
|
1145
|
-
return makeResult(
|
|
1409
|
+
return makeResult(
|
|
1410
|
+
taskId,
|
|
1411
|
+
segmentId,
|
|
1412
|
+
workerAgentId,
|
|
1413
|
+
"failed",
|
|
1414
|
+
startTime,
|
|
1146
1415
|
`Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
|
|
1147
|
-
false,
|
|
1416
|
+
false,
|
|
1417
|
+
totalIterations,
|
|
1418
|
+
cumulativeCostUsd,
|
|
1419
|
+
cumulativeTokens,
|
|
1420
|
+
config,
|
|
1421
|
+
statusPath,
|
|
1422
|
+
reviewerStatePath,
|
|
1423
|
+
lastTelemetry,
|
|
1424
|
+
snapshotSegmentCtx,
|
|
1425
|
+
);
|
|
1148
1426
|
}
|
|
1149
1427
|
|
|
1150
1428
|
// TP-145: Determine if this is a non-final segment of a multi-segment task.
|
|
1151
1429
|
// If more segments remain after this one, suppress .DONE creation so that
|
|
1152
1430
|
// the engine can advance the segment frontier and execute subsequent segments.
|
|
1153
1431
|
// .DONE must only exist when ALL segments of a multi-segment task are complete.
|
|
1154
|
-
const isNonFinalSegment =
|
|
1155
|
-
&&
|
|
1156
|
-
|
|
1157
|
-
|
|
1432
|
+
const isNonFinalSegment =
|
|
1433
|
+
segmentId != null &&
|
|
1434
|
+
Array.isArray(unit.task.segmentIds) &&
|
|
1435
|
+
unit.task.segmentIds.length > 1 &&
|
|
1436
|
+
unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
|
|
1158
1437
|
|
|
1159
1438
|
// TP-165: Check for pending expansion requests in the worker's outbox.
|
|
1160
1439
|
// If the worker filed expansion requests, more segments may be added by the
|
|
1161
1440
|
// engine at the segment boundary — .DONE must not be created even if this
|
|
1162
1441
|
// appears to be the final segment based on the static segmentIds list.
|
|
1163
|
-
const hasPendingExpansionRequests =
|
|
1164
|
-
|
|
1165
|
-
|
|
1442
|
+
const hasPendingExpansionRequests =
|
|
1443
|
+
segmentId != null &&
|
|
1444
|
+
hasPendingExpansionRequestFiles(config.stateRoot, config.batchId, workerAgentId);
|
|
1166
1445
|
|
|
1167
1446
|
if (isNonFinalSegment || hasPendingExpansionRequests) {
|
|
1168
1447
|
// Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
|
|
@@ -1171,23 +1450,50 @@ export async function executeTaskV2(
|
|
|
1171
1450
|
// write access and sometimes create .DONE on their own, bypassing this gate).
|
|
1172
1451
|
if (existsSync(donePath)) {
|
|
1173
1452
|
let deleted = false;
|
|
1174
|
-
try {
|
|
1453
|
+
try {
|
|
1454
|
+
unlinkSync(donePath);
|
|
1455
|
+
deleted = true;
|
|
1456
|
+
} catch {
|
|
1457
|
+
/* best effort */
|
|
1458
|
+
}
|
|
1175
1459
|
if (deleted) {
|
|
1176
|
-
logExecution(
|
|
1177
|
-
|
|
1460
|
+
logExecution(
|
|
1461
|
+
statusPath,
|
|
1462
|
+
"Segment complete",
|
|
1463
|
+
`Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`,
|
|
1464
|
+
);
|
|
1178
1465
|
} else {
|
|
1179
|
-
logExecution(
|
|
1180
|
-
|
|
1466
|
+
logExecution(
|
|
1467
|
+
statusPath,
|
|
1468
|
+
"Segment complete",
|
|
1469
|
+
`⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`,
|
|
1470
|
+
);
|
|
1181
1471
|
}
|
|
1182
1472
|
} else {
|
|
1183
|
-
logExecution(
|
|
1184
|
-
|
|
1473
|
+
logExecution(
|
|
1474
|
+
statusPath,
|
|
1475
|
+
"Segment complete",
|
|
1476
|
+
`Segment ${segmentId} succeeded (not final — .DONE suppressed)`,
|
|
1477
|
+
);
|
|
1185
1478
|
}
|
|
1186
|
-
const suppressionReason = isNonFinalSegment
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1479
|
+
const suppressionReason = isNonFinalSegment ? "non-final" : "pending expansion requests";
|
|
1480
|
+
return makeResult(
|
|
1481
|
+
taskId,
|
|
1482
|
+
segmentId,
|
|
1483
|
+
workerAgentId,
|
|
1484
|
+
"succeeded",
|
|
1485
|
+
startTime,
|
|
1486
|
+
`Segment completed (${suppressionReason} — .DONE suppressed)`,
|
|
1487
|
+
false,
|
|
1488
|
+
totalIterations,
|
|
1489
|
+
cumulativeCostUsd,
|
|
1490
|
+
cumulativeTokens,
|
|
1491
|
+
config,
|
|
1492
|
+
statusPath,
|
|
1493
|
+
reviewerStatePath,
|
|
1494
|
+
lastTelemetry,
|
|
1495
|
+
snapshotSegmentCtx,
|
|
1496
|
+
);
|
|
1191
1497
|
}
|
|
1192
1498
|
|
|
1193
1499
|
// Create .DONE if not already present (final segment or single-segment/whole-task execution)
|
|
@@ -1197,8 +1503,23 @@ export async function executeTaskV2(
|
|
|
1197
1503
|
updateStatusField(statusPath, "Status", "✅ Complete");
|
|
1198
1504
|
logExecution(statusPath, "Task complete", ".DONE created");
|
|
1199
1505
|
|
|
1200
|
-
return makeResult(
|
|
1201
|
-
|
|
1506
|
+
return makeResult(
|
|
1507
|
+
taskId,
|
|
1508
|
+
segmentId,
|
|
1509
|
+
workerAgentId,
|
|
1510
|
+
"succeeded",
|
|
1511
|
+
startTime,
|
|
1512
|
+
".DONE file created by lane-runner",
|
|
1513
|
+
true,
|
|
1514
|
+
totalIterations,
|
|
1515
|
+
cumulativeCostUsd,
|
|
1516
|
+
cumulativeTokens,
|
|
1517
|
+
config,
|
|
1518
|
+
statusPath,
|
|
1519
|
+
reviewerStatePath,
|
|
1520
|
+
lastTelemetry,
|
|
1521
|
+
snapshotSegmentCtx,
|
|
1522
|
+
);
|
|
1202
1523
|
}
|
|
1203
1524
|
|
|
1204
1525
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
@@ -1262,17 +1583,18 @@ function makeResult(
|
|
|
1262
1583
|
/** TP-174: Segment context for segment-scoped snapshot progress */
|
|
1263
1584
|
segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
|
|
1264
1585
|
): LaneRunnerTaskResult {
|
|
1265
|
-
const telemetry =
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1586
|
+
const telemetry =
|
|
1587
|
+
status === "skipped"
|
|
1588
|
+
? undefined
|
|
1589
|
+
: {
|
|
1590
|
+
inputTokens: finalTelemetry?.inputTokens ?? 0,
|
|
1591
|
+
outputTokens: finalTelemetry?.outputTokens ?? 0,
|
|
1592
|
+
cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
|
|
1593
|
+
cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
|
|
1594
|
+
costUsd: finalTelemetry?.costUsd ?? 0,
|
|
1595
|
+
toolCalls: finalTelemetry?.toolCalls ?? 0,
|
|
1596
|
+
durationMs: finalTelemetry?.durationMs ?? 0,
|
|
1597
|
+
};
|
|
1276
1598
|
|
|
1277
1599
|
const result: LaneRunnerTaskResult = {
|
|
1278
1600
|
outcome: {
|
|
@@ -1295,7 +1617,16 @@ function makeResult(
|
|
|
1295
1617
|
// TP-115: Emit terminal snapshot with real telemetry from agent-host result
|
|
1296
1618
|
if (config && statusPath && reviewerStatePath) {
|
|
1297
1619
|
const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
|
|
1298
|
-
emitSnapshot(
|
|
1620
|
+
emitSnapshot(
|
|
1621
|
+
config,
|
|
1622
|
+
taskId,
|
|
1623
|
+
segmentId,
|
|
1624
|
+
terminalStatus,
|
|
1625
|
+
finalTelemetry ?? {},
|
|
1626
|
+
statusPath,
|
|
1627
|
+
reviewerStatePath,
|
|
1628
|
+
segmentCtx,
|
|
1629
|
+
);
|
|
1299
1630
|
}
|
|
1300
1631
|
|
|
1301
1632
|
return result;
|
|
@@ -1308,9 +1639,10 @@ export function readReviewerTelemetrySnapshot(
|
|
|
1308
1639
|
config: LaneRunnerConfig,
|
|
1309
1640
|
reviewerStatePathOrStatusPath: string,
|
|
1310
1641
|
): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
|
|
1311
|
-
const reviewerPath =
|
|
1312
|
-
|
|
1313
|
-
|
|
1642
|
+
const reviewerPath =
|
|
1643
|
+
basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md"
|
|
1644
|
+
? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json")
|
|
1645
|
+
: reviewerStatePathOrStatusPath;
|
|
1314
1646
|
if (!existsSync(reviewerPath)) return null;
|
|
1315
1647
|
|
|
1316
1648
|
try {
|
|
@@ -1334,7 +1666,7 @@ export function readReviewerTelemetrySnapshot(
|
|
|
1334
1666
|
if (parsed.status !== "running") return null;
|
|
1335
1667
|
|
|
1336
1668
|
// Stale guard: if updatedAt is present and older than threshold, ignore
|
|
1337
|
-
if (parsed.updatedAt &&
|
|
1669
|
+
if (parsed.updatedAt && Date.now() - parsed.updatedAt > REVIEWER_STATE_STALE_MS) return null;
|
|
1338
1670
|
|
|
1339
1671
|
return {
|
|
1340
1672
|
agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
|
|
@@ -1413,7 +1745,9 @@ function emitSnapshot(
|
|
|
1413
1745
|
iteration: parsed.iteration,
|
|
1414
1746
|
reviews: parsed.reviewCounter,
|
|
1415
1747
|
};
|
|
1416
|
-
} catch {
|
|
1748
|
+
} catch {
|
|
1749
|
+
/* best effort */
|
|
1750
|
+
}
|
|
1417
1751
|
|
|
1418
1752
|
const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath);
|
|
1419
1753
|
|
|
@@ -1451,4 +1785,3 @@ function emitSnapshot(
|
|
|
1451
1785
|
return false;
|
|
1452
1786
|
}
|
|
1453
1787
|
}
|
|
1454
|
-
|