parallel-codex-tui 0.2.10 → 0.3.1

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.
@@ -1,10 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cp, readdir } from "node:fs/promises";
2
3
  import { join } from "node:path";
4
+ import { runRuntimePreflight } from "../doctor.js";
3
5
  import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "../core/file-store.js";
4
6
  import { runWithLeaseFinalization } from "../core/lease-finalization.js";
5
7
  import { extractMainResponse } from "../core/main-response.js";
6
8
  import { claimTaskRunLease, TaskRunLeaseConflictError } from "../core/process-ownership.js";
7
9
  import { routerRuntimeDir } from "../core/paths.js";
10
+ import { RoleConfigurationManager, roleSelectionWithEngines } from "../core/role-configuration.js";
8
11
  import { classifyRouterFailure, routerFallbackIsTransient } from "../core/router-audit.js";
9
12
  import { sanitizeRouterText } from "../core/router-redaction.js";
10
13
  import { routeRequestWithCodex, routerCommandLabel, routerProxyContext } from "../core/router.js";
@@ -59,6 +62,7 @@ export class Orchestrator {
59
62
  routerConfigLoader;
60
63
  dependencies;
61
64
  activeFeatureRuns = new Map();
65
+ roleConfiguration;
62
66
  constructor(config, sessions, workers, routeRunner, routerCwd = routerRuntimeDir(config.projectRoot, config.dataDir), routerConfigLoader, dependencies = {}) {
63
67
  this.config = config;
64
68
  this.sessions = sessions;
@@ -67,17 +71,21 @@ export class Orchestrator {
67
71
  this.routerCwd = routerCwd;
68
72
  this.routerConfigLoader = routerConfigLoader;
69
73
  this.dependencies = dependencies;
74
+ this.roleConfiguration = dependencies.roleConfiguration ?? RoleConfigurationManager.transient(config);
70
75
  }
71
76
  async handleRequest(input) {
72
77
  throwIfCancelled(input.signal);
73
- const route = await this.routeRequest(input.request, input.cwd, input.signal, "initial", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
78
+ const routed = await this.routeRequest(input.request, input.cwd, input.signal, "initial", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
79
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest();
80
+ const route = routeWithRoleSelection(routed, roleSelection);
74
81
  input.onRoute?.(route);
75
82
  throwIfCancelled(input.signal);
76
83
  const workers = [];
84
+ const executionInput = { ...input, roleSelection };
77
85
  if (route.mode === "simple") {
78
86
  try {
79
87
  input.onStatus?.({ taskId: "main", main: "starting" });
80
- const output = await this.runMain(input, workers);
88
+ const output = await this.runMain(executionInput, workers);
81
89
  input.onStatus?.({ taskId: "main", main: "done" });
82
90
  return {
83
91
  mode: "simple",
@@ -104,18 +112,20 @@ export class Orchestrator {
104
112
  userPath: join(task.dir, "turns", "0001", "user.md"),
105
113
  routePath: join(task.dir, "turns", "0001", "route.json")
106
114
  };
107
- return this.withTaskRunLease(task, () => this.runInitialTask(input, task, route, turn, workers));
115
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelectionWithEngines(route, roleSelection));
116
+ return this.withTaskRunLease(task, () => this.runInitialTask(executionInput, task, route, turn, workers));
108
117
  }
109
118
  async handleTaskTurn(input) {
110
119
  throwIfCancelled(input.signal);
111
120
  const task = this.sessions.taskFromId(input.taskId);
112
- const route = input.route ?? await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
121
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
122
+ const route = routeWithRoleSelection(input.route ?? await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress), roleSelection);
113
123
  if (!input.route) {
114
124
  input.onRoute?.(route);
115
125
  }
116
126
  throwIfCancelled(input.signal);
117
127
  if (route.mode === "simple") {
118
- return this.answerTaskQuestion({ ...input, route });
128
+ return this.answerTaskQuestion({ ...input, route, roleSelection });
119
129
  }
120
130
  return this.withTaskRunLease(task, async () => {
121
131
  throwIfCancelled(input.signal);
@@ -124,8 +134,9 @@ export class Orchestrator {
124
134
  request: input.request,
125
135
  route
126
136
  });
137
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelectionWithEngines(route, roleSelection));
127
138
  const workers = [];
128
- return this.runPairTask(input, task, route, turn, workers);
139
+ return this.runPairTask({ ...input, roleSelection }, task, route, turn, workers);
129
140
  });
130
141
  }
131
142
  async retryTask(input) {
@@ -175,20 +186,126 @@ export class Orchestrator {
175
186
  };
176
187
  const assignmentPath = join(featureDir, "assignment.json");
177
188
  const current = await readFeatureAssignment({ assignmentPath }, fallback);
178
- const assignment = await writeFeatureAssignment({ assignmentPath }, input.role === "actor" ? input.engine : current.actor_engine, input.role === "critic" ? input.engine : current.critic_engine);
189
+ const nextModel = input.model === undefined
190
+ ? input.role === "actor"
191
+ ? current.actor_engine === input.engine ? current.actor_model : ""
192
+ : current.critic_engine === input.engine ? current.critic_model : ""
193
+ : input.model;
194
+ const assignment = await writeFeatureAssignment({ assignmentPath }, input.role === "actor" ? input.engine : current.actor_engine, input.role === "critic" ? input.engine : current.critic_engine, input.role === "actor" ? nextModel : current.actor_model, input.role === "critic" ? nextModel : current.critic_model, {
195
+ actorOverride: input.role === "actor" ? true : current.actor_override,
196
+ criticOverride: input.role === "critic" ? true : current.critic_override
197
+ });
179
198
  await appendJsonLine(join(task.dir, "dialogue", "actor-critic.jsonl"), {
180
199
  time: new Date().toISOString(),
181
200
  feature_id: input.featureId,
182
201
  turn_id: status.turn_id,
183
202
  type: "feature.assignment_changed",
184
203
  role: input.role,
185
- message: `${capitalize(input.role)} reassigned to ${input.engine}.`,
204
+ message: `${capitalize(input.role)} reassigned to ${input.engine}/${nextModel.trim() || "default"}.`,
186
205
  paths: { assignment: assignmentPath }
187
206
  });
188
- await this.sessions.appendEvent(task, "feature.assignment_changed", `${input.featureId} ${input.role} reassigned to ${input.engine}`);
207
+ await this.sessions.appendEvent(task, "feature.assignment_changed", `${input.featureId} ${input.role} reassigned to ${input.engine}/${nextModel.trim() || "default"}`);
189
208
  return { featureId: input.featureId, assignment };
190
209
  });
191
210
  }
211
+ async roleConfigurationSnapshot(taskId) {
212
+ const task = taskId ? this.sessions.taskFromId(taskId) : null;
213
+ if (task && !(await pathExists(task.dir))) {
214
+ throw new Error(`Task session not found: ${taskId}`);
215
+ }
216
+ return this.roleConfiguration.snapshot(task?.dir);
217
+ }
218
+ async validateRoleConfiguration(roles) {
219
+ const parsed = this.roleConfiguration.validate(roles);
220
+ const candidate = structuredClone(this.config);
221
+ candidate.router.defaultMode = "auto";
222
+ candidate.pairing.main = parsed.main.engine;
223
+ candidate.pairing.judge = parsed.judge.engine;
224
+ candidate.pairing.actor = parsed.actor.engine;
225
+ candidate.pairing.critic = parsed.critic.engine;
226
+ const preflight = this.dependencies.roleConfigurationPreflight
227
+ ?? ((config, workspaceRoot) => runRuntimePreflight(config, workspaceRoot, process.env, { includeRouter: false }));
228
+ return preflight(candidate, this.roleConfiguration.workspaceRootPath());
229
+ }
230
+ async updateRoleConfiguration(input) {
231
+ if (input.scope !== "task") {
232
+ await this.roleConfiguration.apply(input.scope, input.roles);
233
+ return this.roleConfigurationSnapshot(input.taskId);
234
+ }
235
+ const taskId = input.taskId;
236
+ if (!taskId) {
237
+ throw new Error("No active Task is available for a current-task role configuration.");
238
+ }
239
+ const task = this.sessions.taskFromId(taskId);
240
+ if (!(await pathExists(task.dir))) {
241
+ throw new Error(`Task session not found: ${taskId}`);
242
+ }
243
+ return this.withTaskRunLease(task, async () => {
244
+ await this.roleConfiguration.apply("task", input.roles, task.dir);
245
+ await this.synchronizeRetryRoleConfiguration(task, input.roles);
246
+ await this.sessions.appendEvent(task, "task.role_configuration_changed", "Current Task role configuration updated");
247
+ return this.roleConfiguration.snapshot(task.dir);
248
+ });
249
+ }
250
+ async clearRoleConfiguration(scope, taskId) {
251
+ if (scope !== "task") {
252
+ await this.roleConfiguration.clear(scope);
253
+ return this.roleConfigurationSnapshot(taskId);
254
+ }
255
+ if (!taskId) {
256
+ throw new Error("No active Task is available for a current-task role configuration.");
257
+ }
258
+ const task = this.sessions.taskFromId(taskId);
259
+ if (!(await pathExists(task.dir))) {
260
+ throw new Error(`Task session not found: ${taskId}`);
261
+ }
262
+ return this.withTaskRunLease(task, async () => {
263
+ await this.roleConfiguration.clear("task", task.dir);
264
+ const roles = await this.roleConfiguration.selectionForTask(task.dir);
265
+ await this.synchronizeRetryRoleConfiguration(task, roles);
266
+ await this.sessions.appendEvent(task, "task.role_configuration_cleared", "Current Task role configuration reset");
267
+ return this.roleConfiguration.snapshot(task.dir);
268
+ });
269
+ }
270
+ async synchronizeRetryRoleConfiguration(task, roles) {
271
+ const meta = await readTaskMetaIfValid(task.metaPath);
272
+ if (!meta || !new Set(["failed", "cancelled", "paused"]).has(meta.status)) {
273
+ return;
274
+ }
275
+ const turn = await this.sessions.latestTurn(task);
276
+ if (!turn) {
277
+ return;
278
+ }
279
+ const route = routeWithRoleSelection(await readJson(turn.routePath, RouteDecisionSchema), roles);
280
+ const executionRoles = roleSelectionWithEngines(route, roles);
281
+ await writeJson(turn.routePath, route);
282
+ await this.sessions.recordLatestRoute(task, route);
283
+ await this.roleConfiguration.writeTurnSelection(turn.dir, executionRoles);
284
+ const featuresDir = join(task.dir, "features");
285
+ if (!(await pathExists(featuresDir))) {
286
+ return;
287
+ }
288
+ const entries = await readdir(featuresDir, { withFileTypes: true });
289
+ for (const entry of entries) {
290
+ if (!entry.isDirectory()) {
291
+ continue;
292
+ }
293
+ const featureDir = join(featuresDir, entry.name);
294
+ const status = await readFeatureStatusIfValid(join(featureDir, "status.json"));
295
+ if (!status || status.task_id !== task.id || status.state === "approved") {
296
+ continue;
297
+ }
298
+ const assignmentPath = join(featureDir, "assignment.json");
299
+ const current = await readFeatureAssignment({ assignmentPath }, {
300
+ actor: executionRoles.actor.engine,
301
+ critic: executionRoles.critic.engine
302
+ });
303
+ await writeFeatureAssignment({ assignmentPath }, current.actor_override ? current.actor_engine : executionRoles.actor.engine, current.critic_override ? current.critic_engine : executionRoles.critic.engine, current.actor_override ? current.actor_model : executionRoles.actor.model, current.critic_override ? current.critic_model : executionRoles.critic.model, {
304
+ actorOverride: current.actor_override,
305
+ criticOverride: current.critic_override
306
+ });
307
+ }
308
+ }
192
309
  async resumeTaskRun(input, pausedFeatureId) {
193
310
  throwIfCancelled(input.signal);
194
311
  const task = this.sessions.taskFromId(input.taskId);
@@ -226,11 +343,15 @@ export class Orchestrator {
226
343
  throw new Error(`Task ${input.taskId} turn ${turn.turnId} has no request to retry.`);
227
344
  }
228
345
  const route = await readJson(turn.routePath, RouteDecisionSchema);
346
+ const roleSelection = await this.roleConfiguration.readTurnSelection(turn.dir)
347
+ ?? roleSelectionWithEngines(route, await this.roleConfiguration.selectionForTask(task.dir));
348
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelection);
229
349
  const executionInput = {
230
350
  ...input,
231
351
  request,
232
352
  cwd: meta.cwd,
233
- retry: true
353
+ retry: true,
354
+ roleSelection
234
355
  };
235
356
  const workers = [];
236
357
  input.onRoute?.(route);
@@ -311,14 +432,18 @@ export class Orchestrator {
311
432
  }
312
433
  async routeTaskFollowUp(input) {
313
434
  throwIfCancelled(input.signal);
314
- const route = await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
435
+ const routed = await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
436
+ const task = this.sessions.taskFromId(input.taskId);
437
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
438
+ const route = routeWithRoleSelection(routed, roleSelection);
315
439
  input.onRoute?.(route);
316
440
  throwIfCancelled(input.signal);
317
441
  return {
318
442
  mode: route.mode,
319
443
  taskId: route.mode === "complex" ? input.taskId : null,
320
444
  reason: route.reason,
321
- route
445
+ route,
446
+ roleSelection
322
447
  };
323
448
  }
324
449
  async answerTaskQuestion(input) {
@@ -327,7 +452,11 @@ export class Orchestrator {
327
452
  if (!(await pathExists(task.dir))) {
328
453
  throw new Error(`Task session not found: ${input.taskId}`);
329
454
  }
330
- return this.withTaskRunLease(task, () => this.answerTaskQuestionWithLease(input, task));
455
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
456
+ return this.withTaskRunLease(task, () => this.answerTaskQuestionWithLease({
457
+ ...input,
458
+ roleSelection
459
+ }, task));
331
460
  }
332
461
  async answerTaskQuestionWithLease(input, task) {
333
462
  throwIfCancelled(input.signal);
@@ -437,6 +566,8 @@ export class Orchestrator {
437
566
  feature,
438
567
  actorEngine: route.actor_engine,
439
568
  criticEngine: route.critic_engine,
569
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
570
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
440
571
  resume: input.retry
441
572
  })));
