taskplane 0.24.29 → 0.24.31

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.
@@ -455,6 +455,27 @@ function renderSummary(batch) {
455
455
  const wavePlan = batch.wavePlan || [tasks.map(t => t.taskId)]; // fallback: single wave
456
456
  const currentWaveIdx = batch.currentWaveIndex || 0;
457
457
 
458
+ // TP-148: Build wave segment context — for each task appearing in multiple waves,
459
+ // determine which segment corresponds to each wave appearance.
460
+ const taskWaveAppearance = new Map(); // taskId → count of appearances so far
461
+ const waveSegmentLabels = wavePlan.map((taskIds) => {
462
+ const labels = new Map(); // taskId → label string
463
+ for (const tid of taskIds) {
464
+ const task = taskMap.get(tid);
465
+ const segmentIds = task?.segmentIds;
466
+ if (!segmentIds || segmentIds.length <= 1) continue;
467
+ const count = (taskWaveAppearance.get(tid) || 0);
468
+ taskWaveAppearance.set(tid, count + 1);
469
+ const segId = segmentIds[count];
470
+ if (segId) {
471
+ const parsed = parseSegmentId(segId);
472
+ const repo = parsed ? parsed.repoId : "";
473
+ labels.set(tid, `${tid} (segment ${count + 1}/${segmentIds.length}: ${repo})`);
474
+ }
475
+ }
476
+ return labels;
477
+ });
478
+
458
479
  // Compute per-wave and overall checkbox totals
459
480
  let batchChecked = 0, batchTotal = 0;
460
481
  const waveStats = wavePlan.map((taskIds, waveIdx) => {
@@ -503,7 +524,10 @@ function renderSummary(batch) {
503
524
  const fillWidth = isDone ? 100 : fillPct;
504
525
  const segClass = isCurrent ? "wave-seg-current" : isFuture ? "wave-seg-future" : "";
505
526
 
506
- barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${ws.taskIds.join(', ')})">`;
527
+ // TP-148: Use segment-aware labels in tooltip when available
528
+ const segLabels = waveSegmentLabels[ws.waveIdx] || new Map();
529
+ const tooltipTasks = ws.taskIds.map(tid => segLabels.get(tid) || tid).join(', ');
530
+ barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${tooltipTasks})">`;
507
531
  barHtml += ` <div class="wave-seg-fill ${fillClass}" style="width:${fillWidth.toFixed(1)}%"></div>`;
508
532
  barHtml += ` <span class="wave-seg-label">W${ws.waveIdx + 1}</span>`;
509
533
  barHtml += `</div>`;
@@ -1144,7 +1168,7 @@ function renderMailboxAuditEvent(evt) {
1144
1168
  } else {
1145
1169
  // Unknown event type — render generically
1146
1170
  direction = evt.from ? `${escapeHtml(evt.from)}` : '';
1147
- preview = JSON.stringify(evt).slice(0, 120);
1171
+ preview = JSON.stringify(evt);
1148
1172
  }
1149
1173
 
1150
1174
  return `<div class="message-row">`
@@ -1175,7 +1199,7 @@ function renderMailboxDirMessage(msg) {
1175
1199
  else if (msg._status === 'reply-acked') statusBadge = '<span class="msg-badge msg-delivered">reply (acked)</span>';
1176
1200
  else statusBadge = '';
1177
1201
  const typeBadge = `<span class="msg-badge msg-type">${escapeHtml(msg.type || '')}</span>`;
1178
- const preview = (msg.content || '').slice(0, 120);
1202
+ const preview = msg.content || '';
1179
1203
  const broadcastTag = msg._isBroadcast ? ' <span class="msg-badge msg-type">broadcast</span>' : '';
1180
1204
 
1181
1205
  return `<div class="message-row">`
@@ -1787,9 +1787,9 @@ body {
1787
1787
  }
1788
1788
  .message-row {
1789
1789
  display: flex;
1790
- align-items: center;
1790
+ align-items: flex-start;
1791
1791
  gap: 8px;
1792
- padding: 4px 8px;
1792
+ padding: 6px 8px;
1793
1793
  font-size: 0.8rem;
1794
1794
  border-radius: 4px;
1795
1795
  background: var(--bg-secondary);
@@ -1826,9 +1826,8 @@ body {
1826
1826
  }
1827
1827
  .msg-preview {
1828
1828
  color: var(--text-primary);
1829
- overflow: hidden;
1830
- text-overflow: ellipsis;
1831
- white-space: nowrap;
1829
+ white-space: pre-wrap;
1830
+ word-break: break-word;
1832
1831
  flex: 1;
1833
1832
  }
1834
1833
  .msg-rate-limited {
@@ -1118,6 +1118,9 @@ function buildDashboardState() {
1118
1118
  // Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo).
1119
1119
  // Additive field — absent in v1 state files, frontend must default to "repo".
1120
1120
  mode: state.mode || "repo",
1121
+ // TP-148: Segment records for wave display context (v4+).
1122
+ // Each record has taskId, segmentId, repoId, status.
1123
+ segments: state.segments || [],
1121
1124
  },
1122
1125
  sessions,
1123
1126
  tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
@@ -22,7 +22,7 @@ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsPro
22
22
  import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
23
23
  import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SegmentExpansionRequest, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
24
24
  import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
25
- import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
25
+ import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, preserveSkippedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
26
26
  import { runPreflightCleanup, formatPreflightCleanup } from "./cleanup.ts";
27
27
 
28
28
  // ── Tier 0: Automatic Recovery Helpers (TP-039) ─────────────────────
@@ -346,9 +346,28 @@ export function validateSegmentExpansionRequestAtBoundary(
346
346
  if (requestedRepoSet.size !== request.requestedRepoIds.length) {
347
347
  return "duplicate repoIds in requestedRepoIds";
348
348
  }
349
+
350
+ // TP-145: Build a set of known repo IDs that edge endpoints may reference.
351
+ // This includes all requestedRepoIds plus the anchor segment's repo and
352
+ // any already-completed segments' repos. Workers commonly reference the
353
+ // anchor repo in edges (e.g., { from: "shared-libs", to: "web-client" })
354
+ // which is valid — the dependency is implicit for after-current placement.
355
+ const knownEdgeRepoIds = new Set(requestedRepoSet);
356
+ const orderedSegments = segmentState.orderedSegments ?? [];
357
+ const anchorSegment = orderedSegments.find((seg) => seg.segmentId === segmentId);
358
+ if (anchorSegment) {
359
+ knownEdgeRepoIds.add(anchorSegment.repoId);
360
+ }
361
+ for (const seg of orderedSegments) {
362
+ const status = segmentState.statusBySegmentId?.get(seg.segmentId);
363
+ if (status === "succeeded" || status === "failed" || status === "skipped") {
364
+ knownEdgeRepoIds.add(seg.repoId);
365
+ }
366
+ }
367
+
349
368
  for (const edge of request.edges) {
350
- if (!requestedRepoSet.has(edge.from) || !requestedRepoSet.has(edge.to)) {
351
- return "edge references a repo outside requestedRepoIds";
369
+ if (!knownEdgeRepoIds.has(edge.from) || !knownEdgeRepoIds.has(edge.to)) {
370
+ return "edge references a repo outside requestedRepoIds and known segments";
352
371
  }
353
372
  }
354
373
 
@@ -2289,10 +2308,29 @@ export async function executeOrchBatch(
2289
2308
  ORCH_MESSAGES.orchWaveStart(waveIdx + 1, runtimeSegmentRounds.length, waveTasks.length, lanes.length),
2290
2309
  "info",
2291
2310
  );
2311
+ // TP-148: Build per-task segment context for the wave_start event
2312
+ const waveSegmentContext: Array<{ taskId: string; segmentIndex: number; totalSegments: number; repoId: string; segmentId: string }> = [];
2313
+ for (const taskId of waveTasks) {
2314
+ const segState = segmentStateByTask.get(taskId);
2315
+ if (segState && segState.orderedSegments.length > 1) {
2316
+ const idx = segState.nextSegmentIndex;
2317
+ const seg = segState.orderedSegments[idx];
2318
+ if (seg) {
2319
+ waveSegmentContext.push({
2320
+ taskId,
2321
+ segmentIndex: idx + 1,
2322
+ totalSegments: segState.orderedSegments.length,
2323
+ repoId: seg.repoId,
2324
+ segmentId: seg.segmentId,
2325
+ });
2326
+ }
2327
+ }
2328
+ }
2292
2329
  emitEvent(stateRoot, {
2293
2330
  ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase),
2294
2331
  taskIds: waveTasks,
2295
2332
  laneCount: lanes.length,
2333
+ ...(waveSegmentContext.length > 0 ? { segmentContext: waveSegmentContext } : {}),
2296
2334
  }, onEngineEvent);
2297
2335
  // TP-029: Track repos from newly allocated lanes for cleanup coverage
2298
2336
  for (const lane of lanes) {
@@ -2521,10 +2559,12 @@ export async function executeOrchBatch(
2521
2559
  const segmentState = segmentStateByTask.get(taskId);
2522
2560
  if (!task || !segmentState) continue;
2523
2561
 
2524
- const activeSegmentId = task.activeSegmentId;
2562
+ // Use the completing segment ID from the task outcome, not task.activeSegmentId
2563
+ // which may already be null (advanced by pre-wave loop for next wave).
2564
+ const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
2565
+ const activeSegmentId = outcome?.segmentId ?? task.activeSegmentId;
2525
2566
  if (activeSegmentId) {
2526
2567
  segmentState.statusBySegmentId.set(activeSegmentId, "succeeded");
2527
- const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
2528
2568
  upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "succeeded", outcome, laneByTaskId.get(taskId));
2529
2569
 
2530
2570
  const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
@@ -2610,6 +2650,26 @@ export async function executeOrchBatch(
2610
2650
  batchState.orchBranch,
2611
2651
  );
2612
2652
  const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "succeeded");
2653
+
2654
+ // TP-145 hardening: if .DONE was prematurely created by the
2655
+ // completing segment (because it was the last segment at that
2656
+ // time), remove it now. The task is no longer complete — new
2657
+ // segments have been added and must execute first.
2658
+ // Only delete if segments were actually inserted (avoid
2659
+ // reopening a completed task on no-op mutations).
2660
+ const doneDir = task.packetTaskPath || task.taskFolder;
2661
+ if (doneDir && mutation.insertedSegmentIds.length > 0) {
2662
+ const donePath = join(doneDir, ".DONE");
2663
+ if (existsSync(donePath)) {
2664
+ try {
2665
+ unlinkSync(donePath);
2666
+ execLog("batch", batchState.batchId, "removed premature .DONE after segment expansion", {
2667
+ taskId, donePath, requestId,
2668
+ });
2669
+ } catch { /* non-fatal */ }
2670
+ }
2671
+ }
2672
+
2613
2673
  if (persistedInsertedSegments || recordedRequestId || mutation.insertedSegmentIds.length > 0) {
2614
2674
  persistRuntimeState("segment-expansion-approved", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
2615
2675
  }
@@ -2672,11 +2732,11 @@ export async function executeOrchBatch(
2672
2732
  const task = discovery.pending.get(taskId);
2673
2733
  const segmentState = segmentStateByTask.get(taskId);
2674
2734
  if (!task || !segmentState) continue;
2675
- const activeSegmentId = task.activeSegmentId;
2735
+ const failOutcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
2736
+ const activeSegmentId = failOutcome?.segmentId ?? task.activeSegmentId;
2676
2737
  if (activeSegmentId) {
2677
2738
  segmentState.statusBySegmentId.set(activeSegmentId, "failed");
2678
- const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
2679
- upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "failed", outcome, laneByTaskId.get(taskId));
2739
+ upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "failed", failOutcome, laneByTaskId.get(taskId));
2680
2740
 
2681
2741
  const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
2682
2742
  if (workerAgentId) {
@@ -2913,13 +2973,16 @@ export async function executeOrchBatch(
2913
2973
  // files (especially for level-0 / fast tasks). Check each merge-candidate
2914
2974
  // lane worktree and auto-commit any remaining changes so they're included
2915
2975
  // in the merge. Skips lanes with only failed/stalled tasks (no merge).
2976
+ // TP-147: Also auto-commit skipped-task lanes so partial progress
2977
+ // (STATUS.md updates, partial code) is preserved on their branch.
2916
2978
  for (const lane of waveResult.allocatedLanes) {
2917
2979
  if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue;
2918
- // Only check lanes that have at least one succeeded task (merge candidates)
2919
2980
  const laneOutcome = laneOutcomeByNumber.get(lane.laneNumber);
2920
2981
  if (!laneOutcome) continue;
2921
2982
  const hasSucceeded = laneOutcome.tasks.some(t => t.status === "succeeded");
2922
- if (!hasSucceeded) continue;
2983
+ const hasSkipped = laneOutcome.tasks.some(t => t.status === "skipped");
2984
+ // Auto-commit merge candidates (succeeded) and skipped-task lanes
2985
+ if (!hasSucceeded && !hasSkipped) continue;
2923
2986
  try {
2924
2987
  const addResult = runGit(["add", "-A"], lane.worktreePath);
2925
2988
  if (!addResult.ok) {
@@ -3463,6 +3526,34 @@ export async function executeOrchBatch(
3463
3526
  }
3464
3527
  // TP-028: Stamp task outcomes with partial progress data for persistence
3465
3528
  applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
3529
+
3530
+ // TP-147: Also preserve skipped task branches before inter-wave reset
3531
+ const skippedPpResult = preserveSkippedLaneProgress(
3532
+ latestAllocatedLanes,
3533
+ allTaskOutcomes,
3534
+ ppOpId,
3535
+ batchState.batchId,
3536
+ (repoId) => {
3537
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
3538
+ let targetBranch = batchState.orchBranch;
3539
+ if (repoId && perRepoRoot !== repoRoot) {
3540
+ try {
3541
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
3542
+ } catch { /* fall back to orchBranch */ }
3543
+ }
3544
+ return { repoRoot: perRepoRoot, targetBranch };
3545
+ },
3546
+ );
3547
+ // Merge unsafe branches from skipped tasks into the main set
3548
+ for (const branch of skippedPpResult.unsafeBranches) {
3549
+ ppUnsafeBranches.add(branch);
3550
+ }
3551
+ if (skippedPpResult.results.some(r => r.saved)) {
3552
+ execLog("batch", batchState.batchId,
3553
+ `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before inter-wave reset`);
3554
+ }
3555
+ // Stamp skipped task outcomes with partial progress data
3556
+ applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes);
3466
3557
  }
3467
3558
 
3468
3559
  // ── Post-merge: Reset worktrees for next wave ────────────
@@ -3809,6 +3900,30 @@ export async function executeOrchBatch(
3809
3900
  };
3810
3901
  });
3811
3902
 
3903
+ // TP-147: Ensure ALL tasks from the wave plan are represented in history.
3904
+ // Tasks that never got allocated (blocked by upstream failures, never started)
3905
+ // won't have entries in allTaskOutcomes. Add them with appropriate status.
3906
+ const coveredTaskIds = new Set(taskSummaries.map(t => t.taskId));
3907
+ for (let wi = 0; wi < wavePlan.length; wi++) {
3908
+ for (const taskId of wavePlan[wi]) {
3909
+ if (coveredTaskIds.has(taskId)) continue;
3910
+ // Determine the appropriate status for uncovered tasks
3911
+ const isBlocked = batchState.blockedTaskIds.has(taskId);
3912
+ const status: BatchTaskSummary["status"] = isBlocked ? "blocked" : "pending";
3913
+ taskSummaries.push({
3914
+ taskId,
3915
+ taskName: taskId,
3916
+ status,
3917
+ wave: wi + 1,
3918
+ lane: 0,
3919
+ durationMs: 0,
3920
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 },
3921
+ exitReason: isBlocked ? "Blocked by upstream failure" : null,
3922
+ });
3923
+ coveredTaskIds.add(taskId);
3924
+ }
3925
+ }
3926
+
3812
3927
  // Build per-wave summaries
3813
3928
  const waveSummaries: BatchWaveSummary[] = wavePlan.map((taskIds, wi) => {
3814
3929
  const waveTasks = taskSummaries.filter(t => t.wave === wi + 1);
@@ -3850,6 +3965,16 @@ export async function executeOrchBatch(
3850
3965
  ? "completed"
3851
3966
  : "aborted";
3852
3967
 
3968
+ // TP-147: Ensure totalTasks matches actual task array length.
3969
+ // Use taskSummaries.length as authoritative (includes gap-filled tasks)
3970
+ // and log a warning if it diverges from batchState.totalTasks.
3971
+ const actualTotalTasks = taskSummaries.length;
3972
+ if (actualTotalTasks !== batchState.totalTasks) {
3973
+ execLog("batch", batchState.batchId,
3974
+ `WARNING: totalTasks mismatch — batchState.totalTasks=${batchState.totalTasks}, ` +
3975
+ `taskSummaries.length=${actualTotalTasks}. Using taskSummaries.length for history.`);
3976
+ }
3977
+
3853
3978
  const summary: BatchHistorySummary = {
3854
3979
  batchId: batchState.batchId,
3855
3980
  status: historyStatus,
@@ -3857,7 +3982,7 @@ export async function executeOrchBatch(
3857
3982
  endedAt: Date.now(),
3858
3983
  durationMs: Date.now() - batchState.startedAt,
3859
3984
  totalWaves: wavePlan.length,
3860
- totalTasks: batchState.totalTasks,
3985
+ totalTasks: actualTotalTasks,
3861
3986
  succeededTasks: batchState.succeededTasks,
3862
3987
  failedTasks: batchState.failedTasks,
3863
3988
  skippedTasks: batchState.skippedTasks,
@@ -3981,6 +4106,37 @@ export async function executeOrchBatch(
3981
4106
  }
3982
4107
  // TP-028: Stamp task outcomes with partial progress data for persistence
3983
4108
  applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
4109
+
4110
+ // TP-147: Also preserve skipped task branches before terminal cleanup
4111
+ const skippedPpResult = preserveSkippedLaneProgress(
4112
+ latestAllocatedLanes,
4113
+ allTaskOutcomes,
4114
+ ppOpId,
4115
+ batchState.batchId,
4116
+ (repoId) => {
4117
+ const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
4118
+ let targetBranch = batchState.orchBranch;
4119
+ if (repoId && perRepoRoot !== repoRoot) {
4120
+ try {
4121
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
4122
+ } catch { /* fall back to orchBranch */ }
4123
+ }
4124
+ return { repoRoot: perRepoRoot, targetBranch };
4125
+ },
4126
+ );
4127
+ if (skippedPpResult.results.some(r => r.saved)) {
4128
+ execLog("batch", batchState.batchId,
4129
+ `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before terminal cleanup`);
4130
+ }
4131
+ for (const r of skippedPpResult.results) {
4132
+ if (!r.saved && (r.commitCount > 0 || r.error)) {
4133
+ execLog("batch", batchState.batchId,
4134
+ `WARNING: Failed to preserve partial progress for skipped task ${r.taskId} ` +
4135
+ `(${r.commitCount} commit(s) may become unreachable after cleanup)`,
4136
+ { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
4137
+ }
4138
+ }
4139
+ applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes);
3984
4140
  }
3985
4141
 
3986
4142
  // TP-029: Clean up worktrees across ALL encountered repos (not just primary).
@@ -164,7 +164,7 @@ export function execLog(
164
164
  * @returns true if agent is alive
165
165
  * @since TP-112
166
166
  */
167
- export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend): boolean {
167
+ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend, laneNumber?: number): boolean {
168
168
  // Read the registry from the global state root.
169
169
  // Since this is a pure liveness check, we scan for matching agentId
170
170
  // patterns: direct match, or lane-session + "-worker" suffix.
@@ -176,6 +176,18 @@ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: R
176
176
  // Try worker suffix (monitor uses lane session name, registry uses agentId)
177
177
  const workerManifest = agents[`${agentIdOrSessionName}-worker`];
178
178
  if (workerManifest && !isTerminalStatus(workerManifest.status) && isProcessAlive(workerManifest.pid)) return true;
179
+ // TP-148: In workspace mode, laneSessionId includes repoId and uses a local
180
+ // lane number (e.g., "orch-henry-api-lane-1") while the V2 registry uses
181
+ // global lane numbers without repoId (e.g., "orch-henry-lane-3-worker").
182
+ // Fall back to scanning the registry by global lane number when provided.
183
+ if (laneNumber != null) {
184
+ for (const agent of Object.values(agents)) {
185
+ if (agent.laneNumber === laneNumber && agent.role === "worker" &&
186
+ !isTerminalStatus(agent.status) && isProcessAlive(agent.pid)) {
187
+ return true;
188
+ }
189
+ }
190
+ }
179
191
  return false;
180
192
  }
181
193
 
@@ -201,7 +213,7 @@ export function setV2LivenessRegistryCache(registry: import("./process-registry.
201
213
  */
202
214
  export function killV2LaneAgents(
203
215
  sessionName: string,
204
- options?: { stateRoot?: string; batchId?: string; logContext?: string },
216
+ options?: { stateRoot?: string; batchId?: string; logContext?: string; laneNumber?: number },
205
217
  ): void {
206
218
  const registry = _v2LivenessRegistryCache ?? (
207
219
  options?.stateRoot && options?.batchId
@@ -212,16 +224,32 @@ export function killV2LaneAgents(
212
224
 
213
225
  const agents = registry.agents;
214
226
  const logContext = options?.logContext ?? "monitor";
227
+ const killedPids = new Set<number>();
215
228
  for (const suffix of ["-worker", "-reviewer", ""]) {
216
229
  const key = `${sessionName}${suffix}`;
217
230
  const manifest = agents[key];
218
- if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
231
+ if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid) && !killedPids.has(manifest.pid)) {
219
232
  try {
220
233
  process.kill(manifest.pid, "SIGTERM");
234
+ killedPids.add(manifest.pid);
221
235
  execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`);
222
236
  } catch { /* already dead */ }
223
237
  }
224
238
  }
239
+ // TP-148: Workspace-mode fallback — match by global lane number when
240
+ // session name lookup misses (repoId/local-vs-global lane mismatch).
241
+ if (options?.laneNumber != null) {
242
+ for (const agent of Object.values(agents)) {
243
+ if (agent.laneNumber === options.laneNumber &&
244
+ !isTerminalStatus(agent.status) && isProcessAlive(agent.pid) && !killedPids.has(agent.pid)) {
245
+ try {
246
+ process.kill(agent.pid, "SIGTERM");
247
+ killedPids.add(agent.pid);
248
+ execLog(logContext, agent.agentId, `killed V2 agent by lane number (PID ${agent.pid})`);
249
+ } catch { /* already dead */ }
250
+ }
251
+ }
252
+ }
225
253
  }
226
254
 
227
255
  // ── Async File/Status Helpers (TP-070) ───────────────────────────────
@@ -877,7 +905,7 @@ export async function resolveTaskMonitorState(
877
905
  // New task, stale snapshot — give the worker startup grace period
878
906
  sessionAlive = true;
879
907
  } else {
880
- sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
908
+ sessionAlive = isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber);
881
909
  }
882
910
  } else {
883
911
  sessionAlive = true;
@@ -886,7 +914,7 @@ export async function resolveTaskMonitorState(
886
914
  sessionAlive = snap.status === "running";
887
915
  }
888
916
  } else {
889
- sessionAlive = isV2AgentAlive(sessionName, "v2");
917
+ sessionAlive = isV2AgentAlive(sessionName, "v2", v2Context?.laneNumber);
890
918
  }
891
919
  const doneFileFound = await fileExistsAsync(donePath);
892
920
 
@@ -984,7 +1012,7 @@ export async function resolveTaskMonitorState(
984
1012
  stallMinutes,
985
1013
  backend: runtimeBackend ?? "legacy",
986
1014
  });
987
- killV2LaneAgents(sessionName);
1015
+ killV2LaneAgents(sessionName, { laneNumber: v2Context?.laneNumber });
988
1016
 
989
1017
  return {
990
1018
  taskId,
@@ -1249,7 +1277,8 @@ export async function monitorLanes(
1249
1277
  }
1250
1278
 
1251
1279
  // TP-112: Backend-aware lane liveness for snapshot
1252
- const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
1280
+ // TP-148: Pass global laneNumber for workspace-mode fallback lookup
1281
+ const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2", lane.laneNumber);
1253
1282
 
1254
1283
  laneSnapshots.push({
1255
1284
  laneId: lane.laneId,
@@ -1874,7 +1903,7 @@ export async function executeWithStopAll(
1874
1903
 
1875
1904
  // Kill ALL lane sessions immediately
1876
1905
  for (const lane of lanes) {
1877
- killV2LaneAgents(laneSessionIdOf(lane));
1906
+ killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber });
1878
1907
  }
1879
1908
  }
1880
1909
  }
@@ -1888,7 +1917,7 @@ export async function executeWithStopAll(
1888
1917
  pauseSignal.paused = true;
1889
1918
  execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
1890
1919
  for (const lane of lanes) {
1891
- killV2LaneAgents(laneSessionIdOf(lane));
1920
+ killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber });
1892
1921
  }
1893
1922
  }
1894
1923
 
@@ -176,6 +176,19 @@ export async function executeTaskV2(
176
176
  updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
177
177
  logExecution(statusPath, "Task started", "Runtime V2 lane-runner execution");
178
178
 
179
+ // Pre-segment guard: remove any stale .DONE from a prior segment or prior run.
180
+ // This closes the race window where the monitor sees .DONE before lane-runner
181
+ // can suppress it at segment end. For non-final segments, .DONE must not exist
182
+ // at any point during execution.
183
+ const isNonFinalAtStart = segmentId != null
184
+ && Array.isArray(unit.task.segmentIds)
185
+ && unit.task.segmentIds.length > 1
186
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
187
+ if (isNonFinalAtStart && existsSync(donePath)) {
188
+ try { unlinkSync(donePath); } catch { /* best effort */ }
189
+ logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`);
190
+ }
191
+
179
192
  // ── 2. Iteration loop ───────────────────────────────────────────
180
193
  let noProgressCount = 0;
181
194
  let totalIterations = 0;
@@ -528,7 +541,39 @@ export async function executeTaskV2(
528
541
  false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
529
542
  }
530
543
 
531
- // Create .DONE if not already present
544
+ // TP-145: Determine if this is a non-final segment of a multi-segment task.
545
+ // If more segments remain after this one, suppress .DONE creation so that
546
+ // the engine can advance the segment frontier and execute subsequent segments.
547
+ // .DONE must only exist when ALL segments of a multi-segment task are complete.
548
+ const isNonFinalSegment = segmentId != null
549
+ && Array.isArray(unit.task.segmentIds)
550
+ && unit.task.segmentIds.length > 1
551
+ && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
552
+
553
+ if (isNonFinalSegment) {
554
+ // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status.
555
+ // The engine will advance the frontier and dispatch the next segment.
556
+ // Also delete any .DONE the worker may have created directly (workers have
557
+ // write access and sometimes create .DONE on their own, bypassing this gate).
558
+ if (existsSync(donePath)) {
559
+ let deleted = false;
560
+ try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ }
561
+ if (deleted) {
562
+ logExecution(statusPath, "Segment complete",
563
+ `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`);
564
+ } else {
565
+ logExecution(statusPath, "Segment complete",
566
+ `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`);
567
+ }
568
+ } else {
569
+ logExecution(statusPath, "Segment complete",
570
+ `Segment ${segmentId} succeeded (not final — .DONE suppressed)`);
571
+ }
572
+ return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
573
+ "Segment completed (non-final — .DONE suppressed)", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
574
+ }
575
+
576
+ // Create .DONE if not already present (final segment or single-segment/whole-task execution)
532
577
  if (!existsSync(donePath)) {
533
578
  writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
534
579
  }
@@ -397,19 +397,52 @@ export interface IntegrationPlan {
397
397
  failedTasks: number;
398
398
  }
399
399
 
400
+ /**
401
+ * Check whether the git repository has any remotes configured.
402
+ *
403
+ * Used by integration planning to determine if PR mode is possible.
404
+ * A repo without remotes cannot create pull requests.
405
+ *
406
+ * @param cwd - Working directory with the git repo
407
+ * @returns true if at least one remote is configured
408
+ *
409
+ * @since TP-149
410
+ */
411
+ export function hasGitRemotes(cwd: string): boolean {
412
+ try {
413
+ const result = execFileSync("git", ["remote"], {
414
+ encoding: "utf-8",
415
+ timeout: 5_000,
416
+ cwd,
417
+ stdio: ["pipe", "pipe", "pipe"],
418
+ });
419
+ return result.trim().length > 0;
420
+ } catch {
421
+ return false;
422
+ }
423
+ }
424
+
400
425
  /**
401
426
  * Build an integration plan based on the batch state and branch status.
402
427
  *
403
- * Mode selection logic:
404
- * 1. If base branch is protected PR mode (can't push directly)
405
- * 2. If branches have diverged merge mode (ff not possible)
406
- * 3. Otherwise ff mode (cleanest)
428
+ * Mode selection logic (TP-149):
429
+ * 1. Check if remotes exist (determines if PR mode is possible)
430
+ * 2. If base branch is confirmed protected AND remotes exist → PR mode
431
+ * 3. Try fast-forward first (cleanest, most common)
432
+ * 4. If FF not possible (diverged) → merge mode
433
+ *
434
+ * PR mode is only selected when protection is **confirmed** (not "unknown").
435
+ * When protection status is indeterminate (gh unavailable, auth issues),
436
+ * the plan prefers FF → merge over PR, since PR may also fail in that state.
437
+ * Repos without remotes skip protection checks and PR mode entirely.
407
438
  *
408
439
  * @param batchState - Runtime batch state (orchBranch, baseBranch, counts)
409
440
  * @param cwd - Working directory with the git repo
441
+ * @param protectionOverride - Injectable protection status for testing
410
442
  * @returns Integration plan, or null if integration is not possible
411
443
  *
412
444
  * @since TP-043
445
+ * @modified TP-149 — Reordered to FF → merge → PR; check remotes first
413
446
  */
414
447
  export function buildIntegrationPlan(
415
448
  batchState: OrchBatchRuntimeState,
@@ -428,39 +461,21 @@ export function buildIntegrationPlan(
428
461
  const baseBranch = batchState.baseBranch;
429
462
  const batchId = batchState.batchId;
430
463
 
431
- // Step 1: Check branch protection (injectable for testing)
432
- const protection = protectionOverride ?? detectBranchProtection(baseBranch, cwd);
464
+ // Step 1: Check for remotes determines if PR mode is even possible (TP-149)
465
+ const remotes = hasGitRemotes(cwd);
433
466
 
434
- if (protection === "protected") {
435
- return {
436
- mode: "pr",
437
- orchBranch,
438
- baseBranch,
439
- batchId,
440
- branchProtection: protection,
441
- rationale: `Base branch \`${baseBranch}\` is protected — creating a pull request for review.`,
442
- succeededTasks: batchState.succeededTasks,
443
- failedTasks: batchState.failedTasks,
444
- };
445
- }
467
+ // Step 2: Determine protection status
468
+ // - Override: use as-is (test injection path)
469
+ // - Remotes exist: detect via gh API
470
+ // - No remotes: treat as unprotected (can't create PRs anyway)
471
+ const protection = protectionOverride
472
+ ?? (remotes ? detectBranchProtection(baseBranch, cwd) : "unprotected");
446
473
 
447
- if (protection === "unknown") {
448
- // Safe fallback: when protection status can't be determined
449
- // (gh CLI unavailable, no remote, etc.), default to PR mode
450
- // to avoid accidentally pushing to a protected branch.
451
- return {
452
- mode: "pr",
453
- orchBranch,
454
- baseBranch,
455
- batchId,
456
- branchProtection: protection,
457
- rationale: `Could not detect branch protection for \`${baseBranch}\` — defaulting to PR mode for safety.`,
458
- succeededTasks: batchState.succeededTasks,
459
- failedTasks: batchState.failedTasks,
460
- };
461
- }
474
+ // Step 3: Always try FF first, then merge, then PR (TP-149).
475
+ // Protected branches may still allow FF/merge via API tokens.
476
+ // PR is the last resort when direct merge is blocked.
462
477
 
463
- // Step 2: Check ff-ability (is baseBranch ancestor of orchBranch?)
478
+ // Step 3a: Try fast-forward first (cleanest, most common)
464
479
  try {
465
480
  execFileSync("git", ["merge-base", "--is-ancestor", baseBranch, orchBranch], {
466
481
  encoding: "utf-8",
@@ -480,7 +495,20 @@ export function buildIntegrationPlan(
480
495
  failedTasks: batchState.failedTasks,
481
496
  };
482
497
  } catch {
483
- // Branches have diverged — need merge commit
498
+ // Branches have diverged — need merge commit or PR
499
+ // Step 3c: If protected AND remotes exist, prefer PR (merge may be blocked by push protection)
500
+ if (protection === "protected" && remotes) {
501
+ return {
502
+ mode: "pr",
503
+ orchBranch,
504
+ baseBranch,
505
+ batchId,
506
+ branchProtection: protection,
507
+ rationale: `Branches diverged and \`${baseBranch}\` is protected — creating a pull request.`,
508
+ succeededTasks: batchState.succeededTasks,
509
+ failedTasks: batchState.failedTasks,
510
+ };
511
+ }
484
512
  return {
485
513
  mode: "merge",
486
514
  orchBranch,
@@ -3202,7 +3202,7 @@ export interface TokenCounts {
3202
3202
  export interface BatchTaskSummary {
3203
3203
  taskId: string;
3204
3204
  taskName: string;
3205
- status: "succeeded" | "failed" | "skipped" | "blocked" | "stalled";
3205
+ status: "succeeded" | "failed" | "skipped" | "blocked" | "stalled" | "pending";
3206
3206
  wave: number; // 1-based
3207
3207
  lane: number; // 1-based
3208
3208
  durationMs: number;
@@ -575,12 +575,20 @@ export function resolveBaseBranch(
575
575
  if (batchBaseBranch.startsWith("orch/") && repoId) {
576
576
  try {
577
577
  const check = runGit(["rev-parse", "--verify", `refs/heads/${batchBaseBranch}`], repoRoot);
578
- console.error(`[resolveBaseBranch] repoId=${repoId} batchBaseBranch=${batchBaseBranch} repoRoot=${repoRoot} check.ok=${check.ok}`);
579
578
  if (check.ok) {
580
579
  return batchBaseBranch;
581
580
  }
581
+ // TP-146: Orch branch exists as batch base but not in this repo.
582
+ // This means worktrees will branch from the repo's current HEAD
583
+ // instead of the orch branch, bypassing batch isolation.
584
+ console.error(
585
+ `[taskplane] resolveBaseBranch WARNING: orch branch "${batchBaseBranch}" not found in repo "${repoId}" at ${repoRoot} — falling back to repo HEAD. ` +
586
+ `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`,
587
+ );
582
588
  } catch (err) {
583
- console.error(`[resolveBaseBranch] repoId=${repoId} batchBaseBranch=${batchBaseBranch} THREW: ${err}`);
589
+ console.error(
590
+ `[taskplane] resolveBaseBranch WARNING: orch branch check failed for repo "${repoId}" at ${repoRoot}: ${err}`,
591
+ );
584
592
  }
585
593
  }
586
594
 
@@ -963,6 +971,97 @@ export function assignTasksToLanes(
963
971
  }
964
972
 
965
973
 
974
+ // ── Global Lane Cap (TP-148) ─────────────────────────────────────────
975
+
976
+ /**
977
+ * Enforce a global lane cap across all repo groups.
978
+ *
979
+ * In workspace mode, each repo independently allocates up to `maxLanes`.
980
+ * This function reduces the total across all repos to fit within the
981
+ * global `maxLanes` budget by consolidating lanes in repos with the
982
+ * most headroom (most lanes relative to their minimum of 1).
983
+ *
984
+ * Algorithm:
985
+ * 1. If total lanes ≤ maxLanes, no-op.
986
+ * 2. Group lanes by repo, sort repos by lane count descending.
987
+ * 3. Iteratively remove the last lane from the repo with the most
988
+ * lanes, redistributing its tasks to the lightest remaining lane
989
+ * in that repo.
990
+ * 4. Stop when total ≤ maxLanes or all repos are at 1 lane.
991
+ * 5. Renumber global lanes sequentially.
992
+ *
993
+ * Mutates `entries` in place: removes excess entries and renumbers.
994
+ *
995
+ * @param entries - Global lane entries from per-repo allocation
996
+ * @param maxLanes - Global maximum lane count
997
+ */
998
+ export function enforceGlobalLaneCap(
999
+ entries: Array<{
1000
+ globalLane: number;
1001
+ localLane: number;
1002
+ repoId: string | undefined;
1003
+ assignments: LaneAssignment[];
1004
+ }>,
1005
+ maxLanes: number,
1006
+ ): void {
1007
+ if (entries.length <= maxLanes) return;
1008
+
1009
+ // Group entries by repoId
1010
+ const byRepo = new Map<string, typeof entries>();
1011
+ for (const entry of entries) {
1012
+ const key = entry.repoId ?? "";
1013
+ const group = byRepo.get(key) || [];
1014
+ group.push(entry);
1015
+ byRepo.set(key, group);
1016
+ }
1017
+
1018
+ let excess = entries.length - maxLanes;
1019
+
1020
+ while (excess > 0) {
1021
+ // Find the repo with the most lanes (ties broken by key for determinism)
1022
+ let bestKey = "";
1023
+ let bestCount = 0;
1024
+ for (const [key, group] of byRepo) {
1025
+ if (group.length > bestCount || (group.length === bestCount && key < bestKey)) {
1026
+ bestKey = key;
1027
+ bestCount = group.length;
1028
+ }
1029
+ }
1030
+
1031
+ // All repos at 1 lane — can't reduce further
1032
+ if (bestCount <= 1) break;
1033
+
1034
+ // Remove the last lane from this repo and redistribute its tasks
1035
+ const group = byRepo.get(bestKey)!;
1036
+ const removed = group.pop()!;
1037
+ // Merge into the first lane of the same repo (deterministic target)
1038
+ group[0].assignments.push(...removed.assignments);
1039
+ excess--;
1040
+ }
1041
+
1042
+ // Warn if cap could not be fully enforced (more repos than maxLanes)
1043
+ const finalTotal = [...byRepo.values()].reduce((sum, g) => sum + g.length, 0);
1044
+ if (finalTotal > maxLanes) {
1045
+ console.error(
1046
+ `[taskplane] warning: global maxLanes=${maxLanes} could not be enforced — ` +
1047
+ `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` +
1048
+ `Increase maxLanes to at least ${byRepo.size} to avoid this.`,
1049
+ );
1050
+ }
1051
+
1052
+ // Rebuild entries array with sequential global lane numbers
1053
+ entries.length = 0;
1054
+ let globalLane = 1;
1055
+ for (const key of [...byRepo.keys()].sort()) {
1056
+ const group = byRepo.get(key)!;
1057
+ for (const entry of group) {
1058
+ entry.globalLane = globalLane++;
1059
+ entries.push(entry);
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+
966
1065
  /**
967
1066
  * Result of `allocateLanes()`.
968
1067
  *
@@ -1188,6 +1287,13 @@ export function allocateLanes(
1188
1287
  globalLaneOffset += sortedLocalLanes.length;
1189
1288
  }
1190
1289
 
1290
+ // ── Stage 2b: Enforce global lane cap (TP-148) ─────────────────
1291
+ // In workspace mode, each repo group independently allocates up to
1292
+ // maxLanes. If total lanes across all repos exceeds the global
1293
+ // maxLanes limit, reduce lanes in repos with the most headroom.
1294
+ // Preserves at least 1 lane per repo with tasks.
1295
+ enforceGlobalLaneCap(globalLaneEntries, config.orchestrator.max_lanes);
1296
+
1191
1297
  const laneCount = globalLaneEntries.length;
1192
1298
 
1193
1299
  if (laneCount === 0) {
@@ -2266,6 +2266,120 @@ export function preserveFailedLaneProgress(
2266
2266
  }
2267
2267
 
2268
2268
 
2269
+ /**
2270
+ * TP-147: Preserve partial progress for all skipped tasks before cleanup/reset.
2271
+ *
2272
+ * Skipped tasks may have worker commits (STATUS.md updates, partial code)
2273
+ * that would be lost when the worktree is cleaned up. This function saves
2274
+ * their lane branches as task-ID-named saved branches, similar to how
2275
+ * preserveFailedLaneProgress works for failed tasks.
2276
+ *
2277
+ * Unlike failed tasks, skipped-task branches are NOT merged (partial work
2278
+ * could break verification). Instead they are preserved for manual recovery.
2279
+ *
2280
+ * @param allocatedLanes - Lanes from the current/last wave
2281
+ * @param taskOutcomes - All task outcomes accumulated so far
2282
+ * @param opId - Operator identifier
2283
+ * @param batchId - Batch ID
2284
+ * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2285
+ * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2286
+ */
2287
+ export function preserveSkippedLaneProgress(
2288
+ allocatedLanes: AllocatedLane[],
2289
+ taskOutcomes: LaneTaskOutcome[],
2290
+ opId: string,
2291
+ batchId: string,
2292
+ resolveRepo: ResolveRepoContext,
2293
+ ): PreserveFailedLaneProgressResult {
2294
+ const results: SavePartialProgressResult[] = [];
2295
+ const preservedBranches = new Set<string>();
2296
+ const unsafeBranches = new Set<string>();
2297
+
2298
+ // Build a map: taskId → { laneBranch, repoId } from allocated lanes
2299
+ const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2300
+ for (const lane of allocatedLanes) {
2301
+ for (const allocatedTask of lane.tasks) {
2302
+ taskToLane.set(allocatedTask.taskId, {
2303
+ branch: lane.branch,
2304
+ repoId: lane.repoId,
2305
+ });
2306
+ }
2307
+ }
2308
+
2309
+ // Find skipped tasks
2310
+ const skippedTasks = taskOutcomes.filter(
2311
+ (to) => to.status === "skipped",
2312
+ );
2313
+
2314
+ // Track which lane branches we've already processed (a lane may have
2315
+ // multiple tasks; only save once per branch since all commits are shared)
2316
+ const processedBranches = new Set<string>();
2317
+
2318
+ for (const skippedTask of skippedTasks) {
2319
+ const laneInfo = taskToLane.get(skippedTask.taskId);
2320
+ if (!laneInfo) {
2321
+ results.push({
2322
+ saved: false,
2323
+ commitCount: 0,
2324
+ taskId: skippedTask.taskId,
2325
+ error: "Task not found in allocated lanes",
2326
+ });
2327
+ continue;
2328
+ }
2329
+
2330
+ // Skip if we've already processed this branch
2331
+ if (processedBranches.has(laneInfo.branch)) {
2332
+ continue;
2333
+ }
2334
+ processedBranches.add(laneInfo.branch);
2335
+
2336
+ // Resolve repo-specific target branch and repo root
2337
+ const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2338
+
2339
+ const result = savePartialProgress(
2340
+ laneInfo.branch,
2341
+ targetBranch,
2342
+ opId,
2343
+ skippedTask.taskId,
2344
+ batchId,
2345
+ perRepoRoot,
2346
+ laneInfo.repoId,
2347
+ );
2348
+
2349
+ results.push(result);
2350
+
2351
+ if (result.saved) {
2352
+ preservedBranches.add(result.savedBranch!);
2353
+
2354
+ execLog("partial-progress", skippedTask.taskId,
2355
+ `Task ${skippedTask.taskId} was skipped but has ${result.commitCount} commit(s) of partial progress preserved on branch ${result.savedBranch}`,
2356
+ {
2357
+ laneBranch: laneInfo.branch,
2358
+ savedBranch: result.savedBranch,
2359
+ commitCount: result.commitCount,
2360
+ repoId: laneInfo.repoId ?? "(default)",
2361
+ },
2362
+ );
2363
+ } else if (result.commitCount > 0 || result.error) {
2364
+ unsafeBranches.add(laneInfo.branch);
2365
+
2366
+ execLog("partial-progress", skippedTask.taskId,
2367
+ `WARNING: Failed to preserve partial progress for skipped task ${skippedTask.taskId} ` +
2368
+ `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2369
+ {
2370
+ laneBranch: laneInfo.branch,
2371
+ commitCount: result.commitCount,
2372
+ error: result.error ?? "unknown",
2373
+ repoId: laneInfo.repoId ?? "(default)",
2374
+ },
2375
+ );
2376
+ }
2377
+ }
2378
+
2379
+ return { results, preservedBranches, unsafeBranches };
2380
+ }
2381
+
2382
+
2269
2383
  // ── Stale Branch Cleanup (TP-051) ────────────────────────────────────
2270
2384
 
2271
2385
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.29",
3
+ "version": "0.24.31",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -152,7 +152,7 @@ Use tools **proactively** when the situation calls for it:
152
152
  - Operator asks to run tasks or start a batch → call `orch_start(target="all")` (or a specific area)
153
153
  - Operator asks "how's it going?" → call `orch_status()` first, then summarize
154
154
  - Batch paused due to a failure you diagnosed and fixed → call `orch_resume()`
155
- - Batch completed successfully → offer to call `orch_integrate(mode="pr")` or the operator's preferred mode
155
+ - Batch completed successfully → offer to call `orch_integrate()` (fast-forward is default and cleanest; use `mode="merge"` if diverged, `mode="pr"` only if remotes exist and branch is protected)
156
156
  - Batch is stuck or failing repeatedly → call `orch_status()` to diagnose, then `orch_abort()` if needed
157
157
  - Need to investigate before more tasks launch → call `orch_pause()` first
158
158
 
@@ -37,6 +37,13 @@ visibility into your progress. If you batch updates, the dashboard shows
37
37
  7. If all steps are complete, update STATUS.md **Status** field to `✅ Complete`
38
38
  and **Current Step** to the last step name — this is your final action
39
39
 
40
+ ## CRITICAL: Do NOT Create .DONE Files
41
+
42
+ **The `.DONE` file is managed by the runtime, not by you.** Never create,
43
+ write, or touch a `.DONE` file. The lane-runner creates it automatically
44
+ when all segments of your task are complete. If you create `.DONE` early,
45
+ it will cause downstream segments to be skipped and deliverables to be lost.
46
+
40
47
  ## CRITICAL: Never Exit Without Updating STATUS.md
41
48
 
42
49
  **Every turn MUST end with a tool call.** Do NOT produce a text-only response