parallel-codex-tui 0.2.10 → 0.3.0

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.
@@ -5,6 +5,7 @@ import { runWithLeaseFinalization } from "../core/lease-finalization.js";
5
5
  import { extractMainResponse } from "../core/main-response.js";
6
6
  import { claimTaskRunLease, TaskRunLeaseConflictError } from "../core/process-ownership.js";
7
7
  import { routerRuntimeDir } from "../core/paths.js";
8
+ import { RoleConfigurationManager, roleSelectionWithEngines } from "../core/role-configuration.js";
8
9
  import { classifyRouterFailure, routerFallbackIsTransient } from "../core/router-audit.js";
9
10
  import { sanitizeRouterText } from "../core/router-redaction.js";
10
11
  import { routeRequestWithCodex, routerCommandLabel, routerProxyContext } from "../core/router.js";
@@ -59,6 +60,7 @@ export class Orchestrator {
59
60
  routerConfigLoader;
60
61
  dependencies;
61
62
  activeFeatureRuns = new Map();
63
+ roleConfiguration;
62
64
  constructor(config, sessions, workers, routeRunner, routerCwd = routerRuntimeDir(config.projectRoot, config.dataDir), routerConfigLoader, dependencies = {}) {
63
65
  this.config = config;
64
66
  this.sessions = sessions;
@@ -67,17 +69,21 @@ export class Orchestrator {
67
69
  this.routerCwd = routerCwd;
68
70
  this.routerConfigLoader = routerConfigLoader;
69
71
  this.dependencies = dependencies;
72
+ this.roleConfiguration = dependencies.roleConfiguration ?? RoleConfigurationManager.transient(config);
70
73
  }
71
74
  async handleRequest(input) {
72
75
  throwIfCancelled(input.signal);
73
- const route = await this.routeRequest(input.request, input.cwd, input.signal, "initial", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
76
+ const routed = await this.routeRequest(input.request, input.cwd, input.signal, "initial", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
77
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest();
78
+ const route = routeWithRoleSelection(routed, roleSelection);
74
79
  input.onRoute?.(route);
75
80
  throwIfCancelled(input.signal);
76
81
  const workers = [];
82
+ const executionInput = { ...input, roleSelection };
77
83
  if (route.mode === "simple") {
78
84
  try {
79
85
  input.onStatus?.({ taskId: "main", main: "starting" });
80
- const output = await this.runMain(input, workers);
86
+ const output = await this.runMain(executionInput, workers);
81
87
  input.onStatus?.({ taskId: "main", main: "done" });
82
88
  return {
83
89
  mode: "simple",
@@ -104,18 +110,20 @@ export class Orchestrator {
104
110
  userPath: join(task.dir, "turns", "0001", "user.md"),
105
111
  routePath: join(task.dir, "turns", "0001", "route.json")
106
112
  };
107
- return this.withTaskRunLease(task, () => this.runInitialTask(input, task, route, turn, workers));
113
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelectionWithEngines(route, roleSelection));
114
+ return this.withTaskRunLease(task, () => this.runInitialTask(executionInput, task, route, turn, workers));
108
115
  }
109
116
  async handleTaskTurn(input) {
110
117
  throwIfCancelled(input.signal);
111
118
  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);
119
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
120
+ const route = routeWithRoleSelection(input.route ?? await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress), roleSelection);
113
121
  if (!input.route) {
114
122
  input.onRoute?.(route);
115
123
  }
116
124
  throwIfCancelled(input.signal);
117
125
  if (route.mode === "simple") {
118
- return this.answerTaskQuestion({ ...input, route });
126
+ return this.answerTaskQuestion({ ...input, route, roleSelection });
119
127
  }
120
128
  return this.withTaskRunLease(task, async () => {
121
129
  throwIfCancelled(input.signal);
@@ -124,8 +132,9 @@ export class Orchestrator {
124
132
  request: input.request,
125
133
  route
126
134
  });
135
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelectionWithEngines(route, roleSelection));
127
136
  const workers = [];
128
- return this.runPairTask(input, task, route, turn, workers);
137
+ return this.runPairTask({ ...input, roleSelection }, task, route, turn, workers);
129
138
  });
130
139
  }
131
140
  async retryTask(input) {
@@ -189,6 +198,84 @@ export class Orchestrator {
189
198
  return { featureId: input.featureId, assignment };
190
199
  });
191
200
  }
201
+ async roleConfigurationSnapshot(taskId) {
202
+ const task = taskId ? this.sessions.taskFromId(taskId) : null;
203
+ if (task && !(await pathExists(task.dir))) {
204
+ throw new Error(`Task session not found: ${taskId}`);
205
+ }
206
+ return this.roleConfiguration.snapshot(task?.dir);
207
+ }
208
+ async updateRoleConfiguration(input) {
209
+ if (input.scope !== "task") {
210
+ await this.roleConfiguration.apply(input.scope, input.roles);
211
+ return this.roleConfigurationSnapshot(input.taskId);
212
+ }
213
+ const taskId = input.taskId;
214
+ if (!taskId) {
215
+ throw new Error("No active Task is available for a current-task role configuration.");
216
+ }
217
+ const task = this.sessions.taskFromId(taskId);
218
+ if (!(await pathExists(task.dir))) {
219
+ throw new Error(`Task session not found: ${taskId}`);
220
+ }
221
+ return this.withTaskRunLease(task, async () => {
222
+ await this.roleConfiguration.apply("task", input.roles, task.dir);
223
+ await this.synchronizeRetryRoleConfiguration(task, input.roles);
224
+ await this.sessions.appendEvent(task, "task.role_configuration_changed", "Current Task role configuration updated");
225
+ return this.roleConfiguration.snapshot(task.dir);
226
+ });
227
+ }
228
+ async clearRoleConfiguration(scope, taskId) {
229
+ if (scope !== "task") {
230
+ await this.roleConfiguration.clear(scope);
231
+ return this.roleConfigurationSnapshot(taskId);
232
+ }
233
+ if (!taskId) {
234
+ throw new Error("No active Task is available for a current-task role configuration.");
235
+ }
236
+ const task = this.sessions.taskFromId(taskId);
237
+ if (!(await pathExists(task.dir))) {
238
+ throw new Error(`Task session not found: ${taskId}`);
239
+ }
240
+ return this.withTaskRunLease(task, async () => {
241
+ await this.roleConfiguration.clear("task", task.dir);
242
+ const roles = await this.roleConfiguration.selectionForTask(task.dir);
243
+ await this.synchronizeRetryRoleConfiguration(task, roles);
244
+ await this.sessions.appendEvent(task, "task.role_configuration_cleared", "Current Task role configuration reset");
245
+ return this.roleConfiguration.snapshot(task.dir);
246
+ });
247
+ }
248
+ async synchronizeRetryRoleConfiguration(task, roles) {
249
+ const meta = await readTaskMetaIfValid(task.metaPath);
250
+ if (!meta || !new Set(["failed", "cancelled", "paused"]).has(meta.status)) {
251
+ return;
252
+ }
253
+ const turn = await this.sessions.latestTurn(task);
254
+ if (!turn) {
255
+ return;
256
+ }
257
+ const route = routeWithRoleSelection(await readJson(turn.routePath, RouteDecisionSchema), roles);
258
+ const executionRoles = roleSelectionWithEngines(route, roles);
259
+ await writeJson(turn.routePath, route);
260
+ await this.sessions.recordLatestRoute(task, route);
261
+ await this.roleConfiguration.writeTurnSelection(turn.dir, executionRoles);
262
+ const featuresDir = join(task.dir, "features");
263
+ if (!(await pathExists(featuresDir))) {
264
+ return;
265
+ }
266
+ const entries = await readdir(featuresDir, { withFileTypes: true });
267
+ for (const entry of entries) {
268
+ if (!entry.isDirectory()) {
269
+ continue;
270
+ }
271
+ const featureDir = join(featuresDir, entry.name);
272
+ const status = await readFeatureStatusIfValid(join(featureDir, "status.json"));
273
+ if (!status || status.task_id !== task.id || status.state === "approved") {
274
+ continue;
275
+ }
276
+ await writeFeatureAssignment({ assignmentPath: join(featureDir, "assignment.json") }, executionRoles.actor.engine, executionRoles.critic.engine);
277
+ }
278
+ }
192
279
  async resumeTaskRun(input, pausedFeatureId) {
193
280
  throwIfCancelled(input.signal);
194
281
  const task = this.sessions.taskFromId(input.taskId);
@@ -226,11 +313,15 @@ export class Orchestrator {
226
313
  throw new Error(`Task ${input.taskId} turn ${turn.turnId} has no request to retry.`);
227
314
  }
228
315
  const route = await readJson(turn.routePath, RouteDecisionSchema);
316
+ const roleSelection = await this.roleConfiguration.readTurnSelection(turn.dir)
317
+ ?? roleSelectionWithEngines(route, await this.roleConfiguration.selectionForTask(task.dir));
318
+ await this.roleConfiguration.writeTurnSelection(turn.dir, roleSelection);
229
319
  const executionInput = {
230
320
  ...input,
231
321
  request,
232
322
  cwd: meta.cwd,
233
- retry: true
323
+ retry: true,
324
+ roleSelection
234
325
  };
235
326
  const workers = [];
236
327
  input.onRoute?.(route);
@@ -311,14 +402,18 @@ export class Orchestrator {
311
402
  }
312
403
  async routeTaskFollowUp(input) {
313
404
  throwIfCancelled(input.signal);
314
- const route = await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
405
+ const routed = await this.routeRequest(input.request, input.cwd, input.signal, "follow-up", input.onRouteStart, input.onRouteFallback, input.onRouteProgress);
406
+ const task = this.sessions.taskFromId(input.taskId);
407
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
408
+ const route = routeWithRoleSelection(routed, roleSelection);
315
409
  input.onRoute?.(route);
316
410
  throwIfCancelled(input.signal);
317
411
  return {
318
412
  mode: route.mode,
319
413
  taskId: route.mode === "complex" ? input.taskId : null,
320
414
  reason: route.reason,
321
- route
415
+ route,
416
+ roleSelection
322
417
  };
323
418
  }
324
419
  async answerTaskQuestion(input) {
@@ -327,7 +422,11 @@ export class Orchestrator {
327
422
  if (!(await pathExists(task.dir))) {
328
423
  throw new Error(`Task session not found: ${input.taskId}`);
329
424
  }
330
- return this.withTaskRunLease(task, () => this.answerTaskQuestionWithLease(input, task));
425
+ const roleSelection = input.roleSelection ?? await this.roleConfiguration.selectionForRequest(task.dir);
426
+ return this.withTaskRunLease(task, () => this.answerTaskQuestionWithLease({
427
+ ...input,
428
+ roleSelection
429
+ }, task));
331
430
  }
332
431
  async answerTaskQuestionWithLease(input, task) {
333
432
  throwIfCancelled(input.signal);
@@ -1357,7 +1456,8 @@ export class Orchestrator {
1357
1456
  }
1358
1457
  async runMain(input, workers, context, taskId = null) {
1359
1458
  throwIfCancelled(input.signal);
1360
- const engine = this.config.pairing.main;
1459
+ const target = input.roleSelection?.main ?? this.roleConfiguration.futureRoles().main;
1460
+ const engine = target.engine;
1361
1461
  const dir = this.sessions.mainSessionDir();
1362
1462
  let lease;
1363
1463
  try {
@@ -1427,6 +1527,7 @@ export class Orchestrator {
1427
1527
  outputLogPath,
1428
1528
  statusPath,
1429
1529
  prompt,
1530
+ modelConfig: this.roleConfiguration.modelForTarget(input.roleSelection?.main ?? this.roleConfiguration.futureRoles().main),
1430
1531
  signal: input.signal,
1431
1532
  onStatus: (runtimeStatus) => {
1432
1533
  this.recordWorker(input, workers, { ...worker, runtimeStatus });
@@ -1473,6 +1574,7 @@ export class Orchestrator {
1473
1574
  outputLogPath: judge.outputLogPath,
1474
1575
  statusPath: judge.statusPath,
1475
1576
  prompt: await readTextIfExists(judge.promptPath),
1577
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "judge", engine),
1476
1578
  signal: input.signal
1477
1579
  }, "task", await this.previousTurnWorker(task, "judge", engine, turn.turnId));
1478
1580
  ensureWorkerSuccess(result);
@@ -1548,6 +1650,7 @@ export class Orchestrator {
1548
1650
  outputLogPath: finalJudge.outputLogPath,
1549
1651
  statusPath: finalJudge.statusPath,
1550
1652
  prompt: await readTextIfExists(finalJudge.promptPath),
1653
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "judge", engine),
1551
1654
  signal: input.signal
1552
1655
  }, "task", initialJudge);
1553
1656
  ensureWorkerSuccess(result);
@@ -1623,6 +1726,7 @@ export class Orchestrator {
1623
1726
  outputLogPath: actor.outputLogPath,
1624
1727
  statusPath: actor.statusPath,
1625
1728
  prompt: await readTextIfExists(actor.promptPath),
1729
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "actor", engine),
1626
1730
  signal: input.signal
1627
1731
  }, task, feature, featureScoped
1628
1732
  ? undefined
@@ -1683,6 +1787,7 @@ export class Orchestrator {
1683
1787
  outputLogPath: critic.outputLogPath,
1684
1788
  statusPath: critic.statusPath,
1685
1789
  prompt: await readTextIfExists(critic.promptPath),
1790
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "critic", engine),
1686
1791
  signal: input.signal
1687
1792
  }, task, feature, featureScoped
1688
1793
  ? undefined
@@ -1741,6 +1846,7 @@ export class Orchestrator {
1741
1846
  outputLogPath: critic.outputLogPath,
1742
1847
  statusPath: critic.statusPath,
1743
1848
  prompt: await readTextIfExists(critic.promptPath),
1849
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "critic", engine),
1744
1850
  signal: input.signal
1745
1851
  });
