parallel-codex-tui 0.1.4 → 0.1.5

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.
Files changed (40) hide show
  1. package/.parallel-codex/config.example.toml +46 -0
  2. package/README.md +76 -17
  3. package/dist/cli-startup-preflight.js +18 -0
  4. package/dist/cli-startup-recovery.js +13 -1
  5. package/dist/cli.js +18 -6
  6. package/dist/core/collaboration-timeline.js +9 -2
  7. package/dist/core/config.js +161 -103
  8. package/dist/core/router.js +1 -1
  9. package/dist/core/session-index.js +234 -61
  10. package/dist/core/session-manager.js +25 -1
  11. package/dist/core/task-session-details.js +175 -0
  12. package/dist/core/task-state-machine.js +10 -9
  13. package/dist/doctor.js +58 -39
  14. package/dist/domain/schemas.js +15 -1
  15. package/dist/orchestrator/collaboration-channel.js +35 -3
  16. package/dist/orchestrator/final-acceptance.js +86 -0
  17. package/dist/orchestrator/orchestrator.js +405 -69
  18. package/dist/orchestrator/prompts.js +42 -3
  19. package/dist/orchestrator/workspace-sandbox.js +16 -0
  20. package/dist/tui/App.js +401 -52
  21. package/dist/tui/AppShell.js +9 -3
  22. package/dist/tui/FeatureBoardView.js +7 -2
  23. package/dist/tui/InputBar.js +82 -15
  24. package/dist/tui/StatusBar.js +1 -1
  25. package/dist/tui/StatusDetailView.js +164 -0
  26. package/dist/tui/TaskSessionDetailView.js +222 -0
  27. package/dist/tui/TaskSessionsView.js +5 -2
  28. package/dist/tui/WorkerOutputView.js +1 -0
  29. package/dist/tui/WorkerOverviewView.js +23 -4
  30. package/dist/tui/keyboard.js +3 -0
  31. package/dist/tui/status-line.js +42 -0
  32. package/dist/version.js +1 -1
  33. package/dist/workers/capabilities.js +4 -3
  34. package/dist/workers/live-probe.js +4 -3
  35. package/dist/workers/mock-adapter.js +37 -5
  36. package/dist/workers/native-attach.js +32 -17
  37. package/dist/workers/process-adapter.js +2 -0
  38. package/dist/workers/provider.js +26 -0
  39. package/dist/workers/registry.js +12 -22
  40. package/package.json +7 -1
@@ -7,10 +7,12 @@ import { routerRuntimeDir } from "../core/paths.js";
7
7
  import { classifyRouterFailure, routerFallbackIsTransient } from "../core/router-audit.js";
8
8
  import { sanitizeRouterText } from "../core/router-redaction.js";
9
9
  import { routeRequestWithCodex, routerCommandLabel, routerProxyContext } from "../core/router.js";
