@hecer/yoke 1.21.1 → 1.23.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 +48 -0
- package/README.md +8 -1
- package/TODOS.md +6 -0
- package/bench/analyze-codex-comparison.mjs +90 -17
- package/bench/compare-codex.mjs +159 -36
- package/bench/result-schema.mjs +132 -0
- package/canon/manifest.yaml +1 -1
- package/canon/skills/visual-verification/SKILL.md +25 -2
- package/canon/tools/codex-rtk-hook.mjs +6 -16
- package/dist/agents/pi-telemetry.js +2 -1
- package/dist/agents/process-streams.js +12 -64
- package/dist/agents/provider-selection.js +12 -0
- package/dist/agents/telemetry.js +52 -52
- package/dist/change/inbox.js +8 -3
- package/dist/check/command.js +69 -17
- package/dist/check/delivery.js +121 -0
- package/dist/cli.js +91 -3
- package/dist/code-intelligence/adapters/mcp.js +1 -0
- package/dist/code-intelligence/budgets.js +138 -0
- package/dist/code-intelligence/contracts.js +2 -0
- package/dist/code-intelligence/coordinator.js +159 -85
- package/dist/code-intelligence/evidence.js +87 -34
- package/dist/code-intelligence/index.js +1 -0
- package/dist/code-intelligence/mcp-client.js +25 -6
- package/dist/code-intelligence/mcp-server.js +14 -11
- package/dist/code-intelligence/preflight.js +71 -0
- package/dist/dashboard/analytics.js +5 -3
- package/dist/goals/command.js +183 -53
- package/dist/goals/usage.js +87 -0
- package/dist/loop/cache-isolation.js +36 -0
- package/dist/loop/candidate-cleanup.js +47 -17
- package/dist/loop/candidates.js +17 -11
- package/dist/loop/dispatcher.js +89 -26
- package/dist/loop/failure.js +104 -0
- package/dist/loop/gate-snapshot.js +19 -0
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +124 -70
- package/dist/loop/parallel-adapters.js +57 -6
- package/dist/loop/parallel-command.js +49 -7
- package/dist/loop/proof-retention.js +70 -0
- package/dist/loop/recovery.js +23 -5
- package/dist/loop/reporter.js +22 -5
- package/dist/loop/run-command.js +101 -47
- package/dist/loop/runner.js +6 -5
- package/dist/loop/worker.js +152 -91
- package/dist/observability/history.js +2 -1
- package/dist/observability/invocation.js +42 -0
- package/dist/observability/local-report.js +120 -0
- package/dist/observability/usage.js +19 -0
- package/dist/prd/command.js +20 -7
- package/dist/prd/decompose.js +5 -2
- package/dist/retrofit/config.js +29 -2
- package/dist/retrofit/gitignore.js +12 -0
- package/dist/retrofit/planners/codex.js +20 -20
- package/dist/routing/attempts.js +241 -0
- package/dist/routing/capability.js +13 -9
- package/dist/routing/optimization.js +73 -0
- package/dist/routing/registry.js +7 -1
- package/dist/routing/router.js +282 -127
- package/dist/setup/command.js +8 -2
- package/dist/smoke/command.js +387 -85
- package/dist/update/check.js +1 -1
- package/docs/BENCHMARK-MANIFEST.md +131 -0
- package/docs/CODE-INTELLIGENCE.md +43 -1
- package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
- package/docs/DELIVERY-JOURNEYS.md +206 -0
- package/docs/ECONOMIC-ROUTING.md +180 -0
- package/docs/GOALS.md +61 -4
- package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
- package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
- package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
- package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
- package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
- package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
- package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
- package/docs/parallel-execution.md +37 -9
- package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
- package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
- package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -17,6 +17,8 @@ export class CandidateCleanup {
|
|
|
17
17
|
}
|
|
18
18
|
cancelNow(reason) {
|
|
19
19
|
for (const state of this.#states.values()) {
|
|
20
|
+
if (state.completed.has('remove') || state.completed.has('retain'))
|
|
21
|
+
continue;
|
|
20
22
|
state.ownership.controller.abort(reason);
|
|
21
23
|
this.onLifecycle?.(state.ownership, 'cleaning', reason);
|
|
22
24
|
void this.#runStage(state, 'cancel', () => this.lifecycle.cancel(state.ownership, reason));
|
|
@@ -26,6 +28,8 @@ export class CandidateCleanup {
|
|
|
26
28
|
const state = this.#states.get(ownership.candidateId);
|
|
27
29
|
if (!state)
|
|
28
30
|
return;
|
|
31
|
+
if (state.preserve)
|
|
32
|
+
return this.preserve(ownership, reason, state.preserve);
|
|
29
33
|
ownership.controller.abort(reason);
|
|
30
34
|
this.onLifecycle?.(ownership, 'cleaning', reason);
|
|
31
35
|
if (!await this.#runStage(state, 'cancel', () => this.lifecycle.cancel(ownership, reason)))
|
|
@@ -41,6 +45,29 @@ export class CandidateCleanup {
|
|
|
41
45
|
await this.cleanup(state.ownership, reason);
|
|
42
46
|
}
|
|
43
47
|
}
|
|
48
|
+
async preserve(ownership, reason, phase) {
|
|
49
|
+
const state = this.#states.get(ownership.candidateId);
|
|
50
|
+
if (!state || state.completed.has('remove') || state.completed.has('retain'))
|
|
51
|
+
return;
|
|
52
|
+
if (!this.lifecycle.retain || !state.materialized)
|
|
53
|
+
return this.cleanup(ownership, reason);
|
|
54
|
+
state.preserve = phase;
|
|
55
|
+
ownership.controller.abort(reason);
|
|
56
|
+
if (!await this.#runStage(state, 'cancel', () => this.lifecycle.cancel(ownership, reason)))
|
|
57
|
+
return;
|
|
58
|
+
if (!await this.#runStage(state, 'reap', () => this.lifecycle.reap(ownership)))
|
|
59
|
+
return;
|
|
60
|
+
if (await this.#runStage(state, 'retain', () => this.lifecycle.retain(ownership, reason, phase)))
|
|
61
|
+
this.onLifecycle?.(ownership, 'retained', reason);
|
|
62
|
+
}
|
|
63
|
+
async preserveAll(reason, phase) {
|
|
64
|
+
for (const state of this.#states.values())
|
|
65
|
+
await this.preserve(state.ownership, reason, phase(state.ownership.candidateId));
|
|
66
|
+
}
|
|
67
|
+
retainedSummary() {
|
|
68
|
+
const paths = [...this.#states.values()].filter(state => state.completed.has('retain')).map(state => state.ownership.worktree.path);
|
|
69
|
+
return paths.length ? `; work retained at ${paths.join(', ')}` : '';
|
|
70
|
+
}
|
|
44
71
|
hasFailures() {
|
|
45
72
|
return [...this.#states.values()].some(state => state.failed.size > 0);
|
|
46
73
|
}
|
|
@@ -48,23 +75,26 @@ export class CandidateCleanup {
|
|
|
48
75
|
return this.recovery().flatMap(item => item.failedStages.map(failure => `${item.ownership.candidateId}:${failure.stage}: ${failure.summary}`)).join('; ');
|
|
49
76
|
}
|
|
50
77
|
recovery() {
|
|
51
|
-
return [...this.#states.values()].filter(state => state.failed.size > 0).map(state =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
return [...this.#states.values()].filter(state => state.failed.size > 0).map(state => {
|
|
79
|
+
const stages = state.preserve ? ['cancel', 'reap', 'retain'] : stageOrder;
|
|
80
|
+
return {
|
|
81
|
+
ownership: {
|
|
82
|
+
candidateId: state.ownership.candidateId,
|
|
83
|
+
coordinatorId: state.ownership.coordinatorId,
|
|
84
|
+
ownerToken: state.ownership.ownerToken,
|
|
85
|
+
storyId: state.ownership.storyId,
|
|
86
|
+
provider: { ...state.ownership.provider },
|
|
87
|
+
worktree: { ...state.ownership.worktree },
|
|
88
|
+
},
|
|
89
|
+
materialized: state.materialized,
|
|
90
|
+
completedStages: stages.filter(stage => state.completed.has(stage)),
|
|
91
|
+
failedStages: stages.flatMap(stage => {
|
|
92
|
+
const summary = state.failed.get(stage);
|
|
93
|
+
return summary ? [{ stage, summary }] : [];
|
|
94
|
+
}),
|
|
95
|
+
pendingStages: stages.filter(stage => !state.completed.has(stage) && !state.failed.has(stage)),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
68
98
|
}
|
|
69
99
|
#runStage(state, stage, operation) {
|
|
70
100
|
if (state.completed.has(stage))
|
package/dist/loop/candidates.js
CHANGED
|
@@ -16,6 +16,7 @@ export async function coordinateCandidates(input) {
|
|
|
16
16
|
const owned = [];
|
|
17
17
|
const outcomes = new Map();
|
|
18
18
|
const cleanup = new CandidateCleanup(input.lifecycle, input.onLifecycle);
|
|
19
|
+
const preserveAll = (reason) => cleanup.preserveAll(reason, id => outcomes.get(id)?.kind === 'candidate' ? 'integration' : 'implementation');
|
|
19
20
|
const terminalize = async (candidate, reason) => {
|
|
20
21
|
const result = outcomes.get(candidate.candidateId) ?? {
|
|
21
22
|
kind: 'cancelled', storyId: candidate.storyId, worktree: candidate.worktree.path,
|
|
@@ -37,11 +38,11 @@ export async function coordinateCandidates(input) {
|
|
|
37
38
|
input.signal?.addEventListener('abort', cancelActive, { once: true });
|
|
38
39
|
const finishPaused = async () => {
|
|
39
40
|
await terminalizeAll('candidate coordination paused');
|
|
40
|
-
await
|
|
41
|
+
await preserveAll('candidate coordination paused');
|
|
41
42
|
ensureActive();
|
|
42
43
|
return cleanup.hasFailures()
|
|
43
44
|
? blocked(cleanup, 'cleanup-error', 'candidate pause cleanup failed')
|
|
44
|
-
: { kind: 'paused', summary:
|
|
45
|
+
: { kind: 'paused', summary: `candidate coordination paused${cleanup.retainedSummary()}` };
|
|
45
46
|
};
|
|
46
47
|
try {
|
|
47
48
|
const paths = new Set();
|
|
@@ -91,7 +92,8 @@ export async function coordinateCandidates(input) {
|
|
|
91
92
|
case 'review-failure':
|
|
92
93
|
input.onLifecycle?.(run.owned, 'cleaning', run.result.summary);
|
|
93
94
|
await input.recordElimination?.({ ...run.owned, result: run.result });
|
|
94
|
-
|
|
95
|
+
if (!input.lifecycle.retain)
|
|
96
|
+
await cleanup.cleanup(run.owned, run.result.summary);
|
|
95
97
|
ensureActive();
|
|
96
98
|
break;
|
|
97
99
|
case 'paused':
|
|
@@ -101,8 +103,11 @@ export async function coordinateCandidates(input) {
|
|
|
101
103
|
ensureActive();
|
|
102
104
|
if (input.pause?.())
|
|
103
105
|
return await finishPaused();
|
|
104
|
-
if (green.length === 0)
|
|
106
|
+
if (green.length === 0) {
|
|
107
|
+
if (input.lifecycle.retain)
|
|
108
|
+
await preserveAll('no candidate passed every mechanical gate');
|
|
105
109
|
return blocked(cleanup, 'zero-green', 'no candidate passed every mechanical gate');
|
|
110
|
+
}
|
|
106
111
|
for (const candidate of green)
|
|
107
112
|
input.onLifecycle?.(candidate, 'selecting');
|
|
108
113
|
input.onSelecting?.();
|
|
@@ -116,7 +121,8 @@ export async function coordinateCandidates(input) {
|
|
|
116
121
|
ensureActive,
|
|
117
122
|
discard: async (candidate) => {
|
|
118
123
|
await input.recordElimination?.(candidate);
|
|
119
|
-
|
|
124
|
+
if (!input.lifecycle.retain)
|
|
125
|
+
await cleanup.cleanup(candidate, 'candidate was not selected');
|
|
120
126
|
},
|
|
121
127
|
});
|
|
122
128
|
ensureActive();
|
|
@@ -125,7 +131,7 @@ export async function coordinateCandidates(input) {
|
|
|
125
131
|
if (selection.kind === 'inconsistent') {
|
|
126
132
|
for (const candidate of green)
|
|
127
133
|
await input.recordElimination?.({ ...candidate, result: candidate.result }, `selection-inconsistent: ${selection.reason}`);
|
|
128
|
-
await
|
|
134
|
+
await preserveAll(`candidate selection was inconsistent: ${selection.reason}`);
|
|
129
135
|
ensureActive();
|
|
130
136
|
if (input.pause?.())
|
|
131
137
|
return await finishPaused();
|
|
@@ -144,7 +150,7 @@ export async function coordinateCandidates(input) {
|
|
|
144
150
|
if (input.pause?.())
|
|
145
151
|
return await finishPaused();
|
|
146
152
|
if (cleanup.hasFailures()) {
|
|
147
|
-
await cleanup.
|
|
153
|
+
await cleanup.preserve(selection.candidate, 'candidate cleanup failed', 'integration');
|
|
148
154
|
ensureActive();
|
|
149
155
|
if (input.pause?.())
|
|
150
156
|
return await finishPaused();
|
|
@@ -163,13 +169,13 @@ export async function coordinateCandidates(input) {
|
|
|
163
169
|
catch (proofError) {
|
|
164
170
|
proofFailure = message(proofError);
|
|
165
171
|
}
|
|
166
|
-
await
|
|
172
|
+
await preserveAll('candidate coordinator failed');
|
|
167
173
|
if (proofFailure)
|
|
168
174
|
return blocked(cleanup, 'cleanup-error', `candidate terminal proof failed: ${proofFailure}`);
|
|
169
175
|
if (input.signal?.aborted) {
|
|
170
176
|
return cleanup.hasFailures()
|
|
171
177
|
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
172
|
-
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
178
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) + cleanup.retainedSummary() };
|
|
173
179
|
}
|
|
174
180
|
if (input.pause?.()) {
|
|
175
181
|
try {
|
|
@@ -179,7 +185,7 @@ export async function coordinateCandidates(input) {
|
|
|
179
185
|
if (input.signal?.aborted) {
|
|
180
186
|
return cleanup.hasFailures()
|
|
181
187
|
? blocked(cleanup, 'cleanup-error', 'candidate cancellation cleanup failed')
|
|
182
|
-
: { kind: 'cancelled', summary: cancellationSummary(input.signal) };
|
|
188
|
+
: { kind: 'cancelled', summary: cancellationSummary(input.signal) + cleanup.retainedSummary() };
|
|
183
189
|
}
|
|
184
190
|
return blocked(cleanup, 'coordinator-error', message(pauseError));
|
|
185
191
|
}
|
|
@@ -224,5 +230,5 @@ function blocked(cleanup, reason, summary) {
|
|
|
224
230
|
const recovery = cleanup.recovery();
|
|
225
231
|
return cleanup.hasFailures()
|
|
226
232
|
? { kind: 'blocked', reason: 'cleanup-error', summary: cleanup.failureSummary(), recovery }
|
|
227
|
-
: { kind: 'blocked', reason, summary };
|
|
233
|
+
: { kind: 'blocked', reason, summary: summary + cleanup.retainedSummary() };
|
|
228
234
|
}
|
package/dist/loop/dispatcher.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { observeFailure, clearFailureProgress } from './failure.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
2
4
|
import { join, relative } from 'node:path';
|
|
3
5
|
import { appendDecision, contextDir } from '../context/context.js';
|
|
4
6
|
import { acquireClaim, heartbeatClaim, releaseClaim, requestClaimCancellation } from './claims.js';
|
|
@@ -76,6 +78,7 @@ export function createDispatcher(options) {
|
|
|
76
78
|
const reopened = [];
|
|
77
79
|
const failed = [];
|
|
78
80
|
const failureReasons = new Map();
|
|
81
|
+
const failureDetails = new Map();
|
|
79
82
|
const integrationBlocks = [];
|
|
80
83
|
let iterations = 0;
|
|
81
84
|
let providerIndex = 0;
|
|
@@ -144,6 +147,32 @@ export function createDispatcher(options) {
|
|
|
144
147
|
removeWorktree: () => undefined,
|
|
145
148
|
releaseClaim: releaseCandidateClaim,
|
|
146
149
|
});
|
|
150
|
+
const cleanupProcessOnly = createWorkerCleanup({
|
|
151
|
+
cleanupProcess: options.worktrees.cleanupProcess,
|
|
152
|
+
removeWorktree: () => undefined,
|
|
153
|
+
releaseClaim: () => undefined,
|
|
154
|
+
});
|
|
155
|
+
const retainWorker = (input, reason, phase) => {
|
|
156
|
+
if (!options.worktrees.retain) {
|
|
157
|
+
if (input.worktree.recovered)
|
|
158
|
+
cleanupRetained(input);
|
|
159
|
+
else
|
|
160
|
+
cleanup(input);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let recorded = false;
|
|
164
|
+
try {
|
|
165
|
+
// The trusted record must exist before relinquishing this worktree's claim.
|
|
166
|
+
options.worktrees.retain(input, reason, phase);
|
|
167
|
+
recorded = true;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (recorded)
|
|
171
|
+
cleanupRetained(input);
|
|
172
|
+
else
|
|
173
|
+
cleanupProcessOnly(input);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
147
176
|
const persistPass = (input) => {
|
|
148
177
|
if (!options.prdPath)
|
|
149
178
|
return;
|
|
@@ -155,12 +184,23 @@ export function createDispatcher(options) {
|
|
|
155
184
|
const enqueue = (input, result) => {
|
|
156
185
|
let reason = 'integrated verification failed';
|
|
157
186
|
let retained = false;
|
|
187
|
+
let retentionRecorded = false;
|
|
158
188
|
let recoveryBaseCommit = input.worktree.baseCommit;
|
|
159
189
|
const integrationQueuedAt = Date.now();
|
|
160
190
|
let integrationStartedAt;
|
|
161
191
|
const integrationController = new AbortController();
|
|
162
192
|
integrationControllers.set(input.story.id, integrationController);
|
|
163
193
|
let integrationLease;
|
|
194
|
+
let routingSettled = false;
|
|
195
|
+
const settleRouting = (verified, failureKind) => {
|
|
196
|
+
if (routingSettled)
|
|
197
|
+
return;
|
|
198
|
+
routingSettled = true;
|
|
199
|
+
if (failureKind)
|
|
200
|
+
result.routing.recordOutcome?.(verified, failureKind);
|
|
201
|
+
else
|
|
202
|
+
result.routing.recordOutcome?.(verified);
|
|
203
|
+
};
|
|
164
204
|
const task = queue.enqueue({
|
|
165
205
|
storyId: input.story.id,
|
|
166
206
|
rebase: async () => {
|
|
@@ -213,27 +253,36 @@ export function createDispatcher(options) {
|
|
|
213
253
|
input.story.passes = true;
|
|
214
254
|
integrated.push(input.story.id);
|
|
215
255
|
options.onAccepted?.(input.story);
|
|
216
|
-
|
|
256
|
+
settleRouting(true);
|
|
257
|
+
clearFailureProgress(options.targetDir, input.story.id);
|
|
217
258
|
return;
|
|
218
259
|
case 'integrated-but-blocked':
|
|
219
260
|
input.story.passes = true;
|
|
220
261
|
integrated.push(input.story.id);
|
|
221
262
|
options.onAccepted?.(input.story);
|
|
222
263
|
integrationBlocks.push(merge.reason);
|
|
223
|
-
|
|
264
|
+
settleRouting(true);
|
|
265
|
+
clearFailureProgress(options.targetDir, input.story.id);
|
|
224
266
|
return;
|
|
225
267
|
case 'reopened':
|
|
226
268
|
reopened.push(input.story.id);
|
|
227
269
|
reason = merge.reason;
|
|
270
|
+
const observed = existsSync(input.worktree.path) ? observeFailure({ root: options.targetDir, directory: input.worktree.path, story: input.story, stage: 'integration', summary: reason }) : undefined;
|
|
271
|
+
if (observed) {
|
|
272
|
+
failureDetails.set(input.story.id, observed.failure);
|
|
273
|
+
if (observed.action !== 'retry')
|
|
274
|
+
reason = observed.feedback;
|
|
275
|
+
}
|
|
228
276
|
if (options.worktrees.retain) {
|
|
229
277
|
// Retain first, then report/release. Recovery evidence must precede cleanup.
|
|
230
278
|
retained = true;
|
|
231
|
-
options.worktrees.retain({ ...input, worktree: { ...input.worktree, baseCommit: recoveryBaseCommit } }, reason);
|
|
279
|
+
options.worktrees.retain({ ...input, worktree: { ...input.worktree, baseCommit: recoveryBaseCommit } }, reason, observed && observed.action !== 'retry' ? 'implementation' : 'integration');
|
|
280
|
+
retentionRecorded = true;
|
|
232
281
|
failed.push(input.story.id);
|
|
233
282
|
failureReasons.set(input.story.id, `${reason}; candidate retained at ${input.worktree.path}`);
|
|
234
283
|
}
|
|
235
284
|
options.onReopened?.(input.story, { reason, worktree: input.worktree.path, baseCommit: recoveryBaseCommit, ownerToken: input.ownerToken });
|
|
236
|
-
|
|
285
|
+
settleRouting(false, cancellationReason ? 'infrastructure' : undefined);
|
|
237
286
|
return;
|
|
238
287
|
default: {
|
|
239
288
|
const unexpected = merge;
|
|
@@ -241,20 +290,29 @@ export function createDispatcher(options) {
|
|
|
241
290
|
}
|
|
242
291
|
}
|
|
243
292
|
}).finally(async () => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
options.gates.integrationPhase?.(input, undefined);
|
|
293
|
+
// A thrown gate/retention operation also terminates this measured attempt;
|
|
294
|
+
// recovery retains code, but cannot resurrect an in-memory outcome callback.
|
|
247
295
|
try {
|
|
248
|
-
|
|
249
|
-
await integrationLease.release();
|
|
250
|
-
options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
|
|
296
|
+
settleRouting(false, 'infrastructure');
|
|
251
297
|
}
|
|
252
298
|
finally {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
299
|
+
queued.delete(input.story.id);
|
|
300
|
+
integrationControllers.delete(input.story.id);
|
|
301
|
+
try {
|
|
302
|
+
options.gates.integrationPhase?.(input, undefined);
|
|
303
|
+
if (integrationLease)
|
|
304
|
+
await integrationLease.release();
|
|
305
|
+
options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
if (retained && retentionRecorded)
|
|
309
|
+
cleanupRetained(input);
|
|
310
|
+
else if (retained)
|
|
311
|
+
cleanupProcessOnly(input);
|
|
312
|
+
else
|
|
313
|
+
cleanup(input);
|
|
314
|
+
reportProgress();
|
|
315
|
+
}
|
|
258
316
|
}
|
|
259
317
|
});
|
|
260
318
|
queued.set(input.story.id, task);
|
|
@@ -274,7 +332,7 @@ export function createDispatcher(options) {
|
|
|
274
332
|
return;
|
|
275
333
|
}
|
|
276
334
|
const controller = new AbortController();
|
|
277
|
-
const candidateRace = Boolean(options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
|
|
335
|
+
const candidateRace = Boolean(!worktree.recovered && options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
|
|
278
336
|
const input = { story, worktree, provider, cancellation: { signal: controller.signal }, dispatcherId, ownerToken, ...(candidateRace ? { candidateRace: true } : {}) };
|
|
279
337
|
if (!claims.acquire(input)) {
|
|
280
338
|
if (!worktree.recovered)
|
|
@@ -286,6 +344,7 @@ export function createDispatcher(options) {
|
|
|
286
344
|
if (story.area)
|
|
287
345
|
areas.add(story.area);
|
|
288
346
|
const candidateDispatch = candidateRace;
|
|
347
|
+
let workerStarted = false;
|
|
289
348
|
const activeWorker = { input, controller, task: Promise.resolve(), phase: options.acquireResource ? 'waiting-resource' : 'implementing' };
|
|
290
349
|
// Durable admission reporting must succeed before the worker microtask can start.
|
|
291
350
|
active.set(story.id, activeWorker);
|
|
@@ -309,11 +368,12 @@ export function createDispatcher(options) {
|
|
|
309
368
|
try {
|
|
310
369
|
if (worktree.recovered)
|
|
311
370
|
options.worktrees.cleanupProcess?.(input);
|
|
312
|
-
if (worktree.recovered)
|
|
371
|
+
if (worktree.recovered && worktree.recovery?.phase !== 'implementation')
|
|
313
372
|
return { source: 'worker', result: {
|
|
314
373
|
kind: 'candidate', storyId: story.id, worktree: worktree.path, baseCommit: worktree.baseCommit,
|
|
315
374
|
provider, summary: 'retained candidate recovered for independent integration checks', evidence: { criteria: [] }, routing: { outcome: 'pending-integration' },
|
|
316
375
|
} };
|
|
376
|
+
workerStarted = true;
|
|
317
377
|
return await (candidateDispatch && options.candidateCoordinator
|
|
318
378
|
? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
|
|
319
379
|
: options.worker(input).then(result => ({ source: 'worker', result })));
|
|
@@ -362,26 +422,27 @@ export function createDispatcher(options) {
|
|
|
362
422
|
if (result.kind === 'paused') {
|
|
363
423
|
if (result.reason === 'decision')
|
|
364
424
|
cancel(`critical decision required for story ${input.story.id}: ${result.summary}`);
|
|
425
|
+
else if (result.reason === 'ambiguity')
|
|
426
|
+
cancel(`ambiguity requires resolution for story ${input.story.id}: ${result.summary}`);
|
|
365
427
|
else
|
|
366
428
|
paused = true;
|
|
367
|
-
|
|
429
|
+
retainWorker(input, result.summary, 'implementation');
|
|
368
430
|
reportProgress();
|
|
369
431
|
return;
|
|
370
432
|
}
|
|
371
433
|
if (result.kind !== 'candidate') {
|
|
372
434
|
failed.push(story.id);
|
|
373
435
|
failureReasons.set(story.id, result.summary);
|
|
374
|
-
|
|
436
|
+
if (result.failure)
|
|
437
|
+
failureDetails.set(story.id, result.failure);
|
|
438
|
+
retainWorker(input, result.summary, 'implementation');
|
|
375
439
|
reportProgress();
|
|
376
440
|
return;
|
|
377
441
|
}
|
|
378
442
|
if (cancellationReason || paused || options.pause?.()) {
|
|
379
443
|
paused = true;
|
|
380
|
-
result.routing.recordOutcome?.(false);
|
|
381
|
-
|
|
382
|
-
cleanupRetained(input);
|
|
383
|
-
else
|
|
384
|
-
cleanup(input);
|
|
444
|
+
result.routing.recordOutcome?.(false, 'infrastructure');
|
|
445
|
+
retainWorker(input, cancellationReason ?? 'paused after worker checks passed; integration is pending', 'integration');
|
|
385
446
|
reportProgress();
|
|
386
447
|
return;
|
|
387
448
|
}
|
|
@@ -391,7 +452,9 @@ export function createDispatcher(options) {
|
|
|
391
452
|
failed.push(story.id);
|
|
392
453
|
failureReasons.set(story.id, error instanceof Error ? error.message : String(error));
|
|
393
454
|
try {
|
|
394
|
-
if (
|
|
455
|
+
if (workerStarted && !candidateDispatch)
|
|
456
|
+
retainWorker(input, error instanceof Error ? error.message : String(error), 'implementation');
|
|
457
|
+
else if (input.worktree.recovered)
|
|
395
458
|
cleanupRetained(input);
|
|
396
459
|
else
|
|
397
460
|
cleanup(input);
|
|
@@ -459,7 +522,7 @@ export function createDispatcher(options) {
|
|
|
459
522
|
if (failed.length > 0) {
|
|
460
523
|
const storyId = failed[0];
|
|
461
524
|
const detail = storyId ? failureReasons.get(storyId) : undefined;
|
|
462
|
-
return { status: 'blocked', ...(storyId && detail ? { reason: `${reopened.includes(storyId) ? `story ${storyId} integration rejected` : `worker ${storyId} failed`}: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
525
|
+
return { status: 'blocked', ...(storyId && failureDetails.has(storyId) ? { failure: failureDetails.get(storyId) } : {}), ...(storyId && detail ? { reason: `${reopened.includes(storyId) ? `story ${storyId} integration rejected` : `worker ${storyId} failed`}: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
463
526
|
}
|
|
464
527
|
if (iterations >= options.maxIterations)
|
|
465
528
|
return { status: 'cap-reached', iterations, integrated, reopened, failed };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { workspaceFingerprint } from '../workspace/fingerprint.js';
|
|
6
|
+
import { statePath } from '../workspace/state.js';
|
|
7
|
+
import { currentContractKey, readPlanningFile } from '../routing/contracts.js';
|
|
8
|
+
const RecordSchema = z.object({
|
|
9
|
+
version: z.literal(1), fingerprint: z.string().regex(/^[a-f0-9]{64}$/u), signature: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
10
|
+
repeats: z.number().int().min(1).max(1_000_000), summary: z.string().max(4096), updatedAt: z.string().datetime(),
|
|
11
|
+
}).strict();
|
|
12
|
+
function recordPath(root, storyId, scope) {
|
|
13
|
+
const key = createHash('sha256').update(JSON.stringify(storyId ?? null)).digest('hex');
|
|
14
|
+
const suffix = scope === undefined ? '' : `.${createHash('sha256').update(scope).digest('hex')}`;
|
|
15
|
+
return statePath(root, 'failure-progress', `${key}${suffix}.json`);
|
|
16
|
+
}
|
|
17
|
+
function stableFailureSummary(summary) {
|
|
18
|
+
return summary.replace(/\u001b\[[0-9;]*m/gu, '')
|
|
19
|
+
.replace(/\[(?:full|truncated) output:[^\]]*\]/gu, '[saved output]')
|
|
20
|
+
.replace(/\[… omitted; \d+ lines, \d+ bytes total …\]/gu, '[omitted output]')
|
|
21
|
+
.split(/\r?\n/u).map(line => {
|
|
22
|
+
// Known test-reporter metadata is not a different failure. Preserve
|
|
23
|
+
// assertion values, error codes and other numbers in diagnostic content.
|
|
24
|
+
if (/^\s*Start at\s+\d{2}:\d{2}:\d{2}\s*$/u.test(line))
|
|
25
|
+
return '';
|
|
26
|
+
if (/^\s*Duration\s+[\d.]+(?:ms|s)\s+\((?:transform|setup|collect|import|tests|environment|prepare)\b.*\)\s*$/u.test(line))
|
|
27
|
+
return '';
|
|
28
|
+
if (/^\s*Time:\s+[\d.]+\s*s(?:, estimated [\d.]+\s*s)?\s*$/u.test(line))
|
|
29
|
+
return '';
|
|
30
|
+
return /^\s*[✓✔✗✘×❯]\s/u.test(line) ? line.replace(/\s+[\d.]+(?:ms|s)\s*$/u, ' [duration]') : line;
|
|
31
|
+
}).join('\n').replace(/\s+/gu, ' ').trim().slice(0, 4096);
|
|
32
|
+
}
|
|
33
|
+
/** Per-story failure checkpoint, outside isolated workers and independent of provider. */
|
|
34
|
+
export function observeFailure(input) {
|
|
35
|
+
const base = {
|
|
36
|
+
kind: input.stage === 'completion' ? 'completion-failed' : 'verification-failed',
|
|
37
|
+
stage: input.stage, ...(input.story ? { storyId: input.story.id } : {}),
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
const file = recordPath(input.root, input.story?.id, input.scope);
|
|
41
|
+
let previous;
|
|
42
|
+
try {
|
|
43
|
+
const stat = lstatSync(file);
|
|
44
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384)
|
|
45
|
+
throw Error('Invalid failure progress record');
|
|
46
|
+
previous = RecordSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== 'ENOENT')
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
// Use the same approved parent contract as durable routing reservations:
|
|
53
|
+
// a revised plan/upstream contract must unblock a retained, unchanged worker.
|
|
54
|
+
const contract = input.story ? currentContractKey(input.root, input.story) : readPlanningFile(input.root, '.yoke/plan.md', 80_000) ?? '';
|
|
55
|
+
const fingerprint = createHash('sha256').update(workspaceFingerprint(input.directory)).update(JSON.stringify(input.story ?? null)).update(contract).digest('hex');
|
|
56
|
+
const summary = stableFailureSummary(input.summary);
|
|
57
|
+
const signature = createHash('sha256').update(JSON.stringify([input.stage, summary])).digest('hex');
|
|
58
|
+
const repeats = previous?.fingerprint === fingerprint && previous.signature === signature ? Math.min(1_000_000, previous.repeats + 1) : 1;
|
|
59
|
+
const record = RecordSchema.parse({ version: 1, fingerprint, signature, repeats, summary, updatedAt: new Date().toISOString() });
|
|
60
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
61
|
+
const temporary = statePath(input.root, 'failure-progress', `${randomUUID()}.tmp`);
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(temporary, JSON.stringify(record), { flag: 'wx', mode: 0o600 });
|
|
64
|
+
recordPath(input.root, input.story?.id, input.scope);
|
|
65
|
+
renameSync(temporary, file);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rmSync(temporary, { force: true });
|
|
69
|
+
}
|
|
70
|
+
const failure = { ...base, fingerprint, signature, repeats };
|
|
71
|
+
if (repeats >= 3)
|
|
72
|
+
return {
|
|
73
|
+
action: 'blocked', failure: { ...failure, kind: 'no-progress' },
|
|
74
|
+
feedback: `Stopped after ${repeats} unchanged failures at ${input.stage}. The diagnostic retry did not change the code, acceptance contract, or failure. Preserve this work and replan or resolve the blocker before automatic continuation. Last failure: ${input.summary}`,
|
|
75
|
+
};
|
|
76
|
+
if (repeats === 2)
|
|
77
|
+
return {
|
|
78
|
+
action: 'diagnose', failure,
|
|
79
|
+
feedback: `The same ${input.stage} failure occurred twice on unchanged code and acceptance. Before another edit, diagnose the root cause from the saved evidence and use a different, testable hypothesis; do not repeat the previous failed action. One diagnostic attempt remains.\n${input.summary}`,
|
|
80
|
+
};
|
|
81
|
+
return { action: 'retry', failure, feedback: input.summary };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { action: 'blocked', failure: { ...base, kind: 'no-progress' }, feedback: `Failure progress could not be verified; automatic continuation stopped: ${error instanceof Error ? error.message : String(error)}. Last failure: ${input.summary}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function clearFailureProgress(root, storyId) {
|
|
88
|
+
const file = recordPath(root, storyId);
|
|
89
|
+
rmSync(file, { force: true });
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = readdirSync(dirname(file));
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === 'ENOENT')
|
|
96
|
+
return;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const prefix = `${basename(file, '.json')}.`;
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
if (name.startsWith(prefix) && /^[a-f0-9]{64}\.json$/u.test(name.slice(prefix.length)))
|
|
102
|
+
rmSync(statePath(root, 'failure-progress', name), { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { workspaceFingerprint } from '../workspace/fingerprint.js';
|
|
3
|
+
export function gateIdentity(directory, story) {
|
|
4
|
+
try {
|
|
5
|
+
return createHash('sha256').update(workspaceFingerprint(directory)).update(JSON.stringify(story)).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Unavailable identity disables reuse; it never turns an unchecked state green.
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function snapshotGates(directory, story, before, evidence) {
|
|
13
|
+
if (!before || before !== gateIdentity(directory, story))
|
|
14
|
+
return undefined;
|
|
15
|
+
return { identity: before, evidence };
|
|
16
|
+
}
|
|
17
|
+
export function reuseGates(directory, story, snapshot) {
|
|
18
|
+
return snapshot && snapshot.identity === gateIdentity(directory, story) ? snapshot.evidence : undefined;
|
|
19
|
+
}
|
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/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'];
|
|
3
|
+
export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/claims', '.yoke/worktrees', '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/routing-attempts', '.yoke/checks', '.yoke/integration-recovery', '.yoke/failure-progress', '.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) {
|