@hecer/yoke 1.17.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 +15 -0
- package/README.md +19 -6
- package/canon/loop/loop-spec.md +20 -3
- package/canon/manifest.yaml +1 -1
- package/dist/cli.js +72 -3
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +13 -1
- package/dist/loop/prd.js +11 -0
- package/dist/loop/reporter.js +8 -6
- package/dist/loop/run-command.js +388 -15
- package/dist/prd/explore.js +441 -0
- package/docs/CONTINUOUS-EXPLORATION.md +67 -0
- package/docs/parallel-execution.md +12 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -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';
|
|
@@ -26,6 +26,7 @@ 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
28
|
import { MAX_PROJECT_WORKERS, sharedPoolStatus, withSharedWorkerSync } from './resource-pool.js';
|
|
29
|
+
import { runPrdExplore } from '../prd/explore.js';
|
|
29
30
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
30
31
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
31
32
|
export function relativeTime(fromIso, now) {
|
|
@@ -103,8 +104,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
103
104
|
const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
|
|
104
105
|
lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
|
|
105
106
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
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) {
|
|
108
114
|
lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
|
|
109
115
|
}
|
|
110
116
|
return lines.join('\n');
|
|
@@ -113,7 +119,368 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
113
119
|
const minutes = flagMinutes ?? configMinutes ?? DEFAULT_IDLE_MINUTES;
|
|
114
120
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
115
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
|
+
}
|
|
116
481
|
export function runLoopCommand(targetDir, opts) {
|
|
482
|
+
if (opts.explore && !opts.explorationSupervisor)
|
|
483
|
+
return runContinuousExploration(targetDir, opts);
|
|
117
484
|
let parallel = opts.parallel ?? 1;
|
|
118
485
|
const candidates = opts.candidates ?? 1;
|
|
119
486
|
if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
|
|
@@ -257,7 +624,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
257
624
|
}
|
|
258
625
|
if (commitIdentity) {
|
|
259
626
|
const announce = opts.json ? console.error : console.log;
|
|
260
|
-
|
|
627
|
+
if (!opts.quiet)
|
|
628
|
+
announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
|
|
261
629
|
}
|
|
262
630
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
263
631
|
const qualityOverrides = {
|
|
@@ -268,7 +636,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
268
636
|
...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
|
|
269
637
|
};
|
|
270
638
|
if (qualityOverrides.qualityUnbounded) {
|
|
271
|
-
|
|
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.');
|
|
272
641
|
}
|
|
273
642
|
const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
|
|
274
643
|
const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
|
|
@@ -292,7 +661,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
292
661
|
const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
|
|
293
662
|
const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
|
|
294
663
|
const announce = opts.json ? console.error : console.log;
|
|
295
|
-
|
|
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}`);
|
|
296
666
|
}
|
|
297
667
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
298
668
|
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
@@ -417,7 +787,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
417
787
|
})
|
|
418
788
|
: makeRunner(runnerAgent, idleMs, runnerOpts);
|
|
419
789
|
const announce = opts.json ? console.error : console.log;
|
|
420
|
-
|
|
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}`);
|
|
421
792
|
}
|
|
422
793
|
if (config.actions?.length) {
|
|
423
794
|
if (parallel > 1 || candidates > 1) {
|
|
@@ -496,7 +867,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
496
867
|
if (lock.stalePid !== undefined) {
|
|
497
868
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
498
869
|
}
|
|
499
|
-
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
870
|
+
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
|
|
500
871
|
executionReporter = reporter;
|
|
501
872
|
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
502
873
|
storyId,
|
|
@@ -597,11 +968,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
597
968
|
return resumeCode;
|
|
598
969
|
// In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
|
|
599
970
|
const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
+
}
|
|
605
978
|
}
|
|
606
979
|
// Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
|
|
607
980
|
if (result.status === 'complete')
|