@hecer/yoke 1.19.0 → 1.21.1
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 +4 -1
- 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/dist/agents/process-record-identity.js +33 -0
- package/dist/check/command.js +218 -0
- package/dist/cli.js +85 -12
- package/dist/dashboard/server.js +26 -6
- package/dist/goals/codex-native.js +340 -0
- package/dist/goals/command.js +230 -29
- package/dist/loop/cleanup.js +110 -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 +98 -27
- package/dist/loop/run-state.js +116 -0
- package/dist/prd/explore.js +12 -4
- package/dist/retrofit/command.js +22 -0
- package/dist/retrofit/config.js +1 -0
- package/dist/retrofit/gitignore.js +3 -0
- package/dist/retrofit/planners/claude.js +4 -4
- package/dist/retrofit/wsl.js +23 -3
- package/dist/setup/command.js +47 -5
- 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/RELEASE-VALIDATION-1.20.0.md +100 -0
- package/docs/VERIFIED-PROJECTS.md +4 -4
- 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
package/dist/loop/dispatcher.js
CHANGED
|
@@ -34,18 +34,32 @@ async function gateResult(gates, path, worker) {
|
|
|
34
34
|
if (criteria.length > 0 && !gates.verifyCriterion)
|
|
35
35
|
return { passed: false, summary: 'criterion verifier is not configured' };
|
|
36
36
|
for (const criterion of criteria) {
|
|
37
|
-
const result = gates.verifyCriterion?.(path, story, criterion);
|
|
37
|
+
const result = withStoryContext(story.id, () => gates.verifyCriterion?.(path, story, criterion));
|
|
38
38
|
if (!result?.passed)
|
|
39
39
|
return { passed: false, summary: result?.summary ?? 'criterion verification failed' };
|
|
40
40
|
}
|
|
41
41
|
for (const gate of [gates.verify, gates.design, gates.perf, gates.audit]) {
|
|
42
42
|
if (!gate)
|
|
43
43
|
continue;
|
|
44
|
-
const result = gate(path, story);
|
|
44
|
+
const result = withStoryContext(story.id, () => gate(path, story));
|
|
45
45
|
if (!result.passed)
|
|
46
46
|
return result;
|
|
47
47
|
}
|
|
48
|
-
return gates.qualityReview?.(path, story, worker) ?? { passed: true, summary: 'integrated gates passed' };
|
|
48
|
+
return withStoryContext(story.id, () => gates.qualityReview?.(path, story, worker)) ?? { passed: true, summary: 'integrated gates passed' };
|
|
49
|
+
}
|
|
50
|
+
// All gate APIs are synchronous: never leave ambient story context set across an await.
|
|
51
|
+
function withStoryContext(storyId, gate) {
|
|
52
|
+
const previous = process.env.YOKE_STORY;
|
|
53
|
+
process.env.YOKE_STORY = storyId;
|
|
54
|
+
try {
|
|
55
|
+
return gate();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (previous === undefined)
|
|
59
|
+
delete process.env.YOKE_STORY;
|
|
60
|
+
else
|
|
61
|
+
process.env.YOKE_STORY = previous;
|
|
62
|
+
}
|
|
49
63
|
}
|
|
50
64
|
export function createDispatcher(options) {
|
|
51
65
|
const dispatcherId = options.dispatcherId ?? randomUUID();
|
|
@@ -125,6 +139,11 @@ export function createDispatcher(options) {
|
|
|
125
139
|
if (claims.release(input) === false)
|
|
126
140
|
throw new Error('claim release failed');
|
|
127
141
|
};
|
|
142
|
+
const cleanupRetained = createWorkerCleanup({
|
|
143
|
+
cleanupProcess: options.worktrees.cleanupProcess,
|
|
144
|
+
removeWorktree: () => undefined,
|
|
145
|
+
releaseClaim: releaseCandidateClaim,
|
|
146
|
+
});
|
|
128
147
|
const persistPass = (input) => {
|
|
129
148
|
if (!options.prdPath)
|
|
130
149
|
return;
|
|
@@ -135,6 +154,8 @@ export function createDispatcher(options) {
|
|
|
135
154
|
};
|
|
136
155
|
const enqueue = (input, result) => {
|
|
137
156
|
let reason = 'integrated verification failed';
|
|
157
|
+
let retained = false;
|
|
158
|
+
let recoveryBaseCommit = input.worktree.baseCommit;
|
|
138
159
|
const integrationQueuedAt = Date.now();
|
|
139
160
|
let integrationStartedAt;
|
|
140
161
|
const integrationController = new AbortController();
|
|
@@ -160,6 +181,7 @@ export function createDispatcher(options) {
|
|
|
160
181
|
reason = rebase.reason;
|
|
161
182
|
throw new Error(reason);
|
|
162
183
|
}
|
|
184
|
+
recoveryBaseCommit = rebase.expectedHead;
|
|
163
185
|
return rebase.expectedHead;
|
|
164
186
|
},
|
|
165
187
|
verify: async () => {
|
|
@@ -169,7 +191,9 @@ export function createDispatcher(options) {
|
|
|
169
191
|
}
|
|
170
192
|
const gate = await gateResult(options.gates, input.worktree.path, input);
|
|
171
193
|
reason = gate.summary;
|
|
172
|
-
|
|
194
|
+
if (!gate.passed)
|
|
195
|
+
throw new Error(gate.summary);
|
|
196
|
+
return true;
|
|
173
197
|
},
|
|
174
198
|
integrate: async (expectedHead) => {
|
|
175
199
|
if (cancellationReason)
|
|
@@ -200,6 +224,15 @@ export function createDispatcher(options) {
|
|
|
200
224
|
return;
|
|
201
225
|
case 'reopened':
|
|
202
226
|
reopened.push(input.story.id);
|
|
227
|
+
reason = merge.reason;
|
|
228
|
+
if (options.worktrees.retain) {
|
|
229
|
+
// Retain first, then report/release. Recovery evidence must precede cleanup.
|
|
230
|
+
retained = true;
|
|
231
|
+
options.worktrees.retain({ ...input, worktree: { ...input.worktree, baseCommit: recoveryBaseCommit } }, reason);
|
|
232
|
+
failed.push(input.story.id);
|
|
233
|
+
failureReasons.set(input.story.id, `${reason}; candidate retained at ${input.worktree.path}`);
|
|
234
|
+
}
|
|
235
|
+
options.onReopened?.(input.story, { reason, worktree: input.worktree.path, baseCommit: recoveryBaseCommit, ownerToken: input.ownerToken });
|
|
203
236
|
result.routing.recordOutcome?.(false);
|
|
204
237
|
return;
|
|
205
238
|
default: {
|
|
@@ -217,7 +250,10 @@ export function createDispatcher(options) {
|
|
|
217
250
|
options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
|
|
218
251
|
}
|
|
219
252
|
finally {
|
|
220
|
-
|
|
253
|
+
if (retained)
|
|
254
|
+
cleanupRetained(input);
|
|
255
|
+
else
|
|
256
|
+
cleanup(input);
|
|
221
257
|
reportProgress();
|
|
222
258
|
}
|
|
223
259
|
});
|
|
@@ -227,12 +263,22 @@ export function createDispatcher(options) {
|
|
|
227
263
|
const launch = (story) => {
|
|
228
264
|
const provider = providerFor(story);
|
|
229
265
|
const ownerToken = ids();
|
|
230
|
-
|
|
266
|
+
let worktree;
|
|
267
|
+
try {
|
|
268
|
+
worktree = options.worktrees.create({ story, dispatcherId, ownerToken, provider });
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
failed.push(story.id);
|
|
272
|
+
failureReasons.set(story.id, `worktree setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
273
|
+
reportProgress();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
231
276
|
const controller = new AbortController();
|
|
232
277
|
const candidateRace = Boolean(options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
|
|
233
278
|
const input = { story, worktree, provider, cancellation: { signal: controller.signal }, dispatcherId, ownerToken, ...(candidateRace ? { candidateRace: true } : {}) };
|
|
234
279
|
if (!claims.acquire(input)) {
|
|
235
|
-
|
|
280
|
+
if (!worktree.recovered)
|
|
281
|
+
options.worktrees.remove(input);
|
|
236
282
|
return;
|
|
237
283
|
}
|
|
238
284
|
iterations += 1;
|
|
@@ -241,6 +287,19 @@ export function createDispatcher(options) {
|
|
|
241
287
|
areas.add(story.area);
|
|
242
288
|
const candidateDispatch = candidateRace;
|
|
243
289
|
const activeWorker = { input, controller, task: Promise.resolve(), phase: options.acquireResource ? 'waiting-resource' : 'implementing' };
|
|
290
|
+
// Durable admission reporting must succeed before the worker microtask can start.
|
|
291
|
+
active.set(story.id, activeWorker);
|
|
292
|
+
try {
|
|
293
|
+
reportProgress();
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
active.delete(story.id);
|
|
297
|
+
if (worktree.recovered)
|
|
298
|
+
cleanupRetained(input);
|
|
299
|
+
else
|
|
300
|
+
cleanup(input);
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
244
303
|
const task = Promise.resolve().then(async () => {
|
|
245
304
|
const workerLease = options.acquireResource
|
|
246
305
|
? await options.acquireResource({ story, provider, role: 'implementation', units: candidateRace ? options.candidateCount : 1, signal: controller.signal })
|
|
@@ -248,6 +307,13 @@ export function createDispatcher(options) {
|
|
|
248
307
|
activeWorker.phase = 'implementing';
|
|
249
308
|
reportProgress();
|
|
250
309
|
try {
|
|
310
|
+
if (worktree.recovered)
|
|
311
|
+
options.worktrees.cleanupProcess?.(input);
|
|
312
|
+
if (worktree.recovered)
|
|
313
|
+
return { source: 'worker', result: {
|
|
314
|
+
kind: 'candidate', storyId: story.id, worktree: worktree.path, baseCommit: worktree.baseCommit,
|
|
315
|
+
provider, summary: 'retained candidate recovered for independent integration checks', evidence: { criteria: [] }, routing: { outcome: 'pending-integration' },
|
|
316
|
+
} };
|
|
251
317
|
return await (candidateDispatch && options.candidateCoordinator
|
|
252
318
|
? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
|
|
253
319
|
: options.worker(input).then(result => ({ source: 'worker', result })));
|
|
@@ -312,7 +378,10 @@ export function createDispatcher(options) {
|
|
|
312
378
|
if (cancellationReason || paused || options.pause?.()) {
|
|
313
379
|
paused = true;
|
|
314
380
|
result.routing.recordOutcome?.(false);
|
|
315
|
-
|
|
381
|
+
if (input.worktree.recovered)
|
|
382
|
+
cleanupRetained(input);
|
|
383
|
+
else
|
|
384
|
+
cleanup(input);
|
|
316
385
|
reportProgress();
|
|
317
386
|
return;
|
|
318
387
|
}
|
|
@@ -322,7 +391,10 @@ export function createDispatcher(options) {
|
|
|
322
391
|
failed.push(story.id);
|
|
323
392
|
failureReasons.set(story.id, error instanceof Error ? error.message : String(error));
|
|
324
393
|
try {
|
|
325
|
-
|
|
394
|
+
if (input.worktree.recovered)
|
|
395
|
+
cleanupRetained(input);
|
|
396
|
+
else
|
|
397
|
+
cleanup(input);
|
|
326
398
|
}
|
|
327
399
|
catch (cleanupError) {
|
|
328
400
|
reportProgress();
|
|
@@ -387,7 +459,7 @@ export function createDispatcher(options) {
|
|
|
387
459
|
if (failed.length > 0) {
|
|
388
460
|
const storyId = failed[0];
|
|
389
461
|
const detail = storyId ? failureReasons.get(storyId) : undefined;
|
|
390
|
-
return { status: 'blocked', ...(storyId && detail ? { reason: `worker ${storyId} failed: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
462
|
+
return { status: 'blocked', ...(storyId && detail ? { reason: `${reopened.includes(storyId) ? `story ${storyId} integration rejected` : `worker ${storyId} failed`}: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
391
463
|
}
|
|
392
464
|
if (iterations >= options.maxIterations)
|
|
393
465
|
return { status: 'cap-reached', iterations, integrated, reopened, failed };
|
package/dist/loop/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { sanitizeCommitMessage } from './identity.js';
|
|
3
|
-
export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
|
|
3
|
+
export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/claims', '.yoke/worktrees', '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/integration-recovery', '.yoke/run-state.json', '.yoke/run-state.*.tmp', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
|
|
4
4
|
// Directory pathspecs include descendants and also exclude the directory entry.
|
|
5
5
|
export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
|
|
6
6
|
export function stageImplementation(dir) {
|
|
@@ -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
|
+
}
|