taskplane 0.25.8 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/rpc-wrapper.mjs +0 -1
- package/bin/taskplane.mjs +13 -6
- package/dashboard/public/app.js +44 -1
- package/extensions/task-orchestrator.ts +1 -1
- package/extensions/taskplane/abort.ts +2 -1
- package/extensions/taskplane/agent-host.ts +100 -1
- package/extensions/taskplane/cleanup.ts +272 -10
- package/extensions/taskplane/config-loader.ts +53 -0
- package/extensions/taskplane/context-window.ts +66 -0
- package/extensions/taskplane/engine.ts +182 -47
- package/extensions/taskplane/execution.ts +250 -66
- package/extensions/taskplane/extension.ts +5125 -5105
- package/extensions/taskplane/formatting.ts +70 -11
- package/extensions/taskplane/git.ts +34 -0
- package/extensions/taskplane/lane-runner.ts +219 -10
- package/extensions/taskplane/merge.ts +3128 -2917
- package/extensions/taskplane/path-resolver.ts +1 -1
- package/extensions/taskplane/persistence.ts +3 -0
- package/extensions/taskplane/resume.ts +86 -30
- package/extensions/taskplane/sidecar-telemetry.ts +252 -0
- package/extensions/taskplane/supervisor-primer.md +57 -0
- package/extensions/taskplane/supervisor.ts +1 -1
- package/extensions/taskplane/task-executor-core.ts +2 -5
- package/extensions/taskplane/types.ts +27 -2
- package/package.json +1 -3
- package/templates/agents/task-worker.md +429 -387
- package/templates/tasks/CONTEXT.md +3 -4
- package/extensions/task-runner.ts +0 -2784
|
@@ -367,22 +367,71 @@ export function buildDashboardViewModel(
|
|
|
367
367
|
// Build lane cards from monitor state (if available) or current lanes
|
|
368
368
|
const laneCards: OrchLaneCardData[] = [];
|
|
369
369
|
|
|
370
|
-
|
|
370
|
+
// TP-170: Detect stale monitor data from prior waves.
|
|
371
|
+
// When wave N+1 starts, batchState.currentLanes is updated to wave N+1's
|
|
372
|
+
// lanes, but monitorState may still hold wave N's data until the first
|
|
373
|
+
// poll of wave N+1's monitor. Detect this mismatch by checking whether
|
|
374
|
+
// the monitor's lane numbers match the current allocation.
|
|
375
|
+
const monitorIsFresh = monitorState && monitorState.lanes.length > 0 && (
|
|
376
|
+
// If no current allocation, monitor data is the best we have
|
|
377
|
+
// (covers terminal phases like completed/failed/stopped)
|
|
378
|
+
batchState.currentLanes.length === 0 ||
|
|
379
|
+
// If allocated lanes exist, verify monitor lanes match them
|
|
380
|
+
monitorState.lanes.some(ml =>
|
|
381
|
+
batchState.currentLanes.some(cl => cl.laneNumber === ml.laneNumber),
|
|
382
|
+
)
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// TP-170: Build a laneNumber → AllocatedLane index for identity reconciliation.
|
|
386
|
+
// In workspace mode, the monitor’s sessionName (e.g., "orch-henry-api-lane-1")
|
|
387
|
+
// may differ from the V2 registry agentId ("orch-henry-lane-3-worker").
|
|
388
|
+
// Cross-referencing with the current allocation ensures the displayed session
|
|
389
|
+
// name matches the authoritative laneSessionId for the current wave.
|
|
390
|
+
const allocatedByLaneNumber = new Map<number, { laneSessionId: string; laneId: string }>();
|
|
391
|
+
for (const cl of batchState.currentLanes) {
|
|
392
|
+
allocatedByLaneNumber.set(cl.laneNumber, { laneSessionId: cl.laneSessionId, laneId: cl.laneId });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (monitorIsFresh && monitorState) {
|
|
371
396
|
// Sort lanes by laneNumber (deterministic)
|
|
372
397
|
const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
|
|
373
398
|
|
|
374
399
|
for (const lane of sortedLanes) {
|
|
375
400
|
const snap = lane.currentTaskSnapshot;
|
|
401
|
+
const alloc = allocatedByLaneNumber.get(lane.laneNumber);
|
|
402
|
+
|
|
403
|
+
// TP-170: Reconcile task-level vs lane-level sessionAlive.
|
|
404
|
+
// resolveTaskMonitorState may derive sessionAlive from the lane
|
|
405
|
+
// snapshot file (snap.status === "running") while the lane-level
|
|
406
|
+
// sessionAlive comes from isV2AgentAlive (PID check). When the
|
|
407
|
+
// task snapshot says "running" but the lane session is confirmed
|
|
408
|
+
// dead, the task is effectively failed — not still running.
|
|
376
409
|
let status: OrchLaneCardData["status"] = "idle";
|
|
377
|
-
if (lane.failedTasks.length > 0)
|
|
378
|
-
|
|
379
|
-
else if (snap?.status === "
|
|
380
|
-
|
|
410
|
+
if (lane.failedTasks.length > 0) {
|
|
411
|
+
status = "failed";
|
|
412
|
+
} else if (snap?.status === "stalled") {
|
|
413
|
+
status = "stalled";
|
|
414
|
+
} else if (snap?.status === "running") {
|
|
415
|
+
// TP-170: TOCTOU guard — if lane session is dead but task snapshot
|
|
416
|
+
// still says "running", treat as failed instead of showing
|
|
417
|
+
// "session dead" in the card. This prevents the false positive
|
|
418
|
+
// where the lane snapshot file lags behind the PID liveness check.
|
|
419
|
+
status = lane.sessionAlive ? "running" : "failed";
|
|
420
|
+
} else if (
|
|
421
|
+
lane.completedTasks.length > 0 &&
|
|
422
|
+
lane.remainingTasks.length === 0 &&
|
|
423
|
+
!lane.currentTaskId
|
|
424
|
+
) {
|
|
425
|
+
status = "succeeded";
|
|
426
|
+
}
|
|
381
427
|
|
|
382
428
|
laneCards.push({
|
|
383
429
|
laneNumber: lane.laneNumber,
|
|
384
|
-
laneId: lane.laneId,
|
|
385
|
-
|
|
430
|
+
laneId: alloc?.laneId || lane.laneId,
|
|
431
|
+
// TP-170: Prefer the allocation’s laneSessionId (current-wave authority)
|
|
432
|
+
// over the monitor’s sessionName which may use a stale or workspace-local
|
|
433
|
+
// name that doesn’t match the V2 registry.
|
|
434
|
+
sessionName: alloc?.laneSessionId || lane.sessionName,
|
|
386
435
|
sessionAlive: lane.sessionAlive,
|
|
387
436
|
currentTaskId: lane.currentTaskId,
|
|
388
437
|
currentStepName: snap?.currentStepName || null,
|
|
@@ -395,7 +444,9 @@ export function buildDashboardViewModel(
|
|
|
395
444
|
});
|
|
396
445
|
}
|
|
397
446
|
} else if (batchState.currentLanes.length > 0) {
|
|
398
|
-
// No monitor data
|
|
447
|
+
// No fresh monitor data — show lanes from allocation.
|
|
448
|
+
// This covers both initial startup (monitor hasn't polled yet)
|
|
449
|
+
// and wave transitions (monitor data is stale from prior wave).
|
|
399
450
|
const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
|
|
400
451
|
for (const lane of sortedLanes) {
|
|
401
452
|
laneCards.push({
|
|
@@ -495,7 +546,11 @@ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme:
|
|
|
495
546
|
if (card.currentStepName) {
|
|
496
547
|
stepInfo = trunc(card.currentStepName, w - 2);
|
|
497
548
|
} else if (card.currentTaskId && card.totalItems === 0) {
|
|
498
|
-
|
|
549
|
+
// TP-170: Distinguish startup-grace (no STATUS.md yet) from
|
|
550
|
+
// genuine stale data. During startup, the lane is alive but
|
|
551
|
+
// hasn’t written STATUS.md yet — show "starting..." instead of
|
|
552
|
+
// the misleading "waiting for data..." which implies a problem.
|
|
553
|
+
stepInfo = card.sessionAlive ? "starting..." : "no status data";
|
|
499
554
|
} else if (!card.currentTaskId && card.status !== "idle") {
|
|
500
555
|
stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
|
|
501
556
|
}
|
|
@@ -512,8 +567,12 @@ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme:
|
|
|
512
567
|
extraInfo = `${card.totalChecked}/${card.totalItems} ✓`;
|
|
513
568
|
extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
|
|
514
569
|
} else if (!card.sessionAlive && card.status === "running") {
|
|
515
|
-
|
|
516
|
-
|
|
570
|
+
// TP-170: With the TOCTOU guard in buildDashboardViewModel, a lane
|
|
571
|
+
// with a dead session and task snapshot "running" now gets status
|
|
572
|
+
// "failed" instead. This branch guards any remaining edge cases
|
|
573
|
+
// (e.g., allocation-fallback lane assumed alive but actually dead).
|
|
574
|
+
extraInfo = "session ended";
|
|
575
|
+
extraColor = "warning";
|
|
517
576
|
}
|
|
518
577
|
const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
|
|
519
578
|
const extraVis = Math.min(extraInfo.length, w);
|
|
@@ -54,3 +54,37 @@ export function runGit(
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Run a git command with custom environment variables.
|
|
59
|
+
*
|
|
60
|
+
* Used by TP-169 to create commits on the orch branch without
|
|
61
|
+
* modifying HEAD, via GIT_INDEX_FILE for alternate index manipulation.
|
|
62
|
+
*
|
|
63
|
+
* @param args - Git command arguments
|
|
64
|
+
* @param cwd - Working directory
|
|
65
|
+
* @param env - Additional environment variables to set
|
|
66
|
+
*/
|
|
67
|
+
export function runGitWithEnv(
|
|
68
|
+
args: string[],
|
|
69
|
+
cwd: string,
|
|
70
|
+
env: Record<string, string>,
|
|
71
|
+
): { ok: boolean; stdout: string; stderr: string } {
|
|
72
|
+
try {
|
|
73
|
+
const stdout = execFileSync("git", args, {
|
|
74
|
+
encoding: "utf-8",
|
|
75
|
+
timeout: 30_000,
|
|
76
|
+
cwd,
|
|
77
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
78
|
+
env: { ...process.env, ...env },
|
|
79
|
+
}).trim();
|
|
80
|
+
return { ok: true, stdout, stderr: "" };
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
stdout: (e.stdout ?? "").toString().trim(),
|
|
86
|
+
stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* @since TP-105
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "fs";
|
|
19
19
|
import { join, dirname, resolve, basename } from "path";
|
|
20
|
+
import { execSync } from "child_process";
|
|
20
21
|
import { fileURLToPath } from "url";
|
|
21
22
|
|
|
22
23
|
import {
|
|
@@ -40,6 +41,9 @@ import {
|
|
|
40
41
|
|
|
41
42
|
import {
|
|
42
43
|
readOutbox,
|
|
44
|
+
readInbox,
|
|
45
|
+
ackMessage,
|
|
46
|
+
sessionInboxDir,
|
|
43
47
|
ackOutboxMessage,
|
|
44
48
|
appendMailboxAuditEvent,
|
|
45
49
|
} from "./mailbox.ts";
|
|
@@ -299,6 +303,21 @@ export async function executeTaskV2(
|
|
|
299
303
|
`Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`,
|
|
300
304
|
`Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`,
|
|
301
305
|
);
|
|
306
|
+
|
|
307
|
+
// If the worker exited without checking any boxes, add a corrective directive
|
|
308
|
+
if (noProgressCount > 0) {
|
|
309
|
+
promptLines.push(
|
|
310
|
+
``,
|
|
311
|
+
`🚨 CRITICAL: You have exited ${noProgressCount} time(s) without completing work.`,
|
|
312
|
+
`Your previous exit was premature. You said something like "Now let me fix this"`,
|
|
313
|
+
`and then STOPPED instead of actually making the edit.`,
|
|
314
|
+
``,
|
|
315
|
+
`DO NOT DO THIS AGAIN. When you know what to edit, call the edit tool IMMEDIATELY.`,
|
|
316
|
+
`Do not produce a text message describing what you plan to do. Just do it.`,
|
|
317
|
+
`Work continuously through ALL remaining checkboxes until the task is DONE.`,
|
|
318
|
+
`Do not exit between checkboxes or steps.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
// ── Spawn worker ────────────────────────────────────────────
|
|
@@ -351,6 +370,135 @@ export async function executeTaskV2(
|
|
|
351
370
|
...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
|
|
352
371
|
...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
|
|
353
372
|
},
|
|
373
|
+
// TP-172: Exit interception callback — escalate to supervisor when worker
|
|
374
|
+
// exits without making visible progress (no checkboxes, no blocker logged).
|
|
375
|
+
onPrematureExit: config.onSupervisorAlert
|
|
376
|
+
? async (assistantMessage: string): Promise<string | null> => {
|
|
377
|
+
// Check if the worker made visible progress during this turn:
|
|
378
|
+
// 1. Checkbox progress (more items checked)
|
|
379
|
+
// 2. Blocker logged (non-empty Blockers section)
|
|
380
|
+
try {
|
|
381
|
+
const statusContent = readFileSync(statusPath, "utf-8");
|
|
382
|
+
const midStatus = parseStatusMd(statusContent);
|
|
383
|
+
const midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
|
|
384
|
+
if (midTotalChecked > prevTotalChecked) {
|
|
385
|
+
// Worker checked off checkboxes — let it exit normally
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
// Check for blocker entries: extract Blockers section and see if non-empty
|
|
389
|
+
const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i);
|
|
390
|
+
if (blockerMatch) {
|
|
391
|
+
const blockerContent = blockerMatch[1].trim();
|
|
392
|
+
// If blockers section has real content (not just "*None*" or empty)
|
|
393
|
+
if (blockerContent && blockerContent !== "*None*") {
|
|
394
|
+
// Worker logged a blocker — let it exit normally
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch { /* If we can't read STATUS.md, proceed with escalation */ }
|
|
399
|
+
|
|
400
|
+
// No visible progress — compose escalation message
|
|
401
|
+
const truncatedMsg = assistantMessage.slice(0, 500);
|
|
402
|
+
const uncheckedItems: string[] = [];
|
|
403
|
+
try {
|
|
404
|
+
const statusContent = readFileSync(statusPath, "utf-8");
|
|
405
|
+
const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
|
|
406
|
+
if (uncheckedMatches) {
|
|
407
|
+
for (const item of uncheckedMatches.slice(0, 5)) {
|
|
408
|
+
uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch { /* best effort */ }
|
|
412
|
+
|
|
413
|
+
const currentStepInfo = remainingSteps.length > 0
|
|
414
|
+
? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
|
|
415
|
+
: "Unknown";
|
|
416
|
+
|
|
417
|
+
// Fire supervisor alert
|
|
418
|
+
try {
|
|
419
|
+
config.onSupervisorAlert!({
|
|
420
|
+
category: "worker-exit-intercept",
|
|
421
|
+
summary:
|
|
422
|
+
`🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` +
|
|
423
|
+
` Task: ${taskId}\n` +
|
|
424
|
+
` Current step: ${currentStepInfo}\n` +
|
|
425
|
+
` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
|
|
426
|
+
` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
|
|
427
|
+
` Worker said: "${truncatedMsg}"\n` +
|
|
428
|
+
`\nSend a steering message to ${workerAgentId} with targeted instructions,` +
|
|
429
|
+
` or reply "skip" / "let it fail" to close the session.`,
|
|
430
|
+
context: {
|
|
431
|
+
taskId,
|
|
432
|
+
laneId: `lane-${config.laneNumber}`,
|
|
433
|
+
laneNumber: config.laneNumber,
|
|
434
|
+
agentId: workerAgentId,
|
|
435
|
+
exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`,
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
} catch { /* best effort — don't block on alert failure */ }
|
|
439
|
+
|
|
440
|
+
// Poll worker mailbox inbox for supervisor reply (60s timeout)
|
|
441
|
+
const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000;
|
|
442
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
443
|
+
const escalationTimestamp = Date.now();
|
|
444
|
+
const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
|
|
445
|
+
|
|
446
|
+
const supervisorReply = await new Promise<string | null>((resolve) => {
|
|
447
|
+
const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS;
|
|
448
|
+
const poll = () => {
|
|
449
|
+
if (Date.now() >= deadline) {
|
|
450
|
+
resolve(null); // Timeout — fall back to corrective re-spawn
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const messages = readInbox(inboxDir, config.batchId);
|
|
455
|
+
// Only accept messages newer than escalation timestamp
|
|
456
|
+
for (const { filename, message } of messages) {
|
|
457
|
+
if (message.timestamp >= escalationTimestamp && message.from === "supervisor") {
|
|
458
|
+
// Consume the message
|
|
459
|
+
const ackDir = join(dirname(inboxDir), "ack");
|
|
460
|
+
try { ackMessage(inboxDir, filename); } catch { /* best effort */ }
|
|
461
|
+
resolve(message.content);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch { /* inbox not ready yet */ }
|
|
466
|
+
setTimeout(poll, POLL_INTERVAL_MS);
|
|
467
|
+
};
|
|
468
|
+
poll();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (!supervisorReply) {
|
|
472
|
+
// Timeout — let the session close, corrective re-spawn will handle it
|
|
473
|
+
logExecution(statusPath, "Exit intercept timeout",
|
|
474
|
+
`Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`);
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Interpret supervisor reply: close directives vs instructional content
|
|
479
|
+
const normalizedReply = supervisorReply.trim().toLowerCase();
|
|
480
|
+
const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"];
|
|
481
|
+
// Only short messages (< 30 chars) can be close directives.
|
|
482
|
+
// Longer messages are always instructions even if they start with "stop".
|
|
483
|
+
const isShortEnoughForDirective = normalizedReply.length < 30;
|
|
484
|
+
if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d =>
|
|
485
|
+
normalizedReply === d ||
|
|
486
|
+
normalizedReply.startsWith(d + ":") ||
|
|
487
|
+
normalizedReply.startsWith(d + " ") ||
|
|
488
|
+
normalizedReply.startsWith(d + ".") ||
|
|
489
|
+
normalizedReply.startsWith(d + " -")
|
|
490
|
+
)) {
|
|
491
|
+
logExecution(statusPath, "Exit intercept close",
|
|
492
|
+
`Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Instructional reply — return as new prompt for the worker
|
|
497
|
+
logExecution(statusPath, "Exit intercept reprompt",
|
|
498
|
+
`Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`);
|
|
499
|
+
return supervisorReply;
|
|
500
|
+
}
|
|
501
|
+
: undefined,
|
|
354
502
|
};
|
|
355
503
|
|
|
356
504
|
// Context pressure: write wrap-up signal before kill
|
|
@@ -510,13 +658,39 @@ export async function executeTaskV2(
|
|
|
510
658
|
const progressDelta = afterTotalChecked - prevTotalChecked;
|
|
511
659
|
|
|
512
660
|
if (progressDelta <= 0) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
661
|
+
// Check for soft progress: uncommitted changes in the worktree
|
|
662
|
+
// indicate the worker is actively editing code even if no checkbox
|
|
663
|
+
// was checked yet. This avoids false stall detection on complex
|
|
664
|
+
// steps where analysis + editing spans multiple tool calls.
|
|
665
|
+
let hasSoftProgress = false;
|
|
666
|
+
try {
|
|
667
|
+
const diffOutput = execSync("git diff --stat HEAD", {
|
|
668
|
+
cwd: unit.worktreePath,
|
|
669
|
+
timeout: 5000,
|
|
670
|
+
encoding: "utf-8",
|
|
671
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
672
|
+
}).trim();
|
|
673
|
+
// Only count source file changes as soft progress, not just STATUS.md
|
|
674
|
+
const changedFiles = diffOutput.split("\n").filter(l => l.includes("|"));
|
|
675
|
+
const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering"));
|
|
676
|
+
hasSoftProgress = sourceChanges.length > 0;
|
|
677
|
+
} catch { /* git not available or timeout — treat as no soft progress */ }
|
|
678
|
+
|
|
679
|
+
if (hasSoftProgress) {
|
|
680
|
+
// Worker has uncommitted code changes — don't count toward stall.
|
|
681
|
+
// Reset the counter since the worker is actively editing.
|
|
682
|
+
logExecution(statusPath, "Soft progress",
|
|
683
|
+
`Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`);
|
|
684
|
+
noProgressCount = 0;
|
|
685
|
+
} else {
|
|
686
|
+
noProgressCount++;
|
|
687
|
+
logExecution(statusPath, "No progress",
|
|
688
|
+
`Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
|
|
689
|
+
if (noProgressCount >= config.noProgressLimit) {
|
|
690
|
+
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
691
|
+
return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
|
|
692
|
+
`No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
|
|
693
|
+
}
|
|
520
694
|
}
|
|
521
695
|
} else {
|
|
522
696
|
noProgressCount = 0;
|
|
@@ -569,7 +743,15 @@ export async function executeTaskV2(
|
|
|
569
743
|
&& unit.task.segmentIds.length > 1
|
|
570
744
|
&& unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
|
|
571
745
|
|
|
572
|
-
|
|
746
|
+
// TP-165: Check for pending expansion requests in the worker's outbox.
|
|
747
|
+
// If the worker filed expansion requests, more segments may be added by the
|
|
748
|
+
// engine at the segment boundary — .DONE must not be created even if this
|
|
749
|
+
// appears to be the final segment based on the static segmentIds list.
|
|
750
|
+
const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles(
|
|
751
|
+
config.stateRoot, config.batchId, workerAgentId,
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
if (isNonFinalSegment || hasPendingExpansionRequests) {
|
|
573
755
|
// Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
|
|
574
756
|
// The engine will advance the frontier and dispatch the next segment.
|
|
575
757
|
// Also delete any .DONE the worker may have created directly (workers have
|
|
@@ -588,8 +770,11 @@ export async function executeTaskV2(
|
|
|
588
770
|
logExecution(statusPath, "Segment complete",
|
|
589
771
|
`Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
|
|
590
772
|
}
|
|
773
|
+
const suppressionReason = isNonFinalSegment
|
|
774
|
+
? "non-final"
|
|
775
|
+
: "pending expansion requests";
|
|
591
776
|
return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
|
|
592
|
-
|
|
777
|
+
`Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
|
|
593
778
|
}
|
|
594
779
|
|
|
595
780
|
// Create .DONE if not already present (final segment or single-segment/whole-task execution)
|
|
@@ -605,6 +790,30 @@ export async function executeTaskV2(
|
|
|
605
790
|
|
|
606
791
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
607
792
|
|
|
793
|
+
/**
|
|
794
|
+
* TP-165: Check if the worker's outbox contains pending segment expansion requests.
|
|
795
|
+
*
|
|
796
|
+
* Pending expansion request files match `segment-expansion-*.json` (not renamed
|
|
797
|
+
* to `.processed`, `.rejected`, etc.). If any exist, the engine will process them
|
|
798
|
+
* at the segment boundary — and may add more segments to the task.
|
|
799
|
+
*
|
|
800
|
+
* @returns true if at least one pending expansion request file exists
|
|
801
|
+
*/
|
|
802
|
+
export function hasPendingExpansionRequestFiles(
|
|
803
|
+
stateRoot: string,
|
|
804
|
+
batchId: string,
|
|
805
|
+
agentId: string,
|
|
806
|
+
): boolean {
|
|
807
|
+
const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox");
|
|
808
|
+
if (!existsSync(outboxDir)) return false;
|
|
809
|
+
try {
|
|
810
|
+
const entries = readdirSync(outboxDir);
|
|
811
|
+
return entries.some((entry) => /^segment-expansion-.+\.json$/.test(entry));
|
|
812
|
+
} catch {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
608
817
|
export function mapLaneTaskStatusToTerminalSnapshotStatus(
|
|
609
818
|
status: LaneTaskStatus,
|
|
610
819
|
): "idle" | "complete" | "failed" {
|