@hecer/yoke 1.18.0 → 1.20.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 +46 -0
- package/README.md +86 -910
- package/bench/RESULTS.md +30 -0
- package/bench/analyze-codex-comparison.mjs +22 -0
- package/bench/compare-codex.mjs +71 -0
- package/bench/fixtures/independent-utils/.yoke/prd.yaml +30 -0
- package/bench/fixtures/independent-utils/bench-verify.mjs +4 -0
- package/bench/fixtures/independent-utils/package.json +1 -0
- package/bench/fixtures/independent-utils/src/chunk.mjs +1 -0
- package/bench/fixtures/independent-utils/src/range.mjs +1 -0
- package/bench/fixtures/independent-utils/src/unique.mjs +1 -0
- package/bench/fixtures/independent-utils/tests/STORY-1.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-2.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-3.test.mjs +8 -0
- package/bench/probe-goal-integration.mjs +93 -0
- package/bench/probe-parallel-gate-context.mjs +29 -0
- package/bench/results/codex-comparison-2026-09-29.json +305 -0
- package/bench/results/goal-integration-probes-2026-09-29.json +107 -0
- package/bench/results/native-goal-validation-2026-09-30.json +122 -0
- package/bench/results/parallel-validation-2026-09-30.json +86 -0
- package/canon/manifest.yaml +1 -1
- package/dist/agents/process-record-identity.js +33 -0
- package/dist/agents/process.js +42 -11
- package/dist/agents/providers.js +11 -2
- package/dist/agents/sol-pi-runtime.js +76 -0
- package/dist/check/command.js +218 -0
- package/dist/cli.js +11 -7
- package/dist/code-intelligence/adapters/graphify.js +1 -1
- package/dist/code-intelligence/adapters/mcp.js +25 -4
- package/dist/code-intelligence/adapters/serena.js +1 -1
- package/dist/code-intelligence/coordinator.js +2 -1
- package/dist/code-intelligence/evidence.js +12 -1
- package/dist/code-intelligence/mcp-client.js +2 -2
- package/dist/dashboard/page.js +1 -1
- package/dist/dashboard/panels.js +25 -3
- package/dist/dashboard/server.js +88 -7
- package/dist/goals/codex-native.js +340 -0
- package/dist/goals/command.js +230 -29
- package/dist/loop/cleanup.js +10 -1
- package/dist/loop/dispatcher.js +82 -10
- package/dist/loop/git.js +1 -1
- package/dist/loop/parallel-adapters.js +53 -7
- package/dist/loop/parallel-command.js +8 -1
- package/dist/loop/recovery.js +81 -2
- package/dist/loop/reporter.js +3 -0
- package/dist/loop/resource-pool.js +21 -15
- package/dist/loop/run-command.js +91 -25
- package/dist/loop/run-state.js +116 -0
- package/dist/prd/explore.js +12 -4
- package/dist/retrofit/config.js +29 -0
- package/dist/retrofit/gitignore.js +3 -0
- package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
- package/docs/CONTINUOUS-EXPLORATION.md +6 -2
- package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
- package/docs/GOALS.md +109 -0
- package/docs/HARNESSES.md +1 -1
- package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
- package/docs/SOL-PI.md +41 -0
- package/docs/VERIFIED-PROJECTS.md +4 -4
- package/docs/code-intelligence-stdio-fix-2026-09-27.md +8 -0
- package/docs/parallel-execution.md +15 -0
- package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
- package/gemini-extension.json +1 -1
- package/package.json +5 -1
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
|
|
5
6
|
import { realGitOps, stageImplementation } from './git.js';
|
|
6
7
|
import { isPidAlive } from './lock.js';
|
|
7
8
|
import { storyPathSegment } from './prd.js';
|
|
8
9
|
import { killProcessTreeForCleanup } from './watchdog.js';
|
|
10
|
+
import { parallelAcceptanceDigest, recoverParallelWorktree, retainParallelWorktree } from './recovery.js';
|
|
11
|
+
import { statePath } from '../workspace/state.js';
|
|
9
12
|
export function makeParallelAdapters(targetDir, identity, injectedGit) {
|
|
10
13
|
return injectedGit
|
|
11
14
|
? injectedAdapters(targetDir, identity, injectedGit)
|
|
@@ -37,18 +40,40 @@ function injectedAdapters(targetDir, identity, git) {
|
|
|
37
40
|
function productionAdapters(targetDir, identity) {
|
|
38
41
|
const owned = new Set();
|
|
39
42
|
const removed = new Set();
|
|
43
|
+
const acceptanceDigests = new Map();
|
|
44
|
+
const ownershipTokens = new Map();
|
|
40
45
|
return {
|
|
41
46
|
worktrees: {
|
|
42
47
|
create: input => {
|
|
48
|
+
acceptanceDigests.set(input.story.id, parallelAcceptanceDigest(targetDir));
|
|
43
49
|
const path = worktreePath(targetDir, input);
|
|
50
|
+
const recovered = recoverParallelWorktree(targetDir, recoveryPath(targetDir, input.story.id), input.story.id);
|
|
51
|
+
if (recovered) {
|
|
52
|
+
owned.add(recovered.path);
|
|
53
|
+
ownershipTokens.set(recovered.path, recovered.ownerToken);
|
|
54
|
+
return recovered;
|
|
55
|
+
}
|
|
44
56
|
const baseCommit = gitText(targetDir, ['rev-parse', 'HEAD']);
|
|
57
|
+
preflightWorktreePath(targetDir, path);
|
|
45
58
|
mkdirSync(dirname(path), { recursive: true });
|
|
46
59
|
realGitOps.addWorktree(targetDir, path);
|
|
47
60
|
owned.add(path);
|
|
61
|
+
ownershipTokens.set(path, input.ownerToken);
|
|
48
62
|
return { path, baseCommit };
|
|
49
63
|
},
|
|
50
64
|
cleanupProcess: cleanupProviderProcesses,
|
|
51
|
-
|
|
65
|
+
retain: (input, reason) => {
|
|
66
|
+
const file = recoveryPath(targetDir, input.story.id);
|
|
67
|
+
// Preserve the path's original ownership identity when a retry is rejected again.
|
|
68
|
+
const ownerToken = ownershipTokens.get(input.worktree.path) ?? input.ownerToken;
|
|
69
|
+
retainParallelWorktree(targetDir, file, { storyId: input.story.id, worktree: input.worktree.path, baseCommit: input.worktree.baseCommit, prdHash: acceptanceDigests.get(input.story.id), ownerToken, reason });
|
|
70
|
+
},
|
|
71
|
+
remove: input => {
|
|
72
|
+
removeOwnedWorktree(targetDir, input, owned, removed, realGitOps);
|
|
73
|
+
const file = recoveryPath(targetDir, input.story.id);
|
|
74
|
+
if (input.worktree.recovered && existsSync(file))
|
|
75
|
+
unlinkSync(file);
|
|
76
|
+
},
|
|
52
77
|
},
|
|
53
78
|
git: {
|
|
54
79
|
isClean: dir => realGitOps.isClean(dir),
|
|
@@ -56,23 +81,27 @@ function productionAdapters(targetDir, identity) {
|
|
|
56
81
|
commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
|
|
57
82
|
integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
|
|
58
83
|
},
|
|
59
|
-
candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps),
|
|
84
|
+
candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens),
|
|
60
85
|
};
|
|
61
86
|
}
|
|
62
87
|
function cleanupProviderProcesses(input) {
|
|
63
88
|
reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
|
|
64
89
|
}
|
|
65
|
-
function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
|
|
90
|
+
function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens) {
|
|
66
91
|
return {
|
|
67
92
|
reserve: input => {
|
|
68
93
|
const worktree = input.candidateId === 'candidate-1'
|
|
69
94
|
? primary.worktree
|
|
70
95
|
: { path: candidateWorktreePath(targetDir, input), baseCommit: primary.worktree.baseCommit };
|
|
96
|
+
if (input.candidateId !== 'candidate-1')
|
|
97
|
+
ownershipTokens?.set(worktree.path, input.ownerToken);
|
|
71
98
|
writeCandidateStatus(targetDir, input, worktree.path, 'reserved');
|
|
72
99
|
return worktree;
|
|
73
100
|
},
|
|
74
101
|
materialize: ownership => {
|
|
75
102
|
if (ownership.worktree.path !== primary.worktree.path) {
|
|
103
|
+
if (git === realGitOps)
|
|
104
|
+
preflightWorktreePath(targetDir, ownership.worktree.path);
|
|
76
105
|
mkdirSync(dirname(ownership.worktree.path), { recursive: true });
|
|
77
106
|
git.addWorktree(targetDir, ownership.worktree.path);
|
|
78
107
|
owned.add(ownership.worktree.path);
|
|
@@ -92,7 +121,7 @@ function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
|
|
|
92
121
|
};
|
|
93
122
|
}
|
|
94
123
|
function worktreePath(targetDir, input) {
|
|
95
|
-
return
|
|
124
|
+
return shortWorktreePath(targetDir, input.story.id, input.ownerToken);
|
|
96
125
|
}
|
|
97
126
|
function removeOwnedWorktree(targetDir, input, owned, removed, git) {
|
|
98
127
|
if (removed.has(input.worktree.path))
|
|
@@ -105,7 +134,24 @@ function removeOwnedWorktree(targetDir, input, owned, removed, git) {
|
|
|
105
134
|
removed.add(input.worktree.path);
|
|
106
135
|
}
|
|
107
136
|
function candidateWorktreePath(targetDir, input) {
|
|
108
|
-
return
|
|
137
|
+
return shortWorktreePath(targetDir, input.storyId, input.ownerToken);
|
|
138
|
+
}
|
|
139
|
+
function shortWorktreePath(targetDir, storyId, ownerToken) {
|
|
140
|
+
// 96 bits derived from the complete ownership identity; Git refuses an existing path.
|
|
141
|
+
const name = createHash('sha256').update(JSON.stringify([storyId, ownerToken])).digest('hex').slice(0, 24);
|
|
142
|
+
return join(targetDir, '.yoke', 'worktrees', name);
|
|
143
|
+
}
|
|
144
|
+
function recoveryPath(targetDir, storyId) {
|
|
145
|
+
return statePath(targetDir, 'integration-recovery', `${storyPathSegment(storyId)}.json`);
|
|
146
|
+
}
|
|
147
|
+
function preflightWorktreePath(targetDir, path) {
|
|
148
|
+
if (process.platform !== 'win32')
|
|
149
|
+
return;
|
|
150
|
+
const common = resolve(targetDir, gitText(targetDir, ['rev-parse', '--git-common-dir']));
|
|
151
|
+
const administrativePath = join(common, 'worktrees', basename(path));
|
|
152
|
+
if ([resolve(path, '.git'), administrativePath].some(value => value.length >= 240)) {
|
|
153
|
+
throw new Error(`Parallel worktree path exceeds the Windows Git path budget: ${path}. Move the project to a shorter root and retry; implementation has not started.`);
|
|
154
|
+
}
|
|
109
155
|
}
|
|
110
156
|
function writeCandidateStatus(targetDir, input, worktree, state, reason) {
|
|
111
157
|
const file = join(targetDir, '.yoke', 'proof', storyPathSegment(input.storyId), 'candidates', input.candidateId, 'status.json');
|
|
@@ -50,7 +50,13 @@ export async function runParallelLoopCommand(input) {
|
|
|
50
50
|
units: request.units,
|
|
51
51
|
signal: request.signal,
|
|
52
52
|
});
|
|
53
|
-
|
|
53
|
+
try {
|
|
54
|
+
input.reporter.resourceWait?.({ id: request.story.id, title: request.story.title }, request.role, request.units, Date.now() - started, request.provider.provider);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
await lease.release();
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
54
60
|
return lease;
|
|
55
61
|
},
|
|
56
62
|
resourceStatus: () => sharedPoolStatus(),
|
|
@@ -71,6 +77,7 @@ export async function runParallelLoopCommand(input) {
|
|
|
71
77
|
pause,
|
|
72
78
|
onProgress: status => input.reporter.parallel?.(status),
|
|
73
79
|
onAccepted: story => input.reporter.accepted?.(story),
|
|
80
|
+
onReopened: (story, evidence) => input.reporter.reopened?.(story, evidence),
|
|
74
81
|
gates: {
|
|
75
82
|
verify: input.verify,
|
|
76
83
|
design: input.design,
|
package/dist/loop/recovery.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync as filesystemRealpathSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync as filesystemRealpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, isAbsolute } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { z } from 'zod';
|
|
7
|
+
import { statePath } from '../workspace/state.js';
|
|
8
|
+
import { loadPrd } from './prd.js';
|
|
6
9
|
const Recovery = z.object({ version: z.literal(1), root: z.string(), worktree: z.string(), base: z.string(), prdHash: z.string() }).strict();
|
|
7
10
|
const digest = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
8
11
|
const pathIdentity = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
|
|
@@ -49,3 +52,79 @@ export function prepareIsolatedWorktree(directory, worktree, resume) {
|
|
|
49
52
|
// Record is outside the worker checkout and binds reuse to its source state.
|
|
50
53
|
writeFileSync(record, JSON.stringify({ version: 1, root, worktree: wt, base, prdHash }), { mode: 0o600 });
|
|
51
54
|
}
|
|
55
|
+
const ParallelRecovery = z.object({
|
|
56
|
+
version: z.literal(1), root: z.string().max(4096), storyId: z.string().max(1024), worktree: z.string().max(4096), baseCommit: z.string().max(128),
|
|
57
|
+
prdHash: z.string().length(64), ownerToken: z.string().min(1).max(256), reason: z.string().max(16384), state: z.literal('retained'), recordedAt: z.string().datetime(),
|
|
58
|
+
}).strict();
|
|
59
|
+
export function parallelAcceptanceDigest(directory) {
|
|
60
|
+
// Accepted sibling stories change only passes. Their acceptance contracts remain protected.
|
|
61
|
+
const contract = loadPrd(join(directory, '.yoke', 'prd.yaml')).map(({ passes: _passes, ...story }) => story);
|
|
62
|
+
return createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
function parallelRecordPath(directory, file) {
|
|
65
|
+
const safe = statePath(directory, 'integration-recovery', basename(file));
|
|
66
|
+
if (pathIdentity(resolve(file)) !== pathIdentity(resolve(safe)))
|
|
67
|
+
throw new Error('Invalid parallel recovery record path');
|
|
68
|
+
return safe;
|
|
69
|
+
}
|
|
70
|
+
export function discardParallelRecoveryRecords(directory) {
|
|
71
|
+
const parent = statePath(directory, 'integration-recovery');
|
|
72
|
+
if (!existsSync(parent))
|
|
73
|
+
return;
|
|
74
|
+
for (const name of readdirSync(parent)) {
|
|
75
|
+
if (!name.endsWith('.json') && !name.endsWith('.tmp'))
|
|
76
|
+
continue;
|
|
77
|
+
const file = statePath(directory, 'integration-recovery', name);
|
|
78
|
+
if (!lstatSync(file).isFile())
|
|
79
|
+
throw new Error('Parallel recovery record is not a file');
|
|
80
|
+
rmSync(file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Records are outside the candidate and bind reuse to unchanged target acceptance. */
|
|
84
|
+
export function retainParallelWorktree(directory, file, input) {
|
|
85
|
+
const root = realpathSync(directory);
|
|
86
|
+
const worktree = realpathSync(input.worktree);
|
|
87
|
+
const record = ParallelRecovery.parse({ version: 1, root, ...input, reason: input.reason.slice(0, 16384), worktree, state: 'retained', recordedAt: new Date().toISOString() });
|
|
88
|
+
const safe = parallelRecordPath(directory, file);
|
|
89
|
+
mkdirSync(dirname(safe), { recursive: true });
|
|
90
|
+
const temp = statePath(directory, 'integration-recovery', `${randomUUID()}.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(temp, JSON.stringify(record), { flag: 'wx', mode: 0o600 });
|
|
93
|
+
parallelRecordPath(directory, file);
|
|
94
|
+
renameSync(temp, safe);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
rmSync(temp, { force: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function recoverParallelWorktree(directory, file, storyId) {
|
|
101
|
+
const safe = parallelRecordPath(directory, file);
|
|
102
|
+
if (!existsSync(safe))
|
|
103
|
+
return undefined;
|
|
104
|
+
const stat = lstatSync(safe);
|
|
105
|
+
if (!stat.isFile())
|
|
106
|
+
throw new Error('Parallel recovery record is not a file');
|
|
107
|
+
if (stat.size > 65536)
|
|
108
|
+
throw new Error('Parallel recovery record is too large');
|
|
109
|
+
const saved = ParallelRecovery.parse(JSON.parse(readFileSync(safe, 'utf8')));
|
|
110
|
+
if (!existsSync(saved.worktree))
|
|
111
|
+
throw new Error(`Retained candidate is missing: ${saved.worktree}; resolve its recovery record before retrying`);
|
|
112
|
+
const root = realpathSync(directory);
|
|
113
|
+
const actual = realpathSync(saved.worktree);
|
|
114
|
+
const parent = realpathSync(join(root, '.yoke', 'worktrees'));
|
|
115
|
+
const rel = relative(parent, actual);
|
|
116
|
+
const expectedName = createHash('sha256').update(JSON.stringify([storyId, saved.ownerToken])).digest('hex').slice(0, 24);
|
|
117
|
+
if (saved.storyId !== storyId || pathIdentity(saved.root) !== pathIdentity(root) || pathIdentity(actual) !== pathIdentity(saved.worktree) || isAbsolute(rel) || rel !== expectedName)
|
|
118
|
+
throw new Error('Retained candidate ownership or path binding is invalid');
|
|
119
|
+
const git = (args, cwd = root) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
120
|
+
if (saved.baseCommit !== git(['rev-parse', 'HEAD']) || saved.prdHash !== parallelAcceptanceDigest(root))
|
|
121
|
+
throw new Error(`Retained candidate is stale against target or PRD: ${actual}; reconcile it before retrying`);
|
|
122
|
+
const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'])));
|
|
123
|
+
if (pathIdentity(realpathSync(resolve(actual, git(['rev-parse', '--git-common-dir'], actual)))) !== pathIdentity(common))
|
|
124
|
+
throw new Error('Retained candidate belongs to another repository');
|
|
125
|
+
const registered = git(['worktree', 'list', '--porcelain']).split(/\r?\n/u).filter(line => line.startsWith('worktree ')).map(line => realpathSync(resolve(line.slice(9))));
|
|
126
|
+
if (!registered.some(path => pathIdentity(path) === pathIdentity(actual)))
|
|
127
|
+
throw new Error('Retained candidate is not a registered worktree');
|
|
128
|
+
git(['merge-base', '--is-ancestor', saved.baseCommit, 'HEAD'], actual);
|
|
129
|
+
return { path: actual, baseCommit: saved.baseCommit, recovered: true, ownerToken: saved.ownerToken };
|
|
130
|
+
}
|
package/dist/loop/reporter.js
CHANGED
|
@@ -303,6 +303,9 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
303
303
|
integrationDuration(story, durationMs, provider) {
|
|
304
304
|
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration', provider, durationMs: Math.max(0, durationMs) });
|
|
305
305
|
},
|
|
306
|
+
reopened(story, evidence) {
|
|
307
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'status', storyId: story.id, data: { status: 'reopened', ...evidence } });
|
|
308
|
+
},
|
|
306
309
|
parallelWorker(status) {
|
|
307
310
|
const base = current;
|
|
308
311
|
const parallel = base?.parallel;
|
|
@@ -51,12 +51,12 @@ export function globalWorkerLimit(environment = process.env) {
|
|
|
51
51
|
cachedGlobalLimit = value;
|
|
52
52
|
return value;
|
|
53
53
|
}
|
|
54
|
-
function poolDir() {
|
|
54
|
+
function poolDir(resource) {
|
|
55
55
|
const base = process.env.LOCALAPPDATA || process.env.XDG_STATE_HOME || join(homedir(), '.yoke');
|
|
56
|
-
return join(resolve(base), 'Yoke', 'parallel-pool');
|
|
56
|
+
return join(resolve(base), 'Yoke', resource === 'check' ? 'check-pool' : 'parallel-pool');
|
|
57
57
|
}
|
|
58
|
-
function ensurePoolDir() {
|
|
59
|
-
const dir = poolDir();
|
|
58
|
+
function ensurePoolDir(resource) {
|
|
59
|
+
const dir = poolDir(resource);
|
|
60
60
|
mkdirSync(dir, { recursive: true });
|
|
61
61
|
const stat = lstatSync(dir);
|
|
62
62
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
@@ -119,8 +119,8 @@ function staleDead(record, now) {
|
|
|
119
119
|
const created = Date.parse(record.createdAt);
|
|
120
120
|
return !Number.isFinite(created) || now - created > STALE_OWNER_MS;
|
|
121
121
|
}
|
|
122
|
-
function withPoolLock(operation) {
|
|
123
|
-
const dir = ensurePoolDir();
|
|
122
|
+
function withPoolLock(operation, resource) {
|
|
123
|
+
const dir = ensurePoolDir(resource);
|
|
124
124
|
const lockFile = join(dir, 'pool');
|
|
125
125
|
return withClaimOperations(lockFile, { now: new Date(), staleMs: STALE_OWNER_MS, isAlive: isPidAlive }, () => readRecords(dir), () => [], records => {
|
|
126
126
|
const now = Date.now();
|
|
@@ -184,7 +184,7 @@ function writeRecord(dir, record) {
|
|
|
184
184
|
}
|
|
185
185
|
export async function acquireSharedWorker(request) {
|
|
186
186
|
const units = request.units ?? 1;
|
|
187
|
-
const limit = globalWorkerLimit();
|
|
187
|
+
const limit = request.resource === 'check' ? globalCheckLimit() : globalWorkerLimit();
|
|
188
188
|
if (!Number.isInteger(units) || units < 1 || units > MAX_PROJECT_WORKERS)
|
|
189
189
|
throw new Error(`Worker reservation units must be an integer from 1 to ${MAX_PROJECT_WORKERS}`);
|
|
190
190
|
if (units > limit)
|
|
@@ -213,7 +213,7 @@ export async function acquireSharedWorker(request) {
|
|
|
213
213
|
while (!published) {
|
|
214
214
|
if (request.signal?.aborted)
|
|
215
215
|
throw new Error(abortReason(request.signal));
|
|
216
|
-
const result = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; });
|
|
216
|
+
const result = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; }, request.resource);
|
|
217
217
|
if (result === true)
|
|
218
218
|
published = true;
|
|
219
219
|
else
|
|
@@ -237,7 +237,7 @@ export async function acquireSharedWorker(request) {
|
|
|
237
237
|
replaceRecord(dir, { ...head, overtakes: head.overtakes + 1 });
|
|
238
238
|
replaceRecord(dir, { ...own, state: 'active' });
|
|
239
239
|
return true;
|
|
240
|
-
});
|
|
240
|
+
}, request.resource);
|
|
241
241
|
if (granted === true) {
|
|
242
242
|
const acquiredAt = new Date().toISOString();
|
|
243
243
|
let released = false;
|
|
@@ -254,7 +254,7 @@ export async function acquireSharedWorker(request) {
|
|
|
254
254
|
return false;
|
|
255
255
|
rmSync(recordPath(dir, id), { force: true });
|
|
256
256
|
return true;
|
|
257
|
-
});
|
|
257
|
+
}, request.resource);
|
|
258
258
|
if (result !== null) {
|
|
259
259
|
released = true;
|
|
260
260
|
return result;
|
|
@@ -274,7 +274,7 @@ export async function acquireSharedWorker(request) {
|
|
|
274
274
|
if (own)
|
|
275
275
|
rmSync(recordPath(dir, id), { force: true });
|
|
276
276
|
return true;
|
|
277
|
-
});
|
|
277
|
+
}, request.resource);
|
|
278
278
|
if (removed !== null)
|
|
279
279
|
break;
|
|
280
280
|
await waitForCapacity();
|
|
@@ -347,10 +347,10 @@ export function withSharedWorkerSync(request, operation) {
|
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
|
-
export function sharedPoolStatus() {
|
|
351
|
-
const limit = globalWorkerLimit();
|
|
352
|
-
const snapshot = withPoolLock(records => records.map(record => ({ ...record })));
|
|
353
|
-
const records = snapshot ?? readRecords(ensurePoolDir()).filter(record => !staleDead(record, Date.now()));
|
|
350
|
+
export function sharedPoolStatus(resource) {
|
|
351
|
+
const limit = resource === 'check' ? globalCheckLimit() : globalWorkerLimit();
|
|
352
|
+
const snapshot = withPoolLock(records => records.map(record => ({ ...record })), resource);
|
|
353
|
+
const records = snapshot ?? readRecords(ensurePoolDir(resource)).filter(record => !staleDead(record, Date.now()));
|
|
354
354
|
const sharedLimit = effectiveLimit(records, limit);
|
|
355
355
|
const active = records.filter(record => record.state === 'active');
|
|
356
356
|
const waiting = records.filter(record => record.state === 'waiting');
|
|
@@ -367,3 +367,9 @@ export function sharedPoolStatus() {
|
|
|
367
367
|
oldestWaitMs: Number.isFinite(oldest) ? Math.max(0, Date.now() - oldest) : 0,
|
|
368
368
|
};
|
|
369
369
|
}
|
|
370
|
+
export function globalCheckLimit(environment = process.env) {
|
|
371
|
+
const raw = environment.YOKE_MAX_PARALLEL_CHECKS ?? '1';
|
|
372
|
+
if (!/^[1-8]$/u.test(raw))
|
|
373
|
+
throw new Error('YOKE_MAX_PARALLEL_CHECKS must be an integer from 1 to 8');
|
|
374
|
+
return Number(raw);
|
|
375
|
+
}
|
package/dist/loop/run-command.js
CHANGED
|
@@ -2,13 +2,15 @@ import { roleSelection } from "../routing/capability.js";
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
|
|
5
|
-
import { allPass, loadPrd, progress, selectNextStory } from './prd.js';
|
|
5
|
+
import { allPass, loadPrd, progress, selectNextStory, storyPathSegment } from './prd.js';
|
|
6
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';
|
|
10
10
|
import { readStatus, makeReporter, fmtDuration } from './reporter.js';
|
|
11
|
-
import { acquireLock, releaseLock } from './lock.js';
|
|
11
|
+
import { acquireLock, releaseLock, readLock } from './lock.js';
|
|
12
|
+
import { statePath } from '../workspace/state.js';
|
|
13
|
+
import { accountRunIterations, createLoopRun, readRunState, writeRunState } from './run-state.js';
|
|
12
14
|
import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
13
15
|
import { resolveCommitIdentity } from './identity.js';
|
|
14
16
|
import { runAudit } from '../audit/command.js';
|
|
@@ -232,7 +234,7 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
232
234
|
console.error('--explore-limit must be a positive, supported duration');
|
|
233
235
|
return 2;
|
|
234
236
|
}
|
|
235
|
-
const limitDeadline = options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs;
|
|
237
|
+
const limitDeadline = options.savedRun?.exploreDeadline ?? (options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs);
|
|
236
238
|
const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
|
|
237
239
|
let config;
|
|
238
240
|
try {
|
|
@@ -242,11 +244,11 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
242
244
|
config = null;
|
|
243
245
|
}
|
|
244
246
|
const defaultImplementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
|
|
245
|
-
const defaultExplorer = options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
|
|
247
|
+
const defaultExplorer = options.explorationPlanner?.agent ?? options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
|
|
246
248
|
const available = options.isAvailable ?? isAgentAvailable;
|
|
247
249
|
const reviewRequested = !options.reviewRunner && Boolean(options.review || options.reviewer);
|
|
248
250
|
const innerOptions = { ...options, isolate: true, explore: true, explorationSupervisor: true, reporter, quiet: true };
|
|
249
|
-
let usedIterations = 0;
|
|
251
|
+
let usedIterations = options.savedRun?.consumedIterations ?? 0;
|
|
250
252
|
let retryCount = 0;
|
|
251
253
|
let explorationFailures = 0;
|
|
252
254
|
let noActionScans = 0;
|
|
@@ -284,6 +286,8 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
284
286
|
return 'limit';
|
|
285
287
|
const explorer = retryProviders(targetDir, explorationAgent, available);
|
|
286
288
|
const result = runPrdExplore(targetDir, {
|
|
289
|
+
ownedLockToken: options.ownedLockToken,
|
|
290
|
+
...(explorationAgent === options.explorationPlanner?.agent ? { selection: options.explorationPlanner.selection } : {}),
|
|
287
291
|
runner: explorationAgent,
|
|
288
292
|
timeoutMinutes: options.timeoutMinutes,
|
|
289
293
|
isAvailable: options.isAvailable ?? isAgentAvailable,
|
|
@@ -370,7 +374,9 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
370
374
|
}
|
|
371
375
|
const afterStatus = readStatus(targetDir);
|
|
372
376
|
const changedStatus = afterStatus?.updatedAt !== beforeStatus?.updatedAt;
|
|
373
|
-
if (
|
|
377
|
+
if (options.savedRun)
|
|
378
|
+
usedIterations = options.savedRun.consumedIterations ?? usedIterations;
|
|
379
|
+
else if (changedStatus && priorProgress.passed < priorProgress.total)
|
|
374
380
|
usedIterations += Math.max(0, afterStatus?.iteration ?? 0);
|
|
375
381
|
if (unexpectedFailure) {
|
|
376
382
|
retryCount++;
|
|
@@ -468,7 +474,7 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
468
474
|
}
|
|
469
475
|
retryCount++;
|
|
470
476
|
advanceRecoveryProviders();
|
|
471
|
-
retryWorktree =
|
|
477
|
+
retryWorktree = (afterStatus?.parallel?.reopened ?? 0) === 0;
|
|
472
478
|
const wait = retryDelay(retryCount);
|
|
473
479
|
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
480
|
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.`);
|
|
@@ -480,7 +486,7 @@ async function runContinuousExploration(targetDir, options) {
|
|
|
480
486
|
}
|
|
481
487
|
export function runLoopCommand(targetDir, opts) {
|
|
482
488
|
if (opts.explore && !opts.explorationSupervisor)
|
|
483
|
-
return
|
|
489
|
+
return startContinuousExploration(targetDir, opts);
|
|
484
490
|
let parallel = opts.parallel ?? 1;
|
|
485
491
|
const candidates = opts.candidates ?? 1;
|
|
486
492
|
if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
|
|
@@ -554,7 +560,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
554
560
|
return 2;
|
|
555
561
|
}
|
|
556
562
|
const isolate = opts.isolate ?? config.loop.isolate ?? true;
|
|
557
|
-
const
|
|
563
|
+
const retainedParallel = opts.explorationSupervisor && loadPrd(path).some(story => !story.passes && existsSync(statePath(targetDir, 'integration-recovery', `${storyPathSegment(story.id)}.json`)));
|
|
564
|
+
const useParallelDispatcher = parallel > 1 || candidates > 1 || retainedParallel === true;
|
|
558
565
|
if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
|
|
559
566
|
console.error('--resume-worktree requires isolation and one worker');
|
|
560
567
|
return 2;
|
|
@@ -668,11 +675,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
668
675
|
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
669
676
|
const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
|
|
670
677
|
const runnerSelection = {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
678
|
+
...(opts.selection ?? {
|
|
679
|
+
provider: config.runner?.provider,
|
|
680
|
+
model: config.runner?.model,
|
|
681
|
+
reasoningEffort: config.runner?.reasoningEffort,
|
|
682
|
+
variant: config.runner?.variant,
|
|
683
|
+
bare: config.runner?.bare,
|
|
684
|
+
}),
|
|
676
685
|
nativeMultiAgent: false,
|
|
677
686
|
};
|
|
678
687
|
const parallelProviders = [{
|
|
@@ -735,13 +744,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
735
744
|
timeoutMs: idleMs,
|
|
736
745
|
isAvailable: available,
|
|
737
746
|
permissions,
|
|
738
|
-
selection:
|
|
739
|
-
provider: config.runner?.provider,
|
|
740
|
-
model: config.runner?.model,
|
|
741
|
-
reasoningEffort: config.runner?.reasoningEffort,
|
|
742
|
-
variant: config.runner?.variant,
|
|
743
|
-
bare: config.runner?.bare,
|
|
744
|
-
},
|
|
747
|
+
selection: runnerSelection,
|
|
745
748
|
commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
|
|
746
749
|
}));
|
|
747
750
|
let runner = opts.runner;
|
|
@@ -854,7 +857,14 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
854
857
|
}
|
|
855
858
|
let lock;
|
|
856
859
|
try {
|
|
857
|
-
|
|
860
|
+
statePath(targetDir, 'loop.lock');
|
|
861
|
+
if (opts.ownedLockToken) {
|
|
862
|
+
if (readLock(targetDir)?.ownerToken !== opts.ownedLockToken)
|
|
863
|
+
throw new Error('Invalid borrowed loop lock');
|
|
864
|
+
lock = { acquired: true, ownerToken: opts.ownedLockToken };
|
|
865
|
+
}
|
|
866
|
+
else
|
|
867
|
+
lock = acquireLock(targetDir);
|
|
858
868
|
}
|
|
859
869
|
catch (error) {
|
|
860
870
|
console.error(`Cannot acquire the Yoke loop lock: ${error.message}`);
|
|
@@ -867,8 +877,22 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
867
877
|
if (lock.stalePid !== undefined) {
|
|
868
878
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
869
879
|
}
|
|
870
|
-
|
|
880
|
+
let reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
|
|
871
881
|
executionReporter = reporter;
|
|
882
|
+
try {
|
|
883
|
+
const saved = opts.savedRun ?? createLoopRun({ ...opts, agent: runnerAgent, selection: runnerSelection, parallel, isolate, routing: routingEnabled, timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes });
|
|
884
|
+
if (opts.savedRun && readRunState(targetDir)?.runId !== opts.savedRun.runId)
|
|
885
|
+
throw new Error('Saved run changed before resume');
|
|
886
|
+
writeRunState(targetDir, saved, lock.ownerToken);
|
|
887
|
+
reporter = accountRunIterations(targetDir, saved, lock.ownerToken, reporter);
|
|
888
|
+
executionReporter = reporter;
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
if (!opts.ownedLockToken)
|
|
892
|
+
releaseLock(targetDir, lock.ownerToken);
|
|
893
|
+
reporter.blocked(`could not persist loop run: ${error.message}`);
|
|
894
|
+
return 1;
|
|
895
|
+
}
|
|
872
896
|
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
873
897
|
storyId,
|
|
874
898
|
requestId,
|
|
@@ -938,7 +962,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
938
962
|
completion,
|
|
939
963
|
quality,
|
|
940
964
|
onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
|
|
941
|
-
}).then(code => reconcileDecisionResume() ?? code).finally(() =>
|
|
965
|
+
}).then(code => reconcileDecisionResume() ?? code).finally(() => { if (!opts.ownedLockToken)
|
|
966
|
+
releaseLock(targetDir, lock.ownerToken); });
|
|
942
967
|
}
|
|
943
968
|
try {
|
|
944
969
|
const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
|
|
@@ -984,6 +1009,47 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
984
1009
|
return 1;
|
|
985
1010
|
}
|
|
986
1011
|
finally {
|
|
987
|
-
|
|
1012
|
+
if (!opts.ownedLockToken)
|
|
1013
|
+
releaseLock(targetDir, lock.ownerToken);
|
|
988
1014
|
}
|
|
989
1015
|
}
|
|
1016
|
+
async function startContinuousExploration(targetDir, options) {
|
|
1017
|
+
let lock;
|
|
1018
|
+
try {
|
|
1019
|
+
if (options.isolate === false)
|
|
1020
|
+
throw new Error('--explore requires isolated story worktrees');
|
|
1021
|
+
if (options.exploreLimitMs !== undefined && (!Number.isSafeInteger(options.exploreLimitMs) || options.exploreLimitMs <= 0 || Date.now() + options.exploreLimitMs > 8_640_000_000_000_000))
|
|
1022
|
+
throw new Error('--explore-limit must be a positive, supported duration');
|
|
1023
|
+
statePath(targetDir, 'loop.lock');
|
|
1024
|
+
lock = acquireLock(targetDir);
|
|
1025
|
+
if (!lock.acquired) {
|
|
1026
|
+
console.error('Another loop is already running here');
|
|
1027
|
+
return 2;
|
|
1028
|
+
}
|
|
1029
|
+
const config = loadConfig(targetDir);
|
|
1030
|
+
const implementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
|
|
1031
|
+
const implementationSelection = options.selection ?? config?.runner ?? {};
|
|
1032
|
+
const explorationPlanner = options.explorationPlanner ?? resolvePlanner(config, implementationAgent, implementationSelection, options.agent);
|
|
1033
|
+
const run = options.savedRun ?? createLoopRun({ ...options, agent: implementationAgent, selection: implementationSelection, explorationPlanner, timeoutMinutes: options.timeoutMinutes ?? config?.loop.timeoutMinutes, ...(options.parallel === undefined && !options.parallelAuto ? typeof config?.loop.parallel === 'number' ? { parallel: config.loop.parallel } : { parallelAuto: true } : {}) });
|
|
1034
|
+
if (options.savedRun && readRunState(targetDir)?.runId !== run.runId)
|
|
1035
|
+
throw new Error('Saved run changed before resume');
|
|
1036
|
+
writeRunState(targetDir, run, lock.ownerToken);
|
|
1037
|
+
return await runContinuousExploration(targetDir, { ...options, selection: run.options.selection, explorationPlanner: run.options.explorationPlanner ?? explorationPlanner, savedRun: run, ownedLockToken: lock.ownerToken });
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
console.error(`Cannot start exploration: ${error.message}`);
|
|
1041
|
+
return 2;
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
if (lock?.acquired)
|
|
1045
|
+
releaseLock(targetDir, lock.ownerToken);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/** Resume the saved execution identity without extending its exploration deadline. */
|
|
1049
|
+
export function resumeLoopCommand(targetDir) {
|
|
1050
|
+
const run = readRunState(targetDir);
|
|
1051
|
+
if (!run || run.mode === 'goal')
|
|
1052
|
+
throw new Error('No saved loop run to resume');
|
|
1053
|
+
const { native: _native, ...options } = run.options;
|
|
1054
|
+
return runLoopCommand(targetDir, { ...options, ...(run.mode === 'loop' && options.maxIterations !== undefined ? { maxIterations: Math.max(0, options.maxIterations - (run.consumedIterations ?? 0)) } : {}), explore: run.mode === 'explore', savedRun: run, permissions: 'safe' });
|
|
1055
|
+
}
|