@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
|
@@ -1,55 +1,118 @@
|
|
|
1
1
|
import { estimateDurations } from './durations.js';
|
|
2
2
|
import { criticalPathRanks, writeScopesOverlap } from '../loop/scheduler.js';
|
|
3
|
-
/** Simulate
|
|
4
|
-
export function estimateSchedule(stories, maxConcurrency, history) {
|
|
3
|
+
/** Simulate dependency-aware implementation slots and a separate serialized integration lane. */
|
|
4
|
+
export function estimateSchedule(stories, maxConcurrency, history, stages = {}) {
|
|
5
5
|
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1)
|
|
6
6
|
return { available: false, reason: 'Invalid concurrency' };
|
|
7
7
|
if (new Set(stories.map(story => story.id)).size !== stories.length)
|
|
8
8
|
return { available: false, reason: 'Duplicate story IDs' };
|
|
9
|
-
const
|
|
10
|
-
if (!
|
|
9
|
+
const totalEstimate = estimateDurations(history.map(item => item.ms));
|
|
10
|
+
if (!totalEstimate)
|
|
11
11
|
return { available: false, reason: 'No measured duration history' };
|
|
12
|
+
const integrationEstimate = estimateDurations((stages.integrationMs ?? []).filter(validSample));
|
|
13
|
+
const queueEstimate = estimateDurations((stages.integrationQueueWaitMs ?? []).filter(validSample));
|
|
14
|
+
const integrations = integrationEstimate ?? { typicalMs: 0, lowerMs: 0, upperMs: 0, sampleCount: 0, confidence: 'low' };
|
|
15
|
+
const queueWait = queueEstimate ?? { typicalMs: 0, lowerMs: 0, upperMs: 0, sampleCount: 0, confidence: 'low' };
|
|
16
|
+
const globalLimit = Number.isInteger(stages.globalLimit) && stages.globalLimit >= 1 ? stages.globalLimit : maxConcurrency;
|
|
17
|
+
const workerUnitsPerStory = Number.isInteger(stages.workerUnitsPerStory) && stages.workerUnitsPerStory >= 1 ? stages.workerUnitsPerStory : 1;
|
|
18
|
+
if (workerUnitsPerStory > globalLimit)
|
|
19
|
+
return { available: false, reason: 'Worker weight exceeds the shared resource limit' };
|
|
12
20
|
const ranks = criticalPathRanks(stories);
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const pending = stories.filter(story => !story.passes).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || (ranks.get(b.id) ?? 0) - (ranks.get(a.id) ?? 0) || a.id.localeCompare(b.id));
|
|
22
|
+
const simulate = (implementationScale, integrationScale) => {
|
|
23
|
+
const pendingWork = [...pending];
|
|
24
|
+
const work = new Map(pending.map(story => {
|
|
25
|
+
const estimate = estimateDurations(history.filter(item => item.storyId === story.id).map(item => item.ms)) ?? totalEstimate;
|
|
26
|
+
const subtract = integrationEstimate ? integrations[integrationScale] + queueWait[integrationScale] : 0;
|
|
27
|
+
return [story.id, Math.max(0, estimate[implementationScale] - subtract)];
|
|
28
|
+
}));
|
|
29
|
+
const integrationDuration = integrations[integrationScale];
|
|
18
30
|
const complete = new Set(stories.filter(story => story.passes).map(story => story.id));
|
|
19
31
|
const active = [];
|
|
20
|
-
const
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
const queued = [];
|
|
33
|
+
let integrating;
|
|
34
|
+
const taskTimes = new Map();
|
|
35
|
+
let time = 0, implementationWorkMs = 0, integrationQueueWaitMs = 0, integrationLaneMs = 0;
|
|
36
|
+
let completedEvents = 0;
|
|
37
|
+
while (pendingWork.length || active.length || queued.length || integrating) {
|
|
38
|
+
for (let index = 0; index < pendingWork.length && active.length < maxConcurrency && (active.length * workerUnitsPerStory) + (integrating ? 1 : 0) + workerUnitsPerStory <= globalLimit;) {
|
|
39
|
+
const story = pendingWork[index];
|
|
40
|
+
const occupied = [...active.map(item => item.story), ...queued.map(item => item.story), ...(integrating ? [integrating.story] : [])];
|
|
41
|
+
const dependenciesReady = (story.needs ?? []).every(id => complete.has(id));
|
|
42
|
+
const scopesFree = (!story.area || !occupied.some(item => item.area === story.area))
|
|
43
|
+
&& !occupied.some(item => writeScopesOverlap(story.writes, item.writes));
|
|
44
|
+
if (dependenciesReady && scopesFree) {
|
|
45
|
+
const duration = work.get(story.id) ?? 0;
|
|
46
|
+
active.push({ story, ends: time + duration });
|
|
47
|
+
taskTimes.set(story.id, { storyId: story.id, startMs: time, endMs: 0 });
|
|
48
|
+
implementationWorkMs += duration;
|
|
49
|
+
pendingWork.splice(index, 1);
|
|
30
50
|
}
|
|
31
51
|
else
|
|
32
52
|
index++;
|
|
33
53
|
}
|
|
34
|
-
if (!active.length)
|
|
54
|
+
if (!integrating && queued.length && active.length * workerUnitsPerStory + 1 <= globalLimit) {
|
|
55
|
+
const next = queued.shift();
|
|
56
|
+
integrationQueueWaitMs += Math.max(0, time - next.readyAt);
|
|
57
|
+
integrationLaneMs += integrationDuration;
|
|
58
|
+
integrating = { story: next.story, ends: time + integrationDuration };
|
|
59
|
+
}
|
|
60
|
+
const nextEnd = Math.min(...active.map(item => item.ends), ...(integrating ? [integrating.ends] : []));
|
|
61
|
+
if (!Number.isFinite(nextEnd))
|
|
35
62
|
return undefined;
|
|
36
|
-
time =
|
|
63
|
+
time = nextEnd;
|
|
64
|
+
const finishedIntegration = integrating && integrating.ends === time ? integrating : undefined;
|
|
65
|
+
if (finishedIntegration) {
|
|
66
|
+
complete.add(finishedIntegration.story.id);
|
|
67
|
+
const task = taskTimes.get(finishedIntegration.story.id);
|
|
68
|
+
if (task)
|
|
69
|
+
task.endMs = time;
|
|
70
|
+
integrating = undefined;
|
|
71
|
+
completedEvents++;
|
|
72
|
+
}
|
|
73
|
+
const finishedWorkers = active.filter(item => item.ends === time).sort((a, b) => a.story.id.localeCompare(b.story.id));
|
|
74
|
+
for (const item of finishedWorkers)
|
|
75
|
+
queued.push({ story: item.story, readyAt: time });
|
|
37
76
|
for (let index = active.length - 1; index >= 0; index--)
|
|
38
|
-
if (active[index].
|
|
39
|
-
complete.add(active[index].story.id);
|
|
77
|
+
if (active[index].ends === time)
|
|
40
78
|
active.splice(index, 1);
|
|
41
|
-
|
|
79
|
+
if (finishedWorkers.length || finishedIntegration)
|
|
80
|
+
continue;
|
|
81
|
+
if (!active.length && !queued.length && !integrating && pendingWork.length)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (completedEvents > stories.length)
|
|
84
|
+
return undefined;
|
|
42
85
|
}
|
|
43
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
time,
|
|
88
|
+
implementationWorkMs,
|
|
89
|
+
integrationQueueWaitMs,
|
|
90
|
+
integrationLaneMs,
|
|
91
|
+
tasks: [...taskTimes.values()],
|
|
92
|
+
};
|
|
44
93
|
};
|
|
45
|
-
const typical = simulate('typicalMs'
|
|
94
|
+
const typical = simulate('typicalMs', 'typicalMs');
|
|
95
|
+
const lower = simulate('lowerMs', 'lowerMs');
|
|
96
|
+
const upper = simulate('upperMs', 'upperMs');
|
|
46
97
|
if (!typical || !lower || !upper)
|
|
47
98
|
return { available: false, reason: 'Cyclic or missing dependencies' };
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
lowerMs: Math.min(lower.time, typical.time, upper.time),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
99
|
+
return {
|
|
100
|
+
available: true,
|
|
101
|
+
etaMs: typical.time,
|
|
102
|
+
lowerMs: Math.min(lower.time, typical.time, upper.time),
|
|
103
|
+
upperMs: Math.max(lower.time, typical.time, upper.time),
|
|
104
|
+
sampleCount: totalEstimate.sampleCount,
|
|
105
|
+
confidence: !integrationEstimate || integrationEstimate.confidence === 'low' ? 'low' : totalEstimate.confidence,
|
|
106
|
+
implementationWorkMs: typical.implementationWorkMs,
|
|
107
|
+
integrationQueueWaitMs: typical.integrationQueueWaitMs,
|
|
108
|
+
integrationLaneMs: typical.integrationLaneMs,
|
|
109
|
+
integrationSampleCount: integrationEstimate?.sampleCount ?? 0,
|
|
110
|
+
maxConcurrency,
|
|
111
|
+
globalLimit,
|
|
112
|
+
workerUnitsPerStory,
|
|
113
|
+
tasks: typical.tasks,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function validSample(value) {
|
|
117
|
+
return Number.isFinite(value) && value > 0;
|
|
55
118
|
}
|
package/dist/loop/claim-lease.js
CHANGED
|
@@ -113,8 +113,8 @@ export function withClaimOperations(file, context, readRecords, claimFiles, task
|
|
|
113
113
|
if (!token)
|
|
114
114
|
return null;
|
|
115
115
|
const acquired = [];
|
|
116
|
-
const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
|
|
117
116
|
try {
|
|
117
|
+
const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
|
|
118
118
|
for (const nextFile of orderedFiles) {
|
|
119
119
|
const nextToken = acquireClaimOperation(nextFile, context);
|
|
120
120
|
if (!nextToken)
|
package/dist/loop/dispatcher.js
CHANGED
|
@@ -55,6 +55,7 @@ export function createDispatcher(options) {
|
|
|
55
55
|
const queue = new MergeQueue();
|
|
56
56
|
const active = new Map();
|
|
57
57
|
const queued = new Map();
|
|
58
|
+
const integrationControllers = new Map();
|
|
58
59
|
const areas = new Set();
|
|
59
60
|
const reservedWrites = new Map();
|
|
60
61
|
const integrated = [];
|
|
@@ -66,23 +67,34 @@ export function createDispatcher(options) {
|
|
|
66
67
|
let providerIndex = 0;
|
|
67
68
|
let paused = false;
|
|
68
69
|
let cancellationReason;
|
|
69
|
-
const reportProgress = () =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
70
|
+
const reportProgress = () => {
|
|
71
|
+
let globalPool;
|
|
72
|
+
try {
|
|
73
|
+
globalPool = options.resourceStatus?.();
|
|
74
|
+
}
|
|
75
|
+
catch { /* Resource status is observational; admission remains fail-closed. */ }
|
|
76
|
+
options.onProgress?.({
|
|
77
|
+
dispatcherId,
|
|
78
|
+
maxConcurrency: options.maxConcurrency,
|
|
79
|
+
workerUnitsPerStory: options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator ? options.candidateCount : 1,
|
|
80
|
+
activeWorkers: active.size,
|
|
81
|
+
waitingWorkers: [...active.values()].filter(worker => worker.phase === 'waiting-resource').length,
|
|
82
|
+
queuedCandidates: queued.size,
|
|
83
|
+
queuedIntegrations: queued.size,
|
|
84
|
+
...(globalPool ? { globalPool } : {}),
|
|
85
|
+
integrated: integrated.length,
|
|
86
|
+
reopened: reopened.length,
|
|
87
|
+
iteration: iterations,
|
|
88
|
+
progress: progress(options.stories),
|
|
89
|
+
workers: [...active.values()].map(worker => ({
|
|
90
|
+
story: worker.input.story.id,
|
|
91
|
+
storyTitle: worker.input.story.title,
|
|
92
|
+
provider: worker.input.provider.provider,
|
|
93
|
+
...(worker.input.provider.model ? { model: worker.input.provider.model } : {}),
|
|
94
|
+
phase: worker.phase,
|
|
95
|
+
})),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
86
98
|
const providerFor = (story) => {
|
|
87
99
|
if (story.agent) {
|
|
88
100
|
const configured = options.affinityProviders?.find(provider => provider.provider === story.agent)
|
|
@@ -123,9 +135,22 @@ export function createDispatcher(options) {
|
|
|
123
135
|
};
|
|
124
136
|
const enqueue = (input, result) => {
|
|
125
137
|
let reason = 'integrated verification failed';
|
|
138
|
+
const integrationQueuedAt = Date.now();
|
|
139
|
+
let integrationStartedAt;
|
|
140
|
+
const integrationController = new AbortController();
|
|
141
|
+
integrationControllers.set(input.story.id, integrationController);
|
|
142
|
+
let integrationLease;
|
|
126
143
|
const task = queue.enqueue({
|
|
127
144
|
storyId: input.story.id,
|
|
128
145
|
rebase: async () => {
|
|
146
|
+
if (cancellationReason)
|
|
147
|
+
throw new Error(cancellationReason);
|
|
148
|
+
options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, undefined);
|
|
149
|
+
if (options.acquireResource) {
|
|
150
|
+
options.gates.integrationPhase?.(input, 'waiting-resource');
|
|
151
|
+
integrationLease = await options.acquireResource({ story: input.story, provider: input.provider, role: 'integration', units: 1, signal: integrationController.signal });
|
|
152
|
+
}
|
|
153
|
+
integrationStartedAt = Date.now();
|
|
129
154
|
if (cancellationReason)
|
|
130
155
|
throw new Error(cancellationReason);
|
|
131
156
|
if (!options.git.isClean(options.targetDir))
|
|
@@ -182,11 +207,19 @@ export function createDispatcher(options) {
|
|
|
182
207
|
throw new Error(`unexpected merge result: ${String(unexpected)}`);
|
|
183
208
|
}
|
|
184
209
|
}
|
|
185
|
-
}).finally(() => {
|
|
210
|
+
}).finally(async () => {
|
|
186
211
|
queued.delete(input.story.id);
|
|
212
|
+
integrationControllers.delete(input.story.id);
|
|
187
213
|
options.gates.integrationPhase?.(input, undefined);
|
|
188
|
-
|
|
189
|
-
|
|
214
|
+
try {
|
|
215
|
+
if (integrationLease)
|
|
216
|
+
await integrationLease.release();
|
|
217
|
+
options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
cleanup(input);
|
|
221
|
+
reportProgress();
|
|
222
|
+
}
|
|
190
223
|
});
|
|
191
224
|
queued.set(input.story.id, task);
|
|
192
225
|
reportProgress();
|
|
@@ -207,9 +240,23 @@ export function createDispatcher(options) {
|
|
|
207
240
|
if (story.area)
|
|
208
241
|
areas.add(story.area);
|
|
209
242
|
const candidateDispatch = candidateRace;
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
243
|
+
const activeWorker = { input, controller, task: Promise.resolve(), phase: options.acquireResource ? 'waiting-resource' : 'implementing' };
|
|
244
|
+
const task = Promise.resolve().then(async () => {
|
|
245
|
+
const workerLease = options.acquireResource
|
|
246
|
+
? await options.acquireResource({ story, provider, role: 'implementation', units: candidateRace ? options.candidateCount : 1, signal: controller.signal })
|
|
247
|
+
: undefined;
|
|
248
|
+
activeWorker.phase = 'implementing';
|
|
249
|
+
reportProgress();
|
|
250
|
+
try {
|
|
251
|
+
return await (candidateDispatch && options.candidateCoordinator
|
|
252
|
+
? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
|
|
253
|
+
: options.worker(input).then(result => ({ source: 'worker', result })));
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
if (workerLease)
|
|
257
|
+
await workerLease.release();
|
|
258
|
+
}
|
|
259
|
+
}).then(async (outcome) => {
|
|
213
260
|
active.delete(story.id);
|
|
214
261
|
claims.heartbeat(input);
|
|
215
262
|
if (outcome.source === 'candidates') {
|
|
@@ -285,7 +332,8 @@ export function createDispatcher(options) {
|
|
|
285
332
|
if (!(error instanceof Error))
|
|
286
333
|
throw error;
|
|
287
334
|
});
|
|
288
|
-
|
|
335
|
+
Object.assign(activeWorker, { task });
|
|
336
|
+
active.set(story.id, activeWorker);
|
|
289
337
|
reportProgress();
|
|
290
338
|
};
|
|
291
339
|
const cancel = (reason) => {
|
|
@@ -296,6 +344,8 @@ export function createDispatcher(options) {
|
|
|
296
344
|
claims.cancel?.(worker.input, reason);
|
|
297
345
|
worker.controller.abort(reason);
|
|
298
346
|
}
|
|
347
|
+
for (const controller of integrationControllers.values())
|
|
348
|
+
controller.abort(reason);
|
|
299
349
|
};
|
|
300
350
|
const run = async () => {
|
|
301
351
|
for (;;) {
|
|
@@ -303,8 +353,8 @@ export function createDispatcher(options) {
|
|
|
303
353
|
paused = true;
|
|
304
354
|
if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
|
|
305
355
|
const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
|
|
306
|
-
//
|
|
307
|
-
const slots = Math.max(0, options.maxConcurrency - active.size
|
|
356
|
+
// Implementation slots are reusable while candidates wait for the separate integration lane.
|
|
357
|
+
const slots = Math.max(0, options.maxConcurrency - active.size);
|
|
308
358
|
const ready = readyStories(options.stories, { activeAreas: areas, activeWrites: [...reservedWrites.values()] }).filter(story => !busy.has(story.id));
|
|
309
359
|
let launched = 0;
|
|
310
360
|
for (const story of ready) {
|
package/dist/loop/git.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { sanitizeCommitMessage } from './identity.js';
|
|
3
|
-
export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
|
|
3
|
+
export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
|
|
4
4
|
// Directory pathspecs include descendants and also exclude the directory entry.
|
|
5
5
|
export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
|
|
6
6
|
export function stageImplementation(dir) {
|
package/dist/loop/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { knownInfrastructureFailure } from "../routing/capability.js";
|
|
2
|
-
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { acceptanceProtectionProblem } from '../check/command.js';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
|
|
@@ -102,6 +102,18 @@ function runQualityReview(opts, executionDir, story, reporter) {
|
|
|
102
102
|
export function pauseFilePath(targetDir) {
|
|
103
103
|
return join(targetDir, '.yoke', 'loop.pause');
|
|
104
104
|
}
|
|
105
|
+
/** Request a pause at the next safe story or exploration boundary. */
|
|
106
|
+
export function requestLoopPause(targetDir) {
|
|
107
|
+
const file = pauseFilePath(targetDir);
|
|
108
|
+
mkdirSync(join(targetDir, '.yoke'), { recursive: true });
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(file, `${new Date().toISOString()}\n`, { flag: 'wx' });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== 'EEXIST')
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
105
117
|
function consumePause(targetDir) {
|
|
106
118
|
const file = pauseFilePath(targetDir);
|
|
107
119
|
if (!existsSync(file))
|
|
@@ -11,6 +11,7 @@ import { makeParallelAdapters } from './parallel-adapters.js';
|
|
|
11
11
|
import { loadPrd, progress } from './prd.js';
|
|
12
12
|
import { makeAsyncRunner } from './runner.js';
|
|
13
13
|
import { runStoryWorker } from './worker.js';
|
|
14
|
+
import { acquireSharedWorker, sharedPoolStatus } from './resource-pool.js';
|
|
14
15
|
export async function runParallelLoopCommand(input) {
|
|
15
16
|
const originalVerify = input.verify;
|
|
16
17
|
input = { ...input, verify: path => {
|
|
@@ -39,6 +40,26 @@ export async function runParallelLoopCommand(input) {
|
|
|
39
40
|
stories: loadPrd(input.prdPath),
|
|
40
41
|
maxConcurrency: input.maxConcurrency,
|
|
41
42
|
maxIterations: input.maxIterations,
|
|
43
|
+
acquireResource: async (request) => {
|
|
44
|
+
const started = Date.now();
|
|
45
|
+
const lease = await acquireSharedWorker({
|
|
46
|
+
targetDir: input.targetDir,
|
|
47
|
+
storyId: request.story.id,
|
|
48
|
+
provider: request.provider.provider,
|
|
49
|
+
role: request.role,
|
|
50
|
+
units: request.units,
|
|
51
|
+
signal: request.signal,
|
|
52
|
+
});
|
|
53
|
+
input.reporter.resourceWait?.({ id: request.story.id, title: request.story.title }, request.role, request.units, Date.now() - started, request.provider.provider);
|
|
54
|
+
return lease;
|
|
55
|
+
},
|
|
56
|
+
resourceStatus: () => sharedPoolStatus(),
|
|
57
|
+
onIntegrationMetrics: (worker, queueWaitMs, integrationMs) => {
|
|
58
|
+
if (integrationMs === undefined)
|
|
59
|
+
input.reporter.integrationQueueWait?.({ id: worker.story.id, title: worker.story.title }, queueWaitMs, worker.provider.provider);
|
|
60
|
+
else
|
|
61
|
+
input.reporter.integrationDuration?.({ id: worker.story.id, title: worker.story.title }, integrationMs, worker.provider.provider);
|
|
62
|
+
},
|
|
42
63
|
providers: input.providers,
|
|
43
64
|
affinityProviders: input.affinityProviders,
|
|
44
65
|
worktrees: adapters.worktrees,
|
package/dist/loop/prd.js
CHANGED
|
@@ -11,6 +11,15 @@ export const AcceptanceCriterionSchema = z.object({
|
|
|
11
11
|
text: z.string().min(1),
|
|
12
12
|
verify: z.array(z.string().min(1)).min(1),
|
|
13
13
|
});
|
|
14
|
+
export const ExplorationOriginSchema = z.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
rationale: z.string().min(1).max(2_000),
|
|
17
|
+
expectedBenefit: z.string().min(1).max(2_000),
|
|
18
|
+
confidence: z.number().min(0.8).max(1),
|
|
19
|
+
risk: z.enum(['low', 'medium']),
|
|
20
|
+
evidence: z.array(z.object({ path: z.string().min(1).max(500), observation: z.string().min(1).max(1_000) }).strict()).min(1).max(6),
|
|
21
|
+
discoveredAt: z.string().datetime(),
|
|
22
|
+
}).strict();
|
|
14
23
|
export function criterionCommandProblem(criterion) {
|
|
15
24
|
const normalizedId = criterion.id.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
16
25
|
for (const raw of criterion.verify) {
|
|
@@ -46,6 +55,8 @@ export const StorySchema = z.object({
|
|
|
46
55
|
agent: AgentSchema.optional(),
|
|
47
56
|
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
48
57
|
sourceChange: z.string().min(1).optional(),
|
|
58
|
+
/** Evidence retained for stories created by opt-in continuous exploration. */
|
|
59
|
+
exploration: ExplorationOriginSchema.optional(),
|
|
49
60
|
quality: StoryQualityDeclarationSchema.optional(),
|
|
50
61
|
assessment: AssessmentSchema.optional(),
|
|
51
62
|
/** Binding to the task, upstream contracts and approved planning brief. */
|
package/dist/loop/reporter.js
CHANGED
|
@@ -220,10 +220,12 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
220
220
|
persist({ ...base, state: 'running', phase: undefined, story: story.id, storyTitle: story.title,
|
|
221
221
|
progress, ...(eta ? { eta } : {}), updatedAt: now().toISOString() }, 'story-done', `✓ ${story.id} done${took} — ${progress.passed}/${progress.total} (${percentOf(progress)}%)${hint}`);
|
|
222
222
|
},
|
|
223
|
-
phase(phase) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
phase(phase, reason, progress) {
|
|
224
|
+
const status = current ?? emptyStatus(now().toISOString());
|
|
225
|
+
const base = phase === 'exploring' || phase === 'waiting-exploration' || phase === 'waiting-recovery'
|
|
226
|
+
? (({ story: _story, storyTitle: _storyTitle, ...withoutStory }) => withoutStory)(status)
|
|
227
|
+
: status;
|
|
228
|
+
persist({ ...base, state: 'running', phase, ...(progress ? { progress } : {}), ...(reason ? { reason } : { reason: undefined }), updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
|
|
227
229
|
},
|
|
228
230
|
blocked(reason) {
|
|
229
231
|
const base = current ?? emptyStatus(now().toISOString());
|
|
@@ -237,9 +239,9 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
237
239
|
persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
238
240
|
progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
|
|
239
241
|
},
|
|
240
|
-
paused(progress) {
|
|
242
|
+
paused(progress, reason) {
|
|
241
243
|
persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
|
|
242
|
-
progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
|
|
244
|
+
progress, ...(reason ? { reason } : {}), updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
|
|
243
245
|
},
|
|
244
246
|
quality(status) {
|
|
245
247
|
const base = current ?? emptyStatus(now().toISOString());
|
|
@@ -260,7 +262,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
260
262
|
selectedVariant: previous.selectedVariant,
|
|
261
263
|
...(previous.model ? { model: previous.model } : {}),
|
|
262
264
|
provider: previous.provider,
|
|
263
|
-
|
|
265
|
+
phase: worker.phase === 'implementing' && previous.phase && previous.phase !== 'waiting-resource' ? previous.phase : worker.phase,
|
|
264
266
|
...(previous.quality ? { quality: previous.quality } : {}),
|
|
265
267
|
}
|
|
266
268
|
: { ...worker, startedAt: now().toISOString() };
|
|
@@ -283,6 +285,24 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
283
285
|
updatedAt: now().toISOString(),
|
|
284
286
|
}, 'parallel', ` · parallel ${status.activeWorkers}/${status.maxConcurrency}`);
|
|
285
287
|
},
|
|
288
|
+
resourceWait(story, role, units, waitMs, provider) {
|
|
289
|
+
appendEvent(dir, {
|
|
290
|
+
runId,
|
|
291
|
+
timestamp: now().toISOString(),
|
|
292
|
+
type: 'phase-ended',
|
|
293
|
+
storyId: story.id,
|
|
294
|
+
phase: `resource-wait-${role}`,
|
|
295
|
+
provider,
|
|
296
|
+
durationMs: Math.max(0, waitMs),
|
|
297
|
+
data: { units },
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
integrationQueueWait(story, waitMs, provider) {
|
|
301
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration-queue', provider, durationMs: Math.max(0, waitMs) });
|
|
302
|
+
},
|
|
303
|
+
integrationDuration(story, durationMs, provider) {
|
|
304
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration', provider, durationMs: Math.max(0, durationMs) });
|
|
305
|
+
},
|
|
286
306
|
parallelWorker(status) {
|
|
287
307
|
const base = current;
|
|
288
308
|
const parallel = base?.parallel;
|