442
573
  return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -448,6 +579,8 @@ export class Orchestrator {
448
579
  judgeDir: judge.dir,
449
580
  actorEngine: route.actor_engine,
450
581
  criticEngine: route.critic_engine,
582
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
583
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
451
584
  resume: input.retry
452
585
  });
453
586
  features = [feature];
@@ -483,6 +616,8 @@ export class Orchestrator {
483
616
  feature,
484
617
  actorEngine: route.actor_engine,
485
618
  criticEngine: route.critic_engine,
619
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
620
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
486
621
  resume: input.retry
487
622
  })));
488
623
  return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -494,6 +629,8 @@ export class Orchestrator {
494
629
  judgeDir: judge.dir,
495
630
  actorEngine: route.actor_engine,
496
631
  criticEngine: route.critic_engine,
632
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
633
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
497
634
  resume: input.retry
498
635
  });
499
636
  features = [feature];
@@ -662,6 +799,8 @@ export class Orchestrator {
662
799
  feature,
663
800
  actorEngine: route.actor_engine,
664
801
  criticEngine: route.critic_engine,
802
+ actorModel: selectedRoleModel(currentInput.roleSelection, "actor", route.actor_engine),
803
+ criticModel: selectedRoleModel(currentInput.roleSelection, "critic", route.critic_engine),
665
804
  resume: true,
666
805
  refreshDefinition: true
667
806
  })));
