@hecer/yoke 1.20.0 → 1.22.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.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +6 -1
  5. package/bench/analyze-codex-comparison.mjs +90 -17
  6. package/bench/compare-codex.mjs +159 -36
  7. package/bench/result-schema.mjs +132 -0
  8. package/canon/manifest.yaml +1 -1
  9. package/dist/agents/pi-telemetry.js +2 -1
  10. package/dist/agents/process-streams.js +12 -64
  11. package/dist/agents/provider-selection.js +12 -0
  12. package/dist/agents/telemetry.js +52 -52
  13. package/dist/change/inbox.js +8 -3
  14. package/dist/check/command.js +69 -17
  15. package/dist/check/delivery.js +121 -0
  16. package/dist/cli.js +86 -8
  17. package/dist/code-intelligence/budgets.js +138 -0
  18. package/dist/code-intelligence/contracts.js +2 -0
  19. package/dist/code-intelligence/coordinator.js +156 -84
  20. package/dist/code-intelligence/evidence.js +87 -34
  21. package/dist/code-intelligence/mcp-client.js +10 -2
  22. package/dist/code-intelligence/mcp-server.js +10 -10
  23. package/dist/dashboard/analytics.js +5 -3
  24. package/dist/goals/command.js +183 -53
  25. package/dist/goals/usage.js +87 -0
  26. package/dist/loop/candidate-cleanup.js +47 -17
  27. package/dist/loop/candidates.js +17 -11
  28. package/dist/loop/cleanup.js +100 -0
  29. package/dist/loop/dispatcher.js +89 -26
  30. package/dist/loop/failure.js +104 -0
  31. package/dist/loop/gate-snapshot.js +19 -0
  32. package/dist/loop/git.js +1 -1
  33. package/dist/loop/loop.js +100 -66
  34. package/dist/loop/parallel-adapters.js +22 -4
  35. package/dist/loop/parallel-command.js +32 -5
  36. package/dist/loop/recovery.js +23 -5
  37. package/dist/loop/reporter.js +21 -4
  38. package/dist/loop/run-command.js +102 -48
  39. package/dist/loop/runner.js +5 -4
  40. package/dist/loop/worker.js +145 -91
  41. package/dist/observability/history.js +1 -1
  42. package/dist/observability/invocation.js +42 -0
  43. package/dist/observability/usage.js +17 -0
  44. package/dist/prd/command.js +20 -7
  45. package/dist/prd/decompose.js +5 -2
  46. package/dist/retrofit/command.js +22 -0
  47. package/dist/retrofit/config.js +26 -1
  48. package/dist/retrofit/gitignore.js +2 -0
  49. package/dist/retrofit/planners/claude.js +4 -4
  50. package/dist/retrofit/wsl.js +23 -3
  51. package/dist/routing/attempts.js +241 -0
  52. package/dist/routing/capability.js +13 -9
  53. package/dist/routing/optimization.js +73 -0
  54. package/dist/routing/registry.js +7 -1
  55. package/dist/routing/router.js +280 -127
  56. package/dist/setup/command.js +54 -6
  57. package/dist/smoke/command.js +302 -74
  58. package/docs/BENCHMARK-MANIFEST.md +131 -0
  59. package/docs/CODE-INTELLIGENCE.md +43 -1
  60. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  61. package/docs/DELIVERY-JOURNEYS.md +199 -0
  62. package/docs/ECONOMIC-ROUTING.md +180 -0
  63. package/docs/GOALS.md +61 -4
  64. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  65. package/docs/parallel-execution.md +37 -9
  66. package/gemini-extension.json +1 -1
  67. 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
- ownership: {
53
- candidateId: state.ownership.candidateId,
54
- coordinatorId: state.ownership.coordinatorId,
55
- ownerToken: state.ownership.ownerToken,
56
- storyId: state.ownership.storyId,
57
- provider: { ...state.ownership.provider },
58
- worktree: { ...state.ownership.worktree },
59
- },
60
- materialized: state.materialized,
61
- completedStages: stageOrder.filter(stage => state.completed.has(stage)),
62
- failedStages: stageOrder.flatMap(stage => {
63
- const summary = state.failed.get(stage);
64
- return summary ? [{ stage, summary }] : [];
65
- }),
66
- pendingStages: stageOrder.filter(stage => !state.completed.has(stage) && !state.failed.has(stage)),
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))
@@ -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 cleanup.cleanupAll('candidate coordination paused');
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: 'candidate coordination paused' };
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
- await cleanup.cleanup(run.owned, run.result.summary);
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
- await cleanup.cleanup(candidate, 'candidate was not selected');
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 cleanup.cleanupAll(`candidate selection was inconsistent: ${selection.reason}`);
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.cleanup(selection.candidate, 'candidate cleanup failed');
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 cleanup.cleanupAll('candidate coordinator failed');
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
  }
@@ -8,6 +8,7 @@ import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js'
8
8
  import { cleanupClaims } from './claims.js';
9
9
  import { clearStatus } from './reporter.js';
