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.
@@ -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
- if (monitorState && monitorState.lanes.length > 0) {
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) status = "failed";
378
- else if (snap?.status === "stalled") status = "stalled";
379
- else if (snap?.status === "running") status = "running";
380
- else if (lane.completedTasks.length > 0 && lane.remainingTasks.length === 0 && !lane.currentTaskId) status = "succeeded";
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
- sessionName: lane.sessionName,
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 yet — show lanes from allocation
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
- stepInfo = "waiting for data...";
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
- extraInfo = "session dead";
516
- extraColor = "error";
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
- noProgressCount++;
514
- logExecution(statusPath, "No progress",
515
- `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
516
- if (noProgressCount >= config.noProgressLimit) {
517
- logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
518
- return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
519
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
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
- if (isNonFinalSegment) {
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
- "Segment completed (non-final — .DONE suppressed)", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
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" {