taskplane 0.24.30 → 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.
- package/dashboard/public/app.js +27 -3
- package/dashboard/public/style.css +4 -5
- package/dashboard/server.cjs +3 -0
- package/extensions/taskplane/engine.ts +160 -6
- package/extensions/taskplane/execution.ts +38 -9
- package/extensions/taskplane/lane-runner.ts +46 -1
- package/extensions/taskplane/supervisor.ts +63 -35
- package/extensions/taskplane/types.ts +1 -1
- package/extensions/taskplane/waves.ts +108 -2
- package/extensions/taskplane/worktree.ts +114 -0
- package/package.json +1 -1
- package/templates/agents/supervisor.md +1 -1
- package/templates/agents/task-worker.md +7 -0
package/dashboard/public/app.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 =
|
|
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:
|
|
1790
|
+
align-items: flex-start;
|
|
1791
1791
|
gap: 8px;
|
|
1792
|
-
padding:
|
|
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
|
-
|
|
1830
|
-
|
|
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 {
|
package/dashboard/server.cjs
CHANGED
|
@@ -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 (!
|
|
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) {
|
|
@@ -2612,6 +2650,26 @@ export async function executeOrchBatch(
|
|
|
2612
2650
|
batchState.orchBranch,
|
|
2613
2651
|
);
|
|
2614
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
|
+
|
|
2615
2673
|
if (persistedInsertedSegments || recordedRequestId || mutation.insertedSegmentIds.length > 0) {
|
|
2616
2674
|
persistRuntimeState("segment-expansion-approved", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
2617
2675
|
}
|
|
@@ -2915,13 +2973,16 @@ export async function executeOrchBatch(
|
|
|
2915
2973
|
// files (especially for level-0 / fast tasks). Check each merge-candidate
|
|
2916
2974
|
// lane worktree and auto-commit any remaining changes so they're included
|
|
2917
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.
|
|
2918
2978
|
for (const lane of waveResult.allocatedLanes) {
|
|
2919
2979
|
if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue;
|
|
2920
|
-
// Only check lanes that have at least one succeeded task (merge candidates)
|
|
2921
2980
|
const laneOutcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2922
2981
|
if (!laneOutcome) continue;
|
|
2923
2982
|
const hasSucceeded = laneOutcome.tasks.some(t => t.status === "succeeded");
|
|
2924
|
-
|
|
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;
|
|
2925
2986
|
try {
|
|
2926
2987
|
const addResult = runGit(["add", "-A"], lane.worktreePath);
|
|
2927
2988
|
if (!addResult.ok) {
|
|
@@ -3465,6 +3526,34 @@ export async function executeOrchBatch(
|
|
|
3465
3526
|
}
|
|
3466
3527
|
// TP-028: Stamp task outcomes with partial progress data for persistence
|
|
3467
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);
|
|
3468
3557
|
}
|
|
3469
3558
|
|
|
3470
3559
|
// ── Post-merge: Reset worktrees for next wave ────────────
|
|
@@ -3811,6 +3900,30 @@ export async function executeOrchBatch(
|
|
|
3811
3900
|
};
|
|
3812
3901
|
});
|
|
3813
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
|
+
|
|
3814
3927
|
// Build per-wave summaries
|
|
3815
3928
|
const waveSummaries: BatchWaveSummary[] = wavePlan.map((taskIds, wi) => {
|
|
3816
3929
|
const waveTasks = taskSummaries.filter(t => t.wave === wi + 1);
|
|
@@ -3852,6 +3965,16 @@ export async function executeOrchBatch(
|
|
|
3852
3965
|
? "completed"
|
|
3853
3966
|
: "aborted";
|
|
3854
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
|
+
|
|
3855
3978
|
const summary: BatchHistorySummary = {
|
|
3856
3979
|
batchId: batchState.batchId,
|
|
3857
3980
|
status: historyStatus,
|
|
@@ -3859,7 +3982,7 @@ export async function executeOrchBatch(
|
|
|
3859
3982
|
endedAt: Date.now(),
|
|
3860
3983
|
durationMs: Date.now() - batchState.startedAt,
|
|
3861
3984
|
totalWaves: wavePlan.length,
|
|
3862
|
-
totalTasks:
|
|
3985
|
+
totalTasks: actualTotalTasks,
|
|
3863
3986
|
succeededTasks: batchState.succeededTasks,
|
|
3864
3987
|
failedTasks: batchState.failedTasks,
|
|
3865
3988
|
skippedTasks: batchState.skippedTasks,
|
|
@@ -3983,6 +4106,37 @@ export async function executeOrchBatch(
|
|
|
3983
4106
|
}
|
|
3984
4107
|
// TP-028: Stamp task outcomes with partial progress data for persistence
|
|
3985
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);
|
|
3986
4140
|
}
|
|
3987
4141
|
|
|
3988
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
405
|
-
* 2. If
|
|
406
|
-
* 3.
|
|
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
|
|
432
|
-
const
|
|
464
|
+
// Step 1: Check for remotes — determines if PR mode is even possible (TP-149)
|
|
465
|
+
const remotes = hasGitRemotes(cwd);
|
|
433
466
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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(
|
|
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
|
@@ -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"
|
|
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
|