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.
@@ -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 < rawWaves.length; 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 = rawWaves[waveIdx];
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, rawWaves.length, waveTasks.length, lanes.length),
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.nextSegmentIndex += 1;
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: rawWaves.length,
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 < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
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 < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
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;