@@ -746,7 +885,11 @@ export class Orchestrator {
746
885
  await this.sessions.updateTaskStatus(task, "actor_running");
747
886
  const actorRunById = new Map();
748
887
  if (restoredWave) {
749
- const restoredActors = await Promise.all(wave.map((definition) => this.loadCompletedFeatureActor(task, requiredFeatureAssignment(assignments, requiredChannel(channels, definition)).actor_engine, definition, requiredChannel(channels, definition))));
888
+ const restoredActors = await Promise.all(wave.map((definition) => {
889
+ const channel = requiredChannel(channels, definition);
890
+ const assignment = requiredFeatureAssignment(assignments, channel);
891
+ return this.loadCompletedFeatureActor(task, assignment.actor_engine, assignment.actor_model, definition, channel);
892
+ }));
750
893
  for (const actorRun of restoredActors) {
751
894
  if (actorRun) {
752
895
  actorRunById.set(actorRun.definition.id, actorRun);
@@ -765,7 +908,8 @@ export class Orchestrator {
765
908
  }
766
909
  const freshActorRuns = await mapWithConcurrency(pendingActors, concurrency, async (definition) => {
767
910
  const channel = requiredChannel(channels, definition);
768
- const actor = await this.runActor(input, task, requiredFeatureAssignment(assignments, channel).actor_engine, judge.dir, workers, turn, channel, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel));
911
+ const assignment = requiredFeatureAssignment(assignments, channel);
912
+ const actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, channel, assignment.actor_model, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel), true);
769
913
  actorCompleted += 1;
770
914
  reportProgress("actor", actorCompleted, wave.length, actorCompleted === wave.length ? "done" : "running", "waiting");
771
915
  return { definition, channel, actor };
@@ -784,7 +928,10 @@ export class Orchestrator {
784
928
  await this.sessions.updateTaskStatus(task, "critic_running");
785
929
  const pairRunById = new Map();
786
930
  if (restoredWave) {
787
- const restoredPairs = await Promise.all(actorRuns.map((actorRun) => this.loadCompletedFeaturePair(task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, actorRun)));
931
+ const restoredPairs = await Promise.all(actorRuns.map((actorRun) => {
932
+ const assignment = requiredFeatureAssignment(assignments, actorRun.channel);
933
+ return this.loadCompletedFeaturePair(task, assignment.critic_engine, assignment.critic_model, actorRun);
934
+ }));
788
935
  for (const pairRun of restoredPairs) {
789
936
  if (pairRun) {
790
937
  pairRunById.set(pairRun.definition.id, pairRun);
@@ -799,8 +946,9 @@ export class Orchestrator {
799
946
  await this.sessions.appendEvent(task, "feature.wave_critic_checkpoints_reused", `Reused ${criticCompleted}/${actorRuns.length} completed Critic checkpoints in wave ${waveNumber}/${featureWaves.length}`);
800
947
  }
801
948
  const freshPairRuns = await mapWithConcurrency(pendingCritics, concurrency, async (actorRun) => {
949
+ const assignment = requiredFeatureAssignment(assignments, actorRun.channel);
802
950
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, actorRun.channel.id);
803
- const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, true, reviewWorkspace);
951
+ const critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, assignment.critic_model, true, reviewWorkspace);
804
952
  criticCompleted += 1;
805
953
  reportProgress("critic", criticCompleted, actorRuns.length, "done", criticCompleted === actorRuns.length ? "done" : "running");
806
954
  return { ...actorRun, critic };
@@ -860,7 +1008,8 @@ export class Orchestrator {
860
1008
  let revisionCompleted = 0;
861
1009
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
862
1010
  const revisedActors = await mapWithConcurrency(revisionRuns, concurrency, async (pair) => {
863
- 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));
1011
+ const assignment = requiredFeatureAssignment(assignments, pair.channel);
1012
+ const actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, pair.channel, assignment.actor_model, buildRevisionRequest(pair.review, pair.channel, revisionRound, this.config.orchestration.maxRevisionRounds), true, requiredFeatureWorkspace(workspaceWave.featureDirs, pair.channel));
864
1013
  await requireActorFindingReplies(pair.channel, [...(revisionFindingIds.get(pair.channel.id) ?? [])]);
865
1014
  revisionCompleted += 1;
866
1015
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
@@ -871,8 +1020,9 @@ export class Orchestrator {
871
1020
  let recheckCompleted = 0;
872
1021
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
873
1022
  const revisedPairs = await mapWithConcurrency(revisedActors, concurrency, async (pair) => {
1023
+ const assignment = requiredFeatureAssignment(assignments, pair.channel);
874
1024
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, pair.channel.id);
875
- const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, pair.channel).critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, true, reviewWorkspace);
1025
+ const critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, assignment.critic_model, true, reviewWorkspace);
876
1026
  recheckCompleted += 1;
877
1027
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
878
1028
  return {
@@ -1002,6 +1152,10 @@ export class Orchestrator {
1002
1152
  };
1003
1153
  const workspaceRootDir = join(task.dir, "workspaces", `turn-${turn.turnId}`, "wave-0001");
1004
1154
  const integratedCheckpoint = input.retry && await waveIntegrationCheckpointMatches(workspaceRootDir, turn.turnId, 1, [feature.id]);
1155
+ const [actorExecution, criticExecution] = await Promise.all([
1156
+ this.featureWorkerExecution(turn, "actor", assignment.actor_engine, assignment.actor_model),
1157
+ this.featureWorkerExecution(turn, "critic", assignment.critic_engine, assignment.critic_model)
1158
+ ]);
1005
1159
  if (integratedCheckpoint) {
1006
1160
  const recovered = !(await featureIsApproved(feature));
1007
1161
  await this.sessions.appendEvent(task, recovered ? "feature.wave_checkpoint_recovered" : "feature.wave_checkpoint_reused", `${recovered ? "Recovered" : "Reused"} integrated checkpoint for single feature: ${feature.id}`);
@@ -1012,7 +1166,7 @@ export class Orchestrator {
1012
1166
  critic: "done",
1013
1167
  featureProgress: { wave: 1, waves: 1, phase: "integration", completed: 1, total: 1 }
1014
1168
  });
1015
- 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);
1169
+ return this.completeTask(task, turn, judge, this.workerFiles(task, taskWorkerId("actor", assignment.actor_engine, turn.turnId, undefined, actorExecution.modelKey)), this.workerFiles(task, taskWorkerId("critic", assignment.critic_engine, turn.turnId, undefined, criticExecution.modelKey)), feature, input, workers, await integratedWaveChangedPaths(workspaceRootDir), workspaceManager, route.judge_engine);
1016
1170
  }
1017
1171
  const restoredWave = input.retry ? await workspaceManager.restoreWave(workspaceInput) : null;
1018
1172
  const workspaceWave = restoredWave ?? await workspaceManager.prepareWave(workspaceInput);
@@ -1023,7 +1177,7 @@ export class Orchestrator {
1023
1177
  throwIfCancelled(input.signal);
1024
1178
  await this.sessions.updateTaskStatus(task, "actor_running");
1025
1179
  const restoredActor = restoredWave
1026
- ? await this.loadCompletedActor(task, assignment.actor_engine, feature, false)
1180
+ ? await this.loadCompletedActor(task, assignment.actor_engine, assignment.actor_model, feature, false)
1027
1181
  : null;
1028
1182
  if (restoredActor) {
1029
1183
  await this.sessions.appendEvent(task, "feature.wave_actor_checkpoints_reused", "Reused 1/1 completed Actor checkpoint in wave 1/1");
@@ -1036,13 +1190,13 @@ export class Orchestrator {
1036
1190
  });
1037
1191
  let actor = restoredActor;
1038
1192
  if (!actor) {
1039
- actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, undefined, false, workspaceDir, true);
1193
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, assignment.actor_model, undefined, false, workspaceDir, true);
1040
1194
  }
1041
1195
  await updateFeatureStatus(feature, "actor_done");
1042
1196
  throwIfCancelled(input.signal);
1043
1197
  await this.sessions.updateTaskStatus(task, "critic_running");
1044
1198
  const restoredCritic = restoredActor
1045
- ? await this.loadCompletedCritic(task, assignment.critic_engine, feature, false)
1199
+ ? await this.loadCompletedCritic(task, assignment.critic_engine, assignment.critic_model, feature, false)
1046
1200
  : null;
1047
1201
  if (restoredCritic) {
1048
1202
  await this.sessions.appendEvent(task, "feature.wave_critic_checkpoints_reused", "Reused 1/1 completed Critic checkpoint in wave 1/1");
@@ -1057,7 +1211,7 @@ export class Orchestrator {
1057
1211
  let critic = restoredCritic;
1058
1212
  if (!critic) {
1059
1213
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
1060
- critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
1214
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, assignment.critic_model, false, reviewWorkspace, true);
1061
1215
  }
1062
1216
  await updateFeatureStatus(feature, "critic_done");
1063
1217
  throwIfCancelled(input.signal);
@@ -1088,14 +1242,14 @@ export class Orchestrator {
1088
1242
  critic: "done"
1089
1243
  });
1090
1244
  await this.sessions.updateTaskStatus(task, "actor_running");
1091
- 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);
1245
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, assignment.actor_model, buildRevisionRequest(review, feature, revisionRound, this.config.orchestration.maxRevisionRounds), false, workspaceDir, true);
1092
1246
  await requireActorFindingReplies(feature, [...revisionFindingIds]);
1093
1247
  await updateFeatureStatus(feature, "actor_done");
1094
1248
  throwIfCancelled(input.signal);
1095
1249
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
1096
1250
  await this.sessions.updateTaskStatus(task, "critic_running");
1097
1251
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "rerunning" });
1098
- critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
1252
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, assignment.critic_model, false, reviewWorkspace, true);
1099
1253
  await updateFeatureStatus(feature, "critic_done");
