parallel-codex-tui 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parallel-codex/config.example.toml +46 -0
- package/README.md +96 -19
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +13 -1
- package/dist/cli.js +19 -7
- package/dist/core/clipboard.js +97 -0
- package/dist/core/collaboration-timeline.js +9 -2
- package/dist/core/config.js +161 -103
- package/dist/core/router.js +1 -1
- package/dist/core/session-index.js +234 -61
- package/dist/core/session-manager.js +25 -1
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +10 -9
- package/dist/doctor.js +58 -39
- package/dist/domain/schemas.js +15 -1
- package/dist/orchestrator/collaboration-channel.js +35 -3
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/orchestrator.js +405 -69
- package/dist/orchestrator/prompts.js +42 -3
- package/dist/orchestrator/workspace-sandbox.js +16 -0
- package/dist/tui/App.js +514 -56
- package/dist/tui/AppShell.js +9 -3
- package/dist/tui/FeatureBoardView.js +7 -2
- package/dist/tui/InputBar.js +87 -15
- package/dist/tui/StatusBar.js +1 -1
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +5 -2
- package/dist/tui/WorkerOutputView.js +6 -1
- package/dist/tui/WorkerOverviewView.js +23 -4
- package/dist/tui/keyboard.js +6 -0
- package/dist/tui/status-line.js +42 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +4 -3
- package/dist/workers/live-probe.js +4 -3
- package/dist/workers/mock-adapter.js +37 -5
- package/dist/workers/native-attach.js +32 -17
- package/dist/workers/process-adapter.js +2 -0
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -22
- 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 {
|
|
13
|
-
import {
|
|
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
|
-
|
|
123
|
-
|
|
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",
|
|
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
|
-
||
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
690
|
+
let finalPairs = pairRuns;
|
|
566
691
|
const revisionFindingIds = new Map();
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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",
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
676
|
-
waveReviewPaths.push(
|
|
677
|
-
await writeText(
|
|
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 =
|
|
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",
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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",
|
|
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?.({
|
|
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,
|
|
813
|
-
await requireActorFindingReplies(feature,
|
|
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,
|
|
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
|
|
825
|
-
throw new Error(`Critic
|
|
974
|
+
if (decision === "missing") {
|
|
975
|
+
throw new Error(`Critic review for ${feature.id} must include APPROVED or REVISION_REQUIRED.`);
|
|
826
976
|
}
|
|
827
|
-
|
|
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
|
|
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) {
|