@hecer/yoke 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/audit/command.js +13 -5
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/git.js +6 -4
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +26 -2
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { reduceCandidatePairComparisons } from '../quality/verdict.js';
|
|
3
|
+
import { parseCandidateEvidence } from './candidate-boundaries.js';
|
|
4
|
+
export async function selectCandidate(input) {
|
|
5
|
+
const first = input.candidates[0];
|
|
6
|
+
if (!first)
|
|
7
|
+
return { kind: 'inconsistent', reason: 'selection requires one candidate' };
|
|
8
|
+
let selected = first;
|
|
9
|
+
let selectedEvidence = await prepareEvidence(input, first);
|
|
10
|
+
input.ensureActive();
|
|
11
|
+
if (input.pause?.())
|
|
12
|
+
return { kind: 'paused' };
|
|
13
|
+
const comparisons = [];
|
|
14
|
+
for (let index = 1; index < input.candidates.length; index += 1) {
|
|
15
|
+
const challenger = input.candidates[index];
|
|
16
|
+
if (!challenger)
|
|
17
|
+
continue;
|
|
18
|
+
const challengerEvidence = await prepareEvidence(input, challenger);
|
|
19
|
+
input.ensureActive();
|
|
20
|
+
if (input.pause?.())
|
|
21
|
+
return { kind: 'paused' };
|
|
22
|
+
const normal = issueComparison({ left: { candidate: selected, evidence: selectedEvidence }, right: { candidate: challenger, evidence: challengerEvidence }, provenance: input.judgeProvenance });
|
|
23
|
+
const swapped = issueComparison({ left: { candidate: challenger, evidence: challengerEvidence }, right: { candidate: selected, evidence: selectedEvidence }, provenance: input.judgeProvenance });
|
|
24
|
+
const normalVerdict = await input.judge(structuredClone(normal.expected.request));
|
|
25
|
+
input.ensureActive();
|
|
26
|
+
if (input.pause?.())
|
|
27
|
+
return { kind: 'paused' };
|
|
28
|
+
const swappedVerdict = await input.judge(structuredClone(swapped.expected.request));
|
|
29
|
+
input.ensureActive();
|
|
30
|
+
if (input.pause?.())
|
|
31
|
+
return { kind: 'paused' };
|
|
32
|
+
const reduction = reduceCandidatePairComparisons({ expected: { normal: normal.expected, swapped: swapped.expected }, normal: normalVerdict, swapped: swappedVerdict });
|
|
33
|
+
if (reduction.kind === 'inconsistent')
|
|
34
|
+
return reduction;
|
|
35
|
+
const winner = reduction.winnerCandidateId === selected.candidateId ? selected : challenger;
|
|
36
|
+
const loser = winner.candidateId === selected.candidateId ? challenger : selected;
|
|
37
|
+
comparisons.push({
|
|
38
|
+
normal: { request: structuredClone(normal.expected.request), verdict: structuredClone(reduction.normal) },
|
|
39
|
+
swapped: { request: structuredClone(swapped.expected.request), verdict: structuredClone(reduction.swapped) },
|
|
40
|
+
selectedCandidateId: reduction.winnerCandidateId,
|
|
41
|
+
selectedDigest: reduction.winnerDigest,
|
|
42
|
+
});
|
|
43
|
+
await input.discard(loser);
|
|
44
|
+
input.ensureActive();
|
|
45
|
+
if (input.pause?.())
|
|
46
|
+
return { kind: 'paused' };
|
|
47
|
+
if (winner.candidateId === challenger.candidateId) {
|
|
48
|
+
selected = challenger;
|
|
49
|
+
selectedEvidence = challengerEvidence;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
input.ensureActive();
|
|
53
|
+
return input.pause?.() ? { kind: 'paused' } : { kind: 'selected', candidate: selected, evidence: selectedEvidence.trusted, comparisons };
|
|
54
|
+
}
|
|
55
|
+
async function prepareEvidence(input, candidate) {
|
|
56
|
+
const trusted = parseCandidateEvidence(input.evidence(candidate));
|
|
57
|
+
const handle = { digest: `evidence-${randomUUID()}`, artifacts: trusted.artifacts.map(() => `artifact-${randomUUID()}`) };
|
|
58
|
+
await input.stageEvidence({
|
|
59
|
+
handle: { digest: handle.digest, artifacts: [...handle.artifacts] },
|
|
60
|
+
trusted: { digest: trusted.digest, artifacts: [...trusted.artifacts] },
|
|
61
|
+
});
|
|
62
|
+
return { trusted, handle };
|
|
63
|
+
}
|
|
64
|
+
function issueComparison(input) {
|
|
65
|
+
const request = {
|
|
66
|
+
permissions: 'read-only',
|
|
67
|
+
attemptId: randomUUID(),
|
|
68
|
+
left: { digest: input.left.evidence.handle.digest, artifacts: [...input.left.evidence.handle.artifacts], label: 'A' },
|
|
69
|
+
right: { digest: input.right.evidence.handle.digest, artifacts: [...input.right.evidence.handle.artifacts], label: 'B' },
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
request,
|
|
73
|
+
expected: {
|
|
74
|
+
request: {
|
|
75
|
+
permissions: request.permissions,
|
|
76
|
+
attemptId: request.attemptId,
|
|
77
|
+
left: { ...request.left, artifacts: [...request.left.artifacts] },
|
|
78
|
+
right: { ...request.right, artifacts: [...request.right.artifacts] },
|
|
79
|
+
},
|
|
80
|
+
candidateIds: { left: input.left.candidate.candidateId, right: input.right.candidate.candidateId },
|
|
81
|
+
provenance: { ...input.provenance },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { candidateConfigProblem, canonicalWorktreePath } from './candidate-boundaries.js';
|
|
2
|
+
import { CandidateCleanup } from './candidate-cleanup.js';
|
|
3
|
+
import { selectCandidate } from './candidate-selection.js';
|
|
4
|
+
import { runStoryWorker } from './worker.js';
|
|
5
|
+
class CandidateCancellationError extends Error {
|
|
6
|
+
name = 'CandidateCancellationError';
|
|
7
|
+
}
|
|
8
|
+
export async function coordinateCandidates(input) {
|
|
9
|
+
const configProblem = candidateConfigProblem(input);
|
|
10
|
+
if (configProblem)
|
|
11
|
+
return { kind: 'blocked', reason: 'invalid-config', summary: configProblem };
|
|
12
|
+
if (input.signal?.aborted)
|
|
13
|
+
return { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
14
|
+
const definitions = input.candidates.slice(0, input.maxCandidates);
|
|
15
|
+
const pathPlatform = input.pathPlatform ?? (process.platform === 'win32' ? 'win32' : 'posix');
|
|
16
|
+
const owned = [];
|
|
17
|
+
const outcomes = new Map();
|
|
18
|
+
const cleanup = new CandidateCleanup(input.lifecycle, input.onLifecycle);
|
|
19
|
+
const terminalize = async (candidate, reason) => {
|
|
20
|
+
const result = outcomes.get(candidate.candidateId) ?? {
|
|
21
|
+
kind: 'cancelled', storyId: candidate.storyId, worktree: candidate.worktree.path,
|
|
22
|
+
baseCommit: candidate.worktree.baseCommit, provider: candidate.provider, summary: reason,
|
|
23
|
+
evidence: { criteria: [] },
|
|
24
|
+
};
|
|
25
|
+
await input.recordResult?.({ ...candidate, result });
|
|
26
|
+
await input.recordElimination?.({ ...candidate, result });
|
|
27
|
+
};
|
|
28
|
+
const terminalizeAll = async (reason) => {
|
|
29
|
+
for (const candidate of owned)
|
|
30
|
+
await terminalize(candidate, reason);
|
|
31
|
+
};
|
|
32
|
+
const ensureActive = () => {
|
|
33
|
+
if (input.signal?.aborted)
|
|
34
|
+
throw new CandidateCancellationError(cancellationSummary(input.signal));
|
|
35
|
+
};
|
|
36
|
+
const cancelActive = () => cleanup.cancelNow(input.signal ? cancellationSummary(input.signal) : 'candidate coordinator cancelled');
|
|
37
|
+
input.signal?.addEventListener('abort', cancelActive, { once: true });
|
|
38
|
+
const finishPaused = async () => {
|
|
39
|
+
await terminalizeAll('candidate coordination paused');
|
|
40
|
+
await cleanup.cleanupAll('candidate coordination paused');
|
|
41
|
+
ensureActive();
|
|
42
|
+
return cleanup.hasFailures()
|
|
43
|
+
? blocked(cleanup, 'cleanup-error', 'candidate pause cleanup failed')
|
|
44
|
+
: { kind: 'paused', summary: 'candidate coordination paused' };
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const paths = new Set();
|
|
48
|
+
for (const definition of definitions) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const provenance = provenanceFor(input, definition);
|
|
51
|
+
const worktree = input.lifecycle.reserve({ ...provenance, baseCommit: input.baseCommit });
|
|
52
|
+
const candidate = { ...provenance, worktree, signal: controller.signal, controller, definition };
|
|
53
|
+
owned.push(candidate);
|
|
54
|
+
cleanup.register(candidate);
|
|
55
|
+
input.onLifecycle?.(candidate, 'reserved');
|
|
56
|
+
const canonicalPath = canonicalWorktreePath(worktree.path, pathPlatform);
|
|
57
|
+
if (worktree.baseCommit !== input.baseCommit || canonicalPath === null || paths.has(canonicalPath)) {
|
|
58
|
+
await cleanup.cleanupAll('candidate worktree invariant failed');
|
|
59
|
+
ensureActive();
|
|
60
|
+
return blocked(cleanup, 'worktree-invariant', 'candidate worktrees must be unique absolute paths and share the configured base');
|
|
61
|
+
}
|
|
62
|
+
paths.add(canonicalPath);
|
|
63
|
+
}
|
|
64
|
+
for (const candidate of owned) {
|
|
65
|
+
await input.lifecycle.materialize(candidate);
|
|
66
|
+
cleanup.markMaterialized(candidate.candidateId);
|
|
67
|
+
input.onLifecycle?.(candidate, 'materialized');
|
|
68
|
+
ensureActive();
|
|
69
|
+
if (input.pause?.())
|
|
70
|
+
return await finishPaused();
|
|
71
|
+
}
|
|
72
|
+
for (const candidate of owned)
|
|
73
|
+
input.onLifecycle?.(candidate, 'running');
|
|
74
|
+
const runs = await runCandidates(input, owned);
|
|
75
|
+
for (const run of runs)
|
|
76
|
+
outcomes.set(run.owned.candidateId, run.result);
|
|
77
|
+
ensureActive();
|
|
78
|
+
if (input.pause?.() || runs.some(run => run.result.kind === 'paused')) {
|
|
79
|
+
return await finishPaused();
|
|
80
|
+
}
|
|
81
|
+
const green = [];
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
await input.recordResult?.({ ...run.owned, result: run.result });
|
|
84
|
+
switch (run.result.kind) {
|
|
85
|
+
case 'candidate':
|
|
86
|
+
green.push({ ...run.owned, result: run.result });
|
|
87
|
+
break;
|
|
88
|
+
case 'cancelled':
|
|
89
|
+
case 'mechanical-failure':
|
|
90
|
+
case 'quality-failure':
|
|
91
|
+
case 'review-failure':
|
|
92
|
+
input.onLifecycle?.(run.owned, 'cleaning', run.result.summary);
|
|
93
|
+
await input.recordElimination?.({ ...run.owned, result: run.result });
|
|
94
|
+
await cleanup.cleanup(run.owned, run.result.summary);
|
|
95
|
+
ensureActive();
|
|
96
|
+
break;
|
|
97
|
+
case 'paused':
|
|
98
|
+
throw new Error('paused candidates must be handled before mechanical filtering');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
ensureActive();
|
|
102
|
+
if (input.pause?.())
|
|
103
|
+
return await finishPaused();
|
|
104
|
+
if (green.length === 0)
|
|
105
|
+
return blocked(cleanup, 'zero-green', 'no candidate passed every mechanical gate');
|
|
106
|
+
for (const candidate of green)
|
|
107
|
+
input.onLifecycle?.(candidate, 'selecting');
|
|
108
|
+
input.onSelecting?.();
|
|
109
|
+
const selection = await selectCandidate({
|
|
110
|
+
candidates: green,
|
|
111
|
+
evidence: input.evidence,
|
|
112
|
+
stageEvidence: input.stageEvidence,
|
|
113
|
+
judgeProvenance: input.judgeProvenance,
|
|
114
|
+
judge: input.judge,
|
|
115
|
+
...(input.pause ? { pause: input.pause } : {}),
|
|
116
|
+
ensureActive,
|
|
117
|
+
discard: async (candidate) => {
|
|
118
|
+
await input.recordElimination?.(candidate);
|
|
119
|
+
await cleanup.cleanup(candidate, 'candidate was not selected');
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
ensureActive();
|
|
123
|
+
if (selection.kind === 'paused')
|
|
124
|
+
return await finishPaused();
|
|
125
|
+
if (selection.kind === 'inconsistent') {
|
|
126
|
+
for (const candidate of green)
|
|
127
|
+
await input.recordElimination?.({ ...candidate, result: candidate.result }, `selection-inconsistent: ${selection.reason}`);
|
|
128
|
+
await cleanup.cleanupAll(`candidate selection was inconsistent: ${selection.reason}`);
|
|
129
|
+
ensureActive();
|
|
130
|
+
if (input.pause?.())
|
|
131
|
+
return await finishPaused();
|
|
132
|
+
return blocked(cleanup, 'selection-inconsistent', selection.reason);
|
|
133
|
+
}
|
|
134
|
+
const { controller: _controller, definition: _definition, ...winner } = selection.candidate;
|
|
135
|
+
const selectedWinner = {
|
|
136
|
+
...winner,
|
|
137
|
+
digest: selection.evidence.digest,
|
|
138
|
+
artifacts: [...selection.evidence.artifacts],
|
|
139
|
+
comparisons: [...selection.comparisons],
|
|
140
|
+
};
|
|
141
|
+
await input.recordSelection?.(selectedWinner);
|
|
142
|
+
await cleanup.cleanupAll('candidate was not selected', selection.candidate.candidateId);
|
|
143
|
+
ensureActive();
|
|
144
|
+
if (input.pause?.())
|
|
145
|
+
return await finishPaused();
|
|
146
|
+
if (cleanup.hasFailures()) {
|
|
147
|
+
await cleanup.cleanup(selection.candidate, 'candidate cleanup failed');
|
|
148
|
+
ensureActive();
|
|
149
|
+
if (input.pause?.())
|
|
150
|
+
return await finishPaused();
|
|
151
|
+
return blocked(cleanup, 'cleanup-error', 'candidate loser cleanup failed');
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kind: 'winner',
|
|
155
|
+
winner: selectedWinner,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
let proofFailure;
|
|
160
|
+
try {
|
|
161
|
+
await terminalizeAll(`candidate coordinator failed: ${message(error)}`);
|
|
162
|
+
}
|
|
163
|
+
catch (proofError) {
|
|
164
|
+
proofFailure = message(proofError);
|
|
165
|
+
}
|
|
166
|
+
await cleanup.cleanupAll('candidate coordinator failed');
|
|
167
|
+
if (proofFailure)
|
|
168
|
+
return blocked(cleanup, 'cleanup-error', `candidate terminal proof failed: ${proofFailure}`);
|
|
169
|
+
if (input.signal?.aborted) {
|
|
170
|
+
return cleanup.hasFailures()
|
|
171
|
+
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
172
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
173
|
+
}
|
|
174
|
+
if (input.pause?.()) {
|
|
175
|
+
try {
|
|
176
|
+
return await finishPaused();
|
|
177
|
+
}
|
|
178
|
+
catch (pauseError) {
|
|
179
|
+
if (input.signal?.aborted) {
|
|
180
|
+
return cleanup.hasFailures()
|
|
181
|
+
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
182
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
183
|
+
}
|
|
184
|
+
return blocked(cleanup, 'coordinator-error', message(pauseError));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return blocked(cleanup, 'coordinator-error', message(error));
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
input.signal?.removeEventListener('abort', cancelActive);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function runCandidates(input, owned) {
|
|
194
|
+
const settled = await Promise.allSettled(owned.map(async (candidate) => ({
|
|
195
|
+
owned: candidate,
|
|
196
|
+
result: await runStoryWorker({
|
|
197
|
+
...candidate.definition.worker,
|
|
198
|
+
...(candidate.definition.runnerFor ? { runner: candidate.definition.runnerFor({ signal: candidate.signal }) } : {}),
|
|
199
|
+
...(candidate.definition.reporterFor ? { reporter: candidate.definition.reporterFor(candidate) } : {}),
|
|
200
|
+
story: structuredClone(input.story),
|
|
201
|
+
worktree: candidate.worktree.path,
|
|
202
|
+
baseCommit: candidate.worktree.baseCommit,
|
|
203
|
+
cancellation: { signal: candidate.signal },
|
|
204
|
+
}),
|
|
205
|
+
})));
|
|
206
|
+
const runs = [];
|
|
207
|
+
for (const outcome of settled) {
|
|
208
|
+
if (outcome.status === 'rejected')
|
|
209
|
+
throw new Error(`candidate worker failed: ${message(outcome.reason)}`);
|
|
210
|
+
runs.push(outcome.value);
|
|
211
|
+
}
|
|
212
|
+
return runs;
|
|
213
|
+
}
|
|
214
|
+
function provenanceFor(input, definition) {
|
|
215
|
+
return { candidateId: definition.id, coordinatorId: input.coordinatorId, ownerToken: input.ownerToken(definition.id), storyId: input.story.id, provider: definition.worker.provider };
|
|
216
|
+
}
|
|
217
|
+
function cancellationSummary(signal) {
|
|
218
|
+
return typeof signal.reason === 'string' && signal.reason.length > 0 ? signal.reason : 'candidate coordinator cancelled';
|
|
219
|
+
}
|
|
220
|
+
function message(error) {
|
|
221
|
+
return error instanceof Error ? error.message : String(error);
|
|
222
|
+
}
|
|
223
|
+
function blocked(cleanup, reason, summary) {
|
|
224
|
+
const recovery = cleanup.recovery();
|
|
225
|
+
return cleanup.hasFailures()
|
|
226
|
+
? { kind: 'blocked', reason: 'cleanup-error', summary: cleanup.failureSummary(), recovery }
|
|
227
|
+
: { kind: 'blocked', reason, summary };
|
|
228
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const ClaimOperationLeaseSchema = z.object({
|
|
6
|
+
schemaVersion: z.literal(1),
|
|
7
|
+
token: z.string().uuid(),
|
|
8
|
+
pid: z.number().int().positive(),
|
|
9
|
+
createdAt: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
function operationPath(file) {
|
|
12
|
+
return `${file}.operation`;
|
|
13
|
+
}
|
|
14
|
+
function recoveryPath(file) {
|
|
15
|
+
return `${operationPath(file)}.recovery`;
|
|
16
|
+
}
|
|
17
|
+
function errorCode(error) {
|
|
18
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
19
|
+
return undefined;
|
|
20
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
21
|
+
}
|
|
22
|
+
function publish(file, value, token = randomUUID()) {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
const temporary = `${file}.${token}.tmp`;
|
|
25
|
+
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx' });
|
|
26
|
+
try {
|
|
27
|
+
linkSync(temporary, file);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (errorCode(error) === 'EEXIST')
|
|
32
|
+
return false;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
rmSync(temporary, { force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readLease(file) {
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
return ClaimOperationLeaseSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function expired(lease, options) {
|
|
50
|
+
const createdAt = Date.parse(lease.createdAt);
|
|
51
|
+
return !options.isAlive(lease.pid) && (!Number.isFinite(createdAt) || options.now.getTime() - createdAt > options.staleMs);
|
|
52
|
+
}
|
|
53
|
+
function lease(token, options) {
|
|
54
|
+
return { schemaVersion: 1, token, pid: process.pid, createdAt: options.now.toISOString() };
|
|
55
|
+
}
|
|
56
|
+
function acquireRecovery(file, options) {
|
|
57
|
+
const token = randomUUID();
|
|
58
|
+
if (publish(file, lease(token, options), token))
|
|
59
|
+
return token;
|
|
60
|
+
const observed = readLease(file);
|
|
61
|
+
if (!observed || !expired(observed, options))
|
|
62
|
+
return null;
|
|
63
|
+
options.filesystem?.beforeRecoveryRemove?.(file);
|
|
64
|
+
const current = readLease(file);
|
|
65
|
+
if (!current || current.token !== observed.token || !expired(current, options))
|
|
66
|
+
return null;
|
|
67
|
+
rmSync(file, { force: true });
|
|
68
|
+
return publish(file, lease(token, options), token) ? token : null;
|
|
69
|
+
}
|
|
70
|
+
export function publishClaimFile(file, value) {
|
|
71
|
+
return publish(file, value);
|
|
72
|
+
}
|
|
73
|
+
export function replaceClaimFile(file, value) {
|
|
74
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx' });
|
|
76
|
+
renameSync(temporary, file);
|
|
77
|
+
}
|
|
78
|
+
export function acquireClaimOperation(file, options) {
|
|
79
|
+
const token = randomUUID();
|
|
80
|
+
const candidate = lease(token, options);
|
|
81
|
+
const operation = operationPath(file);
|
|
82
|
+
if (publish(operation, candidate, token))
|
|
83
|
+
return token;
|
|
84
|
+
const observed = readLease(operation);
|
|
85
|
+
if (!observed || !expired(observed, options))
|
|
86
|
+
return null;
|
|
87
|
+
const recovery = recoveryPath(file);
|
|
88
|
+
const recoveryToken = acquireRecovery(recovery, options);
|
|
89
|
+
if (!recoveryToken)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const current = readLease(operation);
|
|
93
|
+
if (!current || !expired(current, options))
|
|
94
|
+
return null;
|
|
95
|
+
rmSync(operation, { force: true });
|
|
96
|
+
return publish(operation, candidate, token) ? token : null;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
releaseClaimOperation(recovery, recoveryToken, true);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function releaseClaimOperation(file, token, rawPath = false) {
|
|
103
|
+
const operation = rawPath ? file : operationPath(file);
|
|
104
|
+
const current = readLease(operation);
|
|
105
|
+
if (current?.token === token)
|
|
106
|
+
rmSync(operation, { force: true });
|
|
107
|
+
}
|
|
108
|
+
export function withClaimOperation(file, context, readRecords, task) {
|
|
109
|
+
return withClaimOperations(file, context, readRecords, () => [], task);
|
|
110
|
+
}
|
|
111
|
+
export function withClaimOperations(file, context, readRecords, claimFiles, task) {
|
|
112
|
+
const token = acquireClaimOperation(file, context);
|
|
113
|
+
if (!token)
|
|
114
|
+
return null;
|
|
115
|
+
const acquired = [];
|
|
116
|
+
const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
|
|
117
|
+
try {
|
|
118
|
+
for (const nextFile of orderedFiles) {
|
|
119
|
+
const nextToken = acquireClaimOperation(nextFile, context);
|
|
120
|
+
if (!nextToken)
|
|
121
|
+
return null;
|
|
122
|
+
acquired.push({ file: nextFile, token: nextToken });
|
|
123
|
+
}
|
|
124
|
+
return task(readRecords());
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
for (const lease of acquired.reverse())
|
|
128
|
+
releaseClaimOperation(lease.file, lease.token);
|
|
129
|
+
releaseClaimOperation(file, token);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/loop/claims.js
CHANGED
|
@@ -1,57 +1,194 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isPidAlive } from './lock.js';
|
|
5
|
+
import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
|
|
6
|
+
import { storyPathSegment } from './prd.js';
|
|
7
|
+
export const CLAIM_SCHEMA_VERSION = 2;
|
|
8
|
+
const LegacyClaimSchema = z.object({
|
|
9
|
+
storyId: z.string().min(1),
|
|
10
|
+
owner: z.string().min(1),
|
|
11
|
+
pid: z.number().int().positive(),
|
|
12
|
+
claimedAt: z.string().min(1),
|
|
13
|
+
});
|
|
14
|
+
const RichClaimSchema = LegacyClaimSchema.extend({
|
|
15
|
+
schemaVersion: z.literal(CLAIM_SCHEMA_VERSION),
|
|
16
|
+
dispatcherId: z.string().min(1),
|
|
17
|
+
ownerToken: z.string().min(1),
|
|
18
|
+
heartbeatAt: z.string().min(1),
|
|
19
|
+
baseCommit: z.string().min(1).optional(),
|
|
20
|
+
worktree: z.string().min(1).optional(),
|
|
21
|
+
provider: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
22
|
+
model: z.string().min(1).optional(),
|
|
23
|
+
role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
|
|
24
|
+
cancellation: z.object({
|
|
25
|
+
requestedAt: z.string().min(1),
|
|
26
|
+
reason: z.string().min(1),
|
|
27
|
+
}).optional(),
|
|
28
|
+
});
|
|
29
|
+
const StoredClaimSchema = z.union([RichClaimSchema, LegacyClaimSchema]);
|
|
30
|
+
function claimsDir(dir) {
|
|
31
|
+
return join(dir, '.yoke', 'claims');
|
|
32
|
+
}
|
|
33
|
+
function pathFor(dir, id) {
|
|
34
|
+
return join(claimsDir(dir), `${storyPathSegment(id)}.json`);
|
|
35
|
+
}
|
|
36
|
+
function claimOwner(claim) {
|
|
37
|
+
return 'ownerToken' in claim ? claim.ownerToken : claim.owner;
|
|
38
|
+
}
|
|
39
|
+
function readClaimFile(file) {
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return null;
|
|
16
42
|
try {
|
|
17
|
-
|
|
18
|
-
writeFileSync(fd, JSON.stringify(claim));
|
|
19
|
-
closeSync(fd);
|
|
20
|
-
return claim;
|
|
43
|
+
return StoredClaimSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
21
44
|
}
|
|
22
45
|
catch {
|
|
23
46
|
return null;
|
|
24
47
|
}
|
|
25
48
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
function claimRecords(dir, storyId) {
|
|
50
|
+
const canonicalPath = pathFor(dir, storyId);
|
|
51
|
+
const records = [];
|
|
52
|
+
const canonicalClaim = readClaimFile(canonicalPath);
|
|
53
|
+
if (canonicalClaim?.storyId === storyId)
|
|
54
|
+
records.push({ path: canonicalPath, claim: canonicalClaim });
|
|
55
|
+
if (!existsSync(claimsDir(dir)))
|
|
56
|
+
return records;
|
|
57
|
+
for (const file of readdirSync(claimsDir(dir))) {
|
|
58
|
+
const path = join(claimsDir(dir), file);
|
|
59
|
+
if (path === canonicalPath)
|
|
60
|
+
continue;
|
|
61
|
+
const claim = readClaimFile(path);
|
|
62
|
+
if (claim?.storyId === storyId)
|
|
63
|
+
records.push({ path, claim });
|
|
64
|
+
}
|
|
65
|
+
return records;
|
|
66
|
+
}
|
|
67
|
+
function resolveClaimRecord(dir, storyId) {
|
|
68
|
+
return claimRecords(dir, storyId)[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
function matchingClaimRecords(records, ownerToken) {
|
|
71
|
+
return records.filter(record => claimOwner(record.claim) === ownerToken);
|
|
72
|
+
}
|
|
73
|
+
export function readClaim(dir, storyId) {
|
|
74
|
+
return resolveClaimRecord(dir, storyId)?.claim ?? null;
|
|
75
|
+
}
|
|
76
|
+
function isExpired(claim, now, staleMs, isAlive) {
|
|
77
|
+
const heartbeatAt = 'heartbeatAt' in claim ? claim.heartbeatAt : claim.claimedAt;
|
|
78
|
+
const heartbeatMs = Date.parse(heartbeatAt);
|
|
79
|
+
return !isAlive(claim.pid) && (!Number.isFinite(heartbeatMs) || now.getTime() - heartbeatMs > staleMs);
|
|
80
|
+
}
|
|
81
|
+
function richClaim(storyId, owner, options) {
|
|
82
|
+
const now = options.now ?? new Date();
|
|
83
|
+
const ownerToken = options.ownerToken ?? owner;
|
|
84
|
+
return {
|
|
85
|
+
schemaVersion: CLAIM_SCHEMA_VERSION,
|
|
86
|
+
storyId,
|
|
87
|
+
owner: ownerToken,
|
|
88
|
+
ownerToken,
|
|
89
|
+
dispatcherId: options.dispatcherId ?? owner,
|
|
90
|
+
pid: options.pid ?? process.pid,
|
|
91
|
+
claimedAt: now.toISOString(),
|
|
92
|
+
heartbeatAt: now.toISOString(),
|
|
93
|
+
...(options.baseCommit ? { baseCommit: options.baseCommit } : {}),
|
|
94
|
+
...(options.worktree ? { worktree: options.worktree } : {}),
|
|
95
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
96
|
+
...(options.model ? { model: options.model } : {}),
|
|
97
|
+
...(options.role ? { role: options.role } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function promoteClaim(claim, now) {
|
|
101
|
+
if ('schemaVersion' in claim)
|
|
102
|
+
return claim;
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: CLAIM_SCHEMA_VERSION,
|
|
105
|
+
storyId: claim.storyId,
|
|
106
|
+
owner: claim.owner,
|
|
107
|
+
ownerToken: claim.owner,
|
|
108
|
+
dispatcherId: claim.owner,
|
|
109
|
+
pid: claim.pid,
|
|
110
|
+
claimedAt: claim.claimedAt,
|
|
111
|
+
heartbeatAt: now.toISOString(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function acquireClaim(dir, storyId, owner, options = {}) {
|
|
115
|
+
const candidate = richClaim(storyId, owner, options);
|
|
116
|
+
const now = options.now ?? new Date();
|
|
117
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
118
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
119
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
120
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
121
|
+
if (records.some(record => !isExpired(record.claim, now, staleMs, isAlive)))
|
|
122
|
+
return null;
|
|
123
|
+
for (const record of records)
|
|
124
|
+
rmSync(record.path, { force: true });
|
|
125
|
+
return publishClaimFile(pathFor(dir, storyId), candidate) ? candidate : null;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export function heartbeatClaim(dir, storyId, ownerToken, options = {}) {
|
|
129
|
+
const now = options.now ?? new Date();
|
|
130
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
131
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
132
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
133
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
134
|
+
const record = records.find(candidate => claimOwner(candidate.claim) === ownerToken);
|
|
135
|
+
if (!record)
|
|
136
|
+
return null;
|
|
137
|
+
const updated = { ...promoteClaim(record.claim, now), heartbeatAt: now.toISOString() };
|
|
138
|
+
for (const matchingRecord of matchingClaimRecords(records, ownerToken))
|
|
139
|
+
replaceClaimFile(matchingRecord.path, updated);
|
|
140
|
+
return updated;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export function requestClaimCancellation(dir, storyId, ownerToken, reason, options = {}) {
|
|
144
|
+
const now = options.now ?? new Date();
|
|
145
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
146
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
147
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
148
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
149
|
+
const record = records.find(candidate => claimOwner(candidate.claim) === ownerToken);
|
|
150
|
+
if (!record)
|
|
151
|
+
return null;
|
|
152
|
+
const updated = {
|
|
153
|
+
...promoteClaim(record.claim, now),
|
|
154
|
+
cancellation: { reason, requestedAt: now.toISOString() },
|
|
155
|
+
};
|
|
156
|
+
for (const matchingRecord of matchingClaimRecords(records, ownerToken))
|
|
157
|
+
replaceClaimFile(matchingRecord.path, updated);
|
|
158
|
+
return updated;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export function releaseClaim(dir, storyId, ownerToken, options = {}) {
|
|
162
|
+
const now = options.now ?? new Date();
|
|
163
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
164
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
165
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
166
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
167
|
+
const matchingRecords = matchingClaimRecords(records, ownerToken);
|
|
168
|
+
if (!matchingRecords.length)
|
|
31
169
|
return false;
|
|
32
|
-
|
|
170
|
+
for (const record of matchingRecords)
|
|
171
|
+
rmSync(record.path, { force: true });
|
|
33
172
|
return true;
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
173
|
+
}) ?? false;
|
|
38
174
|
}
|
|
39
|
-
export function cleanupClaims(dir,
|
|
40
|
-
const
|
|
175
|
+
export function cleanupClaims(dir, ownerToken) {
|
|
176
|
+
const dirPath = claimsDir(dir);
|
|
41
177
|
let removed = 0;
|
|
42
178
|
try {
|
|
43
|
-
for (const file of readdirSync(
|
|
44
|
-
const path = join(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
179
|
+
for (const file of readdirSync(dirPath)) {
|
|
180
|
+
const path = join(dirPath, file);
|
|
181
|
+
const claim = readClaimFile(path);
|
|
182
|
+
if (!claim)
|
|
183
|
+
continue;
|
|
184
|
+
if (!ownerToken || claimOwner(claim) === ownerToken) {
|
|
185
|
+
rmSync(path, { force: true });
|
|
186
|
+
removed += 1;
|
|
51
187
|
}
|
|
52
|
-
catch { /* scoped cleanup never guesses ownership */ }
|
|
53
188
|
}
|
|
54
189
|
}
|
|
55
|
-
catch {
|
|
190
|
+
catch {
|
|
191
|
+
return removed;
|
|
192
|
+
}
|
|
56
193
|
return removed;
|
|
57
194
|
}
|