1100
1254
  throwIfCancelled(input.signal);
1101
1255
  review = await readTextIfExists(`${critic.dir}/review.md`);
@@ -1357,7 +1511,8 @@ export class Orchestrator {
1357
1511
  }
1358
1512
  async runMain(input, workers, context, taskId = null) {
1359
1513
  throwIfCancelled(input.signal);
1360
- const engine = this.config.pairing.main;
1514
+ const target = input.roleSelection?.main ?? this.roleConfiguration.futureRoles().main;
1515
+ const engine = target.engine;
1361
1516
  const dir = this.sessions.mainSessionDir();
1362
1517
  let lease;
1363
1518
  try {
@@ -1427,6 +1582,7 @@ export class Orchestrator {
1427
1582
  outputLogPath,
1428
1583
  statusPath,
1429
1584
  prompt,
1585
+ modelConfig: this.roleConfiguration.modelForTarget(input.roleSelection?.main ?? this.roleConfiguration.futureRoles().main),
1430
1586
  signal: input.signal,
1431
1587
  onStatus: (runtimeStatus) => {
1432
1588
  this.recordWorker(input, workers, { ...worker, runtimeStatus });
@@ -1473,6 +1629,7 @@ export class Orchestrator {
1473
1629
  outputLogPath: judge.outputLogPath,
1474
1630
  statusPath: judge.statusPath,
1475
1631
  prompt: await readTextIfExists(judge.promptPath),
1632
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "judge", engine),
1476
1633
  signal: input.signal
1477
1634
  }, "task", await this.previousTurnWorker(task, "judge", engine, turn.turnId));
1478
1635
  ensureWorkerSuccess(result);
@@ -1548,6 +1705,7 @@ export class Orchestrator {
1548
1705
  outputLogPath: finalJudge.outputLogPath,
1549
1706
  statusPath: finalJudge.statusPath,
1550
1707
  prompt: await readTextIfExists(finalJudge.promptPath),
1708
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "judge", engine),
1551
1709
  signal: input.signal
1552
1710
  }, "task", initialJudge);
