@hecer/yoke 1.16.0 → 1.18.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 +36 -0
- package/README.md +32 -14
- package/bench/README.md +10 -0
- package/bench/RESULTS.md +23 -0
- package/bench/fixtures/parallel-work/scenarios.json +33 -0
- package/bench/run-parallel-matrix.mjs +71 -0
- package/canon/loop/loop-spec.md +20 -3
- package/canon/manifest.yaml +1 -1
- package/dist/cli.js +96 -7
- package/dist/dashboard/panels.js +3 -1
- package/dist/dashboard/server.js +11 -2
- package/dist/estimation/schedule.js +96 -33
- package/dist/loop/claim-lease.js +1 -1
- package/dist/loop/dispatcher.js +76 -26
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +13 -1
- package/dist/loop/parallel-command.js +21 -0
- package/dist/loop/prd.js +11 -0
- package/dist/loop/reporter.js +27 -7
- package/dist/loop/resource-pool.js +369 -0
- package/dist/loop/run-command.js +436 -23
- package/dist/prd/decompose.js +232 -0
- package/dist/prd/explore.js +441 -0
- package/dist/retrofit/config.js +1 -1
- package/docs/CONTINUOUS-EXPLORATION.md +67 -0
- package/docs/VERIFIED-PROJECTS.md +4 -2
- package/docs/parallel-execution.md +130 -0
- package/docs/superpowers/plans/2026-09-27-parallel-task-efficiency.md +65 -0
- package/gemini-extension.json +1 -1
- package/package.json +2 -1
package/dist/loop/run-command.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { roleSelection } from "../routing/capability.js";
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
|
|
5
|
-
import { loadPrd, progress } from './prd.js';
|
|
6
|
-
import { runLoop } from './loop.js';
|
|
5
|
+
import { allPass, loadPrd, progress, selectNextStory } from './prd.js';
|
|
6
|
+
import { pauseFilePath, requestLoopPause, runLoop } from './loop.js';
|
|
7
7
|
import { commitPaths, realGitOps } from './git.js';
|
|
8
8
|
import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
|
|
9
9
|
import { commandVerifier, commandsVerifier, retryingVerifier } from './verify.js';
|
|
@@ -25,6 +25,8 @@ import { detectUiProject } from '../retrofit/ui-detect.js';
|
|
|
25
25
|
import { designVerifier } from '../scan/gate.js';
|
|
26
26
|
import { prepareIsolatedWorktree } from './recovery.js';
|
|
27
27
|
import { AGENT_LIST, SUPPORTED_AGENTS } from '../agents/catalog.js';
|
|
28
|
+
import { MAX_PROJECT_WORKERS, sharedPoolStatus, withSharedWorkerSync } from './resource-pool.js';
|
|
29
|
+
import { runPrdExplore } from '../prd/explore.js';
|
|
28
30
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
29
31
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
30
32
|
export function relativeTime(fromIso, now) {
|
|
@@ -58,14 +60,24 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
58
60
|
const p = progress(loadPrd(path));
|
|
59
61
|
prog = `${p.passed}/${p.total} stories pass`;
|
|
60
62
|
}
|
|
63
|
+
const sharedPoolLine = () => {
|
|
64
|
+
try {
|
|
65
|
+
const pool = sharedPoolStatus();
|
|
66
|
+
return `Shared pool: ${pool.activeUnits}/${pool.limit} units · ${pool.activeByRole.implementation} implementation · ${pool.activeByRole.integration} integration · ${pool.waitingWorkers} waiting${pool.oldestWaitMs ? ` · oldest wait ${fmtDuration(pool.oldestWaitMs)}` : ''}`;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return `Shared pool unavailable: ${error.message}`;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
61
72
|
const st = readStatus(targetDir);
|
|
62
73
|
if (!st)
|
|
63
|
-
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
|
|
74
|
+
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}\n${sharedPoolLine()}`;
|
|
64
75
|
const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
|
|
65
76
|
const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
|
|
66
77
|
const meta = [st.phase, `iteration ${st.iteration}`, `backlog ${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
|
|
67
78
|
.filter(Boolean).join(' · ');
|
|
68
79
|
const lines = [head, ` ${meta}`];
|
|
80
|
+
lines.push(` ${sharedPoolLine()}`);
|
|
69
81
|
for (const process of st.supervision ?? []) {
|
|
70
82
|
lines.push(` provider PID ${process.childPid ?? 'not started'}: ${process.state} · attempt ${process.retry + 1} · identity/liveness ${process.liveness ?? 'unknown'} · supervisor heartbeat ${relativeTime(process.heartbeatAt, now())} · last output ${process.lastOutputAt ? relativeTime(process.lastOutputAt, now()) : 'none'} · last successful tool/edit ${process.lastProgressAt ? relativeTime(process.lastProgressAt, now()) : 'none'}${process.reason ? ' · ' + process.reason : ''}`);
|
|
71
83
|
}
|
|
@@ -77,7 +89,7 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
77
89
|
if (st.quality)
|
|
78
90
|
lines.push(` quality: round ${st.quality.currentRound} · ${st.quality.usedRepairs}${st.quality.unbounded ? ' unbounded repairs' : `/${st.quality.maxRepairs ?? 0} repairs`} · ${st.quality.policy}`);
|
|
79
91
|
if (st.parallel)
|
|
80
|
-
lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers · ${st.parallel.queuedCandidates} queued · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
|
|
92
|
+
lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers (local; ${st.parallel.waitingWorkers ?? 0} waiting) · ${st.parallel.queuedIntegrations ?? st.parallel.queuedCandidates} queued for integration · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
|
|
81
93
|
const integrator = st.parallel?.integrator;
|
|
82
94
|
if (integrator) {
|
|
83
95
|
const quality = integrator.quality
|
|
@@ -92,8 +104,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
92
104
|
const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
|
|
93
105
|
lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
|
|
94
106
|
}
|
|
95
|
-
const
|
|
96
|
-
|
|
107
|
+
const nowMs = now().getTime();
|
|
108
|
+
const ageMs = nowMs - Date.parse(st.updatedAt);
|
|
109
|
+
const providerProgressMs = Math.max(0, ...((st.supervision ?? [])
|
|
110
|
+
.filter(process => process.liveness === 'alive' && process.lastProgressAt)
|
|
111
|
+
.map(process => Date.parse(process.lastProgressAt))
|
|
112
|
+
.filter(Number.isFinite)));
|
|
113
|
+
if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000 && nowMs - providerProgressMs > STALE_MINUTES * 60_000) {
|
|
97
114
|
lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
|
|
98
115
|
}
|
|
99
116
|
return lines.join('\n');
|
|
@@ -102,15 +119,376 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
102
119
|
const minutes = flagMinutes ?? configMinutes ?? DEFAULT_IDLE_MINUTES;
|
|
103
120
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
104
121
|
}
|
|
122
|
+
const DEFAULT_EXPLORE_INTERVAL_MINUTES = 30;
|
|
123
|
+
const EXPLORE_RETRY_BASE_MS = 30_000;
|
|
124
|
+
const EXPLORE_RETRY_MAX_MS = 15 * 60_000;
|
|
125
|
+
function consumeExplorePause(targetDir) {
|
|
126
|
+
const path = pauseFilePath(targetDir);
|
|
127
|
+
if (!existsSync(path))
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
}
|
|
132
|
+
catch { /* a pause request still wins if cleanup fails */ }
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
function currentProgress(targetDir) {
|
|
136
|
+
try {
|
|
137
|
+
return progress(loadPrd(prdPath(targetDir)));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { passed: 0, total: 0 };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function allCurrentStoriesPass(targetDir) {
|
|
144
|
+
try {
|
|
145
|
+
return allPass(loadPrd(prdPath(targetDir)));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function waitForExplorePause(targetDir, durationMs, heartbeat, limitDeadline) {
|
|
152
|
+
const waitDeadline = Date.now() + durationMs;
|
|
153
|
+
let nextHeartbeat = Date.now() + 5 * 60_000;
|
|
154
|
+
while (Date.now() < waitDeadline && (limitDeadline === undefined || Date.now() < limitDeadline)) {
|
|
155
|
+
if (consumeExplorePause(targetDir))
|
|
156
|
+
return true;
|
|
157
|
+
if (Date.now() >= nextHeartbeat) {
|
|
158
|
+
heartbeat?.();
|
|
159
|
+
nextHeartbeat = Date.now() + 5 * 60_000;
|
|
160
|
+
}
|
|
161
|
+
const untilWaitEnds = waitDeadline - Date.now();
|
|
162
|
+
const untilLimit = limitDeadline === undefined ? untilWaitEnds : limitDeadline - Date.now();
|
|
163
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(1, Math.min(1_000, untilWaitEnds, untilLimit))));
|
|
164
|
+
}
|
|
165
|
+
return consumeExplorePause(targetDir);
|
|
166
|
+
}
|
|
167
|
+
function retryProviders(targetDir, preferred, available = isAgentAvailable) {
|
|
168
|
+
let config;
|
|
169
|
+
try {
|
|
170
|
+
config = loadConfig(targetDir);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
config = null;
|
|
174
|
+
}
|
|
175
|
+
const primary = resolveRunnerAgent(config, preferred, detectHostAgent());
|
|
176
|
+
return [...new Set([primary, ...(config?.agents ?? [])])].filter(agent => available(agent));
|
|
177
|
+
}
|
|
178
|
+
function retryReviewers(preferred, implementer, available) {
|
|
179
|
+
return [...new Set([...(preferred ? [preferred] : []), ...SUPPORTED_AGENTS])].filter(agent => agent !== implementer && available(agent));
|
|
180
|
+
}
|
|
181
|
+
function nextProvider(providers, current) {
|
|
182
|
+
if (!providers.length)
|
|
183
|
+
return current;
|
|
184
|
+
const index = current ? providers.indexOf(current) : -1;
|
|
185
|
+
return providers[(index + 1 + providers.length) % providers.length];
|
|
186
|
+
}
|
|
187
|
+
function retryDelay(failures) {
|
|
188
|
+
return Math.min(EXPLORE_RETRY_MAX_MS, EXPLORE_RETRY_BASE_MS * 2 ** Math.min(10, Math.max(0, failures - 1)));
|
|
189
|
+
}
|
|
190
|
+
function scheduleExploreLimitPause(targetDir, deadline) {
|
|
191
|
+
if (deadline === undefined)
|
|
192
|
+
return () => { };
|
|
193
|
+
let timer;
|
|
194
|
+
const check = () => {
|
|
195
|
+
const remaining = deadline - Date.now();
|
|
196
|
+
if (remaining <= 0) {
|
|
197
|
+
requestLoopPause(targetDir);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
timer = setTimeout(check, Math.min(remaining, 2_147_483_647));
|
|
201
|
+
timer.unref?.();
|
|
202
|
+
};
|
|
203
|
+
check();
|
|
204
|
+
return () => { if (timer)
|
|
205
|
+
clearTimeout(timer); };
|
|
206
|
+
}
|
|
207
|
+
function reportExploration(targetDir, summary, json, nextStep) {
|
|
208
|
+
const say = json ? (line) => console.error(line) : (line) => console.log(line);
|
|
209
|
+
let next = nextStep;
|
|
210
|
+
try {
|
|
211
|
+
if (!next) {
|
|
212
|
+
const story = selectNextStory(loadPrd(prdPath(targetDir)));
|
|
213
|
+
if (story)
|
|
214
|
+
next = `${story.id}: ${story.title}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch { /* the persistent loop status keeps the parse failure visible */ }
|
|
218
|
+
say(`Exploration: ${summary}`);
|
|
219
|
+
say(`Next: ${next ?? 'scan the project again for evidenced improvements'}`);
|
|
220
|
+
}
|
|
221
|
+
async function runContinuousExploration(targetDir, options) {
|
|
222
|
+
if (options.isolate === false) {
|
|
223
|
+
console.error('--explore requires isolated story worktrees so failed work can be resumed safely.');
|
|
224
|
+
return 2;
|
|
225
|
+
}
|
|
226
|
+
const intervalMinutes = options.exploreIntervalMinutes ?? DEFAULT_EXPLORE_INTERVAL_MINUTES;
|
|
227
|
+
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1 || intervalMinutes > 1_440) {
|
|
228
|
+
console.error('--explore-interval must be an integer from 1 to 1440 minutes');
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
if (options.exploreLimitMs !== undefined && (!Number.isSafeInteger(options.exploreLimitMs) || options.exploreLimitMs <= 0 || Date.now() + options.exploreLimitMs > 8_640_000_000_000_000)) {
|
|
232
|
+
console.error('--explore-limit must be a positive, supported duration');
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
const limitDeadline = options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs;
|
|
236
|
+
const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
|
|
237
|
+
let config;
|
|
238
|
+
try {
|
|
239
|
+
config = loadConfig(targetDir);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
config = null;
|
|
243
|
+
}
|
|
244
|
+
const defaultImplementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
|
|
245
|
+
const defaultExplorer = options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
|
|
246
|
+
const available = options.isAvailable ?? isAgentAvailable;
|
|
247
|
+
const reviewRequested = !options.reviewRunner && Boolean(options.review || options.reviewer);
|
|
248
|
+
const innerOptions = { ...options, isolate: true, explore: true, explorationSupervisor: true, reporter, quiet: true };
|
|
249
|
+
let usedIterations = 0;
|
|
250
|
+
let retryCount = 0;
|
|
251
|
+
let explorationFailures = 0;
|
|
252
|
+
let noActionScans = 0;
|
|
253
|
+
let implementationAgent = defaultImplementationAgent;
|
|
254
|
+
let reviewerAgent = options.reviewer ?? (reviewRequested ? SUPPORTED_AGENTS.find(agent => agent !== defaultImplementationAgent && available(agent)) : undefined);
|
|
255
|
+
let explorationAgent = defaultExplorer;
|
|
256
|
+
let retryWorktree = false;
|
|
257
|
+
const safeBatchSize = () => {
|
|
258
|
+
if (retryWorktree)
|
|
259
|
+
return 1;
|
|
260
|
+
const parallelSetting = options.parallelAuto ? 'auto' : options.parallel ?? config?.loop.parallel ?? 'auto';
|
|
261
|
+
if (parallelSetting !== 'auto')
|
|
262
|
+
return Math.max(1, parallelSetting);
|
|
263
|
+
try {
|
|
264
|
+
const pending = loadPrd(prdPath(targetDir)).filter(story => !story.passes);
|
|
265
|
+
if (!config?.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length)) {
|
|
266
|
+
return Math.min(sharedPoolStatus().limit, pending.length);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch { /* the inner command reports the underlying configuration or PRD error */ }
|
|
270
|
+
return 1;
|
|
271
|
+
};
|
|
272
|
+
const say = options.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
273
|
+
reporter.phase('exploring', 'continuous exploration supervisor started', currentProgress(targetDir));
|
|
274
|
+
const limitReached = () => limitDeadline !== undefined && Date.now() >= limitDeadline;
|
|
275
|
+
const stopForTimeLimit = () => {
|
|
276
|
+
consumeExplorePause(targetDir);
|
|
277
|
+
const reason = 'the configured exploration time limit elapsed; stopped at a safe boundary';
|
|
278
|
+
reporter.paused(currentProgress(targetDir), reason);
|
|
279
|
+
say('Exploration time limit elapsed; the loop stopped at a safe boundary. Next: yoke loop run --explore .');
|
|
280
|
+
return 3;
|
|
281
|
+
};
|
|
282
|
+
const scanForWork = async (focus) => {
|
|
283
|
+
if (limitReached())
|
|
284
|
+
return 'limit';
|
|
285
|
+
const explorer = retryProviders(targetDir, explorationAgent, available);
|
|
286
|
+
const result = runPrdExplore(targetDir, {
|
|
287
|
+
runner: explorationAgent,
|
|
288
|
+
timeoutMinutes: options.timeoutMinutes,
|
|
289
|
+
isAvailable: options.isAvailable ?? isAgentAvailable,
|
|
290
|
+
onUsage: usage => reporter.addTokens(usage),
|
|
291
|
+
pause: () => consumeExplorePause(targetDir) || limitReached(),
|
|
292
|
+
...(focus ? { focus } : {}),
|
|
293
|
+
});
|
|
294
|
+
if (result.kind === 'paused') {
|
|
295
|
+
if (limitReached())
|
|
296
|
+
return 'limit';
|
|
297
|
+
reporter.paused(currentProgress(targetDir));
|
|
298
|
+
say('Exploration paused at a safe boundary.');
|
|
299
|
+
return 'paused';
|
|
300
|
+
}
|
|
301
|
+
if (result.kind === 'added') {
|
|
302
|
+
explorationFailures = 0;
|
|
303
|
+
noActionScans = 0;
|
|
304
|
+
explorationAgent = defaultExplorer;
|
|
305
|
+
const titles = result.tasks.map(task => `${task.id}: ${task.title}`).join('; ');
|
|
306
|
+
reporter.phase('exploring', `accepted tasks are committed; implementation is next: ${titles}`, currentProgress(targetDir));
|
|
307
|
+
reportExploration(targetDir, `added ${titles}`, options.json ?? false);
|
|
308
|
+
return 'continue';
|
|
309
|
+
}
|
|
310
|
+
if (result.kind === 'none') {
|
|
311
|
+
explorationFailures = 0;
|
|
312
|
+
noActionScans++;
|
|
313
|
+
if (noActionScans % 3 === 0)
|
|
314
|
+
explorationAgent = nextProvider(explorer, explorationAgent) ?? defaultExplorer;
|
|
315
|
+
const wait = intervalMinutes * 60_000;
|
|
316
|
+
const waitingReason = `${focus ? `${focus}; ` : ''}${result.summary}; scanning again in ${intervalMinutes} minute(s)`;
|
|
317
|
+
reporter.phase('waiting-exploration', waitingReason, currentProgress(targetDir));
|
|
318
|
+
reportExploration(targetDir, 'no new task met the evidence and quality gates', options.json ?? false, `scan again in ${intervalMinutes} minute(s)`);
|
|
319
|
+
return await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-exploration', waitingReason, currentProgress(targetDir)), limitDeadline) ? 'paused' : 'continue';
|
|
320
|
+
}
|
|
321
|
+
explorationFailures++;
|
|
322
|
+
explorationAgent = nextProvider(explorer, result.provider ?? explorationAgent) ?? defaultExplorer;
|
|
323
|
+
const wait = retryDelay(explorationFailures);
|
|
324
|
+
const waitingReason = `${focus ? `${focus}; ` : ''}${result.summary}; retrying exploration in ${Math.ceil(wait / 1_000)} seconds`;
|
|
325
|
+
reporter.phase('waiting-recovery', waitingReason, currentProgress(targetDir));
|
|
326
|
+
reportExploration(targetDir, 'the explorer could not produce an admissible task', options.json ?? false, `retry in ${Math.ceil(wait / 1_000)} seconds`);
|
|
327
|
+
return await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-recovery', waitingReason, currentProgress(targetDir)), limitDeadline) ? 'paused' : 'continue';
|
|
328
|
+
};
|
|
329
|
+
const advanceRecoveryProviders = () => {
|
|
330
|
+
implementationAgent = nextProvider(retryProviders(targetDir, implementationAgent, available), implementationAgent) ?? implementationAgent;
|
|
331
|
+
if (reviewRequested)
|
|
332
|
+
reviewerAgent = nextProvider(retryReviewers(reviewerAgent, implementationAgent, available), reviewerAgent) ?? reviewerAgent;
|
|
333
|
+
};
|
|
334
|
+
for (;;) {
|
|
335
|
+
if (consumeExplorePause(targetDir)) {
|
|
336
|
+
reporter.paused(currentProgress(targetDir));
|
|
337
|
+
say(`Exploration paused. Next: yoke loop run --explore .`);
|
|
338
|
+
return 3;
|
|
339
|
+
}
|
|
340
|
+
if (limitReached())
|
|
341
|
+
return stopForTimeLimit();
|
|
342
|
+
const beforeStatus = readStatus(targetDir);
|
|
343
|
+
const priorProgress = currentProgress(targetDir);
|
|
344
|
+
const remaining = options.maxIterations === undefined ? undefined : Math.max(0, options.maxIterations - usedIterations);
|
|
345
|
+
if (remaining === 0) {
|
|
346
|
+
reporter.capReached(currentProgress(targetDir));
|
|
347
|
+
say(`Exploration reached --max=${options.maxIterations}. Next: yoke loop run --explore .`);
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
let resultCode;
|
|
351
|
+
let unexpectedFailure;
|
|
352
|
+
const batchSize = limitDeadline === undefined ? undefined : safeBatchSize();
|
|
353
|
+
const batchLimit = batchSize === undefined ? remaining : Math.min(remaining ?? batchSize, batchSize);
|
|
354
|
+
const cancelTimePause = scheduleExploreLimitPause(targetDir, limitDeadline);
|
|
355
|
+
try {
|
|
356
|
+
resultCode = await Promise.resolve(runLoopCommand(targetDir, {
|
|
357
|
+
...innerOptions,
|
|
358
|
+
agent: implementationAgent,
|
|
359
|
+
...(reviewRequested && reviewerAgent ? { reviewer: reviewerAgent } : {}),
|
|
360
|
+
...(batchLimit !== undefined ? { maxIterations: batchLimit } : {}),
|
|
361
|
+
...(retryWorktree ? { resumeWorktree: true, parallel: 1, candidates: 1 } : {}),
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
resultCode = 1;
|
|
366
|
+
unexpectedFailure = error instanceof Error ? error.message : String(error);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
cancelTimePause();
|
|
370
|
+
}
|
|
371
|
+
const afterStatus = readStatus(targetDir);
|
|
372
|
+
const changedStatus = afterStatus?.updatedAt !== beforeStatus?.updatedAt;
|
|
373
|
+
if (changedStatus && priorProgress.passed < priorProgress.total)
|
|
374
|
+
usedIterations += Math.max(0, afterStatus?.iteration ?? 0);
|
|
375
|
+
if (unexpectedFailure) {
|
|
376
|
+
retryCount++;
|
|
377
|
+
advanceRecoveryProviders();
|
|
378
|
+
const wait = retryDelay(retryCount);
|
|
379
|
+
const reason = `the loop stopped unexpectedly: ${unexpectedFailure}; retrying with ${implementationAgent} in ${Math.ceil(wait / 1_000)} seconds`;
|
|
380
|
+
reporter.phase('waiting-recovery', reason, currentProgress(targetDir));
|
|
381
|
+
say(`The loop stopped unexpectedly. Next: retry with ${implementationAgent} in ${Math.ceil(wait / 1_000)} seconds or pause with yoke loop pause .`);
|
|
382
|
+
if (await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-recovery', reason, currentProgress(targetDir)), limitDeadline)) {
|
|
383
|
+
reporter.paused(currentProgress(targetDir));
|
|
384
|
+
return 3;
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (resultCode === 3) {
|
|
389
|
+
if (limitReached())
|
|
390
|
+
return stopForTimeLimit();
|
|
391
|
+
say('Exploration paused. Next: yoke loop run --explore .');
|
|
392
|
+
return 3;
|
|
393
|
+
}
|
|
394
|
+
if (limitReached())
|
|
395
|
+
return stopForTimeLimit();
|
|
396
|
+
if (options.maxIterations !== undefined && usedIterations >= options.maxIterations) {
|
|
397
|
+
if (resultCode === 0) {
|
|
398
|
+
reporter.complete(currentProgress(targetDir));
|
|
399
|
+
say(`Current backlog complete; --max=${options.maxIterations} stopped further exploration. Next: yoke loop run --explore .`);
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
reporter.capReached(currentProgress(targetDir));
|
|
403
|
+
say(`Exploration reached --max=${options.maxIterations} with work remaining. Next: yoke loop run --explore .`);
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
if (resultCode === 0) {
|
|
407
|
+
retryCount = 0;
|
|
408
|
+
retryWorktree = false;
|
|
409
|
+
const availableProviders = retryProviders(targetDir, defaultImplementationAgent, options.isAvailable ?? isAgentAvailable);
|
|
410
|
+
implementationAgent = availableProviders.includes(defaultImplementationAgent) ? defaultImplementationAgent : availableProviders[0] ?? defaultImplementationAgent;
|
|
411
|
+
reporter.phase('exploring', 'the current accepted backlog is complete; scanning for the next verified improvement', currentProgress(targetDir));
|
|
412
|
+
const scan = await scanForWork();
|
|
413
|
+
if (scan === 'paused')
|
|
414
|
+
return 3;
|
|
415
|
+
if (scan === 'limit')
|
|
416
|
+
return stopForTimeLimit();
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (afterStatus?.state === 'cap-reached') {
|
|
420
|
+
if (limitDeadline !== undefined) {
|
|
421
|
+
retryCount = 0;
|
|
422
|
+
retryWorktree = false;
|
|
423
|
+
reporter.phase('exploring', 'the current safe task batch is complete; continuing the accepted backlog', currentProgress(targetDir));
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
say(`The current backlog is incomplete at the requested iteration cap. Next: yoke loop run --explore .`);
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
let pendingDecision = false;
|
|
430
|
+
try {
|
|
431
|
+
pendingDecision = Boolean(readPendingDecision(targetDir));
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
pendingDecision = true;
|
|
435
|
+
}
|
|
436
|
+
if (pendingDecision || afterStatus?.reason?.includes('ambiguous acceptance criteria')) {
|
|
437
|
+
retryWorktree = false;
|
|
438
|
+
const wait = Math.min(intervalMinutes * 60_000, 60_000);
|
|
439
|
+
reporter.phase('waiting-recovery', `story ${afterStatus?.story ?? 'unknown'} needs a human decision before it can continue`, currentProgress(targetDir));
|
|
440
|
+
say(`Story ${afterStatus?.story ?? 'unknown'} is waiting for a human decision. Next: answer it or run yoke loop pause .`);
|
|
441
|
+
if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
|
|
442
|
+
reporter.paused(currentProgress(targetDir));
|
|
443
|
+
return 3;
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (afterStatus?.reason?.startsWith('integrated completion gate failed') && allCurrentStoriesPass(targetDir)) {
|
|
448
|
+
retryWorktree = false;
|
|
449
|
+
reporter.phase('exploring', `all planned tasks pass, but ${afterStatus.reason}; looking for work that can resolve the completion gate`, currentProgress(targetDir));
|
|
450
|
+
const scan = await scanForWork(`The integrated completion gate is still failing: ${afterStatus.reason}`);
|
|
451
|
+
if (scan === 'paused')
|
|
452
|
+
return 3;
|
|
453
|
+
if (scan === 'limit')
|
|
454
|
+
return stopForTimeLimit();
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (resultCode === 2) {
|
|
458
|
+
retryCount++;
|
|
459
|
+
advanceRecoveryProviders();
|
|
460
|
+
const wait = retryDelay(retryCount);
|
|
461
|
+
reporter.phase('waiting-recovery', `the loop could not start; retrying with ${implementationAgent}${reviewerAgent ? ` and reviewer ${reviewerAgent}` : ''} in ${Math.ceil(wait / 1_000)} seconds`, currentProgress(targetDir));
|
|
462
|
+
say(`The unfinished loop could not start. Next: retry with ${implementationAgent}${reviewerAgent ? ` and reviewer ${reviewerAgent}` : ''} in ${Math.ceil(wait / 1_000)} seconds or pause with yoke loop pause .`);
|
|
463
|
+
if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
|
|
464
|
+
reporter.paused(currentProgress(targetDir));
|
|
465
|
+
return 3;
|
|
466
|
+
}
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
retryCount++;
|
|
470
|
+
advanceRecoveryProviders();
|
|
471
|
+
retryWorktree = true;
|
|
472
|
+
const wait = retryDelay(retryCount);
|
|
473
|
+
reporter.phase('waiting-recovery', `story ${afterStatus?.story ?? 'unknown'} remains incomplete: ${afterStatus?.reason ?? 'no passing completion status'}; retrying with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds`, currentProgress(targetDir));
|
|
474
|
+
say(`The current task remains unfinished; its isolated work is retained. Next: retry with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds.`);
|
|
475
|
+
if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
|
|
476
|
+
reporter.paused(currentProgress(targetDir));
|
|
477
|
+
return 3;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
105
481
|
export function runLoopCommand(targetDir, opts) {
|
|
482
|
+
if (opts.explore && !opts.explorationSupervisor)
|
|
483
|
+
return runContinuousExploration(targetDir, opts);
|
|
106
484
|
let parallel = opts.parallel ?? 1;
|
|
107
485
|
const candidates = opts.candidates ?? 1;
|
|
108
486
|
if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
|
|
109
487
|
console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
|
|
110
488
|
return 2;
|
|
111
489
|
}
|
|
112
|
-
if (!Number.isInteger(parallel) || parallel < 1) {
|
|
113
|
-
console.error(
|
|
490
|
+
if (!Number.isInteger(parallel) || parallel < 1 || parallel > MAX_PROJECT_WORKERS) {
|
|
491
|
+
console.error(`--parallel must be an integer from 1 to ${MAX_PROJECT_WORKERS}`);
|
|
114
492
|
return 2;
|
|
115
493
|
}
|
|
116
494
|
if (!Number.isInteger(candidates) || candidates < 1 || candidates > 5) {
|
|
@@ -151,14 +529,32 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
151
529
|
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
152
530
|
return 2;
|
|
153
531
|
}
|
|
532
|
+
let sharedLimit;
|
|
533
|
+
try {
|
|
534
|
+
sharedLimit = sharedPoolStatus().limit;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
console.error(`Cannot read the shared Yoke worker pool: ${error.message}`);
|
|
538
|
+
return 2;
|
|
539
|
+
}
|
|
154
540
|
const parallelSetting = opts.parallelAuto ? 'auto' : opts.parallel ?? config.loop.parallel ?? 'auto';
|
|
155
541
|
if (parallelSetting === 'auto') {
|
|
156
|
-
const
|
|
157
|
-
|
|
542
|
+
const stories = loadPrd(path);
|
|
543
|
+
const pending = stories.filter(story => !story.passes);
|
|
544
|
+
if (!opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length)) {
|
|
545
|
+
parallel = Math.min(sharedLimit, pending.length);
|
|
546
|
+
}
|
|
547
|
+
else
|
|
548
|
+
parallel = 1;
|
|
158
549
|
}
|
|
159
550
|
else
|
|
160
551
|
parallel = parallelSetting;
|
|
552
|
+
if (candidates > sharedLimit) {
|
|
553
|
+
console.error(`--candidates=${candidates} exceeds the shared worker-pool limit (${sharedLimit}); raise YOKE_MAX_PARALLEL_WORKERS (maximum ${MAX_PROJECT_WORKERS}) or lower --candidates`);
|
|
554
|
+
return 2;
|
|
555
|
+
}
|
|
161
556
|
const isolate = opts.isolate ?? config.loop.isolate ?? true;
|
|
557
|
+
const useParallelDispatcher = parallel > 1 || candidates > 1;
|
|
162
558
|
if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
|
|
163
559
|
console.error('--resume-worktree requires isolation and one worker');
|
|
164
560
|
return 2;
|
|
@@ -228,7 +624,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
228
624
|
}
|
|
229
625
|
if (commitIdentity) {
|
|
230
626
|
const announce = opts.json ? console.error : console.log;
|
|
231
|
-
|
|
627
|
+
if (!opts.quiet)
|
|
628
|
+
announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
|
|
232
629
|
}
|
|
233
630
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
234
631
|
const qualityOverrides = {
|
|
@@ -239,7 +636,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
239
636
|
...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
|
|
240
637
|
};
|
|
241
638
|
if (qualityOverrides.qualityUnbounded) {
|
|
242
|
-
|
|
639
|
+
if (!opts.quiet)
|
|
640
|
+
console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
|
|
243
641
|
}
|
|
244
642
|
const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
|
|
245
643
|
const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
|
|
@@ -263,7 +661,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
263
661
|
const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
|
|
264
662
|
const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
|
|
265
663
|
const announce = opts.json ? console.error : console.log;
|
|
266
|
-
|
|
664
|
+
if (!opts.quiet)
|
|
665
|
+
announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
|
|
267
666
|
}
|
|
268
667
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
269
668
|
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
@@ -347,7 +746,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
347
746
|
}));
|
|
348
747
|
let runner = opts.runner;
|
|
349
748
|
if (!runner) {
|
|
350
|
-
const requiredProviders =
|
|
749
|
+
const requiredProviders = useParallelDispatcher
|
|
351
750
|
? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
|
|
352
751
|
: loadPrd(path).filter(story => !story.passes).every(story => config.actions?.some(action => action.storyId === story.id)) ? [] : [runnerAgent];
|
|
353
752
|
const unavailableProvider = requiredProviders.find(agent => !available(agent));
|
|
@@ -388,7 +787,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
388
787
|
})
|
|
389
788
|
: makeRunner(runnerAgent, idleMs, runnerOpts);
|
|
390
789
|
const announce = opts.json ? console.error : console.log;
|
|
391
|
-
|
|
790
|
+
if (!opts.quiet)
|
|
791
|
+
announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : routingRequested ? 'auto (parent; no worker profiles)' : 'off'} · workers: ${parallel} local / ${sharedLimit} shared maximum · isolation: ${isolate || parallel > 1 ? 'on' : 'off'} · cwd: ${targetDir}`);
|
|
392
792
|
}
|
|
393
793
|
if (config.actions?.length) {
|
|
394
794
|
if (parallel > 1 || candidates > 1) {
|
|
@@ -441,6 +841,17 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
441
841
|
}
|
|
442
842
|
};
|
|
443
843
|
}
|
|
844
|
+
if (!useParallelDispatcher) {
|
|
845
|
+
if (runner) {
|
|
846
|
+
const unpooled = runner;
|
|
847
|
+
runner = context => withSharedWorkerSync({ targetDir, storyId: context.story.id, provider: runnerAgent, role: 'implementation', onWait: waitMs => executionReporter?.resourceWait?.({ id: context.story.id, title: context.story.title }, 'implementation', 1, waitMs, runnerAgent) }, () => unpooled(context));
|
|
848
|
+
}
|
|
849
|
+
if (review) {
|
|
850
|
+
const unpooledReview = review;
|
|
851
|
+
const provider = SUPPORTED_AGENTS.includes(reviewProvider) ? reviewProvider : runnerAgent;
|
|
852
|
+
review = context => withSharedWorkerSync({ targetDir, storyId: context.story.id, provider, role: 'integration', onWait: waitMs => executionReporter?.resourceWait?.({ id: context.story.id, title: context.story.title }, 'integration', 1, waitMs, provider) }, () => unpooledReview(context));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
444
855
|
let lock;
|
|
445
856
|
try {
|
|
446
857
|
lock = acquireLock(targetDir);
|
|
@@ -456,7 +867,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
456
867
|
if (lock.stalePid !== undefined) {
|
|
457
868
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
458
869
|
}
|
|
459
|
-
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
870
|
+
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
|
|
460
871
|
executionReporter = reporter;
|
|
461
872
|
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
462
873
|
storyId,
|
|
@@ -496,7 +907,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
496
907
|
return 1;
|
|
497
908
|
}
|
|
498
909
|
};
|
|
499
|
-
if (
|
|
910
|
+
if (useParallelDispatcher) {
|
|
500
911
|
return runParallelLoopCommand({
|
|
501
912
|
targetDir,
|
|
502
913
|
prdPath: path,
|
|
@@ -557,11 +968,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
557
968
|
return resumeCode;
|
|
558
969
|
// In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
|
|
559
970
|
const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
971
|
+
if (!opts.quiet) {
|
|
972
|
+
say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
|
|
973
|
+
if (result.reason)
|
|
974
|
+
say(`Reason: ${result.reason}`);
|
|
975
|
+
if (result.reason && /api key|please run \/login|not logged in|auth/i.test(result.reason)) {
|
|
976
|
+
say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY, or log the agent in for headless use.');
|
|
977
|
+
}
|
|
565
978
|
}
|
|
566
979
|
// Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
|
|
567
980
|
if (result.status === 'complete')
|