taskplane 0.24.21 → 0.24.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/taskplane.mjs +239 -103
- package/extensions/task-runner.ts +2 -110
- package/extensions/taskplane/agent-bridge-extension.ts +196 -3
- package/extensions/taskplane/config-loader.ts +306 -269
- package/extensions/taskplane/config-schema.ts +49 -32
- package/extensions/taskplane/engine-worker.ts +4 -0
- package/extensions/taskplane/engine.ts +884 -12
- package/extensions/taskplane/execution.ts +12 -1
- package/extensions/taskplane/extension.ts +4 -0
- package/extensions/taskplane/lane-runner.ts +5 -0
- package/extensions/taskplane/merge.ts +7 -1
- package/extensions/taskplane/persistence.ts +12 -0
- package/extensions/taskplane/resume.ts +132 -22
- package/extensions/taskplane/settings-tui.ts +149 -172
- package/extensions/taskplane/supervisor.ts +2 -2
- package/extensions/taskplane/types.ts +82 -17
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Main batch execution engine
|
|
3
3
|
* @module orch/engine
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readdirSync, readFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "fs";
|
|
6
6
|
import { join, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
@@ -19,8 +19,8 @@ import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-rep
|
|
|
19
19
|
import { resolveOperatorId } from "./naming.ts";
|
|
20
20
|
import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
21
21
|
import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsProcessAlive } from "./process-registry.ts";
|
|
22
|
-
import { buildBatchProgressSnapshot, buildEngineEventBase, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
|
|
23
|
-
import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
22
|
+
import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
|
|
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
25
|
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
26
26
|
import { runPreflightCleanup, formatPreflightCleanup } from "./cleanup.ts";
|
|
@@ -147,6 +147,552 @@ function buildSegmentDependencyMap(plan: TaskSegmentPlan): Map<string, string[]>
|
|
|
147
147
|
return depsBySegmentId;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
function resolveTaskWorkerAgentId(
|
|
151
|
+
taskId: string,
|
|
152
|
+
allTaskOutcomes: LaneTaskOutcome[],
|
|
153
|
+
laneByTaskId: Map<string, AllocatedLane>,
|
|
154
|
+
): string | null {
|
|
155
|
+
const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
|
|
156
|
+
if (outcome?.sessionName) {
|
|
157
|
+
return outcome.sessionName;
|
|
158
|
+
}
|
|
159
|
+
const lane = laneByTaskId.get(taskId);
|
|
160
|
+
return lane?.laneSessionId ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function listPendingSegmentExpansionRequestFiles(stateRoot: string, batchId: string, agentId: string): string[] {
|
|
164
|
+
const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox");
|
|
165
|
+
if (!existsSync(outboxDir)) return [];
|
|
166
|
+
let entries: string[] = [];
|
|
167
|
+
try {
|
|
168
|
+
entries = readdirSync(outboxDir);
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return entries
|
|
173
|
+
.filter((entry) => /^segment-expansion-.+\.json$/.test(entry))
|
|
174
|
+
.sort((a, b) => a.localeCompare(b))
|
|
175
|
+
.map((entry) => join(outboxDir, entry));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface PendingSegmentExpansionRequest {
|
|
179
|
+
filePath: string;
|
|
180
|
+
request: SegmentExpansionRequest;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface SegmentExpansionParseFailure {
|
|
184
|
+
filePath: string;
|
|
185
|
+
reason: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseSegmentExpansionRequestPayload(payload: unknown): SegmentExpansionRequest | null {
|
|
189
|
+
if (!payload || typeof payload !== "object") return null;
|
|
190
|
+
const candidate = payload as Record<string, unknown>;
|
|
191
|
+
if (typeof candidate.requestId !== "string" || !candidate.requestId.trim()) return null;
|
|
192
|
+
if (typeof candidate.taskId !== "string" || !candidate.taskId.trim()) return null;
|
|
193
|
+
if (typeof candidate.fromSegmentId !== "string" || !candidate.fromSegmentId.trim()) return null;
|
|
194
|
+
if (!Array.isArray(candidate.requestedRepoIds) || candidate.requestedRepoIds.length === 0 || candidate.requestedRepoIds.some((repoId) => typeof repoId !== "string" || !repoId.trim())) return null;
|
|
195
|
+
if (typeof candidate.rationale !== "string") return null;
|
|
196
|
+
if (candidate.placement !== "after-current" && candidate.placement !== "end") return null;
|
|
197
|
+
if (!Array.isArray(candidate.edges)) return null;
|
|
198
|
+
for (const edge of candidate.edges) {
|
|
199
|
+
if (!edge || typeof edge !== "object") return null;
|
|
200
|
+
const typedEdge = edge as Record<string, unknown>;
|
|
201
|
+
if (typeof typedEdge.from !== "string" || !typedEdge.from.trim()) return null;
|
|
202
|
+
if (typeof typedEdge.to !== "string" || !typedEdge.to.trim()) return null;
|
|
203
|
+
}
|
|
204
|
+
if (typeof candidate.timestamp !== "number" || !Number.isFinite(candidate.timestamp)) return null;
|
|
205
|
+
return {
|
|
206
|
+
requestId: candidate.requestId,
|
|
207
|
+
taskId: candidate.taskId,
|
|
208
|
+
fromSegmentId: candidate.fromSegmentId as SegmentExpansionRequest["fromSegmentId"],
|
|
209
|
+
requestedRepoIds: candidate.requestedRepoIds as string[],
|
|
210
|
+
rationale: candidate.rationale,
|
|
211
|
+
placement: candidate.placement,
|
|
212
|
+
edges: candidate.edges as SegmentExpansionRequest["edges"],
|
|
213
|
+
timestamp: candidate.timestamp,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseSegmentExpansionRequests(filePaths: string[]): {
|
|
218
|
+
valid: PendingSegmentExpansionRequest[];
|
|
219
|
+
malformed: SegmentExpansionParseFailure[];
|
|
220
|
+
} {
|
|
221
|
+
const valid: PendingSegmentExpansionRequest[] = [];
|
|
222
|
+
const malformed: SegmentExpansionParseFailure[] = [];
|
|
223
|
+
|
|
224
|
+
for (const filePath of filePaths) {
|
|
225
|
+
let raw = "";
|
|
226
|
+
try {
|
|
227
|
+
raw = readFileSync(filePath, "utf-8");
|
|
228
|
+
} catch (err) {
|
|
229
|
+
malformed.push({
|
|
230
|
+
filePath,
|
|
231
|
+
reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let payload: unknown;
|
|
237
|
+
try {
|
|
238
|
+
payload = JSON.parse(raw);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
malformed.push({
|
|
241
|
+
filePath,
|
|
242
|
+
reason: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const parsed = parseSegmentExpansionRequestPayload(payload);
|
|
248
|
+
if (!parsed) {
|
|
249
|
+
malformed.push({
|
|
250
|
+
filePath,
|
|
251
|
+
reason: "schema validation failed",
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
valid.push({
|
|
257
|
+
filePath,
|
|
258
|
+
request: parsed,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { valid, malformed };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function markSegmentExpansionRequestFile(filePath: string, stateSuffix: "invalid" | "discarded" | "rejected" | "processed"): boolean {
|
|
266
|
+
try {
|
|
267
|
+
renameSync(filePath, `${filePath}.${stateSuffix}`);
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function expansionRequestHasCycle(request: SegmentExpansionRequest): boolean {
|
|
275
|
+
const requestedRepoIds = [...new Set(request.requestedRepoIds)];
|
|
276
|
+
const indegree = new Map<string, number>();
|
|
277
|
+
const outgoing = new Map<string, string[]>();
|
|
278
|
+
for (const repoId of requestedRepoIds) {
|
|
279
|
+
indegree.set(repoId, 0);
|
|
280
|
+
outgoing.set(repoId, []);
|
|
281
|
+
}
|
|
282
|
+
for (const edge of request.edges) {
|
|
283
|
+
if (!indegree.has(edge.from) || !indegree.has(edge.to)) continue;
|
|
284
|
+
outgoing.get(edge.from)!.push(edge.to);
|
|
285
|
+
indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ready = [...requestedRepoIds]
|
|
289
|
+
.filter((repoId) => (indegree.get(repoId) ?? 0) === 0)
|
|
290
|
+
.sort((a, b) => a.localeCompare(b));
|
|
291
|
+
let visited = 0;
|
|
292
|
+
while (ready.length > 0) {
|
|
293
|
+
const next = ready.shift()!;
|
|
294
|
+
visited += 1;
|
|
295
|
+
for (const dep of outgoing.get(next) ?? []) {
|
|
296
|
+
const count = (indegree.get(dep) ?? 0) - 1;
|
|
297
|
+
indegree.set(dep, count);
|
|
298
|
+
if (count === 0) {
|
|
299
|
+
ready.push(dep);
|
|
300
|
+
ready.sort((a, b) => a.localeCompare(b));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return visited !== requestedRepoIds.length;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function validateSegmentExpansionRequestAtBoundary(
|
|
309
|
+
requestFile: PendingSegmentExpansionRequest,
|
|
310
|
+
taskId: string,
|
|
311
|
+
segmentId: string,
|
|
312
|
+
segmentState: SegmentFrontierTaskState,
|
|
313
|
+
workspaceConfig: WorkspaceConfig | null | undefined,
|
|
314
|
+
knownRequestIds: ReadonlySet<string>,
|
|
315
|
+
): string | null {
|
|
316
|
+
const request = requestFile.request;
|
|
317
|
+
if (request.taskId !== taskId || request.fromSegmentId !== segmentId) {
|
|
318
|
+
return "request does not match the active segment boundary";
|
|
319
|
+
}
|
|
320
|
+
if (segmentState.terminalStatus !== "pending") {
|
|
321
|
+
return "task is already in terminal state";
|
|
322
|
+
}
|
|
323
|
+
if (request.placement !== "after-current" && request.placement !== "end") {
|
|
324
|
+
return `unsupported placement \"${request.placement}\"`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (knownRequestIds.has(request.requestId)) {
|
|
328
|
+
return `requestId \"${request.requestId}\" already processed`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (workspaceConfig) {
|
|
332
|
+
for (const repoId of request.requestedRepoIds) {
|
|
333
|
+
if (!workspaceConfig.repos.has(repoId)) {
|
|
334
|
+
return `unknown repoId \"${repoId}\"`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
for (const repoId of request.requestedRepoIds) {
|
|
339
|
+
if (repoId !== "default") {
|
|
340
|
+
return `repo expansion requires workspace mode (unknown repoId \"${repoId}\")`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const requestedRepoSet = new Set(request.requestedRepoIds);
|
|
346
|
+
if (requestedRepoSet.size !== request.requestedRepoIds.length) {
|
|
347
|
+
return "duplicate repoIds in requestedRepoIds";
|
|
348
|
+
}
|
|
349
|
+
for (const edge of request.edges) {
|
|
350
|
+
if (!requestedRepoSet.has(edge.from) || !requestedRepoSet.has(edge.to)) {
|
|
351
|
+
return "edge references a repo outside requestedRepoIds";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (expansionRequestHasCycle(request)) {
|
|
356
|
+
return "expansion request introduces a cycle in requested edges";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function processSegmentExpansionRequestAtBoundary(
|
|
363
|
+
batchId: string,
|
|
364
|
+
taskId: string,
|
|
365
|
+
segmentId: string,
|
|
366
|
+
agentId: string,
|
|
367
|
+
requestFile: PendingSegmentExpansionRequest,
|
|
368
|
+
segmentState: SegmentFrontierTaskState,
|
|
369
|
+
workspaceConfig: WorkspaceConfig | null | undefined,
|
|
370
|
+
knownRequestIds: Set<string>,
|
|
371
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
372
|
+
const validationFailure = validateSegmentExpansionRequestAtBoundary(
|
|
373
|
+
requestFile,
|
|
374
|
+
taskId,
|
|
375
|
+
segmentId,
|
|
376
|
+
segmentState,
|
|
377
|
+
workspaceConfig,
|
|
378
|
+
knownRequestIds,
|
|
379
|
+
);
|
|
380
|
+
if (validationFailure) {
|
|
381
|
+
return { ok: false, reason: validationFailure };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
knownRequestIds.add(requestFile.request.requestId);
|
|
385
|
+
execLog("batch", batchId, "segment expansion request handed off for graph mutation", {
|
|
386
|
+
taskId,
|
|
387
|
+
segmentId,
|
|
388
|
+
agentId,
|
|
389
|
+
requestId: requestFile.request.requestId,
|
|
390
|
+
placement: requestFile.request.placement,
|
|
391
|
+
requestedRepoIds: requestFile.request.requestedRepoIds.join(","),
|
|
392
|
+
requestFile: requestFile.filePath,
|
|
393
|
+
});
|
|
394
|
+
return { ok: true };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildOutgoingBySegmentId(dependsOnBySegmentId: Map<string, string[]>): Map<string, string[]> {
|
|
398
|
+
const outgoingBySegmentId = new Map<string, string[]>();
|
|
399
|
+
for (const segmentId of dependsOnBySegmentId.keys()) {
|
|
400
|
+
outgoingBySegmentId.set(segmentId, []);
|
|
401
|
+
}
|
|
402
|
+
for (const [segmentId, deps] of dependsOnBySegmentId.entries()) {
|
|
403
|
+
for (const dep of deps) {
|
|
404
|
+
const outgoing = outgoingBySegmentId.get(dep) ?? [];
|
|
405
|
+
outgoing.push(segmentId);
|
|
406
|
+
outgoingBySegmentId.set(dep, outgoing);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const [segmentId, outgoing] of outgoingBySegmentId.entries()) {
|
|
410
|
+
outgoingBySegmentId.set(segmentId, [...new Set(outgoing)].sort((a, b) => a.localeCompare(b)));
|
|
411
|
+
}
|
|
412
|
+
return outgoingBySegmentId;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function addDependency(dependencyMap: Map<string, string[]>, segmentId: string, depSegmentId: string): void {
|
|
416
|
+
const deps = dependencyMap.get(segmentId) ?? [];
|
|
417
|
+
if (!deps.includes(depSegmentId)) {
|
|
418
|
+
deps.push(depSegmentId);
|
|
419
|
+
deps.sort((a, b) => a.localeCompare(b));
|
|
420
|
+
dependencyMap.set(segmentId, deps);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function removeDependency(dependencyMap: Map<string, string[]>, segmentId: string, depSegmentId: string): void {
|
|
425
|
+
const deps = dependencyMap.get(segmentId) ?? [];
|
|
426
|
+
const filtered = deps.filter((dep) => dep !== depSegmentId);
|
|
427
|
+
dependencyMap.set(segmentId, filtered);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function recomputeNextPendingSegmentIndex(segmentState: SegmentFrontierTaskState): void {
|
|
431
|
+
const nextPendingIndex = segmentState.orderedSegments.findIndex((segment) => {
|
|
432
|
+
return segmentState.statusBySegmentId.get(segment.segmentId) === "pending";
|
|
433
|
+
});
|
|
434
|
+
segmentState.nextSegmentIndex = nextPendingIndex >= 0
|
|
435
|
+
? nextPendingIndex
|
|
436
|
+
: segmentState.orderedSegments.length;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function hasTaskInFutureSegmentRounds(segmentRounds: string[][], fromIndex: number, taskId: string): boolean {
|
|
440
|
+
for (let idx = fromIndex; idx < segmentRounds.length; idx++) {
|
|
441
|
+
if (segmentRounds[idx]?.includes(taskId)) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Insert one deterministic continuation segment round immediately after the
|
|
450
|
+
* current wave when expansion creates executable pending work beyond planned rounds.
|
|
451
|
+
*/
|
|
452
|
+
export function scheduleContinuationSegmentRound(
|
|
453
|
+
segmentRounds: string[][],
|
|
454
|
+
currentWaveIndex: number,
|
|
455
|
+
taskIds: Iterable<string>,
|
|
456
|
+
): string[] {
|
|
457
|
+
const continuationWave = [...new Set(taskIds)].sort((a, b) => a.localeCompare(b));
|
|
458
|
+
if (continuationWave.length === 0) {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
segmentRounds.splice(currentWaveIndex + 1, 0, continuationWave);
|
|
462
|
+
return continuationWave;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function buildRepoMaxSequenceByRepo(
|
|
466
|
+
orderedSegments: TaskSegmentNode[],
|
|
467
|
+
taskId: string,
|
|
468
|
+
): Map<string, number> {
|
|
469
|
+
const maxSequenceByRepo = new Map<string, number>();
|
|
470
|
+
for (const segment of orderedSegments) {
|
|
471
|
+
const repoId = segment.repoId;
|
|
472
|
+
const basePrefix = `${taskId}::${repoId}`;
|
|
473
|
+
let sequence = 1;
|
|
474
|
+
if (segment.segmentId.startsWith(`${basePrefix}::`)) {
|
|
475
|
+
const suffix = segment.segmentId.slice(`${basePrefix}::`.length);
|
|
476
|
+
const parsed = Number.parseInt(suffix, 10);
|
|
477
|
+
if (Number.isFinite(parsed) && parsed >= 2) {
|
|
478
|
+
sequence = parsed;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const currentMax = maxSequenceByRepo.get(repoId) ?? 0;
|
|
482
|
+
maxSequenceByRepo.set(repoId, Math.max(currentMax, sequence));
|
|
483
|
+
}
|
|
484
|
+
return maxSequenceByRepo;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Apply one approved segment-expansion request to a task frontier DAG.
|
|
489
|
+
*
|
|
490
|
+
* Implements after-current/end rewiring, repeat-repo segment ID disambiguation,
|
|
491
|
+
* deterministic topological reordering, and pending-state insertion.
|
|
492
|
+
*/
|
|
493
|
+
export function applySegmentExpansionMutation(
|
|
494
|
+
segmentState: SegmentFrontierTaskState,
|
|
495
|
+
request: SegmentExpansionRequest,
|
|
496
|
+
anchorSegmentId: string,
|
|
497
|
+
): { insertedSegmentIds: string[] } {
|
|
498
|
+
const existingNodeById = new Map<string, TaskSegmentNode>();
|
|
499
|
+
for (const segment of segmentState.orderedSegments) {
|
|
500
|
+
existingNodeById.set(segment.segmentId, segment);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const dependencyMap = new Map<string, string[]>();
|
|
504
|
+
for (const [segmentId, deps] of segmentState.dependsOnBySegmentId.entries()) {
|
|
505
|
+
dependencyMap.set(segmentId, [...new Set(deps)].sort((a, b) => a.localeCompare(b)));
|
|
506
|
+
}
|
|
507
|
+
for (const segmentId of existingNodeById.keys()) {
|
|
508
|
+
if (!dependencyMap.has(segmentId)) {
|
|
509
|
+
dependencyMap.set(segmentId, []);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Snapshot original state for rollback on topo-sort failure
|
|
514
|
+
const originalOrderedSegments = [...segmentState.orderedSegments];
|
|
515
|
+
const originalDeps = new Map<string, string[]>();
|
|
516
|
+
for (const [k, v] of dependencyMap) originalDeps.set(k, [...v]);
|
|
517
|
+
|
|
518
|
+
const outgoingBeforeMutation = buildOutgoingBySegmentId(dependencyMap);
|
|
519
|
+
const anchorSuccessors = outgoingBeforeMutation.get(anchorSegmentId) ?? [];
|
|
520
|
+
const maxOrder = segmentState.orderedSegments.reduce((max, segment) => Math.max(max, segment.order), -1);
|
|
521
|
+
const repoMaxSequenceByRepo = buildRepoMaxSequenceByRepo(segmentState.orderedSegments, request.taskId);
|
|
522
|
+
|
|
523
|
+
const newNodes: TaskSegmentNode[] = [];
|
|
524
|
+
const segmentIdByRequestedRepoId = new Map<string, string>();
|
|
525
|
+
for (const [idx, repoId] of request.requestedRepoIds.entries()) {
|
|
526
|
+
const nextSequence = (repoMaxSequenceByRepo.get(repoId) ?? 0) + 1;
|
|
527
|
+
repoMaxSequenceByRepo.set(repoId, nextSequence);
|
|
528
|
+
const segmentId = buildSegmentId(request.taskId, repoId, nextSequence);
|
|
529
|
+
segmentIdByRequestedRepoId.set(repoId, segmentId);
|
|
530
|
+
const node: TaskSegmentNode = {
|
|
531
|
+
segmentId,
|
|
532
|
+
taskId: request.taskId,
|
|
533
|
+
repoId,
|
|
534
|
+
order: maxOrder + idx + 1,
|
|
535
|
+
};
|
|
536
|
+
newNodes.push(node);
|
|
537
|
+
existingNodeById.set(node.segmentId, node);
|
|
538
|
+
dependencyMap.set(node.segmentId, []);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (const edge of request.edges) {
|
|
542
|
+
const fromSegmentId = segmentIdByRequestedRepoId.get(edge.from);
|
|
543
|
+
const toSegmentId = segmentIdByRequestedRepoId.get(edge.to);
|
|
544
|
+
if (!fromSegmentId || !toSegmentId) continue;
|
|
545
|
+
addDependency(dependencyMap, toSegmentId, fromSegmentId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const internalIncomingCounts = new Map<string, number>();
|
|
549
|
+
const internalOutgoingCounts = new Map<string, number>();
|
|
550
|
+
for (const node of newNodes) {
|
|
551
|
+
internalIncomingCounts.set(node.segmentId, 0);
|
|
552
|
+
internalOutgoingCounts.set(node.segmentId, 0);
|
|
553
|
+
}
|
|
554
|
+
for (const edge of request.edges) {
|
|
555
|
+
const fromSegmentId = segmentIdByRequestedRepoId.get(edge.from);
|
|
556
|
+
const toSegmentId = segmentIdByRequestedRepoId.get(edge.to);
|
|
557
|
+
if (!fromSegmentId || !toSegmentId) continue;
|
|
558
|
+
internalOutgoingCounts.set(fromSegmentId, (internalOutgoingCounts.get(fromSegmentId) ?? 0) + 1);
|
|
559
|
+
internalIncomingCounts.set(toSegmentId, (internalIncomingCounts.get(toSegmentId) ?? 0) + 1);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const roots = newNodes
|
|
563
|
+
.filter((node) => (internalIncomingCounts.get(node.segmentId) ?? 0) === 0)
|
|
564
|
+
.map((node) => node.segmentId)
|
|
565
|
+
.sort((a, b) => a.localeCompare(b));
|
|
566
|
+
const sinks = newNodes
|
|
567
|
+
.filter((node) => (internalOutgoingCounts.get(node.segmentId) ?? 0) === 0)
|
|
568
|
+
.map((node) => node.segmentId)
|
|
569
|
+
.sort((a, b) => a.localeCompare(b));
|
|
570
|
+
|
|
571
|
+
if (request.placement === "after-current") {
|
|
572
|
+
for (const root of roots) {
|
|
573
|
+
addDependency(dependencyMap, root, anchorSegmentId);
|
|
574
|
+
}
|
|
575
|
+
for (const successor of anchorSuccessors) {
|
|
576
|
+
removeDependency(dependencyMap, successor, anchorSegmentId);
|
|
577
|
+
for (const sink of sinks) {
|
|
578
|
+
addDependency(dependencyMap, successor, sink);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
const terminals = segmentState.orderedSegments
|
|
583
|
+
.map((segment) => segment.segmentId)
|
|
584
|
+
.filter((segmentId) => (outgoingBeforeMutation.get(segmentId) ?? []).length === 0)
|
|
585
|
+
.sort((a, b) => a.localeCompare(b));
|
|
586
|
+
for (const root of roots) {
|
|
587
|
+
for (const terminal of terminals) {
|
|
588
|
+
if (terminal === root) continue;
|
|
589
|
+
addDependency(dependencyMap, root, terminal);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const priorityBySegmentId = new Map<string, number>();
|
|
595
|
+
for (const [idx, segment] of segmentState.orderedSegments.entries()) {
|
|
596
|
+
priorityBySegmentId.set(segment.segmentId, idx);
|
|
597
|
+
}
|
|
598
|
+
for (const [idx, node] of newNodes.entries()) {
|
|
599
|
+
priorityBySegmentId.set(node.segmentId, segmentState.orderedSegments.length + idx);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const outgoing = buildOutgoingBySegmentId(dependencyMap);
|
|
603
|
+
const indegree = new Map<string, number>();
|
|
604
|
+
for (const [segmentId, deps] of dependencyMap.entries()) {
|
|
605
|
+
indegree.set(segmentId, deps.length);
|
|
606
|
+
}
|
|
607
|
+
const ready = [...dependencyMap.keys()]
|
|
608
|
+
.filter((segmentId) => (indegree.get(segmentId) ?? 0) === 0)
|
|
609
|
+
.sort((a, b) => {
|
|
610
|
+
const aPriority = priorityBySegmentId.get(a) ?? Number.MAX_SAFE_INTEGER;
|
|
611
|
+
const bPriority = priorityBySegmentId.get(b) ?? Number.MAX_SAFE_INTEGER;
|
|
612
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
613
|
+
return a.localeCompare(b);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const nextOrderedSegmentIds: string[] = [];
|
|
617
|
+
while (ready.length > 0) {
|
|
618
|
+
const nextSegmentId = ready.shift()!;
|
|
619
|
+
nextOrderedSegmentIds.push(nextSegmentId);
|
|
620
|
+
for (const depSegmentId of outgoing.get(nextSegmentId) ?? []) {
|
|
621
|
+
const count = (indegree.get(depSegmentId) ?? 0) - 1;
|
|
622
|
+
indegree.set(depSegmentId, count);
|
|
623
|
+
if (count === 0) {
|
|
624
|
+
ready.push(depSegmentId);
|
|
625
|
+
ready.sort((a, b) => {
|
|
626
|
+
const aPriority = priorityBySegmentId.get(a) ?? Number.MAX_SAFE_INTEGER;
|
|
627
|
+
const bPriority = priorityBySegmentId.get(b) ?? Number.MAX_SAFE_INTEGER;
|
|
628
|
+
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
629
|
+
return a.localeCompare(b);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (nextOrderedSegmentIds.length !== dependencyMap.size) {
|
|
636
|
+
// Topological sort failed to cover all nodes — likely a cycle introduced
|
|
637
|
+
// by the expansion. Reject the mutation entirely and restore original state.
|
|
638
|
+
execLog("batch", request.taskId, "segment expansion rejected: topological sort failed (possible cycle)", {
|
|
639
|
+
expected: dependencyMap.size,
|
|
640
|
+
covered: nextOrderedSegmentIds.length,
|
|
641
|
+
});
|
|
642
|
+
// Full rollback to pre-mutation state
|
|
643
|
+
for (const node of newNodes) {
|
|
644
|
+
segmentState.statusBySegmentId.delete(node.segmentId);
|
|
645
|
+
}
|
|
646
|
+
segmentState.orderedSegments = originalOrderedSegments;
|
|
647
|
+
segmentState.dependsOnBySegmentId = originalDeps;
|
|
648
|
+
return { insertedSegmentIds: [] };
|
|
649
|
+
}
|
|
650
|
+
const finalOrderedSegmentIds = nextOrderedSegmentIds;
|
|
651
|
+
|
|
652
|
+
const nextOrderedSegments = finalOrderedSegmentIds
|
|
653
|
+
.map((segmentId, idx) => {
|
|
654
|
+
const segment = existingNodeById.get(segmentId);
|
|
655
|
+
if (!segment) return null;
|
|
656
|
+
return {
|
|
657
|
+
...segment,
|
|
658
|
+
order: idx,
|
|
659
|
+
};
|
|
660
|
+
})
|
|
661
|
+
.filter((segment): segment is TaskSegmentNode => segment !== null);
|
|
662
|
+
|
|
663
|
+
segmentState.orderedSegments = nextOrderedSegments;
|
|
664
|
+
segmentState.dependsOnBySegmentId = dependencyMap;
|
|
665
|
+
for (const node of newNodes) {
|
|
666
|
+
segmentState.statusBySegmentId.set(node.segmentId, "pending");
|
|
667
|
+
}
|
|
668
|
+
recomputeNextPendingSegmentIndex(segmentState);
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
insertedSegmentIds: newNodes.map((node) => node.segmentId),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function handoffSegmentExpansionToMutation(
|
|
676
|
+
batchId: string,
|
|
677
|
+
taskId: string,
|
|
678
|
+
segmentId: string,
|
|
679
|
+
agentId: string,
|
|
680
|
+
requestFile: PendingSegmentExpansionRequest,
|
|
681
|
+
segmentState: SegmentFrontierTaskState,
|
|
682
|
+
): { insertedSegmentIds: string[] } {
|
|
683
|
+
const mutation = applySegmentExpansionMutation(segmentState, requestFile.request, segmentId);
|
|
684
|
+
execLog("batch", batchId, "segment expansion request accepted for mutation path", {
|
|
685
|
+
taskId,
|
|
686
|
+
segmentId,
|
|
687
|
+
agentId,
|
|
688
|
+
requestId: requestFile.request.requestId,
|
|
689
|
+
placement: requestFile.request.placement,
|
|
690
|
+
requestedRepoIds: requestFile.request.requestedRepoIds.join(","),
|
|
691
|
+
insertedSegments: mutation.insertedSegmentIds.join(","),
|
|
692
|
+
});
|
|
693
|
+
return mutation;
|
|
694
|
+
}
|
|
695
|
+
|
|
150
696
|
function ensureSegmentRecords(batchState: OrchBatchRuntimeState): PersistedSegmentRecord[] {
|
|
151
697
|
if (!batchState.segments) {
|
|
152
698
|
batchState.segments = [];
|
|
@@ -154,6 +700,130 @@ function ensureSegmentRecords(batchState: OrchBatchRuntimeState): PersistedSegme
|
|
|
154
700
|
return batchState.segments;
|
|
155
701
|
}
|
|
156
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Persist pending segment records for an approved expansion and resync dependency
|
|
705
|
+
* metadata for existing pending records touched by subsequent rewires.
|
|
706
|
+
*/
|
|
707
|
+
export function upsertPendingExpandedSegmentRecords(
|
|
708
|
+
batchState: OrchBatchRuntimeState,
|
|
709
|
+
task: ParsedTask,
|
|
710
|
+
segmentState: SegmentFrontierTaskState,
|
|
711
|
+
insertedSegmentIds: string[],
|
|
712
|
+
expandedFrom: string,
|
|
713
|
+
expansionRequestId: string,
|
|
714
|
+
fallbackBranch: string,
|
|
715
|
+
): boolean {
|
|
716
|
+
const insertedSegmentIdSet = new Set(insertedSegmentIds);
|
|
717
|
+
const pendingSegmentIds = segmentState.orderedSegments
|
|
718
|
+
.filter((segment) => segmentState.statusBySegmentId.get(segment.segmentId) === "pending")
|
|
719
|
+
.map((segment) => segment.segmentId);
|
|
720
|
+
if (pendingSegmentIds.length === 0) return false;
|
|
721
|
+
|
|
722
|
+
const segmentRecords = ensureSegmentRecords(batchState);
|
|
723
|
+
let changed = false;
|
|
724
|
+
|
|
725
|
+
for (const segmentId of pendingSegmentIds) {
|
|
726
|
+
const segment = segmentState.orderedSegments.find((candidate) => candidate.segmentId === segmentId);
|
|
727
|
+
if (!segment) continue;
|
|
728
|
+
const existing = segmentRecords.find((record) => record.segmentId === segmentId);
|
|
729
|
+
if (!existing && !insertedSegmentIdSet.has(segmentId)) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const dependsOnSegmentIds = segmentState.dependsOnBySegmentId.get(segmentId) ?? [];
|
|
734
|
+
const nextExpandedFrom = insertedSegmentIdSet.has(segmentId)
|
|
735
|
+
? expandedFrom
|
|
736
|
+
: existing?.expandedFrom;
|
|
737
|
+
const nextExpansionRequestId = insertedSegmentIdSet.has(segmentId)
|
|
738
|
+
? expansionRequestId
|
|
739
|
+
: existing?.expansionRequestId;
|
|
740
|
+
const next: PersistedSegmentRecord = {
|
|
741
|
+
segmentId,
|
|
742
|
+
taskId: task.taskId,
|
|
743
|
+
repoId: segment.repoId,
|
|
744
|
+
status: "pending",
|
|
745
|
+
laneId: existing?.laneId ?? "",
|
|
746
|
+
sessionName: existing?.sessionName ?? "",
|
|
747
|
+
worktreePath: existing?.worktreePath ?? "",
|
|
748
|
+
branch: existing?.branch ?? fallbackBranch,
|
|
749
|
+
startedAt: null,
|
|
750
|
+
endedAt: null,
|
|
751
|
+
retries: existing?.retries ?? 0,
|
|
752
|
+
exitReason: existing?.exitReason ?? "Segment pending",
|
|
753
|
+
dependsOnSegmentIds,
|
|
754
|
+
expandedFrom: nextExpandedFrom,
|
|
755
|
+
expansionRequestId: nextExpansionRequestId,
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
if (!existing) {
|
|
759
|
+
segmentRecords.push(next);
|
|
760
|
+
changed = true;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const recordChanged =
|
|
765
|
+
existing.taskId !== next.taskId
|
|
766
|
+
|| existing.repoId !== next.repoId
|
|
767
|
+
|| existing.status !== next.status
|
|
768
|
+
|| existing.laneId !== next.laneId
|
|
769
|
+
|| existing.sessionName !== next.sessionName
|
|
770
|
+
|| existing.worktreePath !== next.worktreePath
|
|
771
|
+
|| existing.branch !== next.branch
|
|
772
|
+
|| existing.startedAt !== next.startedAt
|
|
773
|
+
|| existing.endedAt !== next.endedAt
|
|
774
|
+
|| existing.retries !== next.retries
|
|
775
|
+
|| existing.exitReason !== next.exitReason
|
|
776
|
+
|| existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length
|
|
777
|
+
|| existing.dependsOnSegmentIds.some((depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx])
|
|
778
|
+
|| existing.expandedFrom !== next.expandedFrom
|
|
779
|
+
|| existing.expansionRequestId !== next.expansionRequestId;
|
|
780
|
+
|
|
781
|
+
if (recordChanged) {
|
|
782
|
+
Object.assign(existing, next);
|
|
783
|
+
changed = true;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return changed;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Rebuild the in-memory idempotency set from persisted resilience repair history.
|
|
792
|
+
* Used on start/resume to prevent replay of already-processed expansion requests.
|
|
793
|
+
*/
|
|
794
|
+
export function collectProcessedSegmentExpansionRequestIds(
|
|
795
|
+
batchState: Pick<OrchBatchRuntimeState, "resilience">,
|
|
796
|
+
): Set<string> {
|
|
797
|
+
return new Set<string>(
|
|
798
|
+
(batchState.resilience?.repairHistory ?? [])
|
|
799
|
+
.filter((entry) => entry.strategy === "segment-expansion-request")
|
|
800
|
+
.map((entry) => entry.id),
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function recordProcessedSegmentExpansionRequestId(
|
|
805
|
+
batchState: OrchBatchRuntimeState,
|
|
806
|
+
requestId: string,
|
|
807
|
+
status: "succeeded" | "failed" | "skipped",
|
|
808
|
+
): boolean {
|
|
809
|
+
if (!batchState.resilience) {
|
|
810
|
+
batchState.resilience = defaultResilienceState();
|
|
811
|
+
}
|
|
812
|
+
const history = batchState.resilience.repairHistory;
|
|
813
|
+
if (history.some((entry) => entry.strategy === "segment-expansion-request" && entry.id === requestId)) {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
const now = Date.now();
|
|
817
|
+
history.push({
|
|
818
|
+
id: requestId,
|
|
819
|
+
strategy: "segment-expansion-request",
|
|
820
|
+
status,
|
|
821
|
+
startedAt: now,
|
|
822
|
+
endedAt: now,
|
|
823
|
+
});
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
|
|
157
827
|
function upsertRunningSegmentRecord(
|
|
158
828
|
batchState: OrchBatchRuntimeState,
|
|
159
829
|
task: ParsedTask,
|
|
@@ -198,6 +868,8 @@ function upsertRunningSegmentRecord(
|
|
|
198
868
|
exitDiagnostic: existing?.status === "running"
|
|
199
869
|
? existing.exitDiagnostic
|
|
200
870
|
: undefined,
|
|
871
|
+
expandedFrom: existing?.expandedFrom,
|
|
872
|
+
expansionRequestId: existing?.expansionRequestId,
|
|
201
873
|
};
|
|
202
874
|
|
|
203
875
|
if (!existing) {
|
|
@@ -219,7 +891,9 @@ function upsertRunningSegmentRecord(
|
|
|
219
891
|
|| existing.exitReason !== next.exitReason
|
|
220
892
|
|| existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length
|
|
221
893
|
|| existing.dependsOnSegmentIds.some((segmentId, idx) => segmentId !== next.dependsOnSegmentIds[idx])
|
|
222
|
-
|| existing.exitDiagnostic !== next.exitDiagnostic
|
|
894
|
+
|| existing.exitDiagnostic !== next.exitDiagnostic
|
|
895
|
+
|| existing.expandedFrom !== next.expandedFrom
|
|
896
|
+
|| existing.expansionRequestId !== next.expansionRequestId;
|
|
223
897
|
|
|
224
898
|
if (changed) {
|
|
225
899
|
Object.assign(existing, next);
|
|
@@ -266,6 +940,8 @@ function upsertTerminalSegmentRecord(
|
|
|
266
940
|
: "Segment skipped"),
|
|
267
941
|
dependsOnSegmentIds,
|
|
268
942
|
exitDiagnostic: nextExitDiagnostic,
|
|
943
|
+
expandedFrom: existing?.expandedFrom,
|
|
944
|
+
expansionRequestId: existing?.expansionRequestId,
|
|
269
945
|
};
|
|
270
946
|
|
|
271
947
|
if (!existing) {
|
|
@@ -287,7 +963,9 @@ function upsertTerminalSegmentRecord(
|
|
|
287
963
|
|| existing.exitReason !== next.exitReason
|
|
288
964
|
|| existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length
|
|
289
965
|
|| existing.dependsOnSegmentIds.some((depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx])
|
|
290
|
-
|| existing.exitDiagnostic !== next.exitDiagnostic
|
|
966
|
+
|| existing.exitDiagnostic !== next.exitDiagnostic
|
|
967
|
+
|| existing.expandedFrom !== next.expandedFrom
|
|
968
|
+
|| existing.expansionRequestId !== next.expansionRequestId;
|
|
291
969
|
|
|
292
970
|
if (changed) {
|
|
293
971
|
Object.assign(existing, next);
|
|
@@ -1008,6 +1686,7 @@ async function attemptStaleWorktreeRecovery(
|
|
|
1008
1686
|
stateRoot: string,
|
|
1009
1687
|
runtimeBackend?: RuntimeBackend,
|
|
1010
1688
|
onSupervisorAlert?: SupervisorAlertCallback,
|
|
1689
|
+
supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
|
|
1011
1690
|
): Promise<WaveExecutionResult | null> {
|
|
1012
1691
|
// Only attempt recovery for ALLOC_WORKTREE_FAILED
|
|
1013
1692
|
if (!waveResult.allocationError || waveResult.allocationError.code !== "ALLOC_WORKTREE_FAILED") {
|
|
@@ -1109,6 +1788,7 @@ async function attemptStaleWorktreeRecovery(
|
|
|
1109
1788
|
workspaceConfig,
|
|
1110
1789
|
runtimeBackend,
|
|
1111
1790
|
onSupervisorAlert,
|
|
1791
|
+
supervisorAutonomy,
|
|
1112
1792
|
);
|
|
1113
1793
|
|
|
1114
1794
|
return retryResult;
|
|
@@ -1185,6 +1865,7 @@ export async function executeOrchBatch(
|
|
|
1185
1865
|
agentRoot?: string,
|
|
1186
1866
|
onEngineEvent?: EngineEventCallback | null,
|
|
1187
1867
|
onSupervisorAlert?: SupervisorAlertCallback | null,
|
|
1868
|
+
supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
|
|
1188
1869
|
): Promise<void> {
|
|
1189
1870
|
const repoRoot = cwd;
|
|
1190
1871
|
// State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
|
|
@@ -1277,6 +1958,8 @@ export async function executeOrchBatch(
|
|
|
1277
1958
|
let wavePlan: string[][] = [];
|
|
1278
1959
|
// Segment frontier runtime state keyed by parent task ID.
|
|
1279
1960
|
let segmentStateByTask = new Map<string, SegmentFrontierTaskState>();
|
|
1961
|
+
// Processed segment-expansion request IDs (idempotency guard).
|
|
1962
|
+
const processedSegmentExpansionRequestIds = collectProcessedSegmentExpansionRequestIds(batchState);
|
|
1280
1963
|
// Tasks that have reached terminal status at segment frontier level.
|
|
1281
1964
|
const terminalSegmentTasks = new Set<string>();
|
|
1282
1965
|
// Reference to discovery result for enriching taskFolder paths.
|
|
@@ -1504,13 +2187,14 @@ export async function executeOrchBatch(
|
|
|
1504
2187
|
// Otherwise, fall back to the legacy TMUX-backed path.
|
|
1505
2188
|
const backendSelection = selectRuntimeBackend(args, rawWaves, workspaceConfig);
|
|
1506
2189
|
const selectedBackend = backendSelection.backend;
|
|
2190
|
+
const runtimeSegmentRounds = rawWaves.map((waveTasks) => [...waveTasks]);
|
|
1507
2191
|
|
|
1508
2192
|
if (selectedBackend === "v2") {
|
|
1509
2193
|
execLog("batch", batchState.batchId, "Runtime V2 backend selected");
|
|
1510
2194
|
onNotify("🚀 Using Runtime V2 backend (no-TMUX direct execution)", "info");
|
|
1511
2195
|
}
|
|
1512
2196
|
|
|
1513
|
-
for (let waveIdx = 0; waveIdx <
|
|
2197
|
+
for (let waveIdx = 0; waveIdx < runtimeSegmentRounds.length; waveIdx++) {
|
|
1514
2198
|
// Check pause signal before starting each wave
|
|
1515
2199
|
if (batchState.pauseSignal.paused) {
|
|
1516
2200
|
batchState.phase = "paused";
|
|
@@ -1530,7 +2214,7 @@ export async function executeOrchBatch(
|
|
|
1530
2214
|
|
|
1531
2215
|
// Filter wave tasks against blocked + terminal task sets, then bind the
|
|
1532
2216
|
// next active segment for each surviving task.
|
|
1533
|
-
const scheduledWaveTasks =
|
|
2217
|
+
const scheduledWaveTasks = runtimeSegmentRounds[waveIdx];
|
|
1534
2218
|
const blockedInWave: string[] = [];
|
|
1535
2219
|
const terminalInWave: string[] = [];
|
|
1536
2220
|
let waveTasks: string[] = [];
|
|
@@ -1602,7 +2286,7 @@ export async function executeOrchBatch(
|
|
|
1602
2286
|
|
|
1603
2287
|
// Emit wave_start with actual lane count (post-affinity grouping)
|
|
1604
2288
|
onNotify(
|
|
1605
|
-
ORCH_MESSAGES.orchWaveStart(waveIdx + 1,
|
|
2289
|
+
ORCH_MESSAGES.orchWaveStart(waveIdx + 1, runtimeSegmentRounds.length, waveTasks.length, lanes.length),
|
|
1606
2290
|
"info",
|
|
1607
2291
|
);
|
|
1608
2292
|
emitEvent(stateRoot, {
|
|
@@ -1653,6 +2337,7 @@ export async function executeOrchBatch(
|
|
|
1653
2337
|
workspaceConfig,
|
|
1654
2338
|
selectedBackend,
|
|
1655
2339
|
emitAlert,
|
|
2340
|
+
supervisorAutonomy,
|
|
1656
2341
|
);
|
|
1657
2342
|
|
|
1658
2343
|
// ── TP-039: Tier 0 — Stale worktree recovery ────────────
|
|
@@ -1674,6 +2359,7 @@ export async function executeOrchBatch(
|
|
|
1674
2359
|
stateRoot,
|
|
1675
2360
|
selectedBackend,
|
|
1676
2361
|
emitAlert,
|
|
2362
|
+
supervisorAutonomy,
|
|
1677
2363
|
);
|
|
1678
2364
|
if (retryResult) {
|
|
1679
2365
|
const staleRecovered = !retryResult.allocationError;
|
|
@@ -1822,6 +2508,7 @@ export async function executeOrchBatch(
|
|
|
1822
2508
|
const completedTaskIdsThisWave: string[] = [];
|
|
1823
2509
|
const failedTaskIdsThisWave: string[] = [];
|
|
1824
2510
|
const skippedTaskIdsThisWave: string[] = [];
|
|
2511
|
+
const continuationTaskIds = new Set<string>();
|
|
1825
2512
|
const laneByTaskId = new Map<string, AllocatedLane>();
|
|
1826
2513
|
for (const lane of latestAllocatedLanes) {
|
|
1827
2514
|
for (const laneTask of lane.tasks) {
|
|
@@ -1839,16 +2526,147 @@ export async function executeOrchBatch(
|
|
|
1839
2526
|
segmentState.statusBySegmentId.set(activeSegmentId, "succeeded");
|
|
1840
2527
|
const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
|
|
1841
2528
|
upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "succeeded", outcome, laneByTaskId.get(taskId));
|
|
2529
|
+
|
|
2530
|
+
const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
|
|
2531
|
+
if (workerAgentId) {
|
|
2532
|
+
const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId);
|
|
2533
|
+
if (pendingExpansionFiles.length > 0) {
|
|
2534
|
+
const parsedRequests = parseSegmentExpansionRequests(pendingExpansionFiles);
|
|
2535
|
+
for (const malformed of parsedRequests.malformed) {
|
|
2536
|
+
const renamed = markSegmentExpansionRequestFile(malformed.filePath, "invalid");
|
|
2537
|
+
execLog("batch", batchState.batchId, `segment expansion request malformed (${renamed ? "renamed to .invalid" : "rename failed"})`, {
|
|
2538
|
+
taskId,
|
|
2539
|
+
agentId: workerAgentId,
|
|
2540
|
+
segmentId: activeSegmentId,
|
|
2541
|
+
filePath: malformed.filePath,
|
|
2542
|
+
reason: malformed.reason,
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
const orderedRequests = [...parsedRequests.valid].sort((a, b) => a.request.requestId.localeCompare(b.request.requestId));
|
|
2546
|
+
const scopedRequests = orderedRequests.filter((pendingRequest) => (
|
|
2547
|
+
pendingRequest.request.taskId === taskId
|
|
2548
|
+
&& pendingRequest.request.fromSegmentId === activeSegmentId
|
|
2549
|
+
));
|
|
2550
|
+
let rejectedCount = 0;
|
|
2551
|
+
let acceptedCount = 0;
|
|
2552
|
+
for (const pendingRequest of scopedRequests) {
|
|
2553
|
+
const requestId = pendingRequest.request.requestId;
|
|
2554
|
+
const processingResult = processSegmentExpansionRequestAtBoundary(
|
|
2555
|
+
batchState.batchId,
|
|
2556
|
+
taskId,
|
|
2557
|
+
activeSegmentId,
|
|
2558
|
+
workerAgentId,
|
|
2559
|
+
pendingRequest,
|
|
2560
|
+
segmentState,
|
|
2561
|
+
workspaceConfig,
|
|
2562
|
+
processedSegmentExpansionRequestIds,
|
|
2563
|
+
);
|
|
2564
|
+
if (!processingResult.ok) {
|
|
2565
|
+
rejectedCount += 1;
|
|
2566
|
+
processedSegmentExpansionRequestIds.add(requestId);
|
|
2567
|
+
const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "failed");
|
|
2568
|
+
if (recordedRequestId) {
|
|
2569
|
+
persistRuntimeState("segment-expansion-rejected", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
2570
|
+
}
|
|
2571
|
+
const renamedRejected = markSegmentExpansionRequestFile(pendingRequest.filePath, "rejected");
|
|
2572
|
+
emitAlert({
|
|
2573
|
+
category: "segment-expansion-rejected",
|
|
2574
|
+
summary:
|
|
2575
|
+
`❌ Segment expansion rejected\n` +
|
|
2576
|
+
` Task: ${taskId}\n` +
|
|
2577
|
+
` Segment: ${activeSegmentId}\n` +
|
|
2578
|
+
` Request: ${requestId}\n` +
|
|
2579
|
+
` Reason: ${processingResult.reason}\n` +
|
|
2580
|
+
` File state: ${renamedRejected ? ".rejected" : "rename failed"}`,
|
|
2581
|
+
context: {
|
|
2582
|
+
taskId,
|
|
2583
|
+
segmentId: activeSegmentId,
|
|
2584
|
+
agentId: workerAgentId,
|
|
2585
|
+
expansionRequestId: requestId,
|
|
2586
|
+
exitReason: processingResult.reason,
|
|
2587
|
+
},
|
|
2588
|
+
});
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
const beforeSegmentIds = segmentState.orderedSegments.map((segment) => segment.segmentId);
|
|
2593
|
+
const mutation = handoffSegmentExpansionToMutation(
|
|
2594
|
+
batchState.batchId,
|
|
2595
|
+
taskId,
|
|
2596
|
+
activeSegmentId,
|
|
2597
|
+
workerAgentId,
|
|
2598
|
+
pendingRequest,
|
|
2599
|
+
segmentState,
|
|
2600
|
+
);
|
|
2601
|
+
task.segmentIds = segmentState.orderedSegments.map((segment) => segment.segmentId);
|
|
2602
|
+
const afterSegmentIds = [...task.segmentIds];
|
|
2603
|
+
const persistedInsertedSegments = upsertPendingExpandedSegmentRecords(
|
|
2604
|
+
batchState,
|
|
2605
|
+
task,
|
|
2606
|
+
segmentState,
|
|
2607
|
+
mutation.insertedSegmentIds,
|
|
2608
|
+
activeSegmentId,
|
|
2609
|
+
requestId,
|
|
2610
|
+
batchState.orchBranch,
|
|
2611
|
+
);
|
|
2612
|
+
const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "succeeded");
|
|
2613
|
+
if (persistedInsertedSegments || recordedRequestId || mutation.insertedSegmentIds.length > 0) {
|
|
2614
|
+
persistRuntimeState("segment-expansion-approved", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
2615
|
+
}
|
|
2616
|
+
const renamedProcessed = markSegmentExpansionRequestFile(pendingRequest.filePath, "processed");
|
|
2617
|
+
emitAlert({
|
|
2618
|
+
category: "segment-expansion-approved",
|
|
2619
|
+
summary:
|
|
2620
|
+
`✅ Segment expansion approved\n` +
|
|
2621
|
+
` Task: ${taskId}\n` +
|
|
2622
|
+
` Segment: ${activeSegmentId}\n` +
|
|
2623
|
+
` Request: ${requestId}\n` +
|
|
2624
|
+
` Before: ${beforeSegmentIds.join(", ")}\n` +
|
|
2625
|
+
` After: ${afterSegmentIds.join(", ")}\n` +
|
|
2626
|
+
` Inserted: ${mutation.insertedSegmentIds.join(", ")}\n` +
|
|
2627
|
+
` File state: ${renamedProcessed ? ".processed" : "rename failed"}`,
|
|
2628
|
+
context: {
|
|
2629
|
+
taskId,
|
|
2630
|
+
segmentId: activeSegmentId,
|
|
2631
|
+
agentId: workerAgentId,
|
|
2632
|
+
expansionRequestId: requestId,
|
|
2633
|
+
},
|
|
2634
|
+
});
|
|
2635
|
+
acceptedCount += 1;
|
|
2636
|
+
}
|
|
2637
|
+
execLog("batch", batchState.batchId, `segment ${activeSegmentId} completed with ${pendingExpansionFiles.length} pending expansion request(s)`, {
|
|
2638
|
+
taskId,
|
|
2639
|
+
agentId: workerAgentId,
|
|
2640
|
+
segmentId: activeSegmentId,
|
|
2641
|
+
acceptedCount,
|
|
2642
|
+
rejectedCount,
|
|
2643
|
+
validRequests: parsedRequests.valid.length,
|
|
2644
|
+
scopedRequests: scopedRequests.length,
|
|
2645
|
+
ignoredRequests: orderedRequests.length - scopedRequests.length,
|
|
2646
|
+
malformedRequests: parsedRequests.malformed.length,
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
1842
2650
|
}
|
|
1843
|
-
segmentState
|
|
2651
|
+
recomputeNextPendingSegmentIndex(segmentState);
|
|
1844
2652
|
task.activeSegmentId = null;
|
|
1845
2653
|
|
|
1846
2654
|
if (segmentState.nextSegmentIndex >= segmentState.orderedSegments.length) {
|
|
1847
2655
|
segmentState.terminalStatus = "succeeded";
|
|
1848
2656
|
terminalSegmentTasks.add(taskId);
|
|
1849
2657
|
completedTaskIdsThisWave.push(taskId);
|
|
2658
|
+
} else if (!hasTaskInFutureSegmentRounds(runtimeSegmentRounds, waveIdx + 1, taskId)) {
|
|
2659
|
+
continuationTaskIds.add(taskId);
|
|
1850
2660
|
}
|
|
1851
2661
|
}
|
|
2662
|
+
if (continuationTaskIds.size > 0) {
|
|
2663
|
+
const continuationWave = scheduleContinuationSegmentRound(runtimeSegmentRounds, waveIdx, continuationTaskIds);
|
|
2664
|
+
execLog("batch", batchState.batchId, "scheduled continuation segment round for expanded task frontier", {
|
|
2665
|
+
waveIndex: waveIdx,
|
|
2666
|
+
taskIds: continuationWave.join(","),
|
|
2667
|
+
runtimeSegmentRoundCount: runtimeSegmentRounds.length,
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
1852
2670
|
|
|
1853
2671
|
for (const taskId of waveResult.failedTaskIds) {
|
|
1854
2672
|
const task = discovery.pending.get(taskId);
|
|
@@ -1859,6 +2677,60 @@ export async function executeOrchBatch(
|
|
|
1859
2677
|
segmentState.statusBySegmentId.set(activeSegmentId, "failed");
|
|
1860
2678
|
const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
|
|
1861
2679
|
upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "failed", outcome, laneByTaskId.get(taskId));
|
|
2680
|
+
|
|
2681
|
+
const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
|
|
2682
|
+
if (workerAgentId) {
|
|
2683
|
+
const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId);
|
|
2684
|
+
if (pendingExpansionFiles.length > 0) {
|
|
2685
|
+
const parsedRequests = parseSegmentExpansionRequests(pendingExpansionFiles);
|
|
2686
|
+
for (const malformed of parsedRequests.malformed) {
|
|
2687
|
+
markSegmentExpansionRequestFile(malformed.filePath, "invalid");
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
let discardedCount = 0;
|
|
2691
|
+
let ignoredCount = 0;
|
|
2692
|
+
for (const requestFile of parsedRequests.valid) {
|
|
2693
|
+
if (requestFile.request.taskId === taskId && requestFile.request.fromSegmentId === activeSegmentId) {
|
|
2694
|
+
const requestId = requestFile.request.requestId;
|
|
2695
|
+
processedSegmentExpansionRequestIds.add(requestId);
|
|
2696
|
+
const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "skipped");
|
|
2697
|
+
if (recordedRequestId) {
|
|
2698
|
+
persistRuntimeState("segment-expansion-discarded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
2699
|
+
}
|
|
2700
|
+
if (markSegmentExpansionRequestFile(requestFile.filePath, "discarded")) {
|
|
2701
|
+
discardedCount += 1;
|
|
2702
|
+
}
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
ignoredCount += 1;
|
|
2706
|
+
}
|
|
2707
|
+
execLog("batch", batchState.batchId, `segment ${activeSegmentId} failed with ${pendingExpansionFiles.length} pending expansion request(s)`, {
|
|
2708
|
+
taskId,
|
|
2709
|
+
agentId: workerAgentId,
|
|
2710
|
+
segmentId: activeSegmentId,
|
|
2711
|
+
discardedCount,
|
|
2712
|
+
ignoredCount,
|
|
2713
|
+
malformedCount: parsedRequests.malformed.length,
|
|
2714
|
+
});
|
|
2715
|
+
if (discardedCount > 0) {
|
|
2716
|
+
emitAlert({
|
|
2717
|
+
category: "segment-expansion-rejected",
|
|
2718
|
+
summary:
|
|
2719
|
+
`🗑️ Segment expansion requests discarded\n` +
|
|
2720
|
+
` Task: ${taskId}\n` +
|
|
2721
|
+
` Segment: ${activeSegmentId}\n` +
|
|
2722
|
+
` Agent: ${workerAgentId}\n` +
|
|
2723
|
+
` Discarded: ${discardedCount}`,
|
|
2724
|
+
context: {
|
|
2725
|
+
taskId,
|
|
2726
|
+
segmentId: activeSegmentId,
|
|
2727
|
+
agentId: workerAgentId,
|
|
2728
|
+
exitReason: "segment-expansion-discarded-originating-segment-failed",
|
|
2729
|
+
},
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
1862
2734
|
}
|
|
1863
2735
|
task.activeSegmentId = null;
|
|
1864
2736
|
segmentState.terminalStatus = "failed";
|
|
@@ -2153,7 +3025,7 @@ export async function executeOrchBatch(
|
|
|
2153
3025
|
...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase),
|
|
2154
3026
|
laneCount: mergedCount,
|
|
2155
3027
|
durationMs: mergeResult.totalDurationMs,
|
|
2156
|
-
totalWaves:
|
|
3028
|
+
totalWaves: runtimeSegmentRounds.length,
|
|
2157
3029
|
}, onEngineEvent);
|
|
2158
3030
|
} else {
|
|
2159
3031
|
onNotify(
|
|
@@ -2513,7 +3385,7 @@ export async function executeOrchBatch(
|
|
|
2513
3385
|
// Hoisted outside the if-block so unsafeBranches is accessible to the
|
|
2514
3386
|
// reset loop below — both blocks share the same guard condition.
|
|
2515
3387
|
let ppUnsafeBranches = new Set<string>();
|
|
2516
|
-
if (waveIdx <
|
|
3388
|
+
if (waveIdx < runtimeSegmentRounds.length - 1 && !batchState.pauseSignal.paused) {
|
|
2517
3389
|
const ppOpId = resolveOperatorId(orchConfig);
|
|
2518
3390
|
const ppResult = preserveFailedLaneProgress(
|
|
2519
3391
|
latestAllocatedLanes,
|
|
@@ -2559,7 +3431,7 @@ export async function executeOrchBatch(
|
|
|
2559
3431
|
// TP-029: Iterate ALL encountered repo roots (not just primary repoRoot)
|
|
2560
3432
|
// so that repos active in wave N but not in the final wave still get reset.
|
|
2561
3433
|
// Follows the resume.ts encounteredRepoRoots pattern for parity.
|
|
2562
|
-
if (waveIdx <
|
|
3434
|
+
if (waveIdx < runtimeSegmentRounds.length - 1 && !batchState.pauseSignal.paused) {
|
|
2563
3435
|
const resetPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
2564
3436
|
const resetOpId = resolveOperatorId(orchConfig);
|
|
2565
3437
|
let totalResetWorktrees = 0;
|