@hecer/yoke 1.3.0 → 1.5.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 +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- 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/audit/command.js +13 -5
- 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/git.js +6 -4
- 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 +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- 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/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -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 +26 -2
- package/dist/retrofit/gitignore.js +4 -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/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
package/dist/loop/cleanup.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { parseProviderProcessRecord } from '../agents/process-record.js';
|
|
5
|
+
import { processIncarnation } from '../agents/process-incarnation.js';
|
|
4
6
|
import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverLockPath, readLock, isPidAlive, releaseTakeoverLease, releaseTakeoverRecoveryLease, takeoverRecoveryPath, } from './lock.js';
|
|
5
|
-
import {
|
|
7
|
+
import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js';
|
|
8
|
+
import { cleanupClaims } from './claims.js';
|
|
6
9
|
// Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
|
|
7
10
|
// .yoke/runner.pid files (main dir + each worktree). Never by process-name or
|
|
8
11
|
// command-line pattern — that takes down runners belonging to OTHER projects,
|
|
9
12
|
// which then stall mid-story. Skipped entirely while the loop lock holder is
|
|
10
13
|
// alive: a live loop's runner is healthy, not an orphan.
|
|
11
|
-
function reapRecordedRunners(targetDir, wtDir, isAlive,
|
|
14
|
+
export function reapRecordedRunners(targetDir, wtDir, isAlive, isTreeAlive, killChildTree, killWatchdog, currentIncarnation) {
|
|
12
15
|
const pidFiles = [join(targetDir, '.yoke', 'runner.pid')];
|
|
13
16
|
if (existsSync(wtDir)) {
|
|
14
17
|
for (const name of readdirSync(wtDir))
|
|
@@ -18,28 +21,85 @@ function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
|
|
|
18
21
|
for (const file of pidFiles) {
|
|
19
22
|
if (!existsSync(file))
|
|
20
23
|
continue;
|
|
24
|
+
let rec;
|
|
21
25
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
rec = JSON.parse(readFileSync(file, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch { /* malformed record — still consume the file below */ }
|
|
29
|
+
if (!rec) {
|
|
30
|
+
rmSync(file, { force: true });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Child (agent tree) first, then the watchdog wrapper.
|
|
34
|
+
for (const [pid, incarnation, treeOwned] of [[rec.childPid, rec.childIncarnation, true], [rec.watchdogPid, rec.watchdogIncarnation, false]]) {
|
|
35
|
+
const alive = typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && (isAlive(pid) || (treeOwned && isTreeAlive(pid)));
|
|
36
|
+
if (alive && typeof pid === 'number') {
|
|
37
|
+
if (isAlive(pid) && (typeof incarnation !== 'string' || currentIncarnation(pid) !== incarnation)) {
|
|
38
|
+
throw new Error(`could not confirm legacy runner process incarnation for pid ${pid}`);
|
|
39
|
+
}
|
|
40
|
+
const terminated = treeOwned ? killChildTree(pid) : killWatchdog(pid);
|
|
41
|
+
if (!terminated && (isAlive(pid) || (treeOwned && isTreeAlive(pid)))) {
|
|
42
|
+
throw new Error(`could not confirm legacy runner process tree termination for pid ${pid}`);
|
|
28
43
|
}
|
|
44
|
+
if (terminated)
|
|
45
|
+
killed++;
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
|
-
catch { /* malformed record — still consume the file below */ }
|
|
32
48
|
rmSync(file, { force: true });
|
|
33
49
|
}
|
|
34
50
|
return killed;
|
|
35
51
|
}
|
|
52
|
+
export function reapProviderProcesses(targetDir, isAlive, isTreeAlive, killTree, currentIncarnation = processIncarnation) {
|
|
53
|
+
const recordsDir = join(targetDir, '.yoke', 'provider-processes');
|
|
54
|
+
if (!existsSync(recordsDir))
|
|
55
|
+
return 0;
|
|
56
|
+
const expectedTarget = resolve(targetDir);
|
|
57
|
+
let killed = 0;
|
|
58
|
+
for (const name of readdirSync(recordsDir)) {
|
|
59
|
+
const recordPath = join(recordsDir, name);
|
|
60
|
+
let parsed = null;
|
|
61
|
+
try {
|
|
62
|
+
parsed = parseProviderProcessRecord(JSON.parse(readFileSync(recordPath, 'utf8')));
|
|
63
|
+
}
|
|
64
|
+
catch { /* malformed records are not trusted or consumed */ }
|
|
65
|
+
if (!parsed || parsed.targetDir !== expectedTarget)
|
|
66
|
+
continue;
|
|
67
|
+
if (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid)) {
|
|
68
|
+
if (isAlive(parsed.childPid) && currentIncarnation(parsed.childPid) !== parsed.startedAt) {
|
|
69
|
+
throw new Error(`could not confirm provider process incarnation for pid ${parsed.childPid}`);
|
|
70
|
+
}
|
|
71
|
+
const terminated = killTree(parsed.childPid);
|
|
72
|
+
if (!terminated && (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid))) {
|
|
73
|
+
throw new Error(`could not confirm provider process tree termination for pid ${parsed.childPid}`);
|
|
74
|
+
}
|
|
75
|
+
if (terminated)
|
|
76
|
+
killed++;
|
|
77
|
+
}
|
|
78
|
+
rmSync(recordPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
return killed;
|
|
81
|
+
}
|
|
82
|
+
export function isProviderTreeAlive(pid) {
|
|
83
|
+
if (process.platform === 'win32')
|
|
84
|
+
return isPidAlive(pid);
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-pid, 0);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
36
93
|
// Cleans ONLY yoke-created runtime artifacts: recorded orphan runners,
|
|
37
94
|
// .yoke/worktrees/*, and a stale loop.lock. Never touches user-created
|
|
38
95
|
// worktrees, a lock whose holder is alive, or any process it did not record.
|
|
39
96
|
export function runLoopCleanup(targetDir, opts = {}) {
|
|
40
97
|
const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
|
|
41
98
|
const isAlive = opts.isAlive ?? isPidAlive;
|
|
42
|
-
const
|
|
99
|
+
const killWatchdog = opts.killTree ?? killProcessForCleanup;
|
|
100
|
+
const killProviderTree = opts.killTree ?? killProcessTreeForCleanup;
|
|
101
|
+
const providerTreeAlive = process.platform === 'win32' ? isAlive : isProviderTreeAlive;
|
|
102
|
+
const currentIncarnation = opts.processIncarnation ?? processIncarnation;
|
|
43
103
|
const wtDir = join(targetDir, '.yoke', 'worktrees');
|
|
44
104
|
const recoveryFile = takeoverRecoveryPath(targetDir);
|
|
45
105
|
if (opts.discardStaleRecovery && existsSync(recoveryFile)) {
|
|
@@ -112,20 +172,34 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
112
172
|
console.log(`Loop lock held by a live process (pid ${holder.pid}) — no cleanup performed.`);
|
|
113
173
|
return 0;
|
|
114
174
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
175
|
+
let killed;
|
|
176
|
+
try {
|
|
177
|
+
killed = reapRecordedRunners(targetDir, wtDir, isAlive, providerTreeAlive, killProviderTree, killWatchdog, currentIncarnation) + reapProviderProcesses(targetDir, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.error(`Failed to reap recorded process trees: ${error.message}`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
118
183
|
let removed = 0;
|
|
119
184
|
let failed = 0;
|
|
120
185
|
if (existsSync(wtDir)) {
|
|
121
186
|
for (const name of readdirSync(wtDir)) {
|
|
122
187
|
const path = join(wtDir, name);
|
|
188
|
+
try {
|
|
189
|
+
killed += reapProviderProcesses(path, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.error(`Failed to reap provider processes for worktree ${path}: ${error.message}`);
|
|
193
|
+
failed++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
123
196
|
if (!opts.removeWorktrees) {
|
|
124
197
|
console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
|
|
125
198
|
continue;
|
|
126
199
|
}
|
|
127
200
|
try {
|
|
128
201
|
git(['worktree', 'remove', '--force', path], targetDir);
|
|
202
|
+
markCandidateWorktreeRemoved(targetDir, path);
|
|
129
203
|
removed++;
|
|
130
204
|
}
|
|
131
205
|
catch (e) {
|
|
@@ -140,6 +214,10 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
140
214
|
catch { /* best-effort */ }
|
|
141
215
|
}
|
|
142
216
|
}
|
|
217
|
+
if (killed > 0)
|
|
218
|
+
console.log(`Killed ${killed} orphaned process tree(s) from project-scoped Yoke records.`);
|
|
219
|
+
if (opts.removeWorktrees && failed === 0)
|
|
220
|
+
cleanupClaims(targetDir);
|
|
143
221
|
const lockFile = lockPath(targetDir);
|
|
144
222
|
if (existsSync(lockFile)) {
|
|
145
223
|
rmSync(lockFile, { force: true });
|
|
@@ -152,3 +230,27 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
152
230
|
releaseTakeoverLease(targetDir, cleanupLease.ownerToken);
|
|
153
231
|
}
|
|
154
232
|
}
|
|
233
|
+
function markCandidateWorktreeRemoved(targetDir, worktree) {
|
|
234
|
+
const proofRoot = join(targetDir, '.yoke', 'proof');
|
|
235
|
+
if (!existsSync(proofRoot))
|
|
236
|
+
return;
|
|
237
|
+
for (const storyId of readdirSync(proofRoot)) {
|
|
238
|
+
const candidates = join(proofRoot, storyId, 'candidates');
|
|
239
|
+
if (!existsSync(candidates))
|
|
240
|
+
continue;
|
|
241
|
+
for (const candidateId of readdirSync(candidates)) {
|
|
242
|
+
const status = join(candidates, candidateId, 'status.json');
|
|
243
|
+
if (!existsSync(status))
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(readFileSync(status, 'utf8'));
|
|
247
|
+
if (parsed.worktree !== worktree)
|
|
248
|
+
continue;
|
|
249
|
+
writeFileSync(status, JSON.stringify({ ...parsed, state: 'removed', reason: 'external cleanup recovered candidate worktree' }));
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/loop/decision.js
CHANGED
|
@@ -72,12 +72,43 @@ export const DecisionResumeSchema = z.object({
|
|
|
72
72
|
permissions: z.enum(['safe', 'unsafe', 'read-only']).optional(),
|
|
73
73
|
parallel: z.number().int().positive().optional(),
|
|
74
74
|
routing: z.boolean().optional(),
|
|
75
|
+
quality: z.boolean().optional(),
|
|
76
|
+
qualityRounds: z.number().int().positive().optional(),
|
|
77
|
+
qualityMinutes: z.number().int().positive().optional(),
|
|
78
|
+
qualityPolicy: z.enum(['blocking', 'advisory']).optional(),
|
|
79
|
+
candidates: z.number().int().positive().optional(),
|
|
75
80
|
projectId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
76
81
|
baseCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
77
82
|
prdHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
|
|
78
83
|
decisionCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
79
84
|
answerId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
80
85
|
});
|
|
86
|
+
export function buildTrustedDecisionResumeState(input) {
|
|
87
|
+
return DecisionResumeSchema.parse({
|
|
88
|
+
version: 1,
|
|
89
|
+
storyId: input.storyId,
|
|
90
|
+
requestId: input.requestId,
|
|
91
|
+
answered: false,
|
|
92
|
+
...(input.maxIterations !== undefined ? { maxIterations: input.maxIterations } : {}),
|
|
93
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
94
|
+
isolate: input.isolate,
|
|
95
|
+
...(input.reviewer ? { reviewer: input.reviewer } : {}),
|
|
96
|
+
review: input.review,
|
|
97
|
+
allowSelfReview: input.allowSelfReview,
|
|
98
|
+
...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
|
|
99
|
+
json: input.json,
|
|
100
|
+
...(input.onAmbiguity ? { onAmbiguity: input.onAmbiguity } : {}),
|
|
101
|
+
...(input.decisionPolicy ? { decisionPolicy: input.decisionPolicy } : {}),
|
|
102
|
+
permissions: input.permissions,
|
|
103
|
+
parallel: input.parallel,
|
|
104
|
+
routing: input.routing,
|
|
105
|
+
...(input.quality !== undefined ? { quality: input.quality } : {}),
|
|
106
|
+
...(input.qualityRounds !== undefined ? { qualityRounds: input.qualityRounds } : {}),
|
|
107
|
+
...(input.qualityMinutes !== undefined ? { qualityMinutes: input.qualityMinutes } : {}),
|
|
108
|
+
...(input.qualityPolicy ? { qualityPolicy: input.qualityPolicy } : {}),
|
|
109
|
+
...(input.candidates !== undefined ? { candidates: input.candidates } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
81
112
|
export function decisionRequestPath(targetDir) {
|
|
82
113
|
return join(targetDir, '.yoke', 'decision-request.yaml');
|
|
83
114
|
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { appendDecision, contextDir } from '../context/context.js';
|
|
4
|
+
import { acquireClaim, heartbeatClaim, releaseClaim, requestClaimCancellation } from './claims.js';
|
|
5
|
+
import { coordinateCandidates } from './candidates.js';
|
|
6
|
+
import { MergeQueue } from './merge-queue.js';
|
|
7
|
+
import { isAcceptanceCriterion, loadPrd, progress, savePrd } from './prd.js';
|
|
8
|
+
import { readyStories } from './scheduler.js';
|
|
9
|
+
import { createWorkerCleanup } from './worker-cleanup.js';
|
|
10
|
+
function defaultClaims(targetDir, clock) {
|
|
11
|
+
return {
|
|
12
|
+
acquire: input => acquireClaim(targetDir, input.story.id, input.dispatcherId, {
|
|
13
|
+
dispatcherId: input.dispatcherId,
|
|
14
|
+
ownerToken: input.ownerToken,
|
|
15
|
+
...(!input.candidateRace ? {
|
|
16
|
+
baseCommit: input.worktree.baseCommit,
|
|
17
|
+
worktree: input.worktree.path,
|
|
18
|
+
provider: input.provider.provider,
|
|
19
|
+
model: input.provider.model,
|
|
20
|
+
} : {}),
|
|
21
|
+
role: 'implementation',
|
|
22
|
+
now: clock(),
|
|
23
|
+
}) !== null,
|
|
24
|
+
heartbeat: input => { heartbeatClaim(targetDir, input.story.id, input.ownerToken, { now: clock() }); },
|
|
25
|
+
release: input => releaseClaim(targetDir, input.story.id, input.ownerToken, { now: clock() }),
|
|
26
|
+
cancel: (input, reason) => { requestClaimCancellation(targetDir, input.story.id, input.ownerToken, reason, { now: clock() }); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function gateResult(gates, path, worker) {
|
|
30
|
+
const { story } = worker;
|
|
31
|
+
const criteria = story.acceptance.filter(isAcceptanceCriterion);
|
|
32
|
+
if (criteria.length === 0 && gates.requireCriterionEvidence)
|
|
33
|
+
return { passed: false, summary: 'missing criterion evidence' };
|
|
34
|
+
if (criteria.length > 0 && !gates.verifyCriterion)
|
|
35
|
+
return { passed: false, summary: 'criterion verifier is not configured' };
|
|
36
|
+
for (const criterion of criteria) {
|
|
37
|
+
const result = gates.verifyCriterion?.(path, story, criterion);
|
|
38
|
+
if (!result?.passed)
|
|
39
|
+
return { passed: false, summary: result?.summary ?? 'criterion verification failed' };
|
|
40
|
+
}
|
|
41
|
+
for (const gate of [gates.verify, gates.perf, gates.audit]) {
|
|
42
|
+
if (!gate)
|
|
43
|
+
continue;
|
|
44
|
+
const result = gate(path, story);
|
|
45
|
+
if (!result.passed)
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
return gates.qualityReview?.(path, story, worker) ?? { passed: true, summary: 'integrated gates passed' };
|
|
49
|
+
}
|
|
50
|
+
export function createDispatcher(options) {
|
|
51
|
+
const dispatcherId = options.dispatcherId ?? randomUUID();
|
|
52
|
+
const ids = options.id ?? randomUUID;
|
|
53
|
+
const clock = options.clock ?? (() => new Date());
|
|
54
|
+
const claims = options.claims ?? defaultClaims(options.targetDir, clock);
|
|
55
|
+
const queue = new MergeQueue();
|
|
56
|
+
const active = new Map();
|
|
57
|
+
const queued = new Map();
|
|
58
|
+
const areas = new Set();
|
|
59
|
+
const integrated = [];
|
|
60
|
+
const reopened = [];
|
|
61
|
+
const failed = [];
|
|
62
|
+
const failureReasons = new Map();
|
|
63
|
+
const integrationBlocks = [];
|
|
64
|
+
let iterations = 0;
|
|
65
|
+
let providerIndex = 0;
|
|
66
|
+
let paused = false;
|
|
67
|
+
let cancellationReason;
|
|
68
|
+
const reportProgress = () => options.onProgress?.({
|
|
69
|
+
dispatcherId,
|
|
70
|
+
maxConcurrency: options.maxConcurrency,
|
|
71
|
+
activeWorkers: active.size,
|
|
72
|
+
queuedCandidates: queued.size,
|
|
73
|
+
integrated: integrated.length,
|
|
74
|
+
reopened: reopened.length,
|
|
75
|
+
iteration: iterations,
|
|
76
|
+
progress: progress(options.stories),
|
|
77
|
+
workers: [...active.values()].map(worker => ({
|
|
78
|
+
story: worker.input.story.id,
|
|
79
|
+
storyTitle: worker.input.story.title,
|
|
80
|
+
provider: worker.input.provider.provider,
|
|
81
|
+
...(worker.input.provider.model ? { model: worker.input.provider.model } : {}),
|
|
82
|
+
phase: 'implementing',
|
|
83
|
+
})),
|
|
84
|
+
});
|
|
85
|
+
const providerFor = (story) => {
|
|
86
|
+
if (story.agent) {
|
|
87
|
+
const configured = options.affinityProviders?.find(provider => provider.provider === story.agent)
|
|
88
|
+
?? options.providers?.find(provider => provider.provider === story.agent);
|
|
89
|
+
return { ...(configured ?? { provider: story.agent }), provider: story.agent, role: 'implementation' };
|
|
90
|
+
}
|
|
91
|
+
const configured = options.providers?.[providerIndex++ % (options.providers?.length ?? 1)] ?? { provider: 'claude' };
|
|
92
|
+
return { ...configured, role: 'implementation' };
|
|
93
|
+
};
|
|
94
|
+
const cleanupWorker = createWorkerCleanup({
|
|
95
|
+
cleanupProcess: options.worktrees.cleanupProcess,
|
|
96
|
+
removeWorktree: input => options.worktrees.remove(input),
|
|
97
|
+
releaseClaim: input => {
|
|
98
|
+
if (claims.release(input) === false)
|
|
99
|
+
throw new Error('claim release failed');
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const cleanup = (input) => {
|
|
103
|
+
if (input.story.area)
|
|
104
|
+
areas.delete(input.story.area);
|
|
105
|
+
cleanupWorker(input);
|
|
106
|
+
};
|
|
107
|
+
const releaseCandidateClaim = (input) => {
|
|
108
|
+
if (input.story.area)
|
|
109
|
+
areas.delete(input.story.area);
|
|
110
|
+
if (claims.release(input) === false)
|
|
111
|
+
throw new Error('claim release failed');
|
|
112
|
+
};
|
|
113
|
+
const persistPass = (input) => {
|
|
114
|
+
if (!options.prdPath)
|
|
115
|
+
return;
|
|
116
|
+
const path = join(input.worktree.path, relative(options.targetDir, options.prdPath));
|
|
117
|
+
const next = loadPrd(options.prdPath).map(story => story.id === input.story.id ? { ...story, passes: true } : story);
|
|
118
|
+
savePrd(path, next);
|
|
119
|
+
appendDecision(contextDir(input.worktree.path), { storyId: input.story.id, title: input.story.title, summary: `${input.story.id} integrated` });
|
|
120
|
+
};
|
|
121
|
+
const enqueue = (input, result) => {
|
|
122
|
+
let reason = 'integrated verification failed';
|
|
123
|
+
const task = queue.enqueue({
|
|
124
|
+
storyId: input.story.id,
|
|
125
|
+
rebase: async () => {
|
|
126
|
+
if (cancellationReason)
|
|
127
|
+
throw new Error(cancellationReason);
|
|
128
|
+
if (!options.git.isClean(options.targetDir))
|
|
129
|
+
throw new Error('target working tree is not clean');
|
|
130
|
+
const rebase = await options.git.rebase(input);
|
|
131
|
+
if (rebase.kind === 'reopen') {
|
|
132
|
+
reason = rebase.reason;
|
|
133
|
+
throw new Error(reason);
|
|
134
|
+
}
|
|
135
|
+
return rebase.expectedHead;
|
|
136
|
+
},
|
|
137
|
+
verify: async () => {
|
|
138
|
+
if (cancellationReason) {
|
|
139
|
+
reason = cancellationReason;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const gate = await gateResult(options.gates, input.worktree.path, input);
|
|
143
|
+
reason = gate.summary;
|
|
144
|
+
return gate.passed;
|
|
145
|
+
},
|
|
146
|
+
integrate: async (expectedHead) => {
|
|
147
|
+
if (cancellationReason)
|
|
148
|
+
throw new Error(cancellationReason);
|
|
149
|
+
options.gates.integrationPhase?.(input, 'committing');
|
|
150
|
+
persistPass(input);
|
|
151
|
+
await options.git.commit(input);
|
|
152
|
+
await options.git.integrate(input, expectedHead);
|
|
153
|
+
},
|
|
154
|
+
postIntegrateVerify: () => {
|
|
155
|
+
if (!options.git.isClean(options.targetDir))
|
|
156
|
+
throw new Error('target working tree is not clean after integration');
|
|
157
|
+
},
|
|
158
|
+
}).then(merge => {
|
|
159
|
+
switch (merge.status) {
|
|
160
|
+
case 'integrated':
|
|
161
|
+
input.story.passes = true;
|
|
162
|
+
integrated.push(input.story.id);
|
|
163
|
+
result.routing.recordOutcome?.(true);
|
|
164
|
+
return;
|
|
165
|
+
case 'integrated-but-blocked':
|
|
166
|
+
input.story.passes = true;
|
|
167
|
+
integrated.push(input.story.id);
|
|
168
|
+
integrationBlocks.push(merge.reason);
|
|
169
|
+
result.routing.recordOutcome?.(true);
|
|
170
|
+
return;
|
|
171
|
+
case 'reopened':
|
|
172
|
+
reopened.push(input.story.id);
|
|
173
|
+
result.routing.recordOutcome?.(false);
|
|
174
|
+
return;
|
|
175
|
+
default: {
|
|
176
|
+
const unexpected = merge;
|
|
177
|
+
throw new Error(`unexpected merge result: ${String(unexpected)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}).finally(() => {
|
|
181
|
+
queued.delete(input.story.id);
|
|
182
|
+
options.gates.integrationPhase?.(input, undefined);
|
|
183
|
+
cleanup(input);
|
|
184
|
+
reportProgress();
|
|
185
|
+
});
|
|
186
|
+
queued.set(input.story.id, task);
|
|
187
|
+
reportProgress();
|
|
188
|
+
};
|
|
189
|
+
const launch = (story) => {
|
|
190
|
+
const provider = providerFor(story);
|
|
191
|
+
const ownerToken = ids();
|
|
192
|
+
const worktree = options.worktrees.create({ story, dispatcherId, ownerToken, provider });
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const candidateRace = Boolean(options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
|
|
195
|
+
const input = { story, worktree, provider, cancellation: { signal: controller.signal }, dispatcherId, ownerToken, ...(candidateRace ? { candidateRace: true } : {}) };
|
|
196
|
+
if (!claims.acquire(input)) {
|
|
197
|
+
options.worktrees.remove(input);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
iterations += 1;
|
|
201
|
+
if (story.area)
|
|
202
|
+
areas.add(story.area);
|
|
203
|
+
const candidateDispatch = candidateRace;
|
|
204
|
+
const task = (candidateDispatch && options.candidateCoordinator
|
|
205
|
+
? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
|
|
206
|
+
: options.worker(input).then(result => ({ source: 'worker', result }))).then(async (outcome) => {
|
|
207
|
+
active.delete(story.id);
|
|
208
|
+
claims.heartbeat(input);
|
|
209
|
+
if (outcome.source === 'candidates') {
|
|
210
|
+
switch (outcome.result.kind) {
|
|
211
|
+
case 'winner': {
|
|
212
|
+
const winnerInput = {
|
|
213
|
+
...input,
|
|
214
|
+
worktree: outcome.result.winner.worktree,
|
|
215
|
+
provider: outcome.result.winner.provider,
|
|
216
|
+
};
|
|
217
|
+
enqueue(winnerInput, outcome.result.winner.result);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'paused':
|
|
221
|
+
paused = true;
|
|
222
|
+
releaseCandidateClaim(input);
|
|
223
|
+
reportProgress();
|
|
224
|
+
return;
|
|
225
|
+
case 'cancelled':
|
|
226
|
+
releaseCandidateClaim(input);
|
|
227
|
+
reportProgress();
|
|
228
|
+
return;
|
|
229
|
+
case 'blocked':
|
|
230
|
+
failed.push(story.id);
|
|
231
|
+
failureReasons.set(story.id, outcome.result.summary);
|
|
232
|
+
if (outcome.result.reason !== 'cleanup-error' && !outcome.result.recovery?.length)
|
|
233
|
+
releaseCandidateClaim(input);
|
|
234
|
+
reportProgress();
|
|
235
|
+
return;
|
|
236
|
+
default: {
|
|
237
|
+
const unexpected = outcome.result;
|
|
238
|
+
throw new Error(`unexpected candidate coordinator result: ${String(unexpected)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const result = outcome.result;
|
|
243
|
+
if (result.kind === 'paused') {
|
|
244
|
+
if (result.reason === 'decision')
|
|
245
|
+
cancel(`critical decision required for story ${input.story.id}: ${result.summary}`);
|
|
246
|
+
else
|
|
247
|
+
paused = true;
|
|
248
|
+
cleanup(input);
|
|
249
|
+
reportProgress();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (result.kind !== 'candidate') {
|
|
253
|
+
failed.push(story.id);
|
|
254
|
+
failureReasons.set(story.id, result.summary);
|
|
255
|
+
cleanup(input);
|
|
256
|
+
reportProgress();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (cancellationReason || paused || options.pause?.()) {
|
|
260
|
+
paused = true;
|
|
261
|
+
result.routing.recordOutcome?.(false);
|
|
262
|
+
cleanup(input);
|
|
263
|
+
reportProgress();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
enqueue(input, result);
|
|
267
|
+
}, error => {
|
|
268
|
+
active.delete(story.id);
|
|
269
|
+
failed.push(story.id);
|
|
270
|
+
failureReasons.set(story.id, error instanceof Error ? error.message : String(error));
|
|
271
|
+
try {
|
|
272
|
+
cleanup(input);
|
|
273
|
+
}
|
|
274
|
+
catch (cleanupError) {
|
|
275
|
+
reportProgress();
|
|
276
|
+
throw new AggregateError([error, cleanupError], `worker ${story.id} and cleanup failed`);
|
|
277
|
+
}
|
|
278
|
+
reportProgress();
|
|
279
|
+
if (!(error instanceof Error))
|
|
280
|
+
throw error;
|
|
281
|
+
});
|
|
282
|
+
active.set(story.id, { input, controller, task });
|
|
283
|
+
reportProgress();
|
|
284
|
+
};
|
|
285
|
+
const cancel = (reason) => {
|
|
286
|
+
if (cancellationReason)
|
|
287
|
+
return;
|
|
288
|
+
cancellationReason = reason;
|
|
289
|
+
for (const worker of active.values()) {
|
|
290
|
+
claims.cancel?.(worker.input, reason);
|
|
291
|
+
worker.controller.abort(reason);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const run = async () => {
|
|
295
|
+
for (;;) {
|
|
296
|
+
if (options.pause?.())
|
|
297
|
+
paused = true;
|
|
298
|
+
if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
|
|
299
|
+
const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
|
|
300
|
+
const slots = Math.max(0, options.maxConcurrency - active.size);
|
|
301
|
+
const ready = readyStories(options.stories, { activeAreas: areas }).filter(story => !busy.has(story.id)).slice(0, slots);
|
|
302
|
+
for (const story of ready) {
|
|
303
|
+
if (!story.area || !areas.has(story.area))
|
|
304
|
+
launch(story);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (active.size > 0) {
|
|
308
|
+
await Promise.race([...active.values()].map(worker => worker.task));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (queued.size > 0) {
|
|
312
|
+
await Promise.race(queued.values());
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (cancellationReason)
|
|
316
|
+
return { status: 'cancelled', reason: cancellationReason, iterations, integrated, reopened, failed };
|
|
317
|
+
if (paused)
|
|
318
|
+
return { status: 'paused', iterations, integrated, reopened, failed };
|
|
319
|
+
if (integrationBlocks.length > 0)
|
|
320
|
+
return { status: 'blocked', reason: integrationBlocks[0], iterations, integrated, reopened, failed };
|
|
321
|
+
if (options.stories.every(story => story.passes))
|
|
322
|
+
return { status: 'complete', iterations, integrated, reopened, failed };
|
|
323
|
+
if (failed.length > 0) {
|
|
324
|
+
const storyId = failed[0];
|
|
325
|
+
const detail = storyId ? failureReasons.get(storyId) : undefined;
|
|
326
|
+
return { status: 'blocked', ...(storyId && detail ? { reason: `worker ${storyId} failed: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
327
|
+
}
|
|
328
|
+
if (iterations >= options.maxIterations)
|
|
329
|
+
return { status: 'cap-reached', iterations, integrated, reopened, failed };
|
|
330
|
+
return { status: 'blocked', iterations, integrated, reopened, failed };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
return { run, cancel };
|
|
334
|
+
}
|
package/dist/loop/git.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { sanitizeCommitMessage } from './identity.js';
|
|
3
|
+
const OUTPUT_ARTIFACT_EXCLUDE = ':(exclude).yoke/artifacts/**';
|
|
3
4
|
export const realGitOps = {
|
|
4
5
|
isClean(dir) {
|
|
5
|
-
const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
|
|
6
|
+
const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', '.', OUTPUT_ARTIFACT_EXCLUDE], { cwd: dir }).toString();
|
|
6
7
|
return out.trim() === '';
|
|
7
8
|
},
|
|
8
9
|
commitAll(dir, message, identity) {
|
|
9
|
-
execFileSync('git', ['
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
execFileSync('git', ['reset', '--quiet', '--', '.yoke/artifacts'], { cwd: dir, stdio: 'pipe' });
|
|
11
|
+
execFileSync('git', ['add', '-A', '--', '.', OUTPUT_ARTIFACT_EXCLUDE], { cwd: dir, stdio: 'pipe' });
|
|
12
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: dir }).toString().trim();
|
|
13
|
+
if (staged === '') {
|
|
12
14
|
throw new Error('nothing to commit after agent run');
|
|
13
15
|
}
|
|
14
16
|
const identityArgs = identity
|