@hecer/yoke 1.19.0 → 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +4 -1
  5. package/bench/RESULTS.md +30 -0
  6. package/bench/analyze-codex-comparison.mjs +22 -0
  7. package/bench/compare-codex.mjs +71 -0
  8. package/bench/fixtures/independent-utils/.yoke/prd.yaml +30 -0
  9. package/bench/fixtures/independent-utils/bench-verify.mjs +4 -0
  10. package/bench/fixtures/independent-utils/package.json +1 -0
  11. package/bench/fixtures/independent-utils/src/chunk.mjs +1 -0
  12. package/bench/fixtures/independent-utils/src/range.mjs +1 -0
  13. package/bench/fixtures/independent-utils/src/unique.mjs +1 -0
  14. package/bench/fixtures/independent-utils/tests/STORY-1.test.mjs +8 -0
  15. package/bench/fixtures/independent-utils/tests/STORY-2.test.mjs +8 -0
  16. package/bench/fixtures/independent-utils/tests/STORY-3.test.mjs +8 -0
  17. package/bench/probe-goal-integration.mjs +93 -0
  18. package/bench/probe-parallel-gate-context.mjs +29 -0
  19. package/bench/results/codex-comparison-2026-09-29.json +305 -0
  20. package/bench/results/goal-integration-probes-2026-09-29.json +107 -0
  21. package/bench/results/native-goal-validation-2026-09-30.json +122 -0
  22. package/bench/results/parallel-validation-2026-09-30.json +86 -0
  23. package/dist/agents/process-record-identity.js +33 -0
  24. package/dist/check/command.js +218 -0
  25. package/dist/cli.js +85 -12
  26. package/dist/dashboard/server.js +26 -6
  27. package/dist/goals/codex-native.js +340 -0
  28. package/dist/goals/command.js +230 -29
  29. package/dist/loop/cleanup.js +110 -1
  30. package/dist/loop/dispatcher.js +82 -10
  31. package/dist/loop/git.js +1 -1
  32. package/dist/loop/parallel-adapters.js +53 -7
  33. package/dist/loop/parallel-command.js +8 -1
  34. package/dist/loop/recovery.js +81 -2
  35. package/dist/loop/reporter.js +3 -0
  36. package/dist/loop/resource-pool.js +21 -15
  37. package/dist/loop/run-command.js +98 -27
  38. package/dist/loop/run-state.js +116 -0
  39. package/dist/prd/explore.js +12 -4
  40. package/dist/retrofit/command.js +22 -0
  41. package/dist/retrofit/config.js +1 -0
  42. package/dist/retrofit/gitignore.js +3 -0
  43. package/dist/retrofit/planners/claude.js +4 -4
  44. package/dist/retrofit/wsl.js +23 -3
  45. package/dist/setup/command.js +47 -5
  46. package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
  47. package/docs/CONTINUOUS-EXPLORATION.md +6 -2
  48. package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
  49. package/docs/GOALS.md +109 -0
  50. package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
  51. package/docs/VERIFIED-PROJECTS.md +4 -4
  52. package/docs/parallel-execution.md +15 -0
  53. package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
  54. package/gemini-extension.json +1 -1
  55. package/package.json +5 -1
@@ -1,11 +1,11 @@
1
1
  import { loadConfig } from "../retrofit/config.js";
2
2
  import { makeAsyncAdaptiveRunner } from "../routing/router.js";
3
3
  import { resolvePlanner } from '../routing/planning.js';
4
- import { randomUUID } from 'node:crypto';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { z } from 'zod';
8
- import { acceptanceProtectionProblem, checkProject, loadAcceptance, protectAcceptance } from '../check/command.js';
8
+ import { acceptanceProtectionProblem, checkProjectAsync, loadAcceptance, protectAcceptance } from '../check/command.js';
9
9
  import { acquireLock, releaseLock, isPidAlive } from '../loop/lock.js';
10
10
  import { reapProviderProcesses, isProviderTreeAlive } from '../loop/cleanup.js';
11
11
  import { killProcessTreeForCleanup } from '../loop/watchdog.js';
@@ -13,8 +13,43 @@ import { buildProviderInvocation, startProviderProcess } from '../agents/provide
13
13
  import { AgentSchema } from '../agents/contracts.js';
14
14
  import { appendEvent } from '../observability/events.js';
15
15
  import { statePath } from '../workspace/state.js';
