@hecer/yoke 1.3.0 → 1.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +67 -15
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +204 -51
- package/dist/loop/runner.js +67 -32
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +4 -2
- package/dist/retrofit/gitignore.js +3 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
package/dist/agents/providers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ModelSelectionSchema } from './contracts.js';
|
|
2
|
+
export { providerSpawnOptions, startProviderProcess, } from './process.js';
|
|
1
3
|
const argsFor = (agent, permissions) => {
|
|
2
4
|
if (agent === 'claude') {
|
|
3
5
|
const mode = permissions === 'unsafe' ? 'bypassPermissions' : permissions === 'read-only' ? 'plan' : 'auto';
|
|
@@ -19,18 +21,19 @@ const argsFor = (agent, permissions) => {
|
|
|
19
21
|
return ['--approval-mode', approval, '--sandbox'];
|
|
20
22
|
};
|
|
21
23
|
export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe', selection = {}) {
|
|
24
|
+
const parsedSelection = ModelSelectionSchema.parse(selection);
|
|
22
25
|
const args = argsFor(agent, permissions);
|
|
23
|
-
if (
|
|
24
|
-
args.push('--model',
|
|
25
|
-
if (
|
|
26
|
+
if (parsedSelection.model)
|
|
27
|
+
args.push('--model', parsedSelection.model);
|
|
28
|
+
if (parsedSelection.reasoningEffort) {
|
|
26
29
|
if (agent === 'claude')
|
|
27
|
-
args.push('--effort',
|
|
30
|
+
args.push('--effort', parsedSelection.reasoningEffort);
|
|
28
31
|
else if (agent === 'codex')
|
|
29
|
-
args.push('--config', `model_reasoning_effort=${
|
|
32
|
+
args.push('--config', `model_reasoning_effort=${parsedSelection.reasoningEffort}`);
|
|
30
33
|
}
|
|
31
|
-
if (agent === 'codex' &&
|
|
34
|
+
if (agent === 'codex' && parsedSelection.nativeMultiAgent === false)
|
|
32
35
|
args.push('--disable', 'multi_agent');
|
|
33
|
-
if (
|
|
36
|
+
if (parsedSelection.bare) {
|
|
34
37
|
if (agent === 'codex')
|
|
35
38
|
args.push('--ignore-user-config');
|
|
36
39
|
else if (agent === 'claude')
|
package/dist/agents/telemetry.js
CHANGED
|
@@ -1,4 +1,66 @@
|
|
|
1
1
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
2
|
+
function parseJson(value) {
|
|
3
|
+
try {
|
|
4
|
+
return { ok: true, value: JSON.parse(value) };
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
if (error instanceof SyntaxError)
|
|
8
|
+
return { ok: false };
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function directMachineResult(value) {
|
|
16
|
+
return isRecord(value) && value.schemaVersion === 1 ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function parseProviderResult(agent, output) {
|
|
19
|
+
const whole = parseJson(output);
|
|
20
|
+
if (whole.ok) {
|
|
21
|
+
const direct = directMachineResult(whole.value);
|
|
22
|
+
if (direct !== undefined)
|
|
23
|
+
return direct;
|
|
24
|
+
}
|
|
25
|
+
const fragments = [];
|
|
26
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
27
|
+
const parsed = parseJson(line);
|
|
28
|
+
if (!parsed.ok || !isRecord(parsed.value))
|
|
29
|
+
continue;
|
|
30
|
+
const event = parsed.value;
|
|
31
|
+
switch (agent) {
|
|
32
|
+
case 'claude':
|
|
33
|
+
if (event.type === 'result' && typeof event.result === 'string')
|
|
34
|
+
fragments.push(event.result);
|
|
35
|
+
break;
|
|
36
|
+
case 'codex': {
|
|
37
|
+
const item = isRecord(event.item) ? event.item : undefined;
|
|
38
|
+
if (event.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
39
|
+
fragments.push(item.text);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'gemini':
|
|
43
|
+
if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
|
|
44
|
+
fragments.push(event.content);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const joined = parseJson(fragments.join(''));
|
|
49
|
+
if (joined.ok) {
|
|
50
|
+
const direct = directMachineResult(joined.value);
|
|
51
|
+
if (direct !== undefined)
|
|
52
|
+
return direct;
|
|
53
|
+
}
|
|
54
|
+
for (const fragment of fragments.reverse()) {
|
|
55
|
+
const parsed = parseJson(fragment);
|
|
56
|
+
if (!parsed.ok)
|
|
57
|
+
continue;
|
|
58
|
+
const direct = directMachineResult(parsed.value);
|
|
59
|
+
if (direct !== undefined)
|
|
60
|
+
return direct;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
2
64
|
export function parseProviderTelemetry(agent, lines) {
|
|
3
65
|
let inputTokens;
|
|
4
66
|
let cachedInputTokens;
|
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,48 @@ export function runDesignScan(targetDir, opts) {
|
|
|
52
52
|
console.log(`${label} — ✓`);
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
|
+
export function parseQualityFlags(args) {
|
|
56
|
+
const quality = args.includes('--quality') ? true : args.includes('--no-quality') ? false : undefined;
|
|
57
|
+
const qualityUnbounded = args.includes('--quality-unbounded') || undefined;
|
|
58
|
+
const parsePositive = (flag) => {
|
|
59
|
+
const raw = args.find(arg => arg.startsWith(flag));
|
|
60
|
+
if (!raw)
|
|
61
|
+
return undefined;
|
|
62
|
+
const value = Number(raw.slice(flag.length));
|
|
63
|
+
return Number.isInteger(value) && value > 0 ? value : `Invalid ${flag.slice(0, -1)} value: ${raw}`;
|
|
64
|
+
};
|
|
65
|
+
const qualityRounds = parsePositive('--quality-rounds=');
|
|
66
|
+
if (typeof qualityRounds === 'string')
|
|
67
|
+
return { ok: false, error: qualityRounds };
|
|
68
|
+
const qualityMinutes = parsePositive('--quality-minutes=');
|
|
69
|
+
if (typeof qualityMinutes === 'string')
|
|
70
|
+
return { ok: false, error: qualityMinutes };
|
|
71
|
+
const candidates = parsePositive('--candidates=');
|
|
72
|
+
if (typeof candidates === 'string')
|
|
73
|
+
return { ok: false, error: candidates };
|
|
74
|
+
const policyArg = args.find(arg => arg.startsWith('--quality-policy='))?.slice('--quality-policy='.length);
|
|
75
|
+
if (policyArg && policyArg !== 'blocking' && policyArg !== 'advisory') {
|
|
76
|
+
return { ok: false, error: `Invalid --quality-policy value: ${policyArg} (expected blocking|advisory)` };
|
|
77
|
+
}
|
|
78
|
+
const qualityPolicy = policyArg === 'blocking' || policyArg === 'advisory' ? policyArg : undefined;
|
|
79
|
+
if (quality === true && args.includes('--no-quality'))
|
|
80
|
+
return { ok: false, error: 'Cannot use --quality with --no-quality.' };
|
|
81
|
+
if (qualityUnbounded && quality === false)
|
|
82
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --no-quality.' };
|
|
83
|
+
if (qualityUnbounded && (qualityRounds !== undefined || qualityMinutes !== undefined)) {
|
|
84
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --quality-rounds or --quality-minutes.' };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
options: {
|
|
89
|
+
...(qualityUnbounded ? { quality: true, qualityUnbounded: true } : quality !== undefined ? { quality } : {}),
|
|
90
|
+
...(qualityRounds !== undefined ? { qualityRounds } : {}),
|
|
91
|
+
...(qualityMinutes !== undefined ? { qualityMinutes } : {}),
|
|
92
|
+
...(qualityPolicy ? { qualityPolicy } : {}),
|
|
93
|
+
...(candidates !== undefined ? { candidates } : {}),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
55
97
|
export function main(argv) {
|
|
56
98
|
const [cmd, ...rest] = argv;
|
|
57
99
|
switch (cmd) {
|
|
@@ -217,7 +259,12 @@ export function main(argv) {
|
|
|
217
259
|
return 1;
|
|
218
260
|
}
|
|
219
261
|
const { version: _version, storyId: _storyId, requestId: _requestId, answered: _answered, ...resumeOptions } = resume;
|
|
220
|
-
|
|
262
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
263
|
+
if (!qualityFlags.ok) {
|
|
264
|
+
console.error(qualityFlags.error);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options });
|
|
221
268
|
}
|
|
222
269
|
if (sub === 'answer') {
|
|
223
270
|
const choice = rest.find(a => a.startsWith('--choice='))?.slice('--choice='.length);
|
|
@@ -342,9 +389,14 @@ export function main(argv) {
|
|
|
342
389
|
console.error(`Invalid --decision-policy value: ${dpArg} (expected auto|critical)`);
|
|
343
390
|
return 1;
|
|
344
391
|
}
|
|
345
|
-
|
|
392
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
393
|
+
if (!qualityFlags.ok) {
|
|
394
|
+
console.error(qualityFlags.error);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
|
|
346
398
|
}
|
|
347
|
-
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--json]> [targetDir]');
|
|
399
|
+
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
|
|
348
400
|
return 1;
|
|
349
401
|
}
|
|
350
402
|
case 'new': {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { posix, win32 } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const CandidateEvidenceSchema = z.object({
|
|
4
|
+
digest: z.string().min(1),
|
|
5
|
+
artifacts: z.array(z.string()),
|
|
6
|
+
});
|
|
7
|
+
const CandidateIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
8
|
+
export function candidateConfigProblem(input) {
|
|
9
|
+
if (!Number.isInteger(input.maxCandidates) || input.maxCandidates < 1 || input.maxCandidates > 5) {
|
|
10
|
+
return 'maxCandidates must be an integer from 1 to 5';
|
|
11
|
+
}
|
|
12
|
+
if (input.candidates.length === 0)
|
|
13
|
+
return 'at least one candidate is required';
|
|
14
|
+
if (input.candidates.some(candidate => !CandidateIdSchema.safeParse(candidate.id).success)) {
|
|
15
|
+
return 'candidate ids must be non-empty safe identifiers';
|
|
16
|
+
}
|
|
17
|
+
if (new Set(input.candidates.map(candidate => candidate.id)).size !== input.candidates.length) {
|
|
18
|
+
return 'candidate ids must be unique';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function parseCandidateEvidence(value) {
|
|
23
|
+
const parsed = CandidateEvidenceSchema.parse(value);
|
|
24
|
+
return { digest: parsed.digest, artifacts: [...parsed.artifacts] };
|
|
25
|
+
}
|
|
26
|
+
export function canonicalWorktreePath(path, platform) {
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
if (!/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/.test(path))
|
|
29
|
+
return null;
|
|
30
|
+
const normalized = win32.normalize(path).replaceAll('/', '\\').toLowerCase();
|
|
31
|
+
return trimSeparators(normalized, win32.parse(normalized).root, '\\');
|
|
32
|
+
}
|
|
33
|
+
if (!posix.isAbsolute(path))
|
|
34
|
+
return null;
|
|
35
|
+
const normalized = posix.normalize(path);
|
|
36
|
+
return trimSeparators(normalized, posix.parse(normalized).root, '/');
|
|
37
|
+
}
|
|
38
|
+
function trimSeparators(path, root, separator) {
|
|
39
|
+
let key = path;
|
|
40
|
+
while (key.length > root.length && key.endsWith(separator))
|
|
41
|
+
key = key.slice(0, -1);
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const stageOrder = ['cancel', 'reap', 'remove'];
|
|
2
|
+
export class CandidateCleanup {
|
|
3
|
+
lifecycle;
|
|
4
|
+
onLifecycle;
|
|
5
|
+
#states = new Map();
|
|
6
|
+
constructor(lifecycle, onLifecycle) {
|
|
7
|
+
this.lifecycle = lifecycle;
|
|
8
|
+
this.onLifecycle = onLifecycle;
|
|
9
|
+
}
|
|
10
|
+
register(ownership) {
|
|
11
|
+
this.#states.set(ownership.candidateId, { ownership, materialized: false, completed: new Set(), failed: new Map(), pending: new Map() });
|
|
12
|
+
}
|
|
13
|
+
markMaterialized(candidateId) {
|
|
14
|
+
const state = this.#states.get(candidateId);
|
|
15
|
+
if (state)
|
|
16
|
+
state.materialized = true;
|
|
17
|
+
}
|
|
18
|
+
cancelNow(reason) {
|
|
19
|
+
for (const state of this.#states.values()) {
|
|
20
|
+
state.ownership.controller.abort(reason);
|
|
21
|
+
this.onLifecycle?.(state.ownership, 'cleaning', reason);
|
|
22
|
+
void this.#runStage(state, 'cancel', () => this.lifecycle.cancel(state.ownership, reason));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async cleanup(ownership, reason) {
|
|
26
|
+
const state = this.#states.get(ownership.candidateId);
|
|
27
|
+
if (!state)
|
|
28
|
+
return;
|
|
29
|
+
ownership.controller.abort(reason);
|
|
30
|
+
this.onLifecycle?.(ownership, 'cleaning', reason);
|
|
31
|
+
if (!await this.#runStage(state, 'cancel', () => this.lifecycle.cancel(ownership, reason)))
|
|
32
|
+
return;
|
|
33
|
+
if (!await this.#runStage(state, 'reap', () => this.lifecycle.reap(ownership)))
|
|
34
|
+
return;
|
|
35
|
+
if (await this.#runStage(state, 'remove', () => this.lifecycle.remove(ownership)))
|
|
36
|
+
this.onLifecycle?.(ownership, 'removed', reason);
|
|
37
|
+
}
|
|
38
|
+
async cleanupAll(reason, keepCandidateId) {
|
|
39
|
+
for (const state of this.#states.values()) {
|
|
40
|
+
if (state.ownership.candidateId !== keepCandidateId)
|
|
41
|
+
await this.cleanup(state.ownership, reason);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
hasFailures() {
|
|
45
|
+
return [...this.#states.values()].some(state => state.failed.size > 0);
|
|
46
|
+
}
|
|
47
|
+
failureSummary() {
|
|
48
|
+
return this.recovery().flatMap(item => item.failedStages.map(failure => `${item.ownership.candidateId}:${failure.stage}: ${failure.summary}`)).join('; ');
|
|
49
|
+
}
|
|
50
|
+
recovery() {
|
|
51
|
+
return [...this.#states.values()].filter(state => state.failed.size > 0).map(state => ({
|
|
52
|
+
ownership: {
|
|
53
|
+
candidateId: state.ownership.candidateId,
|
|
54
|
+
coordinatorId: state.ownership.coordinatorId,
|
|
55
|
+
ownerToken: state.ownership.ownerToken,
|
|
56
|
+
storyId: state.ownership.storyId,
|
|
57
|
+
provider: { ...state.ownership.provider },
|
|
58
|
+
worktree: { ...state.ownership.worktree },
|
|
59
|
+
},
|
|
60
|
+
materialized: state.materialized,
|
|
61
|
+
completedStages: stageOrder.filter(stage => state.completed.has(stage)),
|
|
62
|
+
failedStages: stageOrder.flatMap(stage => {
|
|
63
|
+
const summary = state.failed.get(stage);
|
|
64
|
+
return summary ? [{ stage, summary }] : [];
|
|
65
|
+
}),
|
|
66
|
+
pendingStages: stageOrder.filter(stage => !state.completed.has(stage) && !state.failed.has(stage)),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
#runStage(state, stage, operation) {
|
|
70
|
+
if (state.completed.has(stage))
|
|
71
|
+
return Promise.resolve(true);
|
|
72
|
+
const pending = state.pending.get(stage);
|
|
73
|
+
if (pending)
|
|
74
|
+
return pending.then(() => state.completed.has(stage));
|
|
75
|
+
try {
|
|
76
|
+
const result = operation();
|
|
77
|
+
if (!(result instanceof Promise)) {
|
|
78
|
+
state.completed.add(stage);
|
|
79
|
+
state.failed.delete(stage);
|
|
80
|
+
return Promise.resolve(true);
|
|
81
|
+
}
|
|
82
|
+
const task = result.then(() => {
|
|
83
|
+
state.completed.add(stage);
|
|
84
|
+
state.failed.delete(stage);
|
|
85
|
+
}).catch(error => {
|
|
86
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
87
|
+
}).finally(() => {
|
|
88
|
+
state.pending.delete(stage);
|
|
89
|
+
});
|
|
90
|
+
state.pending.set(stage, task);
|
|
91
|
+
return task.then(() => state.completed.has(stage));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
95
|
+
return Promise.resolve(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { reduceCandidatePairComparisons } from '../quality/verdict.js';
|
|
3
|
+
import { parseCandidateEvidence } from './candidate-boundaries.js';
|
|
4
|
+
export async function selectCandidate(input) {
|
|
5
|
+
const first = input.candidates[0];
|
|
6
|
+
if (!first)
|
|
7
|
+
return { kind: 'inconsistent', reason: 'selection requires one candidate' };
|
|
8
|
+
let selected = first;
|
|
9
|
+
let selectedEvidence = await prepareEvidence(input, first);
|
|
10
|
+
input.ensureActive();
|
|
11
|
+
if (input.pause?.())
|
|
12
|
+
return { kind: 'paused' };
|
|
13
|
+
const comparisons = [];
|
|
14
|
+
for (let index = 1; index < input.candidates.length; index += 1) {
|
|
15
|
+
const challenger = input.candidates[index];
|
|
16
|
+
if (!challenger)
|
|
17
|
+
continue;
|
|
18
|
+
const challengerEvidence = await prepareEvidence(input, challenger);
|
|
19
|
+
input.ensureActive();
|
|
20
|
+
if (input.pause?.())
|
|
21
|
+
return { kind: 'paused' };
|
|
22
|
+
const normal = issueComparison({ left: { candidate: selected, evidence: selectedEvidence }, right: { candidate: challenger, evidence: challengerEvidence }, provenance: input.judgeProvenance });
|
|
23
|
+
const swapped = issueComparison({ left: { candidate: challenger, evidence: challengerEvidence }, right: { candidate: selected, evidence: selectedEvidence }, provenance: input.judgeProvenance });
|
|
24
|
+
const normalVerdict = await input.judge(structuredClone(normal.expected.request));
|
|
25
|
+
input.ensureActive();
|
|
26
|
+
if (input.pause?.())
|
|
27
|
+
return { kind: 'paused' };
|
|
28
|
+
const swappedVerdict = await input.judge(structuredClone(swapped.expected.request));
|
|
29
|
+
input.ensureActive();
|
|
30
|
+
if (input.pause?.())
|
|
31
|
+
return { kind: 'paused' };
|
|
32
|
+
const reduction = reduceCandidatePairComparisons({ expected: { normal: normal.expected, swapped: swapped.expected }, normal: normalVerdict, swapped: swappedVerdict });
|
|
33
|
+
if (reduction.kind === 'inconsistent')
|
|
34
|
+
return reduction;
|
|
35
|
+
const winner = reduction.winnerCandidateId === selected.candidateId ? selected : challenger;
|
|
36
|
+
const loser = winner.candidateId === selected.candidateId ? challenger : selected;
|
|
37
|
+
comparisons.push({
|
|
38
|
+
normal: { request: structuredClone(normal.expected.request), verdict: structuredClone(reduction.normal) },
|
|
39
|
+
swapped: { request: structuredClone(swapped.expected.request), verdict: structuredClone(reduction.swapped) },
|
|
40
|
+
selectedCandidateId: reduction.winnerCandidateId,
|
|
41
|
+
selectedDigest: reduction.winnerDigest,
|
|
42
|
+
});
|
|
43
|
+
await input.discard(loser);
|
|
44
|
+
input.ensureActive();
|
|
45
|
+
if (input.pause?.())
|
|
46
|
+
return { kind: 'paused' };
|
|
47
|
+
if (winner.candidateId === challenger.candidateId) {
|
|
48
|
+
selected = challenger;
|
|
49
|
+
selectedEvidence = challengerEvidence;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
input.ensureActive();
|
|
53
|
+
return input.pause?.() ? { kind: 'paused' } : { kind: 'selected', candidate: selected, evidence: selectedEvidence.trusted, comparisons };
|
|
54
|
+
}
|
|
55
|
+
async function prepareEvidence(input, candidate) {
|
|
56
|
+
const trusted = parseCandidateEvidence(input.evidence(candidate));
|
|
57
|
+
const handle = { digest: `evidence-${randomUUID()}`, artifacts: trusted.artifacts.map(() => `artifact-${randomUUID()}`) };
|
|
58
|
+
await input.stageEvidence({
|
|
59
|
+
handle: { digest: handle.digest, artifacts: [...handle.artifacts] },
|
|
60
|
+
trusted: { digest: trusted.digest, artifacts: [...trusted.artifacts] },
|
|
61
|
+
});
|
|
62
|
+
return { trusted, handle };
|
|
63
|
+
}
|
|
64
|
+
function issueComparison(input) {
|
|
65
|
+
const request = {
|
|
66
|
+
permissions: 'read-only',
|
|
67
|
+
attemptId: randomUUID(),
|
|
68
|
+
left: { digest: input.left.evidence.handle.digest, artifacts: [...input.left.evidence.handle.artifacts], label: 'A' },
|
|
69
|
+
right: { digest: input.right.evidence.handle.digest, artifacts: [...input.right.evidence.handle.artifacts], label: 'B' },
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
request,
|
|
73
|
+
expected: {
|
|
74
|
+
request: {
|
|
75
|
+
permissions: request.permissions,
|
|
76
|
+
attemptId: request.attemptId,
|
|
77
|
+
left: { ...request.left, artifacts: [...request.left.artifacts] },
|
|
78
|
+
right: { ...request.right, artifacts: [...request.right.artifacts] },
|
|
79
|
+
},
|
|
80
|
+
candidateIds: { left: input.left.candidate.candidateId, right: input.right.candidate.candidateId },
|
|
81
|
+
provenance: { ...input.provenance },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { candidateConfigProblem, canonicalWorktreePath } from './candidate-boundaries.js';
|
|
2
|
+
import { CandidateCleanup } from './candidate-cleanup.js';
|
|
3
|
+
import { selectCandidate } from './candidate-selection.js';
|
|
4
|
+
import { runStoryWorker } from './worker.js';
|
|
5
|
+
class CandidateCancellationError extends Error {
|
|
6
|
+
name = 'CandidateCancellationError';
|
|
7
|
+
}
|
|
8
|
+
export async function coordinateCandidates(input) {
|
|
9
|
+
const configProblem = candidateConfigProblem(input);
|
|
10
|
+
if (configProblem)
|
|
11
|
+
return { kind: 'blocked', reason: 'invalid-config', summary: configProblem };
|
|
12
|
+
if (input.signal?.aborted)
|
|
13
|
+
return { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
14
|
+
const definitions = input.candidates.slice(0, input.maxCandidates);
|
|
15
|
+
const pathPlatform = input.pathPlatform ?? (process.platform === 'win32' ? 'win32' : 'posix');
|
|
16
|
+
const owned = [];
|
|
17
|
+
const outcomes = new Map();
|
|
18
|
+
const cleanup = new CandidateCleanup(input.lifecycle, input.onLifecycle);
|
|
19
|
+
const terminalize = async (candidate, reason) => {
|
|
20
|
+
const result = outcomes.get(candidate.candidateId) ?? {
|
|
21
|
+
kind: 'cancelled', storyId: candidate.storyId, worktree: candidate.worktree.path,
|
|
22
|
+
baseCommit: candidate.worktree.baseCommit, provider: candidate.provider, summary: reason,
|
|
23
|
+
evidence: { criteria: [] },
|
|
24
|
+
};
|
|
25
|
+
await input.recordResult?.({ ...candidate, result });
|
|
26
|
+
await input.recordElimination?.({ ...candidate, result });
|
|
27
|
+
};
|
|
28
|
+
const terminalizeAll = async (reason) => {
|
|
29
|
+
for (const candidate of owned)
|
|
30
|
+
await terminalize(candidate, reason);
|
|
31
|
+
};
|
|
32
|
+
const ensureActive = () => {
|
|
33
|
+
if (input.signal?.aborted)
|
|
34
|
+
throw new CandidateCancellationError(cancellationSummary(input.signal));
|
|
35
|
+
};
|
|
36
|
+
const cancelActive = () => cleanup.cancelNow(input.signal ? cancellationSummary(input.signal) : 'candidate coordinator cancelled');
|
|
37
|
+
input.signal?.addEventListener('abort', cancelActive, { once: true });
|
|
38
|
+
const finishPaused = async () => {
|
|
39
|
+
await terminalizeAll('candidate coordination paused');
|
|
40
|
+
await cleanup.cleanupAll('candidate coordination paused');
|
|
41
|
+
ensureActive();
|
|
42
|
+
return cleanup.hasFailures()
|
|
43
|
+
? blocked(cleanup, 'cleanup-error', 'candidate pause cleanup failed')
|
|
44
|
+
: { kind: 'paused', summary: 'candidate coordination paused' };
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const paths = new Set();
|
|
48
|
+
for (const definition of definitions) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const provenance = provenanceFor(input, definition);
|
|
51
|
+
const worktree = input.lifecycle.reserve({ ...provenance, baseCommit: input.baseCommit });
|
|
52
|
+
const candidate = { ...provenance, worktree, signal: controller.signal, controller, definition };
|
|
53
|
+
owned.push(candidate);
|
|
54
|
+
cleanup.register(candidate);
|
|
55
|
+
input.onLifecycle?.(candidate, 'reserved');
|
|
56
|
+
const canonicalPath = canonicalWorktreePath(worktree.path, pathPlatform);
|
|
57
|
+
if (worktree.baseCommit !== input.baseCommit || canonicalPath === null || paths.has(canonicalPath)) {
|
|
58
|
+
await cleanup.cleanupAll('candidate worktree invariant failed');
|
|
59
|
+
ensureActive();
|
|
60
|
+
return blocked(cleanup, 'worktree-invariant', 'candidate worktrees must be unique absolute paths and share the configured base');
|
|
61
|
+
}
|
|
62
|
+
paths.add(canonicalPath);
|
|
63
|
+
}
|
|
64
|
+
for (const candidate of owned) {
|
|
65
|
+
await input.lifecycle.materialize(candidate);
|
|
66
|
+
cleanup.markMaterialized(candidate.candidateId);
|
|
67
|
+
input.onLifecycle?.(candidate, 'materialized');
|
|
68
|
+
ensureActive();
|
|
69
|
+
if (input.pause?.())
|
|
70
|
+
return await finishPaused();
|
|
71
|
+
}
|
|
72
|
+
for (const candidate of owned)
|
|
73
|
+
input.onLifecycle?.(candidate, 'running');
|
|
74
|
+
const runs = await runCandidates(input, owned);
|
|
75
|
+
for (const run of runs)
|
|
76
|
+
outcomes.set(run.owned.candidateId, run.result);
|
|
77
|
+
ensureActive();
|
|
78
|
+
if (input.pause?.() || runs.some(run => run.result.kind === 'paused')) {
|
|
79
|
+
return await finishPaused();
|
|
80
|
+
}
|
|
81
|
+
const green = [];
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
await input.recordResult?.({ ...run.owned, result: run.result });
|
|
84
|
+
switch (run.result.kind) {
|
|
85
|
+
case 'candidate':
|
|
86
|
+
green.push({ ...run.owned, result: run.result });
|
|
87
|
+
break;
|
|
88
|
+
case 'cancelled':
|
|
89
|
+
case 'mechanical-failure':
|
|
90
|
+
case 'quality-failure':
|
|
91
|
+
case 'review-failure':
|
|
92
|
+
input.onLifecycle?.(run.owned, 'cleaning', run.result.summary);
|
|
93
|
+
await input.recordElimination?.({ ...run.owned, result: run.result });
|
|
94
|
+
await cleanup.cleanup(run.owned, run.result.summary);
|
|
95
|
+
ensureActive();
|
|
96
|
+
break;
|
|
97
|
+
case 'paused':
|
|
98
|
+
throw new Error('paused candidates must be handled before mechanical filtering');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
ensureActive();
|
|
102
|
+
if (input.pause?.())
|
|
103
|
+
return await finishPaused();
|
|
104
|
+
if (green.length === 0)
|
|
105
|
+
return blocked(cleanup, 'zero-green', 'no candidate passed every mechanical gate');
|
|
106
|
+
for (const candidate of green)
|
|
107
|
+
input.onLifecycle?.(candidate, 'selecting');
|
|
108
|
+
input.onSelecting?.();
|
|
109
|
+
const selection = await selectCandidate({
|
|
110
|
+
candidates: green,
|
|
111
|
+
evidence: input.evidence,
|
|
112
|
+
stageEvidence: input.stageEvidence,
|
|
113
|
+
judgeProvenance: input.judgeProvenance,
|
|
114
|
+
judge: input.judge,
|
|
115
|
+
...(input.pause ? { pause: input.pause } : {}),
|
|
116
|
+
ensureActive,
|
|
117
|
+
discard: async (candidate) => {
|
|
118
|
+
await input.recordElimination?.(candidate);
|
|
119
|
+
await cleanup.cleanup(candidate, 'candidate was not selected');
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
ensureActive();
|
|
123
|
+
if (selection.kind === 'paused')
|
|
124
|
+
return await finishPaused();
|
|
125
|
+
if (selection.kind === 'inconsistent') {
|
|
126
|
+
for (const candidate of green)
|
|
127
|
+
await input.recordElimination?.({ ...candidate, result: candidate.result }, `selection-inconsistent: ${selection.reason}`);
|
|
128
|
+
await cleanup.cleanupAll(`candidate selection was inconsistent: ${selection.reason}`);
|
|
129
|
+
ensureActive();
|
|
130
|
+
if (input.pause?.())
|
|
131
|
+
return await finishPaused();
|
|
132
|
+
return blocked(cleanup, 'selection-inconsistent', selection.reason);
|
|
133
|
+
}
|
|
134
|
+
const { controller: _controller, definition: _definition, ...winner } = selection.candidate;
|
|
135
|
+
const selectedWinner = {
|
|
136
|
+
...winner,
|
|
137
|
+
digest: selection.evidence.digest,
|
|
138
|
+
artifacts: [...selection.evidence.artifacts],
|
|
139
|
+
comparisons: [...selection.comparisons],
|
|
140
|
+
};
|
|
141
|
+
await input.recordSelection?.(selectedWinner);
|
|
142
|
+
await cleanup.cleanupAll('candidate was not selected', selection.candidate.candidateId);
|
|
143
|
+
ensureActive();
|
|
144
|
+
if (input.pause?.())
|
|
145
|
+
return await finishPaused();
|
|
146
|
+
if (cleanup.hasFailures()) {
|
|
147
|
+
await cleanup.cleanup(selection.candidate, 'candidate cleanup failed');
|
|
148
|
+
ensureActive();
|
|
149
|
+
if (input.pause?.())
|
|
150
|
+
return await finishPaused();
|
|
151
|
+
return blocked(cleanup, 'cleanup-error', 'candidate loser cleanup failed');
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kind: 'winner',
|
|
155
|
+
winner: selectedWinner,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
let proofFailure;
|
|
160
|
+
try {
|
|
161
|
+
await terminalizeAll(`candidate coordinator failed: ${message(error)}`);
|
|
162
|
+
}
|
|
163
|
+
catch (proofError) {
|
|
164
|
+
proofFailure = message(proofError);
|
|
165
|
+
}
|
|
166
|
+
await cleanup.cleanupAll('candidate coordinator failed');
|
|
167
|
+
if (proofFailure)
|
|
168
|
+
return blocked(cleanup, 'cleanup-error', `candidate terminal proof failed: ${proofFailure}`);
|
|
169
|
+
if (input.signal?.aborted) {
|
|
170
|
+
return cleanup.hasFailures()
|
|
171
|
+
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
172
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
173
|
+
}
|
|
174
|
+
if (input.pause?.()) {
|
|
175
|
+
try {
|
|
176
|
+
return await finishPaused();
|
|
177
|
+
}
|
|
178
|
+
catch (pauseError) {
|
|
179
|
+
if (input.signal?.aborted) {
|
|
180
|
+
return cleanup.hasFailures()
|
|
181
|
+
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
182
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
183
|
+
}
|
|
184
|
+
return blocked(cleanup, 'coordinator-error', message(pauseError));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return blocked(cleanup, 'coordinator-error', message(error));
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
input.signal?.removeEventListener('abort', cancelActive);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function runCandidates(input, owned) {
|
|
194
|
+
const settled = await Promise.allSettled(owned.map(async (candidate) => ({
|
|
195
|
+
owned: candidate,
|
|
196
|
+
result: await runStoryWorker({
|
|
197
|
+
...candidate.definition.worker,
|
|
198
|
+
...(candidate.definition.runnerFor ? { runner: candidate.definition.runnerFor({ signal: candidate.signal }) } : {}),
|
|
199
|
+
...(candidate.definition.reporterFor ? { reporter: candidate.definition.reporterFor(candidate) } : {}),
|
|
200
|
+
story: structuredClone(input.story),
|
|
201
|
+
worktree: candidate.worktree.path,
|
|
202
|
+
baseCommit: candidate.worktree.baseCommit,
|
|
203
|
+
cancellation: { signal: candidate.signal },
|
|
204
|
+
}),
|
|
205
|
+
})));
|
|
206
|
+
const runs = [];
|
|
207
|
+
for (const outcome of settled) {
|
|
208
|
+
if (outcome.status === 'rejected')
|
|
209
|
+
throw new Error(`candidate worker failed: ${message(outcome.reason)}`);
|
|
210
|
+
runs.push(outcome.value);
|
|
211
|
+
}
|
|
212
|
+
return runs;
|
|
213
|
+
}
|
|
214
|
+
function provenanceFor(input, definition) {
|
|
215
|
+
return { candidateId: definition.id, coordinatorId: input.coordinatorId, ownerToken: input.ownerToken(definition.id), storyId: input.story.id, provider: definition.worker.provider };
|
|
216
|
+
}
|
|
217
|
+
function cancellationSummary(signal) {
|
|
218
|
+
return typeof signal.reason === 'string' && signal.reason.length > 0 ? signal.reason : 'candidate coordinator cancelled';
|
|
219
|
+
}
|
|
220
|
+
function message(error) {
|
|
221
|
+
return error instanceof Error ? error.message : String(error);
|
|
222
|
+
}
|
|
223
|
+
function blocked(cleanup, reason, summary) {
|
|
224
|
+
const recovery = cleanup.recovery();
|
|
225
|
+
return cleanup.hasFailures()
|
|
226
|
+
? { kind: 'blocked', reason: 'cleanup-error', summary: cleanup.failureSummary(), recovery }
|
|
227
|
+
: { kind: 'blocked', reason, summary };
|
|
228
|
+
}
|