10
10
  import { discardParallelRecoveryRecords } from './recovery.js';
11
+ import { listProjects } from '../dashboard/registry.js';
11
12
  // Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
12
13
  // .yoke/runner.pid files (main dir + each worktree). Never by process-name or
13
14
  // command-line pattern — that takes down runners belonging to OTHER projects,
@@ -266,3 +267,102 @@ function markCandidateWorktreeRemoved(targetDir, worktree) {
266
267
  }
267
268
  }
268
269
  }
270
+ export function pruneWorktrees(targetDir, opts = {}) {
271
+ const result = { targetDir, removed: [], failed: [] };
272
+ const wtDir = join(targetDir, '.yoke', 'worktrees');
273
+ const candidates = new Set();
274
+ if (existsSync(wtDir)) {
275
+ try {
276
+ for (const entry of readdirSync(wtDir, { withFileTypes: true })) {
277
+ if (entry.isDirectory())
278
+ candidates.add(join(wtDir, entry.name));
279
+ }
280
+ }
281
+ catch { /* ignore read errors */ }
282
+ }
283
+ try {
284
+ const raw = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
285
+ const lines = raw.split(/\r?\n/);
286
+ for (const line of lines) {
287
+ if (line.startsWith('worktree ')) {
288
+ const wtPath = line.slice(9).trim();
289
+ if (wtPath.includes('.yoke/worktrees') || wtPath.includes('.yoke\\worktrees') || wtPath.includes('/.worktrees/') || wtPath.includes('\\.worktrees\\') || wtPath.includes('-story')) {
290
+ candidates.add(wtPath);
291
+ }
292
+ }
293
+ }
294
+ }
295
+ catch { /* not a git repo or git error */ }
296
+ for (const wtPath of candidates) {
297
+ try {
298
+ try {
299
+ execFileSync('git', ['worktree', 'remove', '--force', wtPath], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'] });
300
+ }
301
+ catch {
302
+ if (existsSync(wtPath)) {
303
+ rmSync(wtPath, { recursive: true, force: true });
304
+ }
305
+ }
306
+ markCandidateWorktreeRemoved(targetDir, wtPath);
307
+ result.removed.push(wtPath);
308
+ }
309
+ catch (e) {
310
+ result.failed.push(`${wtPath}: ${e.message}`);
311
+ }
312
+ }
313
+ try {
314
+ execFileSync('git', ['worktree', 'prune'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'] });
315
+ }
316
+ catch { /* best-effort */ }
317
+ return result;
318
+ }
319
+ export function listWorktrees(targetDir) {
320
+ const wtDir = join(targetDir, '.yoke', 'worktrees');
321
+ const candidates = new Set();
322
+ if (existsSync(wtDir)) {
323
+ try {
324
+ for (const entry of readdirSync(wtDir, { withFileTypes: true })) {
325
+ if (entry.isDirectory())
326
+ candidates.add(join(wtDir, entry.name));
327
+ }
328
+ }
329
+ catch { /* ignore read errors */ }
330
+ }
331
+ try {
332
+ const raw = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
333
+ const lines = raw.split(/\r?\n/);
334
+ for (const line of lines) {
335
+ if (line.startsWith('worktree ')) {
336
+ const wtPath = line.slice(9).trim();
337
+ if (wtPath.includes('.yoke/worktrees') || wtPath.includes('.yoke\\worktrees') || wtPath.includes('/.worktrees/') || wtPath.includes('\\.worktrees\\') || wtPath.includes('-story')) {
338
+ candidates.add(wtPath);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ catch { /* not a git repo or git error */ }
344
+ return [...candidates];
345
+ }
346
+ export function listFleetWorktrees() {
347
+ const projects = listProjects();
348
+ const results = {};
349
+ for (const proj of projects) {
350
+ if (proj.root && !proj.error && existsSync(proj.root)) {
351
+ const list = listWorktrees(proj.root);
352
+ if (list.length > 0) {
353
+ results[proj.name || proj.root] = list;
354
+ }
355
+ }
356
+ }
357
+ return results;
358
+ }
359
+ export function pruneFleetWorktrees(opts = {}) {
360
+ const projects = listProjects();
361
+ const fleetResults = {};
362
+ for (const proj of projects) {
363
+ if (proj.root && !proj.error && existsSync(proj.root)) {
364
+ fleetResults[proj.name || proj.root] = pruneWorktrees(proj.root, opts);
365
+ }
366
+ }
367
+ return fleetResults;
368
+ }
@@ -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
- result.routing.recordOutcome?.(true);
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
- result.routing.recordOutcome?.(true);
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
- result.routing.recordOutcome?.(false);
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
- queued.delete(input.story.id);
245
- integrationControllers.delete(input.story.id);
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
- if (integrationLease)
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
- if (retained)
254
- cleanupRetained(input);
255
- else
256
- cleanup(input);
257
- reportProgress();
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
- cleanup(input);
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
- cleanup(input);
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
- if (input.worktree.recovered)
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 (input.worktree.recovered)
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
+ }