16
+ import { acquireSharedWorker } from '../loop/resource-pool.js';
17
+ import { recordGoalRun } from '../loop/run-state.js';
18
+ import { executeCodexGoal, synchronizeNativeGoal, NativeGoalUnavailableError } from './codex-native.js';
16
19
  const Attempt = z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
17
- const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
20
+ const Goal = z.object({
21
+ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000),
22
+ status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(),
23
+ maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440),
24
+ maxWallMinutes: z.number().positive().max(10080).optional(), wallDurationMs: z.number().nonnegative().default(0),
25
+ tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(),
26
+ acceptanceBinding: z.object({ criterionIds: z.array(z.string()).min(1), manifestDigest: z.string().regex(/^[a-f0-9]{64}$/), objectiveDigest: z.string().regex(/^[a-f0-9]{64}$/) }).strict().optional(),
27
+ nativeBinding: z.object({ provider: z.literal('codex'), threadId: z.string().min(1), objectiveRevision: z.string().regex(/^[a-f0-9]{64}$/), modelProvider: z.string().optional(), model: z.string().optional(), usageInvalid: z.literal(true).optional(), usageBaseline: z.object({ inputTokens: z.number().int().nonnegative().safe(), outputTokens: z.number().int().nonnegative().safe(), cachedInputTokens: z.number().int().nonnegative().safe().optional(), cacheWriteInputTokens: z.number().int().nonnegative().safe().optional(), reasoningOutputTokens: z.number().int().nonnegative().safe().optional() }).strict().optional() }).strict().optional(),
28
+ nativeTokensUsed: z.number().nonnegative().optional(),
29
+ pendingRunStartedAt: z.string().datetime().optional(),
30
+ pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional(),
31
+ }).strict();
32
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
33
+ function acceptanceBinding(root, objective, ids) {
34
+ const manifest = loadAcceptance(root);
35
+ if (!manifest || !ids.length || new Set(ids).size !== ids.length || ids.some(id => !manifest.criteria.some(c => c.id === id && c.commands.length)))
36
+ throw new Error('Bind a nonempty unique list of executable acceptance criteria');
37
+ return { criterionIds: ids, manifestDigest: digest(manifest), objectiveDigest: digest(objective) };
38
+ }
39
+ export function bindProjectGoal(root, ids) {
40
+ const lock = acquireLock(root);
41
+ if (!lock.acquired)
42
+ throw new Error('Project is busy');
43
+ try {
44
+ const goal = readProjectGoal(root);
45
+ if (!goal)
46
+ throw new Error('No project goal');
47
+ return save(root, { ...goal, acceptanceBinding: acceptanceBinding(root, goal.objective, ids) });
48
+ }
49
+ finally {
50
+ releaseLock(root, lock.ownerToken);
51
+ }
52
+ }
18
53
  const goalPath = (root) => statePath(root, 'goal.json');