10
- import { RouteDecisionSchema, TaskMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
10
+ import { FeatureStatusSchema, RouteDecisionSchema, TaskMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
11
11
  import { getAdapter } from "../workers/registry.js";
12
- import { appendFeatureDialogue, createFeatureChannel, featureCriticCheckpointIsReusable, featurePromptContext, recordApprovedFindingResolution, requireActorFindingReplies, requireFeatureRevisionFindings, updateFeatureStatus, writeFeatureDecision } from "./collaboration-channel.js";
13
- import { buildActorPrompt, buildCriticPrompt, buildJudgePrompt, buildMainPrompt, buildWaveActorPrompt, buildWaveCriticPrompt } from "./prompts.js";
12
+ import { workerProvider } from "../workers/provider.js";
13
+ import { appendFeatureDialogue, createFeatureChannel, featureCriticCheckpointIsReusable, featurePromptContext, readFeatureAssignment, recordApprovedFindingResolution, requireActorFindingReplies, requireFeatureRevisionFindings, updateFeatureStatus, writeFeatureAssignment, writeFeatureDecision } from "./collaboration-channel.js";
14
+ import { buildActorPrompt, buildCriticPrompt, buildFinalJudgePrompt, buildJudgePrompt, buildMainPrompt, buildWaveActorPrompt, buildWaveCriticPrompt } from "./prompts.js";
15
+ import { validateFinalJudgeAcceptance } from "./final-acceptance.js";
14
16
  import { featureExecutionWaves, parseFeaturePlan } from "./feature-plan.js";
15
17
  import { JUDGE_REQUIRED_ARTIFACTS, JUDGE_VALIDATION_FILE, validateJudgeArtifacts } from "./judge-artifacts.js";
16
18
  import { buildSupervisorSummary } from "./supervisor-summary.js";
@@ -21,6 +23,9 @@ const JUDGE_ARTIFACTS = [
21
23
  ...JUDGE_REQUIRED_ARTIFACTS,
22
24
  "features.json"
23
25
  ];
26
+ const FINAL_ACCEPTANCE_FILE = "final-acceptance.json";
27
+ const FINAL_ACCEPTANCE_VALIDATION_FILE = "final-acceptance-validation.json";
28
+ const COMPLETION_CONTRACT_FILE = "completion-contract.json";
24
29
  class FeatureRunCancelledError extends Error {
25
30
  featureId;
26
31
  constructor(featureId) {
@@ -29,6 +34,14 @@ class FeatureRunCancelledError extends Error {
29
34
  this.name = "FeatureRunCancelledError";
30
35
  }
31
36
  }
37
+ class FeatureRunPausedError extends Error {
38
+ featureId;
39
+ constructor(featureId) {
40
+ super(`Feature ${featureId} was paused before integration. Completed peer checkpoints were preserved.`);
41
+ this.featureId = featureId;
42
+ this.name = "FeatureRunPausedError";
43
+ }
44
+ }
32
45
  export class Orchestrator {
33
46
  config;
34
47
  sessions;
@@ -108,6 +121,67 @@ export class Orchestrator {
108
121
  });
109
122
  }
110
123
  async retryTask(input) {
124
+ return this.resumeTaskRun(input);
125
+ }
126
+ async resumeFeature(input) {
127
+ return this.resumeTaskRun(input, input.featureId);
128
+ }
129
+ async reassignFeature(input) {
130
+ if (!featureIdIsSafe(input.featureId)) {
131
+ throw new Error(`Unsafe feature id: ${input.featureId}`);
132
+ }
133
+ const nextProvider = workerProvider(this.config, input.engine).config;
134
+ if (!nextProvider.assignable) {
135
+ throw new Error(`Worker provider cannot be assigned to a Feature: ${input.engine}`);
136
+ }
137
+ const task = this.sessions.taskFromId(input.taskId);
138
+ if (!(await readTaskMetaIfValid(task.metaPath))) {
139
+ throw new Error(`Task session not found: ${input.taskId}`);
140
+ }
141
+ if (this.activeFeatureRuns.has(featureRunKey(input.taskId, input.featureId))) {
142
+ throw new Error(`Feature ${input.featureId} still has an active worker.`);
143
+ }
144
+ return this.withTaskRunLease(task, async () => {
145
+ const meta = await readTaskMetaIfValid(task.metaPath);
146
+ if (!meta) {
147
+ throw new Error(`Task session not found: ${input.taskId}`);
148
+ }
149
+ if (!new Set(["failed", "cancelled", "paused"]).has(meta.status)) {
150
+ throw new Error(`Task ${input.taskId} is ${meta.status}; reassign a feature only while its task is failed, cancelled, or paused.`);
151
+ }
152
+ if (this.activeFeatureRuns.has(featureRunKey(input.taskId, input.featureId))) {
153
+ throw new Error(`Feature ${input.featureId} still has an active worker.`);
154
+ }
155
+ const featureDir = join(task.dir, "features", input.featureId);
156
+ const status = await readJson(join(featureDir, "status.json"), FeatureStatusSchema);
157
+ if (status.task_id !== task.id || status.feature_id !== input.featureId) {
158
+ throw new Error(`Feature ${input.featureId} does not belong to task ${task.id}.`);
159
+ }
160
+ if (status.state === "approved") {
161
+ throw new Error(`Feature ${input.featureId} is already approved and cannot be reassigned.`);
162
+ }
163
+ const route = await this.sessions.readLatestRoute(task);
164
+ const fallback = {
165
+ actor: route?.actor_engine ?? this.config.pairing.actor,
166
+ critic: route?.critic_engine ?? this.config.pairing.critic
167
+ };
168
+ const assignmentPath = join(featureDir, "assignment.json");
169
+ const current = await readFeatureAssignment({ assignmentPath }, fallback);
170
+ const assignment = await writeFeatureAssignment({ assignmentPath }, input.role === "actor" ? input.engine : current.actor_engine, input.role === "critic" ? input.engine : current.critic_engine);
171
+ await appendJsonLine(join(task.dir, "dialogue", "actor-critic.jsonl"), {
172
+ time: new Date().toISOString(),
173
+ feature_id: input.featureId,
174
+ turn_id: status.turn_id,
175
+ type: "feature.assignment_changed",
176
+ role: input.role,
177
+ message: `${capitalize(input.role)} reassigned to ${input.engine}.`,
178
+ paths: { assignment: assignmentPath }
179
+ });
180
+ await this.sessions.appendEvent(task, "feature.assignment_changed", `${input.featureId} ${input.role} reassigned to ${input.engine}`);
181
+ return { featureId: input.featureId, assignment };
182
+ });
183
+ }
184
+ async resumeTaskRun(input, pausedFeatureId) {
111
185
  throwIfCancelled(input.signal);
112
186
  const task = this.sessions.taskFromId(input.taskId);
113
187
  if (!(await readTaskMetaIfValid(task.metaPath))) {
@@ -119,8 +193,21 @@ export class Orchestrator {
119
193
  if (!meta) {
120
194
  throw new Error(`Task session not found: ${input.taskId}`);
121
195
  }
122
- if (meta.status !== "failed" && meta.status !== "cancelled") {
123
- throw new Error(`Task ${input.taskId} is ${meta.status}; only failed or cancelled tasks can be retried.`);
196
+ const retryableStates = pausedFeatureId
197
+ ? new Set(["paused"])
198
+ : new Set(["failed", "cancelled", "paused"]);
199
+ if (!retryableStates.has(meta.status)) {
200
+ throw new Error(pausedFeatureId
201
+ ? `Task ${input.taskId} is ${meta.status}; only a paused task can resume a feature.`
202
+ : `Task ${input.taskId} is ${meta.status}; only failed, cancelled, or paused tasks can be retried.`);
203
+ }
204
+ if (pausedFeatureId) {
205
+ const featureStatus = await readJson(join(task.dir, "features", pausedFeatureId, "status.json"), FeatureStatusSchema);
206
+ if (featureStatus.task_id !== task.id
207
+ || featureStatus.feature_id !== pausedFeatureId
208
+ || featureStatus.state !== "paused") {
209
+ throw new Error(`Feature ${pausedFeatureId} is not paused in task ${task.id}.`);
210
+ }
124
211
  }
125
212
  const turn = await this.sessions.latestTurn(task);
126
213
  if (!turn) {
@@ -141,7 +228,9 @@ export class Orchestrator {
141
228
  input.onRoute?.(route);
142
229
  throwIfCancelled(input.signal);
143
230
  await this.sessions.recordLatestRoute(task, route);
144
- await this.sessions.appendEvent(task, "task.retrying", `Retrying turn ${turn.turnId}`);
231
+ await this.sessions.appendEvent(task, pausedFeatureId ? "feature.resume_requested" : "task.retrying", pausedFeatureId
232
+ ? `Resuming ${pausedFeatureId} in turn ${turn.turnId}`
233
+ : `Retrying turn ${turn.turnId}`);
145
234
  return turn.turnId === "0001"
146
235
  ? this.runInitialTask(executionInput, task, route, turn, workers)
147
236
  : this.runPairTask(executionInput, task, route, turn, workers);
@@ -149,7 +238,7 @@ export class Orchestrator {
149
238
  }
150
239
  async canRetryTask(taskId) {
151
240
  const meta = await readTaskMetaIfValid(this.sessions.taskFromId(taskId).metaPath);
152
- return meta?.status === "failed" || meta?.status === "cancelled";
241
+ return meta?.status === "failed" || meta?.status === "cancelled" || meta?.status === "paused";
153
242
  }
154
243
  async cancelFeature(taskId, featureId) {
155
244
  const active = this.activeFeatureRuns.get(featureRunKey(taskId, featureId));
@@ -172,6 +261,27 @@ export class Orchestrator {
172
261
  role: active.role
173
262
  };
174
263
  }
264
+ async pauseFeature(taskId, featureId) {
265
+ const active = this.activeFeatureRuns.get(featureRunKey(taskId, featureId));
266
+ if (!active || active.cancelRequested) {
267
+ return { requested: false, featureId };
268
+ }
269
+ if (!active.pauseRequested) {
270
+ active.pauseRequested = true;
271
+ active.controller.abort();
272
+ try {
273
+ await this.sessions.appendEvent(this.sessions.taskFromId(taskId), "feature.pause_requested", `Pause requested for ${featureId} ${active.role}`);
274
+ }
275
+ catch {
276
+ // Pausing must not wait on optional audit evidence.
277
+ }
278
+ }
279
+ return {
280
+ requested: true,
281
+ featureId,
282
+ role: active.role
283
+ };
284
+ }
175
285
  async withTaskRunLease(task, run) {
176
286
  let lease;
177
287
  try {
@@ -288,7 +398,7 @@ export class Orchestrator {
288
398
  });
289
399
  }
290
400
  return workers.sort((left, right) => (workerTurnOrder(left) - workerTurnOrder(right)
291
- || workerRoleOrder(left.role) - workerRoleOrder(right.role)
401
+ || workerStageOrder(left) - workerStageOrder(right)
292
402
  || left.id.localeCompare(right.id)));
293
403
  }
294
404
  async runInitialTask(input, task, route, turn, workers) {
@@ -317,6 +427,8 @@ export class Orchestrator {
317
427
  request: input.request,
318
428
  judgeDir: judge.dir,
319
429
  feature,
430
+ actorEngine: route.actor_engine,
431
+ criticEngine: route.critic_engine,
320
432
  resume: input.retry
321
433
  })));
322
434
  return await this.runFeaturePlan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -326,6 +438,8 @@ export class Orchestrator {
326
438
  turn,
327
439
  request: input.request,
328
440
  judgeDir: judge.dir,
441
+ actorEngine: route.actor_engine,
442
+ criticEngine: route.critic_engine,
329
443
  resume: input.retry
330
444
  });
331
445
  features = [feature];
@@ -359,6 +473,8 @@ export class Orchestrator {
359
473
  request: input.request,
360
474
  judgeDir: judge.dir,
361
475
  feature,
476
+ actorEngine: route.actor_engine,
477
+ criticEngine: route.critic_engine,
362
478
  resume: input.retry
363
479
  })));
364
480
  return await this.runFeaturePlan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -368,6 +484,8 @@ export class Orchestrator {
368
484
  turn,
369
485
  request: input.request,
370
486
  judgeDir: judge.dir,
487
+ actorEngine: route.actor_engine,
488
+ criticEngine: route.critic_engine,
371
489
  resume: input.retry
372
490
  });