1553
1711
  ensureWorkerSuccess(result);
@@ -1576,8 +1734,9 @@ export class Orchestrator {
1576
1734
  await this.sessions.appendEvent(task, "judge.final_approved", `Final Judge approved ${criterionIds.length} acceptance criteria`);
1577
1735
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
1578
1736
  }
1579
- async runActor(input, task, engine, judgeDir, workers, turn, feature, revision, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1580
- const workerId = taskWorkerId("actor", engine, turn.turnId, featureScoped ? feature.id : undefined);
1737
+ async runActor(input, task, engine, judgeDir, workers, turn, feature, modelName = "", revision, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1738
+ const execution = await this.featureWorkerExecution(turn, "actor", engine, modelName);
1739
+ const workerId = taskWorkerId("actor", engine, turn.turnId, featureScoped ? feature.id : undefined, execution.modelKey);
1581
1740
  const workerFiles = this.workerFiles(task, workerId);
1582
1741
  const actor = await this.sessions.initializeWorker(task, {
1583
1742
  workerId,
@@ -1623,6 +1782,7 @@ export class Orchestrator {
1623
1782
  outputLogPath: actor.outputLogPath,
1624
1783
  statusPath: actor.statusPath,
1625
1784
  prompt: await readTextIfExists(actor.promptPath),
1785
+ modelConfig: execution.modelConfig,
1626
1786
  signal: input.signal
1627
1787
  }, task, feature, featureScoped
1628
1788
  ? undefined
@@ -1636,8 +1796,9 @@ export class Orchestrator {
1636
1796
  });
1637
1797
  return actor;
1638
1798
  }
1639
- async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1640
- const workerId = taskWorkerId("critic", engine, turn.turnId, featureScoped ? feature.id : undefined);
1799
+ async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature, modelName = "", featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1800
+ const execution = await this.featureWorkerExecution(turn, "critic", engine, modelName);
1801
+ const workerId = taskWorkerId("critic", engine, turn.turnId, featureScoped ? feature.id : undefined, execution.modelKey);
1641
1802
  const workerFiles = this.workerFiles(task, workerId);
1642
1803
  const critic = await this.sessions.initializeWorker(task, {
1643
1804
  workerId,
@@ -1683,6 +1844,7 @@ export class Orchestrator {
1683
1844
  outputLogPath: critic.outputLogPath,
1684
1845
  statusPath: critic.statusPath,
1685
1846
  prompt: await readTextIfExists(critic.promptPath),
1847
+ modelConfig: execution.modelConfig,
1686
1848
  signal: input.signal
1687
1849
  }, task, feature, featureScoped
1688
1850
  ? undefined
@@ -1741,6 +1903,7 @@ export class Orchestrator {
1741
1903
  outputLogPath: critic.outputLogPath,
1742
1904
  statusPath: critic.statusPath,
1743
1905
  prompt: await readTextIfExists(critic.promptPath),
1906
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "critic", engine),
1744
1907
  signal: input.signal
1745
1908
  });
1746
1909
  ensureWorkerSuccess(result);
@@ -1791,6 +1954,7 @@ export class Orchestrator {
1791
1954
  outputLogPath: actor.outputLogPath,
1792
1955
  statusPath: actor.statusPath,
1793
1956
  prompt: await readTextIfExists(actor.promptPath),
1957
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "actor", engine),
1794
1958
  signal: input.signal
1795
1959
  });