19
54
  function save(root, goal) {
20
55
  const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
@@ -50,7 +85,7 @@ export function createProjectGoal(root, objective, limits = {}) {
50
85
  const pause = statePath(root, 'goal.pause');
51
86
  if (existsSync(pause))
52
87
  unlinkSync(pause);
53
- return save(root, Goal.parse({ version: 1, id: randomUUID(), objective, status: 'active', createdAt: now, updatedAt: now, maxAttempts: limits.maxAttempts ?? 3, maxMinutes: limits.maxMinutes ?? 30, tokenBudget: limits.tokenBudget, attempts: [] }));
88
+ return save(root, Goal.parse({ version: 1, id: randomUUID(), objective, status: 'active', createdAt: now, updatedAt: now, maxAttempts: limits.maxAttempts ?? 3, maxMinutes: limits.maxMinutes ?? 30, maxWallMinutes: limits.maxWallMinutes, tokenBudget: limits.tokenBudget, attempts: [], acceptanceBinding: limits.acceptanceIds ? acceptanceBinding(root, objective.trim(), limits.acceptanceIds) : undefined }));
54
89
  }
55
90
  finally {
56
91
  releaseLock(root, lock.ownerToken);
@@ -77,7 +112,7 @@ export function budgetProjectGoal(root, limits) {
77
112
  const goal = readProjectGoal(root);
78
113
  if (!goal)
79
114
  throw new Error('No project goal');
80
- return save(root, { ...goal, maxAttempts: limits.maxAttempts ?? goal.maxAttempts, maxMinutes: limits.maxMinutes ?? goal.maxMinutes, tokenBudget: limits.clearTokenBudget ? undefined : limits.tokenBudget ?? goal.tokenBudget });
115
+ return save(root, { ...goal, maxAttempts: limits.maxAttempts ?? goal.maxAttempts, maxMinutes: limits.maxMinutes ?? goal.maxMinutes, maxWallMinutes: limits.maxWallMinutes ?? goal.maxWallMinutes, tokenBudget: limits.clearTokenBudget ? undefined : limits.tokenBudget ?? goal.tokenBudget });
81
116
  }
82
117
  finally {
83
118
  releaseLock(root, lock.ownerToken);
@@ -101,6 +136,8 @@ export function goalHandoff(root) {
101
136
  async function executeAgent(input) {
102
137
  const handle = startProviderProcess(input.provider, buildProviderInvocation(input.provider, input.prompt, input.root, 'safe', input.selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
103
138
  const result = await handle.completion;
139
+ if (handle.recordPath && existsSync(handle.recordPath))
140
+ throw Object.assign(new Error('Provider process cleanup could not be confirmed'), { cleanupUnconfirmed: true });
104
141
  return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens, tokens: result.telemetry.tokens };
105
142
  }
106
143
  export async function runProjectGoal(root, options = {}) {
@@ -108,10 +145,62 @@ export async function runProjectGoal(root, options = {}) {
108
145
  if (!lock.acquired)
109
146
  throw new Error('Project is busy; goal and story loops share one lock');
110
147
  let goal = null;
148
+ let runStarted;
149
+ let wallAtStart = 0;
150
+ let wallTimer;
151
+ let pauseTimer;
152
+ let cleanupUnconfirmed = false;
153
+ let resolvedSelection;
154
+ let nativeEnabled = false;
155
+ const controller = new AbortController();
156
+ const tokensUsed = () => goal.attempts.reduce((sum, a) => sum + (a.inputTokens ?? 0) + (a.outputTokens ?? 0), 0);
157
+ const finish = async (status, reason, report) => {
158
+ if (status === 'paused' && existsSync(statePath(root, 'goal.pause')))
159
+ unlinkSync(statePath(root, 'goal.pause'));
160
+ if (goal.nativeBinding && nativeEnabled) {
161
+ const nativeStatus = status === 'complete' ? 'complete' : status === 'paused' ? 'paused' : /token/i.test(reason) ? 'budgetLimited' : 'blocked';
162
+ try {
163
+ const lease = await acquireSharedWorker({ targetDir: root, storyId: goal.id, provider: 'codex', role: 'integration', signal: AbortSignal.timeout(30_000) });
164
+ let retain = false;
165
+ try {
166
+ await synchronizeNativeGoal({ root, binding: goal.nativeBinding, objective: goal.objective, status: nativeStatus, bare: resolvedSelection?.bare, signal: status === 'complete' ? AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) });
167
+ }
168
+ catch (error) {
169
+ const cleanup = error;
170
+ retain = cleanup.cleanupUnconfirmed === true;
171
+ if (retain)
172
+ void cleanup.cleanupConfirmed?.then(() => lease.release()).catch(() => undefined);
173
+ throw error;
174
+ }
175
+ finally {
176
+ if (!retain)
177
+ await lease.release();
178
+ }
179
+ }
180
+ catch (error) {
181
+ status = 'blocked';
182
+ reason += `; native state synchronization failed: ${error.message}`;
183
+ }
184
+ }
185
+ if (status === 'complete' && controller.signal.aborted) {
186
+ status = existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked';
187
+ reason = `${String(controller.signal.reason)}; verified work retained`;
188
+ }
189
+ if (status === 'paused' && existsSync(statePath(root, 'goal.pause')))
190
+ unlinkSync(statePath(root, 'goal.pause'));
191
+ goal = save(root, { ...goal, status, reason, lastCheck: report?.id ?? goal.lastCheck });
192
+ return goal;
193
+ };
111
194
  try {
112
195
  goal = readProjectGoal(root);
113
196
  if (!goal)
114
197
  throw new Error('Set a goal first');
198
+ if (options.expectedGoalId && goal.id !== options.expectedGoalId) {
199
+ goal = null;
200
+ throw new Error('Saved goal identity changed before resume');
201
+ }
202
+ if (goal.pendingRunStartedAt)
203
+ goal = save(root, { ...goal, wallDurationMs: goal.wallDurationMs + Math.max(0, Date.now() - Date.parse(goal.pendingRunStartedAt)), pendingRunStartedAt: undefined });
115
204
  // Exclusive project lock held. Reconcile only this project's recorded trees;
116
205
  // unknown ownership stays blocked rather than racing an orphaned worker.
117
206
  reapProviderProcesses(root, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
@@ -126,6 +215,10 @@ export async function runProjectGoal(root, options = {}) {
126
215
  const manifest = loadAcceptance(root);
127
216
  if (!manifest?.criteria.length || manifest.criteria.some(c => c.commands.length === 0) || !manifest.protected.length)
128
217
  return save(root, { ...goal, status: 'blocked', reason: 'Map every acceptance criterion to an executable command and explicitly protect its test infrastructure before running a goal' });
218
+ if (!goal.acceptanceBinding)
219
+ return save(root, { ...goal, status: 'blocked', reason: 'Bind this objective explicitly with goal bind --criteria=id1,id2 before running it' });
220
+ if (goal.acceptanceBinding.manifestDigest !== digest(manifest) || goal.acceptanceBinding.objectiveDigest !== digest(goal.objective))
221
+ return save(root, { ...goal, status: 'blocked', reason: 'Acceptance binding changed; review the objective and acceptance criteria before rebinding' });
129
222
  try {
130
223
  protectAcceptance(root);
131
224
  }
@@ -133,85 +226,193 @@ export async function runProjectGoal(root, options = {}) {
133
226
  if (error.code !== 'EEXIST')
134
227
  throw error;
135
228
  }
136
- const provider = options.provider ?? 'codex';
137
- const rawExecute = options.execute ?? executeAgent;
138
229
  const config = loadConfig(root);
230
+ const provider = options.provider ?? config?.runner?.agent ?? 'codex';
231
+ const configured = config?.runner;
232
+ const selection = { provider: configured?.provider, model: configured?.model, reasoningEffort: configured?.reasoningEffort, variant: configured?.variant, bare: configured?.bare, ...Object.fromEntries(Object.entries(options.selection ?? {}).filter(([, value]) => value !== undefined)), nativeMultiAgent: false };
233
+ resolvedSelection = selection;
234
+ const native = options.native ?? config?.goals?.nativeCodex ?? false;
235
+ nativeEnabled = native;
236
+ if (native && provider === 'codex' && selection.bare) {
237
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Native Codex app-server does not support bare startup; use --no-native-goal to retain bare execution or explicitly disable runner.bare' });
238
+ return goal;
239
+ }
240
+ recordGoalRun(root, goal.id, lock.ownerToken, { provider, selection, native });
241
+ wallAtStart = goal.wallDurationMs;
242
+ runStarted = Date.now();
243
+ if (goal.maxWallMinutes !== undefined) {
244
+ const remainingWall = goal.maxWallMinutes * 60_000 - wallAtStart;
245
+ if (remainingWall <= 0)
246
+ return await finish('blocked', 'Wall-time budget exhausted; unfinished work retained');
247
+ wallTimer = setTimeout(() => controller.abort('Wall-time budget exhausted'), remainingWall);
248
+ }
249
+ goal = save(root, { ...goal, pendingRunStartedAt: new Date(runStarted).toISOString() });
250
+ pauseTimer = setInterval(() => { if (existsSync(statePath(root, 'goal.pause')))
251
+ controller.abort('Pause requested'); }, 100);
252
+ let attemptAgentMs = 0;
253
+ const admitted = async (input, operation) => {
254
+ const queued = Date.now();
255
+ const lease = await acquireSharedWorker({ targetDir: root, storyId: goal.id, provider: input.provider, role: 'implementation', signal: input.signal });
256
+ const started = Date.now();
257
+ const remaining = goal.maxMinutes * 60_000 - goal.attempts.reduce((sum, a) => sum + a.durationMs, 0) - attemptAgentMs;
258
+ const timer = setTimeout(() => controller.abort('Goal agent-time budget exceeded'), Math.max(0, remaining));
259
+ try {
260
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'resource-wait-implementation', durationMs: started - queued });
261
+ if (remaining <= 0 || input.signal.aborted)
262
+ throw new Error('Goal execution cancelled or agent-time budget exhausted');
263
+ return await operation();
264
+ }
265
+ catch (error) {
266
+ const cleanup = error;
267
+ cleanupUnconfirmed ||= cleanup.cleanupUnconfirmed === true;
268
+ if (cleanup.cleanupUnconfirmed)
269
+ void cleanup.cleanupConfirmed?.then(() => lease.release()).catch(() => undefined);
270
+ throw error;
271
+ }
272
+ finally {
273
+ clearTimeout(timer);
274
+ attemptAgentMs += Date.now() - started;
275
+ if (!cleanupUnconfirmed)
276
+ await lease.release();
277
+ }
278
+ };
279
+ const rawExecute = async (input) => {
280
+ return admitted(input, async () => {
281
+ if (options.execute)
282
+ return await options.execute(input);
283
+ if (native && input.provider === 'codex') {
284
+ let binding = goal.nativeBinding;
285
+ if (binding && (binding.model !== input.selection.model || binding.modelProvider !== input.selection.provider))
286
+ binding = undefined;
287
+ try {
288
+ const result = await executeCodexGoal({ ...input, objective: goal.objective, tokenBudget: goal.tokenBudget === undefined ? undefined : goal.tokenBudget - tokensUsed(), binding, onBinding: (nativeBinding) => { goal = save(root, { ...goal, nativeBinding }); }, onUsageUpdate: (usage) => {
289
+ if (goal.tokenBudget !== undefined && usage.inputTokens !== undefined && usage.outputTokens !== undefined && tokensUsed() + usage.inputTokens + usage.outputTokens > goal.tokenBudget)
290
+ controller.abort('Token budget exceeded during native execution');
291
+ } });
292
+ goal = save(root, { ...goal, nativeTokensUsed: result.nativeTokensUsed });
293
+ return result;
294
+ }
295
+ catch (error) {
296
+ if (!(error instanceof NativeGoalUnavailableError))
297
+ throw error;
298
+ goal = save(root, { ...goal, nativeBinding: undefined, nativeTokensUsed: undefined });
299
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'status', data: { nativeGoal: 'unavailable', fallback: 'codex-exec' } });
300
+ }
301
+ }
302
+ return await executeAgent(input);
303
+ });
304
+ };
139
305
  const execute = async (input) => {
140
306
  if (!config?.routing?.enabled || config.routing.strategy !== "capability")
141
307
  return rawExecute(input);
142
308
  const routed = makeAsyncAdaptiveRunner({ parent: provider, parentSelection: input.selection, projectRoot: root, workers: config.routing.workers, strategy: "capability", maxCandidates: config.routing.maxCandidates, maxAttempts: Math.min(goal.maxAttempts, config.routing.maxAttempts ?? 5),
143
309
  planner: resolvePlanner(config, provider, input.selection), fallback: config.routing.fallback, maxTier: config.routing.maxTier,
144
310
  captureRoute: async (agent, context, prompt, selection) => {
145
- const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
146
- return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
311
+ return admitted({ ...input, provider: agent }, async () => {
312
+ const handle = startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", { ...selection, nativeMultiAgent: false }), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
313
+ const run = await handle.completion;
314
+ if (handle.recordPath && existsSync(handle.recordPath))
315
+ throw Object.assign(new Error('Planner process cleanup could not be confirmed'), { cleanupUnconfirmed: true });
316
+ return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
317
+ });
147
318
  },
148
319
  makeWorker: (agent, selection) => async (context) => {
149
- const result = await rawExecute({ ...input, provider: agent, selection, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
320
+ const result = await rawExecute({ ...input, provider: agent, selection: { ...selection, nativeMultiAgent: false }, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
150
321
  return { ...result, infrastructureFailure: !result.success, tokens: result.tokens ?? (result.inputTokens !== undefined && result.outputTokens !== undefined ? { inputTokens: result.inputTokens, outputTokens: result.outputTokens, model: result.model } : undefined) };
151
322
  }, });
152
323
  const result = await routed({ targetDir: root, story: { id: goal.id, title: goal.objective, priority: 1, passes: false, agent: provider, acceptance: manifest.criteria.map(c => c.text) } });
153
324
  const last = result.tokens?.calls?.at(-1);
154
325
  return { ...result, provider: last?.provider ?? provider, model: last?.actualModel, inputTokens: result.tokens?.measurementComplete ? result.tokens.inputTokens : undefined, outputTokens: result.tokens?.measurementComplete ? result.tokens.outputTokens : undefined };
155
326
  };
156
- let report = checkProject(root);
327
+ let report = await checkProjectAsync(root, { signal: controller.signal });
328
+ if (report.cleanupUnconfirmed) {
329
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Verification cleanup could not be confirmed; retained resource ownership requires inspection', lastCheck: report.id });
330
+ return goal;
331
+ }
157
332
  while (report.status !== 'passed') {
158
333
  const protectionProblem = acceptanceProtectionProblem(root);
159
334
  if (protectionProblem)
160
- return save(root, { ...goal, status: 'blocked', reason: protectionProblem, lastCheck: report.id });
335
+ return await finish('blocked', protectionProblem, report);
161
336
  const pause = statePath(root, 'goal.pause');
162
337
  if (existsSync(pause)) {
163
338
  unlinkSync(pause);
164
- return save(root, { ...goal, status: 'paused', lastCheck: report.id, reason: 'Paused at a safe attempt boundary' });
339
+ return await finish('paused', 'Paused at a safe attempt boundary', report);
165
340
  }
341
+ if (controller.signal.aborted)
342
+ return await finish('blocked', String(controller.signal.reason), report);
166
343
  const remaining = goal.maxMinutes * 60_000 - goal.attempts.reduce((sum, a) => sum + a.durationMs, 0);
167
344
  if (remaining <= 0 || goal.attempts.length >= goal.maxAttempts)
168
- return save(root, { ...goal, status: 'blocked', lastCheck: report.id, reason: 'Execution budget exhausted; unfinished work retained' });
345
+ return await finish('blocked', 'Execution budget exhausted; unfinished work retained', report);
169
346
  if (goal.tokenBudget !== undefined && goal.attempts.length > 0) {
170
347
  const unknown = goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined);
171
348
  const used = goal.attempts.reduce((sum, a) => sum + (a.inputTokens ?? 0) + (a.outputTokens ?? 0), 0);
172
349
  if (unknown || used >= goal.tokenBudget)
173
- return save(root, { ...goal, status: 'blocked', reason: unknown ? 'Token usage unknown; cannot safely start another budgeted attempt' : 'Token budget exhausted' });
350
+ return await finish('blocked', unknown ? 'Token usage unknown; cannot safely start another budgeted attempt' : 'Token budget exhausted', report);
174
351
  }
175
352
  const started = Date.now();
176
- goal = save(root, { ...goal, status: 'running', lastCheck: report.id, reason: undefined, pendingAttempt: { provider, model: options.selection?.model, startedAt: new Date(started).toISOString() } });
177
- const controller = new AbortController();
178
- const timer = setTimeout(() => controller.abort('Goal time budget exceeded'), remaining);
353
+ goal = save(root, { ...goal, status: 'running', lastCheck: report.id, reason: undefined, pendingAttempt: { provider, model: selection.model, startedAt: new Date(started).toISOString() } });
354
+ attemptAgentMs = 0;
179
355
  let result;
180
356
  try {
181
- result = await execute({ root, provider, selection: options.selection ?? {}, signal: controller.signal, prompt: `${goalHandoff(root)}\nCurrent independent findings:\n${JSON.stringify(report.criteria).slice(0, 12000)}` });
357
+ result = await execute({ root, provider, selection, signal: controller.signal, prompt: `${goalHandoff(root)}\nCurrent independent findings:\n${JSON.stringify(report.criteria).slice(0, 12000)}` });
182
358
  }
183
359
  catch (error) {
184
360
  result = { success: false, summary: error.message };
185
361
  }
186
- finally {
187
- clearTimeout(timer);
362
+ const agentDurationMs = attemptAgentMs;
363
+ if (cleanupUnconfirmed) {
364
+ goal.attempts.push({ provider, model: selection.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: false, summary: result.summary, checkId: report.id });
365
+ goal = save(root, { ...goal, pendingAttempt: undefined, status: 'blocked', reason: 'Provider cleanup could not be confirmed; process record and resource reservation retained' });
366
+ return goal;
188
367
  }
189
- const agentDurationMs = Date.now() - started;
190
368
  const checkStarted = Date.now();
191
- report = checkProject(root);
369
+ report = await checkProjectAsync(root, { signal: controller.signal });
192
370
  if (!result.routing?.blocked)
193
371
  result.routing?.recordOutcome(report.status === 'passed');
194
372
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'verify', durationMs: Date.now() - checkStarted, outcome: report.status });
195
- goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
373
+ goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? selection.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
196
374
  const usageAvailable = result.inputTokens !== undefined && result.outputTokens !== undefined;
197
375
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { ...result.tokens, provider: result.provider ?? provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
198
376
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, usageAvailable } });
199
377
  if (report.status === 'passed')
200
378
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'accepted', attemptId: `${goal.id}:${goal.attempts.length}` });
201
379
  goal = save(root, { ...goal, lastCheck: report.id, pendingAttempt: undefined });
380
+ if (report.cleanupUnconfirmed) {
381
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Verification cleanup could not be confirmed; process record and resource reservation retained' });
382
+ return goal;
383
+ }
202
384
  if (result.routing?.blocked)
203
- return save(root, { ...goal, status: "blocked", reason: result.summary });
385
+ return await finish('blocked', result.summary, report);
204
386
  if (controller.signal.aborted)
205
- return save(root, { ...goal, status: 'blocked', reason: 'Time budget exceeded; work retained' });
387
+ return await finish(existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked', `${String(controller.signal.reason)}; work retained`, report);
388
+ if (goal.tokenBudget !== undefined && (goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined) || tokensUsed() > goal.tokenBudget))
389
+ return await finish('blocked', 'Token budget exhausted or usage unknown; work retained', report);
206
390
  }
207
- return save(root, { ...goal, status: 'complete', lastCheck: report.id, reason: 'All executable acceptance checks passed on the current workspace' });
391
+ if (controller.signal.aborted)
392
+ return await finish(existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked', String(controller.signal.reason), report);
393
+ if (goal.tokenBudget !== undefined && goal.attempts.length && (goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined) || tokensUsed() > goal.tokenBudget))
394
+ return await finish('blocked', 'Token budget exhausted or usage unknown; work retained', report);
395
+ return await finish('complete', 'All bound acceptance criteria and regression checks passed on the current workspace', report);
208
396
  }
209
397
  catch (error) {
210
398
  if (goal)
211
- save(root, { ...goal, status: 'blocked', reason: error.message });
399
+ goal = save(root, { ...goal, status: 'blocked', reason: error.message });
212
400
  throw error;
213
401
  }
214
402
  finally {
215
- releaseLock(root, lock.ownerToken);
403
+ if (wallTimer)
404
+ clearTimeout(wallTimer);
405
+ if (pauseTimer)
406
+ clearInterval(pauseTimer);
407
+ try {
408
+ if (goal && runStarted !== undefined) {
409
+ goal.wallDurationMs = wallAtStart + Math.max(0, Date.now() - runStarted);
410
+ goal.pendingRunStartedAt = undefined;
411
+ save(root, goal);
412
+ }
413
+ }
414
+ finally {
415
+ releaseLock(root, lock.ownerToken);
416
+ }
216
417
  }
217
418
  }
@@ -7,6 +7,8 @@ import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverL
7
7
  import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js';
8
8
  import { cleanupClaims } from './claims.js';
9
9
  import { clearStatus } from './reporter.js';
10
+ import { discardParallelRecoveryRecords } from './recovery.js';
11
+ import { listProjects } from '../dashboard/registry.js';
10
12
  // Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
11
13
  // .yoke/runner.pid files (main dir + each worktree). Never by process-name or
12
14
  // command-line pattern — that takes down runners belonging to OTHER projects,
@@ -217,8 +219,16 @@ export function runLoopCleanup(targetDir, opts = {}) {
217
219
  }
218
220
  if (killed > 0)
219
221
  console.log(`Killed ${killed} orphaned process tree(s) from project-scoped Yoke records.`);
220
- if (opts.removeWorktrees && failed === 0)
222
+ if (opts.removeWorktrees && failed === 0) {
221
223
  cleanupClaims(targetDir);
224
+ try {
225
+ discardParallelRecoveryRecords(targetDir);
226
+ }
227
+ catch (error) {
228
+ console.error(`Failed to discard parallel recovery records: ${error.message}`);
229
+ failed++;
230
+ }
231
+ }
222
232
  const lockFile = lockPath(targetDir);
223
233
  if (existsSync(lockFile)) {
224
234
  rmSync(lockFile, { force: true });
@@ -257,3 +267,102 @@ function markCandidateWorktreeRemoved(targetDir, worktree) {
257
267
  }
258
268
  }
259
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
+ }