1746
1852
  ensureWorkerSuccess(result);
@@ -1791,6 +1897,7 @@ export class Orchestrator {
1791
1897
  outputLogPath: actor.outputLogPath,
1792
1898
  statusPath: actor.statusPath,
1793
1899
  prompt: await readTextIfExists(actor.promptPath),
1900
+ modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "actor", engine),
1794
1901
  signal: input.signal
1795
1902
  });
1796
1903
  ensureWorkerSuccess(result);
@@ -1896,6 +2003,7 @@ export class Orchestrator {
1896
2003
  }
1897
2004
  return adapter.run({
1898
2005
  ...spec,
2006
+ modelConfig: spec.modelConfig ?? workerProvider(this.config, engine).config.model,
1899
2007
  nativeSession: existing,
1900
2008
  nativeSessionConfig: workerProvider(this.config, engine).config.nativeSession,
1901
2009
  onNativeSession: async (sessionId) => {
@@ -2260,6 +2368,14 @@ async function readWorkerStatusIfValid(statusPath) {
2260
2368
  return null;
2261
2369
  }
2262
2370
  }
2371
+ async function readFeatureStatusIfValid(statusPath) {
2372
+ try {
2373
+ return await readJson(statusPath, FeatureStatusSchema);
2374
+ }
2375
+ catch {
2376
+ return null;
2377
+ }
2378
+ }
2263
2379
  async function readTaskMetaIfValid(metaPath) {
2264
2380
  if (!(await pathExists(metaPath))) {
2265
2381
  return null;
@@ -2698,3 +2814,11 @@ function judgeValidationError(turn, report) {
2698
2814
  function errorMessage(error) {
2699
2815
  return error instanceof Error ? error.message : String(error);
2700
2816
  }
2817
+ function routeWithRoleSelection(route, roles) {
2818
+ return RouteDecisionSchema.parse({
2819
+ ...route,
2820
+ judge_engine: roles.judge.engine,
2821
+ actor_engine: roles.actor.engine,
2822
+ critic_engine: roles.critic.engine
2823
+ });
2824
+ }