1796
1960
  ensureWorkerSuccess(result);
@@ -1896,6 +2060,7 @@ export class Orchestrator {
1896
2060
  }
1897
2061
  return adapter.run({
1898
2062
  ...spec,
2063
+ modelConfig: spec.modelConfig ?? workerProvider(this.config, engine).config.model,
1899
2064
  nativeSession: existing,
1900
2065
  nativeSessionConfig: workerProvider(this.config, engine).config.nativeSession,
1901
2066
  onNativeSession: async (sessionId) => {
@@ -1950,35 +2115,39 @@ export class Orchestrator {
1950
2115
  }
1951
2116
  return undefined;
1952
2117
  }
1953
- async loadCompletedFeatureActor(task, engine, definition, channel) {
1954
- const actor = await this.loadCompletedActor(task, engine, channel, true);
2118
+ async loadCompletedFeatureActor(task, engine, modelName, definition, channel) {
2119
+ const actor = await this.loadCompletedActor(task, engine, modelName, channel, true);
1955
2120
  if (!actor) {
1956
2121
  return null;
1957
2122
  }
1958
2123
  return { definition, channel, actor };
1959
2124
  }
1960
- async loadCompletedFeaturePair(task, engine, actorRun) {
1961
- const critic = await this.loadCompletedCritic(task, engine, actorRun.channel, true);
2125
+ async loadCompletedFeaturePair(task, engine, modelName, actorRun) {
2126
+ const critic = await this.loadCompletedCritic(task, engine, modelName, actorRun.channel, true);
1962
2127
  return critic ? { ...actorRun, critic } : null;
1963
2128
  }
1964
- async loadCompletedActor(task, engine, channel, featureScoped) {
1965
- const actor = this.workerFiles(task, taskWorkerId("actor", engine, channel.turnId, featureScoped ? channel.id : undefined));
2129
+ async loadCompletedActor(task, engine, modelName, channel, featureScoped) {
2130
+ const execution = await this.featureWorkerExecution({ dir: join(task.dir, "turns", channel.turnId) }, "actor", engine, modelName);
2131
+ const actor = this.workerFiles(task, taskWorkerId("actor", engine, channel.turnId, featureScoped ? channel.id : undefined, execution.modelKey));
1966
2132
  const status = await readWorkerStatusIfValid(actor.statusPath);
1967
2133
  if (status?.state !== "done"
1968
2134
  || status.role !== "actor"
1969
2135
  || status.engine !== engine
2136
+ || (execution.modelKey && status.model_name !== execution.modelConfig.name)
1970
2137
  || (featureScoped ? status.feature_id !== channel.id : Boolean(status.feature_id))) {
1971
2138
  return null;
1972
2139
  }
1973
2140
  await mirrorWorkerFileToFeature(join(actor.dir, "worklog.md"), channel.actorWorklogPath);
1974
2141
  return actor;
1975
2142
  }
1976
- async loadCompletedCritic(task, engine, channel, featureScoped) {
1977
- const critic = this.workerFiles(task, taskWorkerId("critic", engine, channel.turnId, featureScoped ? channel.id : undefined));
2143
+ async loadCompletedCritic(task, engine, modelName, channel, featureScoped) {
2144
+ const execution = await this.featureWorkerExecution({ dir: join(task.dir, "turns", channel.turnId) }, "critic", engine, modelName);
2145
+ const critic = this.workerFiles(task, taskWorkerId("critic", engine, channel.turnId, featureScoped ? channel.id : undefined, execution.modelKey));
1978
2146
  const status = await readWorkerStatusIfValid(critic.statusPath);
1979
2147
  if (status?.state !== "done"
1980
2148
  || status.role !== "critic"
1981
2149
  || status.engine !== engine
2150
+ || (execution.modelKey && status.model_name !== execution.modelConfig.name)
1982
2151
  || (featureScoped ? status.feature_id !== channel.id : Boolean(status.feature_id))) {
1983
2152
  return null;
1984
2153
  }
@@ -1988,6 +2157,17 @@ export class Orchestrator {
1988
2157
  }
1989
2158
  return await featureCriticCheckpointIsReusable(channel, decision) ? critic : null;
1990
2159
  }
2160
+ async featureWorkerExecution(turn, role, engine, assignedModel) {
2161
+ const inherited = await this.roleConfiguration.modelForTurn(turn.dir, role, engine);
2162
+ const requested = assignedModel.trim();
2163
+ const modelConfig = requested
2164
+ ? this.roleConfiguration.modelForTarget({ engine, model: requested })
2165
+ : inherited;
2166
+ return {
2167
+ modelConfig,
2168
+ modelKey: requested && requested !== inherited.name ? requested : ""
2169
+ };
2170
+ }
1991
2171
  async promptTurnContext(task, turn) {
1992
2172
  return {
1993
2173
  turnId: turn.turnId,
@@ -2260,6 +2440,14 @@ async function readWorkerStatusIfValid(statusPath) {
2260
2440
  return null;
2261
2441
  }
2262
2442
  }
2443
+ async function readFeatureStatusIfValid(statusPath) {
2444
+ try {
2445
+ return await readJson(statusPath, FeatureStatusSchema);
2446
+ }
2447
+ catch {
2448
+ return null;
2449
+ }
2450
+ }
2263
2451
  async function readTaskMetaIfValid(metaPath) {
2264
2452
  if (!(await pathExists(metaPath))) {
2265
2453
  return null;
@@ -2363,15 +2551,22 @@ function workerLabelForStatus(status) {
2363
2551
  return workerLabel(status.role, status.engine);
2364
2552
  }
2365
2553
  const base = `${status.role}-${status.engine}`;
2366
- const turnId = status.worker_id.match(new RegExp(`^${base}-(\\d{4,})$`))?.[1];
2554
+ const turnId = status.worker_id.match(new RegExp(`^${base}-(\\d{4,})(?:-model-[a-f0-9]{8})?$`))?.[1];
2367
2555
  return taskWorkerLabel(status.role, status.engine, turnId ?? "0001");
2368
2556
  }
2369
- function taskWorkerId(role, engine, turnId, featureId) {
2557
+ function taskWorkerId(role, engine, turnId, featureId, modelKey = "") {
2370
2558
  const base = `${role}-${engine}`;
2371
- if (featureId) {
2372
- return `${base}-${featureId}`;
2373
- }
2374
- return turnId === "0001" ? base : `${base}-${turnId}`;
2559
+ const scoped = featureId
2560
+ ? `${base}-${featureId}`
2561
+ : turnId === "0001" ? base : `${base}-${turnId}`;
2562
+ return modelKey ? `${scoped}-model-${workerModelKey(modelKey)}` : scoped;
2563
+ }
2564
+ function workerModelKey(model) {
2565
+ return createHash("sha256").update(model).digest("hex").slice(0, 8);
2566
+ }
2567
+ function selectedRoleModel(selection, role, engine) {
2568
+ const target = selection?.[role];
2569
+ return target?.engine === engine ? target.model : "";
2375
2570
  }
2376
2571
  function capitalize(value) {
2377
2572
  return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
@@ -2387,7 +2582,7 @@ function workerStageOrder(worker) {
2387
2582
  function workerTurnOrder(worker) {
2388
2583
  const featureTurn = worker.featureId?.match(/^(\d{4,})(?:-|$)/)?.[1];
2389
2584
  const waveTurn = worker.id.match(/-wave-(\d{4,})-/)?.[1];
2390
- const taskTurn = worker.id.match(/-(\d{4,})$/)?.[1];
2585
+ const taskTurn = worker.id.match(/-(\d{4,})(?:-model-[a-f0-9]{8})?$/)?.[1];
2391
2586
  const parsed = Number(featureTurn ?? waveTurn ?? taskTurn ?? "1");
2392
2587
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
2393
2588
  }
@@ -2698,3 +2893,11 @@ function judgeValidationError(turn, report) {
2698
2893
  function errorMessage(error) {
2699
2894
  return error instanceof Error ? error.message : String(error);
2700
2895
  }
2896
+ function routeWithRoleSelection(route, roles) {
2897
+ return RouteDecisionSchema.parse({
2898
+ ...route,
2899
+ judge_engine: roles.judge.engine,
2900
+ actor_engine: roles.actor.engine,
2901
+ critic_engine: roles.critic.engine
2902
+ });
2903
+ }