@hecer/yoke 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +32 -14
- package/bench/README.md +10 -0
- package/bench/RESULTS.md +23 -0
- package/bench/fixtures/parallel-work/scenarios.json +33 -0
- package/bench/run-parallel-matrix.mjs +71 -0
- package/canon/loop/loop-spec.md +20 -3
- package/canon/manifest.yaml +1 -1
- package/dist/cli.js +96 -7
- package/dist/dashboard/panels.js +3 -1
- package/dist/dashboard/server.js +11 -2
- package/dist/estimation/schedule.js +96 -33
- package/dist/loop/claim-lease.js +1 -1
- package/dist/loop/dispatcher.js +76 -26
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +13 -1
- package/dist/loop/parallel-command.js +21 -0
- package/dist/loop/prd.js +11 -0
- package/dist/loop/reporter.js +27 -7
- package/dist/loop/resource-pool.js +369 -0
- package/dist/loop/run-command.js +436 -23
- package/dist/prd/decompose.js +232 -0
- package/dist/prd/explore.js +441 -0
- package/dist/retrofit/config.js +1 -1
- package/docs/CONTINUOUS-EXPLORATION.md +67 -0
- package/docs/VERIFIED-PROJECTS.md +4 -2
- package/docs/parallel-execution.md +130 -0
- package/docs/superpowers/plans/2026-09-27-parallel-task-efficiency.md +65 -0
- package/gemini-extension.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { AgentSchema } from '../agents/contracts.js';
|
|
7
|
+
import { processIncarnation } from '../agents/process-incarnation.js';
|
|
8
|
+
import { isPidAlive } from './lock.js';
|
|
9
|
+
import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
|
|
10
|
+
export const MAX_PROJECT_WORKERS = 8;
|
|
11
|
+
export const DEFAULT_GLOBAL_WORKERS = 3;
|
|
12
|
+
const STALE_OWNER_MS = 30_000;
|
|
13
|
+
const WAIT_POLL_MS = 200;
|
|
14
|
+
const MAX_QUEUE_OVERTAKES = 3;
|
|
15
|
+
const POOL_SCHEMA_VERSION = 1;
|
|
16
|
+
const PoolRecordSchema = z.object({
|
|
17
|
+
schemaVersion: z.literal(POOL_SCHEMA_VERSION),
|
|
18
|
+
id: z.string().uuid(),
|
|
19
|
+
token: z.string().uuid(),
|
|
20
|
+
pid: z.number().int().positive(),
|
|
21
|
+
incarnation: z.string().min(1).optional(),
|
|
22
|
+
project: z.string().regex(/^[a-f0-9]{16}$/u),
|
|
23
|
+
storyId: z.string().min(1).max(500),
|
|
24
|
+
provider: AgentSchema,
|
|
25
|
+
role: z.enum(['implementation', 'integration']),
|
|
26
|
+
units: z.number().int().min(1).max(MAX_PROJECT_WORKERS),
|
|
27
|
+
limit: z.number().int().min(1).max(MAX_PROJECT_WORKERS).default(DEFAULT_GLOBAL_WORKERS),
|
|
28
|
+
overtakes: z.number().int().nonnegative().default(0),
|
|
29
|
+
state: z.enum(['waiting', 'active']),
|
|
30
|
+
createdAt: z.string().datetime(),
|
|
31
|
+
});
|
|
32
|
+
const identities = new Map();
|
|
33
|
+
let cachedGlobalLimit;
|
|
34
|
+
const syncWaitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
35
|
+
export function globalWorkerLimit(environment = process.env) {
|
|
36
|
+
if (environment === process.env && cachedGlobalLimit !== undefined)
|
|
37
|
+
return cachedGlobalLimit;
|
|
38
|
+
const raw = environment.YOKE_MAX_PARALLEL_WORKERS;
|
|
39
|
+
if (raw === undefined || raw === '') {
|
|
40
|
+
if (environment === process.env)
|
|
41
|
+
cachedGlobalLimit = DEFAULT_GLOBAL_WORKERS;
|
|
42
|
+
return DEFAULT_GLOBAL_WORKERS;
|
|
43
|
+
}
|
|
44
|
+
if (!/^\d+$/u.test(raw))
|
|
45
|
+
throw new Error('YOKE_MAX_PARALLEL_WORKERS must be an integer from 1 to 8');
|
|
46
|
+
const value = Number(raw);
|
|
47
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_PROJECT_WORKERS) {
|
|
48
|
+
throw new Error('YOKE_MAX_PARALLEL_WORKERS must be an integer from 1 to 8');
|
|
49
|
+
}
|
|
50
|
+
if (environment === process.env)
|
|
51
|
+
cachedGlobalLimit = value;
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function poolDir() {
|
|
55
|
+
const base = process.env.LOCALAPPDATA || process.env.XDG_STATE_HOME || join(homedir(), '.yoke');
|
|
56
|
+
return join(resolve(base), 'Yoke', 'parallel-pool');
|
|
57
|
+
}
|
|
58
|
+
function ensurePoolDir() {
|
|
59
|
+
const dir = poolDir();
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const stat = lstatSync(dir);
|
|
62
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
63
|
+
throw new Error('Yoke shared worker-pool directory must be a real directory');
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
function recordPath(dir, id) {
|
|
67
|
+
return join(dir, `worker-${id}.json`);
|
|
68
|
+
}
|
|
69
|
+
function readRecords(dir) {
|
|
70
|
+
const records = [];
|
|
71
|
+
const names = readdirSync(dir).filter(value => value.startsWith('worker-') && value.endsWith('.json'));
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
if (!/^worker-[a-f0-9-]{36}\.json$/u.test(name))
|
|
74
|
+
throw new Error(`Invalid Yoke worker-pool record name: ${name}`);
|
|
75
|
+
const file = join(dir, name);
|
|
76
|
+
const stat = lstatSync(file);
|
|
77
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192)
|
|
78
|
+
throw new Error(`Invalid Yoke worker-pool record: ${name}`);
|
|
79
|
+
let record;
|
|
80
|
+
try {
|
|
81
|
+
record = PoolRecordSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new Error(`Unreadable Yoke worker-pool record: ${name}; inspect ${dir} before retrying`);
|
|
85
|
+
}
|
|
86
|
+
if (name !== `worker-${record.id}.json`)
|
|
87
|
+
throw new Error(`Yoke worker-pool record identity mismatch: ${name}`);
|
|
88
|
+
records.push(record);
|
|
89
|
+
}
|
|
90
|
+
return records;
|
|
91
|
+
}
|
|
92
|
+
function processIdentity(pid) {
|
|
93
|
+
const cached = identities.get(pid);
|
|
94
|
+
// A process cannot change its own incarnation while it is alive. Keep the
|
|
95
|
+
// local identity for the lifetime of this Yoke process so each lease does
|
|
96
|
+
// not spawn another OS query (notably a slow PowerShell/CIM call on Windows).
|
|
97
|
+
if (pid === process.pid && cached)
|
|
98
|
+
return cached.incarnation;
|
|
99
|
+
if (cached && Date.now() - cached.checkedAt < 5_000)
|
|
100
|
+
return cached.incarnation;
|
|
101
|
+
const incarnation = processIncarnation(pid);
|
|
102
|
+
identities.set(pid, { checkedAt: Date.now(), incarnation });
|
|
103
|
+
if (identities.size > 256)
|
|
104
|
+
identities.delete(identities.keys().next().value);
|
|
105
|
+
return incarnation;
|
|
106
|
+
}
|
|
107
|
+
function ownerAlive(record) {
|
|
108
|
+
if (!isPidAlive(record.pid))
|
|
109
|
+
return false;
|
|
110
|
+
if (!record.incarnation)
|
|
111
|
+
return true;
|
|
112
|
+
const current = processIdentity(record.pid);
|
|
113
|
+
// Unknown identity blocks admission. Reclaiming on uncertainty could exceed the cap.
|
|
114
|
+
return current === undefined || current === record.incarnation;
|
|
115
|
+
}
|
|
116
|
+
function staleDead(record, now) {
|
|
117
|
+
if (ownerAlive(record))
|
|
118
|
+
return false;
|
|
119
|
+
const created = Date.parse(record.createdAt);
|
|
120
|
+
return !Number.isFinite(created) || now - created > STALE_OWNER_MS;
|
|
121
|
+
}
|
|
122
|
+
function withPoolLock(operation) {
|
|
123
|
+
const dir = ensurePoolDir();
|
|
124
|
+
const lockFile = join(dir, 'pool');
|
|
125
|
+
return withClaimOperations(lockFile, { now: new Date(), staleMs: STALE_OWNER_MS, isAlive: isPidAlive }, () => readRecords(dir), () => [], records => {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const live = [];
|
|
128
|
+
for (const record of records) {
|
|
129
|
+
if (staleDead(record, now))
|
|
130
|
+
rmSync(recordPath(dir, record.id), { force: true });
|
|
131
|
+
else
|
|
132
|
+
live.push(record);
|
|
133
|
+
}
|
|
134
|
+
return operation(live, dir);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function projectKey(targetDir) {
|
|
138
|
+
return createHash('sha256').update(`${process.platform}\0${resolve(targetDir)}`).digest('hex').slice(0, 16);
|
|
139
|
+
}
|
|
140
|
+
function abortReason(signal) {
|
|
141
|
+
return typeof signal.reason === 'string' && signal.reason.length > 0 ? signal.reason : 'worker cancelled while waiting for shared capacity';
|
|
142
|
+
}
|
|
143
|
+
function waitForCapacity(signal) {
|
|
144
|
+
if (signal?.aborted)
|
|
145
|
+
return Promise.reject(new Error(abortReason(signal)));
|
|
146
|
+
return new Promise((resolveWait, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
signal?.removeEventListener('abort', abort);
|
|
149
|
+
resolveWait();
|
|
150
|
+
}, WAIT_POLL_MS);
|
|
151
|
+
const abort = () => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
signal?.removeEventListener('abort', abort);
|
|
154
|
+
reject(new Error(signal ? abortReason(signal) : 'worker cancelled'));
|
|
155
|
+
};
|
|
156
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function waitForCapacitySync() {
|
|
160
|
+
Atomics.wait(syncWaitCell, 0, 0, WAIT_POLL_MS);
|
|
161
|
+
}
|
|
162
|
+
function capacityWinner(records, limit, now) {
|
|
163
|
+
const active = records.filter(record => record.state === 'active');
|
|
164
|
+
const waiting = records.filter(record => record.state === 'waiting').sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
165
|
+
const used = active.reduce((total, record) => total + record.units, 0);
|
|
166
|
+
const head = waiting[0];
|
|
167
|
+
if (!head || head.createdAt > now.toISOString())
|
|
168
|
+
return undefined;
|
|
169
|
+
if (used + head.units <= limit)
|
|
170
|
+
return head;
|
|
171
|
+
if (head.overtakes >= MAX_QUEUE_OVERTAKES)
|
|
172
|
+
return undefined;
|
|
173
|
+
return waiting.find(record => used + record.units <= limit && record.createdAt <= now.toISOString());
|
|
174
|
+
}
|
|
175
|
+
function effectiveLimit(records, localLimit) {
|
|
176
|
+
return records.reduce((limit, record) => Math.min(limit, record.limit), localLimit);
|
|
177
|
+
}
|
|
178
|
+
function replaceRecord(dir, record) {
|
|
179
|
+
replaceClaimFile(recordPath(dir, record.id), record);
|
|
180
|
+
}
|
|
181
|
+
function writeRecord(dir, record) {
|
|
182
|
+
if (!publishClaimFile(recordPath(dir, record.id), record))
|
|
183
|
+
throw new Error('Could not publish a unique Yoke worker-pool reservation');
|
|
184
|
+
}
|
|
185
|
+
export async function acquireSharedWorker(request) {
|
|
186
|
+
const units = request.units ?? 1;
|
|
187
|
+
const limit = globalWorkerLimit();
|
|
188
|
+
if (!Number.isInteger(units) || units < 1 || units > MAX_PROJECT_WORKERS)
|
|
189
|
+
throw new Error(`Worker reservation units must be an integer from 1 to ${MAX_PROJECT_WORKERS}`);
|
|
190
|
+
if (units > limit)
|
|
191
|
+
throw new Error(`This task needs ${units} parallel worker units but the shared Yoke limit is ${limit}; raise YOKE_MAX_PARALLEL_WORKERS (maximum ${MAX_PROJECT_WORKERS}) or lower --candidates`);
|
|
192
|
+
if (request.signal?.aborted)
|
|
193
|
+
throw new Error(abortReason(request.signal));
|
|
194
|
+
const id = randomUUID(), token = randomUUID(), createdAt = new Date().toISOString();
|
|
195
|
+
const incarnation = processIdentity(process.pid);
|
|
196
|
+
const waiting = {
|
|
197
|
+
schemaVersion: POOL_SCHEMA_VERSION,
|
|
198
|
+
id,
|
|
199
|
+
token,
|
|
200
|
+
pid: process.pid,
|
|
201
|
+
...(incarnation ? { incarnation } : {}),
|
|
202
|
+
project: projectKey(request.targetDir),
|
|
203
|
+
storyId: createHash('sha256').update(request.storyId).digest('hex').slice(0, 16),
|
|
204
|
+
provider: request.provider,
|
|
205
|
+
role: request.role,
|
|
206
|
+
units,
|
|
207
|
+
limit,
|
|
208
|
+
overtakes: 0,
|
|
209
|
+
state: 'waiting',
|
|
210
|
+
createdAt,
|
|
211
|
+
};
|
|
212
|
+
let published = false;
|
|
213
|
+
while (!published) {
|
|
214
|
+
if (request.signal?.aborted)
|
|
215
|
+
throw new Error(abortReason(request.signal));
|
|
216
|
+
const result = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; });
|
|
217
|
+
if (result === true)
|
|
218
|
+
published = true;
|
|
219
|
+
else
|
|
220
|
+
await waitForCapacity(request.signal);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
for (;;) {
|
|
224
|
+
if (request.signal?.aborted)
|
|
225
|
+
throw new Error(abortReason(request.signal));
|
|
226
|
+
const granted = withPoolLock((records, dir) => {
|
|
227
|
+
const own = records.find(record => record.id === id && record.token === token);
|
|
228
|
+
if (!own)
|
|
229
|
+
throw new Error('Yoke worker-pool reservation disappeared while waiting; no worker was started');
|
|
230
|
+
if (own.state === 'active')
|
|
231
|
+
return true;
|
|
232
|
+
const winner = capacityWinner(records, effectiveLimit(records, limit), new Date());
|
|
233
|
+
if (winner?.id !== id)
|
|
234
|
+
return false;
|
|
235
|
+
const head = records.filter(record => record.state === 'waiting').sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))[0];
|
|
236
|
+
if (head && head.id !== id)
|
|
237
|
+
replaceRecord(dir, { ...head, overtakes: head.overtakes + 1 });
|
|
238
|
+
replaceRecord(dir, { ...own, state: 'active' });
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
if (granted === true) {
|
|
242
|
+
const acquiredAt = new Date().toISOString();
|
|
243
|
+
let released = false;
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
acquiredAt,
|
|
247
|
+
async release() {
|
|
248
|
+
if (released)
|
|
249
|
+
return false;
|
|
250
|
+
for (;;) {
|
|
251
|
+
const result = withPoolLock((records, dir) => {
|
|
252
|
+
const own = records.find(record => record.id === id && record.token === token);
|
|
253
|
+
if (!own)
|
|
254
|
+
return false;
|
|
255
|
+
rmSync(recordPath(dir, id), { force: true });
|
|
256
|
+
return true;
|
|
257
|
+
});
|
|
258
|
+
if (result !== null) {
|
|
259
|
+
released = true;
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
await waitForCapacity();
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
await waitForCapacity(request.signal);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
for (;;) {
|
|
272
|
+
const removed = withPoolLock((records, dir) => {
|
|
273
|
+
const own = records.find(record => record.id === id && record.token === token);
|
|
274
|
+
if (own)
|
|
275
|
+
rmSync(recordPath(dir, id), { force: true });
|
|
276
|
+
return true;
|
|
277
|
+
});
|
|
278
|
+
if (removed !== null)
|
|
279
|
+
break;
|
|
280
|
+
await waitForCapacity();
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Synchronous companion for the legacy serial loop, whose runner contract is synchronous. */
|
|
286
|
+
export function withSharedWorkerSync(request, operation) {
|
|
287
|
+
const units = 1;
|
|
288
|
+
const limit = globalWorkerLimit();
|
|
289
|
+
const requestedAt = Date.now();
|
|
290
|
+
const id = randomUUID(), token = randomUUID(), createdAt = new Date().toISOString();
|
|
291
|
+
const incarnation = processIdentity(process.pid);
|
|
292
|
+
const waiting = {
|
|
293
|
+
schemaVersion: POOL_SCHEMA_VERSION,
|
|
294
|
+
id,
|
|
295
|
+
token,
|
|
296
|
+
pid: process.pid,
|
|
297
|
+
...(incarnation ? { incarnation } : {}),
|
|
298
|
+
project: projectKey(request.targetDir),
|
|
299
|
+
storyId: createHash('sha256').update(request.storyId).digest('hex').slice(0, 16),
|
|
300
|
+
provider: request.provider,
|
|
301
|
+
role: request.role,
|
|
302
|
+
units,
|
|
303
|
+
limit,
|
|
304
|
+
overtakes: 0,
|
|
305
|
+
state: 'waiting',
|
|
306
|
+
createdAt,
|
|
307
|
+
};
|
|
308
|
+
for (;;) {
|
|
309
|
+
const published = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; });
|
|
310
|
+
if (published === true)
|
|
311
|
+
break;
|
|
312
|
+
waitForCapacitySync();
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
for (;;) {
|
|
316
|
+
const granted = withPoolLock((records, dir) => {
|
|
317
|
+
const own = records.find(record => record.id === id && record.token === token);
|
|
318
|
+
if (!own)
|
|
319
|
+
throw new Error('Yoke worker-pool reservation disappeared while waiting; no worker was started');
|
|
320
|
+
const winner = capacityWinner(records, effectiveLimit(records, limit), new Date());
|
|
321
|
+
if (winner?.id !== id)
|
|
322
|
+
return false;
|
|
323
|
+
const head = records.filter(record => record.state === 'waiting').sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))[0];
|
|
324
|
+
if (head && head.id !== id)
|
|
325
|
+
replaceRecord(dir, { ...head, overtakes: head.overtakes + 1 });
|
|
326
|
+
replaceRecord(dir, { ...own, state: 'active' });
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
if (granted === true) {
|
|
330
|
+
request.onWait?.(Date.now() - requestedAt);
|
|
331
|
+
return operation();
|
|
332
|
+
}
|
|
333
|
+
waitForCapacitySync();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
for (;;) {
|
|
338
|
+
const removed = withPoolLock((records, dir) => {
|
|
339
|
+
const own = records.find(record => record.id === id && record.token === token);
|
|
340
|
+
if (own)
|
|
341
|
+
rmSync(recordPath(dir, id), { force: true });
|
|
342
|
+
return true;
|
|
343
|
+
});
|
|
344
|
+
if (removed !== null)
|
|
345
|
+
break;
|
|
346
|
+
waitForCapacitySync();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export function sharedPoolStatus() {
|
|
351
|
+
const limit = globalWorkerLimit();
|
|
352
|
+
const snapshot = withPoolLock(records => records.map(record => ({ ...record })));
|
|
353
|
+
const records = snapshot ?? readRecords(ensurePoolDir()).filter(record => !staleDead(record, Date.now()));
|
|
354
|
+
const sharedLimit = effectiveLimit(records, limit);
|
|
355
|
+
const active = records.filter(record => record.state === 'active');
|
|
356
|
+
const waiting = records.filter(record => record.state === 'waiting');
|
|
357
|
+
const activeByRole = { implementation: 0, integration: 0 };
|
|
358
|
+
for (const record of active)
|
|
359
|
+
activeByRole[record.role] += record.units;
|
|
360
|
+
const oldest = waiting.reduce((min, record) => Math.min(min, Date.parse(record.createdAt)), Number.POSITIVE_INFINITY);
|
|
361
|
+
return {
|
|
362
|
+
limit: sharedLimit,
|
|
363
|
+
activeUnits: active.reduce((total, record) => total + record.units, 0),
|
|
364
|
+
activeWorkers: active.length,
|
|
365
|
+
waitingWorkers: waiting.length,
|
|
366
|
+
activeByRole,
|
|
367
|
+
oldestWaitMs: Number.isFinite(oldest) ? Math.max(0, Date.now() - oldest) : 0,
|
|
368
|
+
};
|
|
369
|
+
}
|