373
491
  features = [feature];
@@ -442,6 +560,13 @@ export class Orchestrator {
442
560
  }
443
561
  async runFeaturePlan(input, task, route, turn, workers, judge, plan, features) {
444
562
  const channels = new Map(plan.features.map((definition, index) => [definition.id, features[index]]));
563
+ const assignments = new Map(await Promise.all(features.map(async (feature) => [
564
+ feature.id,
565
+ await readFeatureAssignment(feature, {
566
+ actor: route.actor_engine,
567
+ critic: route.critic_engine
568
+ })
569
+ ])));
445
570
  const summaries = [];
446
571
  const waveReviews = [];
447
572
  const changedPaths = new Set();
@@ -492,7 +617,7 @@ export class Orchestrator {
492
617
  await this.sessions.updateTaskStatus(task, "actor_running");
493
618
  const actorRunById = new Map();
494
619
  if (restoredWave) {
495
- const restoredActors = await Promise.all(wave.map((definition) => this.loadCompletedFeatureActor(task, route.actor_engine, definition, requiredChannel(channels, definition))));
620
+ const restoredActors = await Promise.all(wave.map((definition) => this.loadCompletedFeatureActor(task, requiredFeatureAssignment(assignments, requiredChannel(channels, definition)).actor_engine, definition, requiredChannel(channels, definition))));
496
621
  for (const actorRun of restoredActors) {
497
622
  if (actorRun) {
498
623
  actorRunById.set(actorRun.definition.id, actorRun);
@@ -511,7 +636,7 @@ export class Orchestrator {
511
636
  }
512
637
  const freshActorRuns = await mapWithConcurrency(pendingActors, concurrency, async (definition) => {
513
638
  const channel = requiredChannel(channels, definition);
514
- const actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, channel, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel));
639
+ const actor = await this.runActor(input, task, requiredFeatureAssignment(assignments, channel).actor_engine, judge.dir, workers, turn, channel, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel));
515
640
  actorCompleted += 1;
516
641
  reportProgress("actor", actorCompleted, wave.length, actorCompleted === wave.length ? "done" : "running", "waiting");
517
642
  return { definition, channel, actor };
@@ -530,7 +655,7 @@ export class Orchestrator {
530
655
  await this.sessions.updateTaskStatus(task, "critic_running");
531
656
  const pairRunById = new Map();
532
657
  if (restoredWave) {
533
- const restoredPairs = await Promise.all(actorRuns.map((actorRun) => this.loadCompletedFeaturePair(task, route.critic_engine, actorRun)));
658
+ const restoredPairs = await Promise.all(actorRuns.map((actorRun) => this.loadCompletedFeaturePair(task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, actorRun)));
534
659
  for (const pairRun of restoredPairs) {
535
660
  if (pairRun) {
536
661
  pairRunById.set(pairRun.definition.id, pairRun);
@@ -546,7 +671,7 @@ export class Orchestrator {
546
671
  }
547
672
  const freshPairRuns = await mapWithConcurrency(pendingCritics, concurrency, async (actorRun) => {
548
673
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, actorRun.channel.id);
549
- const critic = await this.runCritic(input, task, route.critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, true, reviewWorkspace);
674
+ const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, true, reviewWorkspace);
550
675
  criticCompleted += 1;
551
676
  reportProgress("critic", criticCompleted, actorRuns.length, "done", criticCompleted === actorRuns.length ? "done" : "running");
552
677
  return { ...actorRun, critic };
@@ -562,30 +687,42 @@ export class Orchestrator {
562
687
  return pairRun;
563
688
  });
564
689
  throwIfCancelled(input.signal);
565
- const revisionRuns = [];
690
+ let finalPairs = pairRuns;
566
691
  const revisionFindingIds = new Map();
567
- for (const pair of pairRuns) {
568
- const review = await readTextIfExists(join(pair.critic.dir, "review.md"));
569
- const decision = criticReviewDecision(review);
570
- if (decision === "revision") {
571
- revisionFindingIds.set(pair.channel.id, await requireFeatureRevisionFindings(pair.channel));
572
- revisionRuns.push({ ...pair, review });
573
- }
574
- else if (decision !== "approved") {
575
- throw new Error(`Critic review for feature ${pair.channel.id} must include APPROVED or REVISION_REQUIRED.`);
692
+ let revisionRound = 0;
693
+ while (true) {
694
+ const revisionRuns = [];
695
+ for (const pair of finalPairs) {
696
+ const review = await readTextIfExists(join(pair.critic.dir, "review.md"));
697
+ const decision = criticReviewDecision(review);
698
+ if (decision === "revision") {
699
+ if (revisionRound >= this.config.orchestration.maxRevisionRounds) {
700
+ throw new Error(`Critic still requires revision for ${pair.channel.id} after ${revisionRound} revision rounds.`);
701
+ }
702
+ const findingIds = await requireFeatureRevisionFindings(pair.channel);
703
+ const accumulated = revisionFindingIds.get(pair.channel.id) ?? new Set();
704
+ findingIds.forEach((id) => accumulated.add(id));
705
+ revisionFindingIds.set(pair.channel.id, accumulated);
706
+ revisionRuns.push({ ...pair, review });
707
+ }
708
+ else if (decision !== "approved") {
709
+ throw new Error(`Critic review for feature ${pair.channel.id} must include APPROVED or REVISION_REQUIRED.`);
710
+ }
711
+ else {
712
+ const accumulated = revisionFindingIds.get(pair.channel.id);
713
+ await recordApprovedFindingResolution(pair.channel, accumulated ? [...accumulated] : [], {
714
+ allowLegacyResolvedFindings: Boolean(input.retry) && !accumulated
715
+ });
716
+ }
576
717
  }
577
- else {
578
- await recordApprovedFindingResolution(pair.channel, [], {
579
- allowLegacyResolvedFindings: Boolean(input.retry)
580
- });
718
+ if (revisionRuns.length === 0) {
719
+ break;
581
720
  }
582
- }
583
- let finalPairs = pairRuns;
584
- if (revisionRuns.length > 0) {
721
+ revisionRound += 1;
585
722
  await this.sessions.updateTaskStatus(task, "revision_needed");
586
723
  await Promise.all(revisionRuns.map(async ({ channel, critic }) => {
587
724
  await updateFeatureStatus(channel, "revision_needed");
588
- await appendFeatureDialogue(channel, "critic.revision_requested", "critic", "Critic requested Actor revision.", {
725
+ await appendFeatureDialogue(channel, "critic.revision_requested", "critic", `Critic requested Actor revision ${revisionRound}/${this.config.orchestration.maxRevisionRounds}.`, {
589
726
  review: join(critic.dir, "review.md"),
590
727
  findings: channel.criticFindingsPath
591
728
  });
@@ -594,8 +731,8 @@ export class Orchestrator {
594
731
  let revisionCompleted = 0;
595
732
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
596
733
  const revisedActors = await mapWithConcurrency(revisionRuns, concurrency, async (pair) => {
597
- const actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, pair.channel, buildRevisionRequest(pair.review, pair.channel), true, requiredFeatureWorkspace(workspaceWave.featureDirs, pair.channel));
598
- await requireActorFindingReplies(pair.channel, revisionFindingIds.get(pair.channel.id) ?? []);
734
+ const actor = await this.runActor(input, task, requiredFeatureAssignment(assignments, pair.channel).actor_engine, judge.dir, workers, turn, pair.channel, buildRevisionRequest(pair.review, pair.channel, revisionRound, this.config.orchestration.maxRevisionRounds), true, requiredFeatureWorkspace(workspaceWave.featureDirs, pair.channel));
735
+ await requireActorFindingReplies(pair.channel, [...(revisionFindingIds.get(pair.channel.id) ?? [])]);
599
736
  revisionCompleted += 1;
600
737
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
601
738
  return { ...pair, actor };
@@ -606,7 +743,7 @@ export class Orchestrator {
606
743
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
607
744
  const revisedPairs = await mapWithConcurrency(revisedActors, concurrency, async (pair) => {
608
745
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, pair.channel.id);
609
- const critic = await this.runCritic(input, task, route.critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, true, reviewWorkspace);
746
+ const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, pair.channel).critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, true, reviewWorkspace);
610
747
  recheckCompleted += 1;
611
748
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
612
749
  return {
@@ -617,14 +754,7 @@ export class Orchestrator {
617
754
  };
618
755
  });
619
756
  const replacements = new Map(revisedPairs.map((pair) => [pair.definition.id, pair]));
620
- finalPairs = pairRuns.map((pair) => replacements.get(pair.definition.id) ?? pair);
621
- for (const pair of revisedPairs) {
622
- const review = await readTextIfExists(join(pair.critic.dir, "review.md"));
623
- if (criticReviewDecision(review) !== "approved") {
624
- throw new Error(`Critic did not approve feature ${pair.channel.id} after Actor revision.`);
625
- }
626
- await recordApprovedFindingResolution(pair.channel, revisionFindingIds.get(pair.channel.id) ?? []);
627
- }
757
+ finalPairs = finalPairs.map((pair) => replacements.get(pair.definition.id) ?? pair);
628
758
  throwIfCancelled(input.signal);
629
759
  }
630
760
  const waveSummaries = await allOrThrow(finalPairs.map(async (pair) => {
@@ -655,9 +785,14 @@ export class Orchestrator {
655
785
  await writeText(firstReviewPath, waveReview);
656
786
  await this.sessions.appendEvent(task, "feature.wave_reviewed", `Wave ${waveNumber}/${featureWaves.length} Critic decision: ${waveDecision}`);
657
787
  let waveRevised = false;
658
- if (waveDecision === "revision") {
788
+ let waveRevisionRound = 0;
789
+ while (waveDecision === "revision") {
790
+ if (waveRevisionRound >= this.config.orchestration.maxRevisionRounds) {
791
+ throw new Error(`Wave ${waveNumber}/${featureWaves.length} Critic still requires revision after ${waveRevisionRound} revision rounds. Live workspace was not changed.`);
792
+ }
793
+ waveRevisionRound += 1;
659
794
  waveRevised = true;
660
- await this.sessions.appendEvent(task, "feature.wave_revision_requested", `Wave ${waveNumber}/${featureWaves.length} Critic requested combined revision`);
795
+ await this.sessions.appendEvent(task, "feature.wave_revision_requested", `Wave ${waveNumber}/${featureWaves.length} Critic requested combined revision ${waveRevisionRound}/${this.config.orchestration.maxRevisionRounds}`);
661
796
  await this.sessions.updateTaskStatus(task, "revision_needed");
662
797
  await Promise.all(finalPairs.map(({ channel }) => updateFeatureStatus(channel, "revision_needed")));
663
798
  reportProgress("revision", 0, 1, "revision", "done");
@@ -672,15 +807,13 @@ export class Orchestrator {
672
807
  waveCritic = await this.runWaveCritic(input, task, route.critic_engine, judge.dir, workers, turn, verificationWorkspace, waveNumber, featureWaves.length, finalPairs.map(({ channel }) => channel.id), true);
673
808
  waveReview = await readTextIfExists(join(waveCritic.dir, "review.md"));
674
809
  waveDecision = criticReviewDecision(waveReview);
675
- const secondReviewPath = join(workspaceWave.rootDir, "verification-review-02.md");
676
- waveReviewPaths.push(secondReviewPath);
677
- await writeText(secondReviewPath, waveReview);
678
- await this.sessions.appendEvent(task, "feature.wave_reviewed", `Wave ${waveNumber}/${featureWaves.length} Critic recheck decision: ${waveDecision}`);
810
+ const reviewPath = join(workspaceWave.rootDir, `verification-review-${String(waveRevisionRound + 1).padStart(2, "0")}.md`);
811
+ waveReviewPaths.push(reviewPath);
812
+ await writeText(reviewPath, waveReview);
813
+ await this.sessions.appendEvent(task, "feature.wave_reviewed", `Wave ${waveNumber}/${featureWaves.length} Critic recheck ${waveRevisionRound}/${this.config.orchestration.maxRevisionRounds} decision: ${waveDecision}`);
679
814
  }
680
815
  if (waveDecision !== "approved") {
681
- const detail = waveDecision === "revision"
682
- ? "still requires revision after the Wave Actor pass"
683
- : "did not include APPROVED or REVISION_REQUIRED";
816
+ const detail = "did not include APPROVED or REVISION_REQUIRED";
684
817
  throw new Error(`Wave ${waveNumber}/${featureWaves.length} Critic ${detail}. Live workspace was not changed.`);
685
818
  }
686
819
  reportProgress("verification", 1, 1, "done", "done");
@@ -713,6 +846,7 @@ export class Orchestrator {
713
846
  changedPaths: [...changedPaths]
714
847
  });
715
848
  await writeText(join(turn.dir, "supervisor-summary.md"), `${summary}\n`);
849
+ await this.runFinalJudgeAcceptance(completionInput(input), task, turn, workers, route.judge_engine, judge, workspaceManager, [...changedPaths].sort());
716
850
  await this.sessions.updateTaskStatus(task, "done");
717
851
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
718
852
  return {
@@ -723,6 +857,10 @@ export class Orchestrator {
723
857
  };
724
858
  }
725
859
  async runActorCriticPair(input, task, route, turn, workers, judge, feature) {
860
+ const assignment = await readFeatureAssignment(feature, {
861
+ actor: route.actor_engine,
862
+ critic: route.critic_engine
863
+ });
726
864
  const workspaceManager = new ParallelWorkspaceManager({
727
865
  workspaceRoot: input.cwd,
728
866
  taskDir: task.dir,
@@ -745,7 +883,7 @@ export class Orchestrator {
745
883
  critic: "done",
746
884
  featureProgress: { wave: 1, waves: 1, phase: "integration", completed: 1, total: 1 }
747
885
  });
748
- return this.completeTask(task, turn, judge, this.workerFiles(task, taskWorkerId("actor", route.actor_engine, turn.turnId)), this.workerFiles(task, taskWorkerId("critic", route.critic_engine, turn.turnId)), feature, input, workers, await integratedWaveChangedPaths(workspaceRootDir));
886
+ return this.completeTask(task, turn, judge, this.workerFiles(task, taskWorkerId("actor", assignment.actor_engine, turn.turnId)), this.workerFiles(task, taskWorkerId("critic", assignment.critic_engine, turn.turnId)), feature, input, workers, await integratedWaveChangedPaths(workspaceRootDir), workspaceManager, route.judge_engine);
749
887
  }
750
888
  const restoredWave = input.retry ? await workspaceManager.restoreWave(workspaceInput) : null;
751
889
  const workspaceWave = restoredWave ?? await workspaceManager.prepareWave(workspaceInput);
@@ -756,7 +894,7 @@ export class Orchestrator {
756
894
  throwIfCancelled(input.signal);
757
895
  await this.sessions.updateTaskStatus(task, "actor_running");
758
896
  const restoredActor = restoredWave
759
- ? await this.loadCompletedActor(task, route.actor_engine, feature, false)
897
+ ? await this.loadCompletedActor(task, assignment.actor_engine, feature, false)
760
898
  : null;
761
899
  if (restoredActor) {
762
900
  await this.sessions.appendEvent(task, "feature.wave_actor_checkpoints_reused", "Reused 1/1 completed Actor checkpoint in wave 1/1");
@@ -769,13 +907,13 @@ export class Orchestrator {
769
907
  });
770
908
  let actor = restoredActor;
771
909
  if (!actor) {
772
- actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, feature, undefined, false, workspaceDir, true);
910
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, undefined, false, workspaceDir, true);
773
911
  }
774
912
  await updateFeatureStatus(feature, "actor_done");
775
913
  throwIfCancelled(input.signal);
776
914
  await this.sessions.updateTaskStatus(task, "critic_running");
777
915
  const restoredCritic = restoredActor
778
- ? await this.loadCompletedCritic(task, route.critic_engine, feature, false)
916
+ ? await this.loadCompletedCritic(task, assignment.critic_engine, feature, false)
779
917
  : null;
780
918
  if (restoredCritic) {
781
919
  await this.sessions.appendEvent(task, "feature.wave_critic_checkpoints_reused", "Reused 1/1 completed Critic checkpoint in wave 1/1");
@@ -790,7 +928,7 @@ export class Orchestrator {
790
928
  let critic = restoredCritic;
791
929
  if (!critic) {
792
930
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
793
- critic = await this.runCritic(input, task, route.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
931
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
794
932
  }
795
933
  await updateFeatureStatus(feature, "critic_done");
796
934
  throwIfCancelled(input.signal);
@@ -799,32 +937,46 @@ export class Orchestrator {
799
937
  if (decision === "missing") {
800
938
  throw new Error(`Critic review for ${feature.id} must include APPROVED or REVISION_REQUIRED.`);
801
939
  }
802
- if (decision === "revision") {
940
+ const revisionFindingIds = new Set();
941
+ let revisionRound = 0;
942
+ while (decision === "revision") {
943
+ if (revisionRound >= this.config.orchestration.maxRevisionRounds) {
944
+ throw new Error(`Critic still requires revision for ${feature.id} after ${revisionRound} revision rounds.`);
945
+ }
946
+ revisionRound += 1;
803
947
  const findingIds = await requireFeatureRevisionFindings(feature);
948
+ findingIds.forEach((id) => revisionFindingIds.add(id));
804
949
  await this.sessions.updateTaskStatus(task, "revision_needed");
805
950
  await updateFeatureStatus(feature, "revision_needed");
806
- await appendFeatureDialogue(feature, "critic.revision_requested", "critic", "Critic requested Actor revision.", {
951
+ await appendFeatureDialogue(feature, "critic.revision_requested", "critic", `Critic requested Actor revision ${revisionRound}/${this.config.orchestration.maxRevisionRounds}.`, {
807
952
  review: join(critic.dir, "review.md"),
808
953
  findings: feature.criticFindingsPath
809
954
  });
810
- input.onStatus?.({ taskId: task.id, judge: "done", actor: "revision", critic: "done" });
955
+ input.onStatus?.({
956
+ taskId: task.id,
957
+ judge: "done",
958
+ actor: `revision ${revisionRound}/${this.config.orchestration.maxRevisionRounds}`,
959
+ critic: "done"
960
+ });
811
961
  await this.sessions.updateTaskStatus(task, "actor_running");
812
- actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, feature, buildRevisionRequest(review, feature), false, workspaceDir, true);
813
- await requireActorFindingReplies(feature, findingIds);
962
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, buildRevisionRequest(review, feature, revisionRound, this.config.orchestration.maxRevisionRounds), false, workspaceDir, true);
963
+ await requireActorFindingReplies(feature, [...revisionFindingIds]);
814
964
  await updateFeatureStatus(feature, "actor_done");
815
965
  throwIfCancelled(input.signal);
816
966
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
817
967
  await this.sessions.updateTaskStatus(task, "critic_running");
818
968
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "rerunning" });
819
- critic = await this.runCritic(input, task, route.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
969
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
820
970
  await updateFeatureStatus(feature, "critic_done");
821
971
  throwIfCancelled(input.signal);
822
972
  review = await readTextIfExists(`${critic.dir}/review.md`);
823
973
  decision = criticReviewDecision(review);
824
- if (decision !== "approved") {
825
- throw new Error(`Critic did not approve ${feature.id} after Actor revision.`);
974
+ if (decision === "missing") {
975
+ throw new Error(`Critic review for ${feature.id} must include APPROVED or REVISION_REQUIRED.`);
826
976
  }
827
- await recordApprovedFindingResolution(feature, findingIds);
977
+ }
978
+ if (revisionFindingIds.size > 0) {
979
+ await recordApprovedFindingResolution(feature, [...revisionFindingIds]);
828
980
  }
829
981
  else {
830
982
  await recordApprovedFindingResolution(feature, [], {
@@ -849,9 +1001,9 @@ export class Orchestrator {
849
1001
  critic: "done",
850
1002
  featureProgress: { wave: 1, waves: 1, phase: "integration", completed: 1, total: 1 }
851
1003
  });
852
- return this.completeTask(task, turn, judge, actor, critic, feature, input, workers, integration.changedPaths);
1004
+ return this.completeTask(task, turn, judge, actor, critic, feature, input, workers, integration.changedPaths, workspaceManager, route.judge_engine);
853
1005
  }
854
- async completeTask(task, turn, judge, actor, critic, feature, input, workers, changedPaths) {
1006
+ async completeTask(task, turn, judge, actor, critic, feature, input, workers, changedPaths, workspaceManager, judgeEngine) {
855
1007
  const summary = await buildSupervisorSummary({
856
1008
  judgeDir: judge.dir,
857
1009
  actorDir: actor.dir,
@@ -861,6 +1013,7 @@ export class Orchestrator {
861
1013
  changedPaths
862
1014
  });
863
1015
  await writeText(join(turn.dir, "supervisor-summary.md"), `${summary}\n`);
1016
+ await this.runFinalJudgeAcceptance(completionInput(input), task, turn, workers, judgeEngine, judge, workspaceManager, [...new Set(changedPaths)].sort());
864
1017
  await writeFeatureDecision(feature, summary);
865
1018
  await updateFeatureStatus(feature, "approved");
866
1019
  await this.sessions.updateTaskStatus(task, "done");
@@ -874,11 +1027,18 @@ export class Orchestrator {
874
1027
  }
875
1028
  async failTask(task, features, input, error) {
876
1029
  const featureCancellation = error instanceof FeatureRunCancelledError ? error : null;
1030
+ const featurePause = error instanceof FeatureRunPausedError ? error : null;
877
1031
  const cancelled = Boolean(featureCancellation) || isCancellation(error, input.signal);
878
- const state = cancelled ? "cancelled" : "failed";
1032
+ const state = featurePause ? "paused" : cancelled ? "cancelled" : "failed";
879
1033
  const convergenceErrors = [];
880
1034
  const featureUpdates = await Promise.allSettled(features.map(async (feature) => {
881
1035
  if (!(await featureIsApproved(feature))) {
1036
+ if (featurePause) {
1037
+ if (feature.id === featurePause.featureId) {
1038
+ await updateFeatureStatus(feature, "paused");
1039
+ }
1040
+ return;
1041
+ }
882
1042
  const featureState = featureCancellation
883
1043
  ? feature.id === featureCancellation.featureId ? "cancelled" : "failed"
884
1044
  : state;
@@ -904,6 +1064,14 @@ export class Orchestrator {
904
1064
  convergenceErrors.push(eventError);
905
1065
  }
906
1066
  }
1067
+ if (featurePause) {
1068
+ try {
1069
+ await this.sessions.appendEvent(task, "feature.paused", `Paused ${featurePause.featureId}; completed peer checkpoints preserved`);
1070
+ }
1071
+ catch (eventError) {
1072
+ convergenceErrors.push(eventError);
1073
+ }
1074
+ }
907
1075
  input.onStatus?.({ taskId: task.id });
908
1076
  if (convergenceErrors.length > 0) {
909
1077
  const details = convergenceErrors.map(errorMessage).join("; ");
@@ -912,6 +1080,9 @@ export class Orchestrator {
912
1080
  if (featureCancellation) {
913
1081
  throw featureCancellation;
914
1082
  }
1083
+ if (featurePause) {
1084
+ throw featurePause;
1085
+ }
915
1086
  throw cancelled ? cancellationError() : error;
916
1087
  }
917
1088
  async routeRequest(request, workspace, signal, scope = "initial", onRouteStart, onRouteFallback, onRouteProgress) {
@@ -1164,8 +1335,107 @@ export class Orchestrator {
1164
1335
  ensureWorkerSuccess(result);
1165
1336
  return judge;
1166
1337
  }
1338
+ async runFinalJudgeAcceptance(input, task, turn, workers, engine, judge, workspaceManager, changedPaths) {
1339
+ const judgeReport = await this.validateJudgeSnapshot(turn);
1340
+ if (judgeReport.state !== "valid") {
1341
+ throw new Error(judgeValidationError(turn, judgeReport));
1342
+ }
1343
+ await writeJson(join(turn.dir, COMPLETION_CONTRACT_FILE), {
1344
+ version: 1,
1345
+ final_judge_required: true
1346
+ });
1347
+ const criterionIds = judgeReport.contract.acceptance.map((item) => item.id);
1348
+ const persistedPath = join(turn.dir, FINAL_ACCEPTANCE_FILE);
1349
+ const validationPath = join(turn.dir, FINAL_ACCEPTANCE_VALIDATION_FILE);
1350
+ if (input.retry) {
1351
+ const checkpoint = await readFinalJudgeAcceptance(persistedPath, criterionIds, changedPaths);
1352
+ if (checkpoint?.report.state === "valid" && checkpoint.acceptance?.decision === "approved") {
1353
+ await this.sessions.appendEvent(task, "judge.final_checkpoint_reused", `Reused approved Final Judge acceptance for turn ${turn.turnId}`);
1354
+ return;
1355
+ }
1356
+ }
1357
+ const verificationWorkspace = await workspaceManager.prepareFinalVerificationWorkspace(turn.turnId);
1358
+ await this.sessions.updateTaskStatus(task, "verifying");
1359
+ input.onStatus?.({ taskId: task.id, judge: "verifying", actor: "done", critic: "done" });
1360
+ await this.sessions.appendEvent(task, "judge.final_started", `Final Judge started integration acceptance for ${criterionIds.length} criteria`);
1361
+ const workerId = `judge-${engine}-final-${turn.turnId}`;
1362
+ const featureTitle = finalJudgeTitle(turn.turnId);
1363
+ const workerFiles = this.workerFiles(task, workerId);
1364
+ await removeIfExists(join(workerFiles.dir, FINAL_ACCEPTANCE_FILE));
1365
+ await removeIfExists(persistedPath);
1366
+ await removeIfExists(validationPath);
1367
+ const finalJudge = await this.sessions.initializeWorker(task, {
1368
+ workerId,
1369
+ featureTitle,
1370
+ role: "judge",
1371
+ engine,
1372
+ preserveOutput: input.retry,
1373
+ prompt: buildFinalJudgePrompt({
1374
+ request: input.request,
1375
+ taskDir: task.dir,
1376
+ judgeDir: judge.dir,
1377
+ workerDir: workerFiles.dir,
1378
+ workspaceDir: verificationWorkspace,
1379
+ supervisorSummaryPath: join(turn.dir, "supervisor-summary.md"),
1380
+ expectedCriterionIds: criterionIds,
1381
+ changedPaths,
1382
+ turn: await this.promptTurnContext(task, turn),
1383
+ role: this.config.roles.judge
1384
+ })
1385
+ });
1386
+ this.recordWorker(input, workers, {
1387
+ id: finalJudge.workerId,
1388
+ role: "judge",
1389
+ engine,
1390
+ label: workerLabel("judge", engine, featureTitle),
1391
+ logPath: finalJudge.outputLogPath,
1392
+ statusPath: finalJudge.statusPath
1393
+ });
1394
+ const initialJudge = this.workerFiles(task, taskWorkerId("judge", engine, turn.turnId));
1395
+ const result = await this.runWorkerWithNativeSession(engine, {
1396
+ workerId: finalJudge.workerId,
1397
+ featureTitle,
1398
+ role: "judge",
1399
+ engine,
1400
+ cwd: verificationWorkspace,
1401
+ enforceWorkspaceIsolation: true,
1402
+ writableDirs: uniquePaths([finalJudge.dir, judge.dir, join(task.dir, "features"), turn.dir]),
1403
+ filesDir: finalJudge.dir,
1404
+ promptPath: finalJudge.promptPath,
1405
+ outputLogPath: finalJudge.outputLogPath,
1406
+ statusPath: finalJudge.statusPath,
1407
+ prompt: await readTextIfExists(finalJudge.promptPath),
1408
+ signal: input.signal
1409
+ }, "task", initialJudge);
1410
+ ensureWorkerSuccess(result);
1411
+ throwIfCancelled(input.signal);
1412
+ const workerAcceptancePath = join(finalJudge.dir, FINAL_ACCEPTANCE_FILE);
1413
+ const raw = await readTextIfExists(workerAcceptancePath);
1414
+ if (raw.trim()) {
1415
+ await writeText(persistedPath, raw);
1416
+ }
1417
+ const validated = await readFinalJudgeAcceptance(workerAcceptancePath, criterionIds, changedPaths);
1418
+ const report = validated?.report ?? {
1419
+ version: 1,
1420
+ state: "invalid",
1421
+ decision: "unknown",
1422
+ issues: [`${FINAL_ACCEPTANCE_FILE} is missing or empty`]
1423
+ };
1424
+ await writeJson(validationPath, report);
1425
+ if (report.state !== "valid") {
1426
+ await this.sessions.appendEvent(task, "judge.final_invalid", report.issues.join("; "));
1427
+ throw new Error(`Final Judge acceptance is invalid: ${report.issues.join("; ")}`);
1428
+ }
1429
+ if (validated?.acceptance?.decision !== "approved") {
1430
+ await this.sessions.appendEvent(task, "judge.final_rejected", validated?.acceptance?.summary ?? "Final Judge rejected integration");
1431
+ throw new Error(`Final Judge rejected integration: ${validated?.acceptance?.summary ?? "no summary"}`);
1432
+ }
1433
+ await this.sessions.appendEvent(task, "judge.final_approved", `Final Judge approved ${criterionIds.length} acceptance criteria`);
1434
+ input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
1435
+ }
1167
1436
  async runActor(input, task, engine, judgeDir, workers, turn, feature, revision, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1168
1437
  const workerId = taskWorkerId("actor", engine, turn.turnId, featureScoped ? feature.id : undefined);
1438
+ const workerFiles = this.workerFiles(task, workerId);
1169
1439
  const actor = await this.sessions.initializeWorker(task, {
1170
1440
  workerId,
1171
1441
  ...(featureScoped ? { featureId: feature.id } : {}),
@@ -1177,6 +1447,7 @@ export class Orchestrator {
1177
1447
  request: input.request,
1178
1448
  taskDir: task.dir,
1179
1449
  judgeDir,
1450
+ workerDir: workerFiles.dir,
1180
1451
  turn: await this.promptTurnContext(task, turn),
1181
1452
  feature: featurePromptContext(feature),
1182
1453
  ...(isolatedWorkspace ? { workspaceDir } : {}),
@@ -1224,6 +1495,7 @@ export class Orchestrator {
1224
1495
  }
1225
1496
  async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1226
1497
  const workerId = taskWorkerId("critic", engine, turn.turnId, featureScoped ? feature.id : undefined);
1498
+ const workerFiles = this.workerFiles(task, workerId);
1227
1499
  const critic = await this.sessions.initializeWorker(task, {
1228
1500
  workerId,
1229
1501
  ...(featureScoped ? { featureId: feature.id } : {}),
@@ -1235,6 +1507,7 @@ export class Orchestrator {
1235
1507
  request: input.request,
1236
1508
  taskDir: task.dir,
1237
1509
  judgeDir,
1510
+ workerDir: workerFiles.dir,
1238
1511
  actorDir,
1239
1512
  turn: await this.promptTurnContext(task, turn),
1240
1513
  feature: featurePromptContext(feature),
@@ -1272,6 +1545,9 @@ export class Orchestrator {
1272
1545
  ? undefined
1273
1546
  : await this.previousTurnWorker(task, "critic", engine, turn.turnId));
1274
1547
  ensureWorkerSuccess(result);
1548
+ if (isolatedWorkspace) {
1549
+ await recoverWorkerFileFromWorkspace(join(workspaceDir, "review.md"), join(critic.dir, "review.md"));
1550
+ }
1275
1551
  await appendFeatureDialogue(feature, "critic.completed", "critic", "Critic completed feature review.", {
1276
1552
  review: join(critic.dir, "review.md"),
1277
1553
  findings: feature.criticFindingsPath
@@ -1397,6 +1673,7 @@ export class Orchestrator {
1397
1673
  const active = {
1398
1674
  controller,
1399
1675
  cancelRequested: false,
1676
+ pauseRequested: false,
1400
1677
  role
1401
1678
  };
1402
1679
  const abortFromParent = () => controller.abort();
@@ -1413,6 +1690,9 @@ export class Orchestrator {
1413
1690
  ...spec,
1414
1691
  signal: controller.signal
1415
1692
  }, "task", resumeFrom);
1693
+ if (active.pauseRequested) {
1694
+ throw new FeatureRunPausedError(feature.id);
1695
+ }
1416
1696
  if (active.cancelRequested) {
1417
1697
  throw new FeatureRunCancelledError(feature.id);
1418
1698
  }
@@ -1421,6 +1701,9 @@ export class Orchestrator {
1421
1701
  return result;
1422
1702
  }
1423
1703
  catch (error) {
1704
+ if (active.pauseRequested && !(error instanceof FeatureRunPausedError)) {
1705
+ throw new FeatureRunPausedError(feature.id);
1706
+ }
1424
1707
  if (active.cancelRequested && !(error instanceof FeatureRunCancelledError)) {
1425
1708
  throw new FeatureRunCancelledError(feature.id);
1426
1709
  }
@@ -1471,7 +1754,7 @@ export class Orchestrator {
1471
1754
  return adapter.run({
1472
1755
  ...spec,
1473
1756
  nativeSession: existing,
1474
- nativeSessionConfig: this.config.workers[engine].nativeSession,
1757
+ nativeSessionConfig: workerProvider(this.config, engine).config.nativeSession,
1475
1758
  onNativeSession: async (sessionId) => {
1476
1759
  const now = new Date().toISOString();
1477
1760
  const previous = await this.sessions.readNativeSession(workerFiles);
@@ -1735,6 +2018,11 @@ function cancellationError() {
1735
2018
  error.name = "AbortError";
1736
2019
  return error;
1737
2020
  }
2021
+ function completionInput(input) {
2022
+ const detached = { ...input };
2023
+ delete detached.signal;
2024
+ return detached;
2025
+ }
1738
2026
  function isCancellation(error, signal) {
1739
2027
  return Boolean(signal?.aborted) || (error instanceof Error && error.name === "AbortError");
1740
2028
  }
@@ -1771,8 +2059,10 @@ function summarizeRetirementReason(reason) {
1771
2059
  const summary = contextLine ?? lines.at(-1) ?? "Native session retired";
1772
2060
  return summary.length > 240 ? `${summary.slice(0, 237)}...` : summary;
1773
2061
  }
1774
- function buildRevisionRequest(review, feature) {
2062
+ function buildRevisionRequest(review, feature, round = 1, maxRounds = 1) {
1775
2063
  return [
2064
+ `Revision round: ${round}/${maxRounds}`,
2065
+ "",
1776
2066
  review.trim(),
1777
2067
  "",
1778
2068
  "Feature mailbox:",
@@ -1791,12 +2081,38 @@ function criticReviewDecision(review) {
1791
2081
  }
1792
2082
  return "missing";
1793
2083
  }
2084
+ async function readFinalJudgeAcceptance(path, criterionIds, changedPaths) {
2085
+ const raw = await readTextIfExists(path);
2086
+ if (!raw.trim()) {
2087
+ return null;
2088
+ }
2089
+ try {
2090
+ return validateFinalJudgeAcceptance(JSON.parse(raw), criterionIds, changedPaths);
2091
+ }
2092
+ catch (error) {
2093
+ return {
2094
+ acceptance: null,
2095
+ report: {
2096
+ version: 1,
2097
+ state: "invalid",
2098
+ decision: "unknown",
2099
+ issues: [`${FINAL_ACCEPTANCE_FILE} is not valid JSON: ${errorMessage(error)}`]
2100
+ }
2101
+ };
2102
+ }
2103
+ }
1794
2104
  async function mirrorWorkerFileToFeature(sourcePath, targetPath) {
1795
2105
  const content = await readTextIfExists(sourcePath);
1796
2106
  if (content.trim()) {
1797
2107
  await writeText(targetPath, content);
1798
2108
  }
1799
2109
  }
2110
+ async function recoverWorkerFileFromWorkspace(sourcePath, targetPath) {
2111
+ if ((await readTextIfExists(targetPath)).trim()) {
2112
+ return;
2113
+ }
2114
+ await mirrorWorkerFileToFeature(sourcePath, targetPath);
2115
+ }
1800
2116
  function extractMainResponse(outputLog) {
1801
2117
  return outputLog
1802
2118
  .split("\n")
@@ -1820,6 +2136,11 @@ function taskWorkerLabel(role, engine, turnId) {
1820
2136
  ? workerLabel(role, engine)
1821
2137
  : workerLabel(role, engine, `Turn ${Number(turnId)}`);
1822
2138
  }
2139
+ function finalJudgeTitle(turnId) {
2140
+ return turnId === "0001"
2141
+ ? "Final acceptance"
2142
+ : `Turn ${Number(turnId)} final`;
2143
+ }
1823
2144
  function workerLabelForStatus(status) {
1824
2145
  const feature = status.feature_title ?? status.feature_id;
1825
2146
  if (feature) {
@@ -1845,6 +2166,11 @@ function capitalize(value) {
1845
2166
  function workerRoleOrder(role) {
1846
2167
  return ["main", "judge", "actor", "critic"].indexOf(role);
1847
2168
  }
2169
+ function workerStageOrder(worker) {
2170
+ return worker.role === "judge" && /-final-\d{4,}$/.test(worker.id)
2171
+ ? 4
2172
+ : workerRoleOrder(worker.role);
2173
+ }
1848
2174
  function workerTurnOrder(worker) {
1849
2175
  const featureTurn = worker.featureId?.match(/^(\d{4,})(?:-|$)/)?.[1];
1850
2176
  const waveTurn = worker.id.match(/-wave-(\d{4,})-/)?.[1];
@@ -1870,9 +2196,19 @@ function requiredChannel(channels, definition) {
1870
2196
  }
1871
2197
  return channel;
1872
2198
  }
2199
+ function requiredFeatureAssignment(assignments, channel) {
2200
+ const assignment = assignments.get(channel.id);
2201
+ if (!assignment) {
2202
+ throw new Error(`Feature engine assignment missing: ${channel.id}`);
2203
+ }
2204
+ return assignment;
2205
+ }
1873
2206
  function featureRunKey(taskId, featureId) {
1874
2207
  return `${taskId}\u0000${featureId}`;
1875
2208
  }
2209
+ function featureIdIsSafe(featureId) {
2210
+ return /^[a-z0-9][a-z0-9-]{0,95}$/.test(featureId);
2211
+ }
1876
2212
  function requiredFeatureWorkspace(featureDirs, channel) {
1877
2213
  const workspace = featureDirs.get(channel.id);
1878
2214
  if (!workspace) {