@hecer/yoke 1.19.0 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +4 -1
- package/bench/RESULTS.md +30 -0
- package/bench/analyze-codex-comparison.mjs +22 -0
- package/bench/compare-codex.mjs +71 -0
- package/bench/fixtures/independent-utils/.yoke/prd.yaml +30 -0
- package/bench/fixtures/independent-utils/bench-verify.mjs +4 -0
- package/bench/fixtures/independent-utils/package.json +1 -0
- package/bench/fixtures/independent-utils/src/chunk.mjs +1 -0
- package/bench/fixtures/independent-utils/src/range.mjs +1 -0
- package/bench/fixtures/independent-utils/src/unique.mjs +1 -0
- package/bench/fixtures/independent-utils/tests/STORY-1.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-2.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-3.test.mjs +8 -0
- package/bench/probe-goal-integration.mjs +93 -0
- package/bench/probe-parallel-gate-context.mjs +29 -0
- package/bench/results/codex-comparison-2026-09-29.json +305 -0
- package/bench/results/goal-integration-probes-2026-09-29.json +107 -0
- package/bench/results/native-goal-validation-2026-09-30.json +122 -0
- package/bench/results/parallel-validation-2026-09-30.json +86 -0
- package/dist/agents/process-record-identity.js +33 -0
- package/dist/check/command.js +218 -0
- package/dist/cli.js +85 -12
- package/dist/dashboard/server.js +26 -6
- package/dist/goals/codex-native.js +340 -0
- package/dist/goals/command.js +230 -29
- package/dist/loop/cleanup.js +110 -1
- package/dist/loop/dispatcher.js +82 -10
- package/dist/loop/git.js +1 -1
- package/dist/loop/parallel-adapters.js +53 -7
- package/dist/loop/parallel-command.js +8 -1
- package/dist/loop/recovery.js +81 -2
- package/dist/loop/reporter.js +3 -0
- package/dist/loop/resource-pool.js +21 -15
- package/dist/loop/run-command.js +98 -27
- package/dist/loop/run-state.js +116 -0
- package/dist/prd/explore.js +12 -4
- package/dist/retrofit/command.js +22 -0
- package/dist/retrofit/config.js +1 -0
- package/dist/retrofit/gitignore.js +3 -0
- package/dist/retrofit/planners/claude.js +4 -4
- package/dist/retrofit/wsl.js +23 -3
- package/dist/setup/command.js +47 -5
- package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
- package/docs/CONTINUOUS-EXPLORATION.md +6 -2
- package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
- package/docs/GOALS.md +109 -0
- package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
- package/docs/VERIFIED-PROJECTS.md +4 -4
- package/docs/parallel-execution.md +15 -0
- package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
- package/gemini-extension.json +1 -1
- package/package.json +5 -1
package/dist/goals/command.js
CHANGED
|
@@ -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,
|
|
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({
|
|
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
|
-
|
|
146
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
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 =
|
|
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 ??
|
|
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
|
|
385
|
+
return await finish('blocked', result.summary, report);
|
|
204
386
|
if (controller.signal.aborted)
|
|
205
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/loop/cleanup.js
CHANGED
|
@@ -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
|
+
}
|