@weldr/runr 0.3.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/CHANGELOG.md +216 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +200 -0
- package/dist/cli.js +464 -0
- package/dist/commands/__tests__/report.test.js +202 -0
- package/dist/commands/compare.js +168 -0
- package/dist/commands/doctor.js +124 -0
- package/dist/commands/follow.js +251 -0
- package/dist/commands/gc.js +161 -0
- package/dist/commands/guards-only.js +89 -0
- package/dist/commands/metrics.js +441 -0
- package/dist/commands/orchestrate.js +800 -0
- package/dist/commands/paths.js +31 -0
- package/dist/commands/preflight.js +152 -0
- package/dist/commands/report.js +478 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run.js +538 -0
- package/dist/commands/status.js +189 -0
- package/dist/commands/summarize.js +220 -0
- package/dist/commands/version.js +82 -0
- package/dist/commands/wait.js +170 -0
- package/dist/config/__tests__/presets.test.js +104 -0
- package/dist/config/load.js +66 -0
- package/dist/config/schema.js +160 -0
- package/dist/context/__tests__/artifact.test.js +130 -0
- package/dist/context/__tests__/pack.test.js +191 -0
- package/dist/context/artifact.js +67 -0
- package/dist/context/index.js +2 -0
- package/dist/context/pack.js +273 -0
- package/dist/diagnosis/analyzer.js +678 -0
- package/dist/diagnosis/formatter.js +136 -0
- package/dist/diagnosis/index.js +6 -0
- package/dist/diagnosis/types.js +7 -0
- package/dist/env/__tests__/fingerprint.test.js +116 -0
- package/dist/env/fingerprint.js +111 -0
- package/dist/orchestrator/__tests__/policy.test.js +185 -0
- package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
- package/dist/orchestrator/artifacts.js +405 -0
- package/dist/orchestrator/state-machine.js +646 -0
- package/dist/orchestrator/types.js +88 -0
- package/dist/ownership/normalize.js +45 -0
- package/dist/repo/context.js +90 -0
- package/dist/repo/git.js +13 -0
- package/dist/repo/worktree.js +239 -0
- package/dist/store/run-store.js +107 -0
- package/dist/store/run-utils.js +69 -0
- package/dist/store/runs-root.js +126 -0
- package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
- package/dist/supervisor/__tests__/ownership.test.js +103 -0
- package/dist/supervisor/__tests__/state-machine.test.js +290 -0
- package/dist/supervisor/collision.js +240 -0
- package/dist/supervisor/evidence-gate.js +98 -0
- package/dist/supervisor/planner.js +18 -0
- package/dist/supervisor/runner.js +1562 -0
- package/dist/supervisor/scope-guard.js +55 -0
- package/dist/supervisor/state-machine.js +121 -0
- package/dist/supervisor/verification-policy.js +64 -0
- package/dist/tasks/task-metadata.js +72 -0
- package/dist/types/schemas.js +1 -0
- package/dist/verification/engine.js +49 -0
- package/dist/workers/__tests__/claude.test.js +88 -0
- package/dist/workers/__tests__/codex.test.js +81 -0
- package/dist/workers/claude.js +119 -0
- package/dist/workers/codex.js +162 -0
- package/dist/workers/json.js +22 -0
- package/dist/workers/mock.js +193 -0
- package/dist/workers/prompts.js +98 -0
- package/dist/workers/schemas.js +39 -0
- package/package.json +47 -0
- package/templates/prompts/implementer.md +70 -0
- package/templates/prompts/planner.md +62 -0
- package/templates/prompts/reviewer.md +77 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator state machine.
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of multi-track orchestration:
|
|
5
|
+
* - Creates initial state from config
|
|
6
|
+
* - Makes scheduling decisions
|
|
7
|
+
* - Handles state transitions when runs complete
|
|
8
|
+
* - Manages collision detection and serialization
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import yaml from 'yaml';
|
|
13
|
+
import { orchestrationConfigSchema, orchestratorStateSchema } from './types.js';
|
|
14
|
+
import { getActiveRuns, checkAllowlistOverlaps, patternsOverlap } from '../supervisor/collision.js';
|
|
15
|
+
import { getRunsRoot, getOrchestrationsRoot, getLegacyOrchestrationsRoot } from '../store/runs-root.js';
|
|
16
|
+
import { getOrchestrationDir, findOrchestrationDir } from './artifacts.js';
|
|
17
|
+
/**
|
|
18
|
+
* Generate a unique orchestrator ID.
|
|
19
|
+
*/
|
|
20
|
+
function makeOrchestratorId() {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const parts = [
|
|
23
|
+
'orch',
|
|
24
|
+
now.getUTCFullYear(),
|
|
25
|
+
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
26
|
+
String(now.getUTCDate()).padStart(2, '0'),
|
|
27
|
+
String(now.getUTCHours()).padStart(2, '0'),
|
|
28
|
+
String(now.getUTCMinutes()).padStart(2, '0'),
|
|
29
|
+
String(now.getUTCSeconds()).padStart(2, '0')
|
|
30
|
+
];
|
|
31
|
+
return parts.join('');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Load and validate orchestration config from file.
|
|
35
|
+
*/
|
|
36
|
+
export function loadOrchestrationConfig(configPath) {
|
|
37
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
38
|
+
const ext = path.extname(configPath).toLowerCase();
|
|
39
|
+
let parsed;
|
|
40
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
41
|
+
parsed = yaml.parse(content);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
parsed = JSON.parse(content);
|
|
45
|
+
}
|
|
46
|
+
return orchestrationConfigSchema.parse(parsed);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create initial orchestrator state from config.
|
|
50
|
+
*/
|
|
51
|
+
export function createInitialOrchestratorState(config, repoPath, options) {
|
|
52
|
+
const tracks = config.tracks.map((tc, idx) => ({
|
|
53
|
+
id: `track-${idx + 1}`,
|
|
54
|
+
name: tc.name,
|
|
55
|
+
steps: tc.steps.map((sc) => ({
|
|
56
|
+
task_path: sc.task,
|
|
57
|
+
allowlist: sc.allowlist
|
|
58
|
+
})),
|
|
59
|
+
current_step: 0,
|
|
60
|
+
status: 'pending'
|
|
61
|
+
}));
|
|
62
|
+
// Build the immutable policy block
|
|
63
|
+
const policy = {
|
|
64
|
+
collision_policy: options.collisionPolicy,
|
|
65
|
+
parallel: options.parallel ?? tracks.length, // Default: all tracks can run
|
|
66
|
+
fast: options.fast ?? false,
|
|
67
|
+
auto_resume: options.autoResume ?? false,
|
|
68
|
+
ownership_required: options.ownershipRequired ?? false,
|
|
69
|
+
time_budget_minutes: options.timeBudgetMinutes,
|
|
70
|
+
max_ticks: options.maxTicks
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
orchestrator_id: makeOrchestratorId(),
|
|
74
|
+
repo_path: repoPath,
|
|
75
|
+
tracks,
|
|
76
|
+
active_runs: {},
|
|
77
|
+
file_claims: {},
|
|
78
|
+
status: 'running',
|
|
79
|
+
started_at: new Date().toISOString(),
|
|
80
|
+
claim_events: [],
|
|
81
|
+
// v1+ policy block
|
|
82
|
+
policy,
|
|
83
|
+
// Legacy fields (kept for backward compat with existing readers)
|
|
84
|
+
collision_policy: policy.collision_policy,
|
|
85
|
+
time_budget_minutes: policy.time_budget_minutes,
|
|
86
|
+
max_ticks: policy.max_ticks,
|
|
87
|
+
fast: policy.fast
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get effective policy from state.
|
|
92
|
+
* Reads from policy block if present, falls back to legacy fields.
|
|
93
|
+
*/
|
|
94
|
+
export function getEffectivePolicy(state) {
|
|
95
|
+
if (state.policy) {
|
|
96
|
+
return state.policy;
|
|
97
|
+
}
|
|
98
|
+
// Migrate from legacy fields (v0 state)
|
|
99
|
+
return {
|
|
100
|
+
collision_policy: state.collision_policy,
|
|
101
|
+
parallel: state.tracks.length, // No parallelism limit in v0
|
|
102
|
+
fast: state.fast ?? false,
|
|
103
|
+
auto_resume: false, // Not available in v0
|
|
104
|
+
ownership_required: false,
|
|
105
|
+
time_budget_minutes: state.time_budget_minutes,
|
|
106
|
+
max_ticks: state.max_ticks
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get the next step for a track (if any).
|
|
111
|
+
*/
|
|
112
|
+
function getNextStep(track) {
|
|
113
|
+
if (track.current_step >= track.steps.length) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return track.steps[track.current_step];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if a track can be launched given current file claims.
|
|
120
|
+
* Uses allowlist overlap detection to prevent parallel runs on same files.
|
|
121
|
+
*/
|
|
122
|
+
function checkTrackCollision(track, step, state, repoPath) {
|
|
123
|
+
// Get the allowlist for this step
|
|
124
|
+
const stepAllowlist = step.allowlist ?? [];
|
|
125
|
+
// If no allowlist defined, can't check for collisions
|
|
126
|
+
if (stepAllowlist.length === 0) {
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
// Build pseudo-ActiveRuns from currently running orchestrator tracks
|
|
130
|
+
const orchestratorRuns = [];
|
|
131
|
+
for (const [trackId, runId] of Object.entries(state.active_runs)) {
|
|
132
|
+
if (trackId === track.id)
|
|
133
|
+
continue; // Don't check against self
|
|
134
|
+
const activeTrack = state.tracks.find((t) => t.id === trackId);
|
|
135
|
+
if (!activeTrack)
|
|
136
|
+
continue;
|
|
137
|
+
// Get the current step of the active track
|
|
138
|
+
const activeStep = activeTrack.steps[activeTrack.current_step];
|
|
139
|
+
if (!activeStep)
|
|
140
|
+
continue;
|
|
141
|
+
// Build a pseudo-ActiveRun for collision checking
|
|
142
|
+
orchestratorRuns.push({
|
|
143
|
+
runId,
|
|
144
|
+
phase: 'IMPLEMENT', // Assume running
|
|
145
|
+
allowlist: activeStep.allowlist ?? [],
|
|
146
|
+
predictedTouchFiles: [], // We don't have files_expected yet
|
|
147
|
+
updatedAt: state.started_at ?? ''
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Also check external active runs
|
|
151
|
+
const externalRuns = getActiveRuns(repoPath);
|
|
152
|
+
const allActiveRuns = [...orchestratorRuns, ...externalRuns];
|
|
153
|
+
// Check for allowlist overlaps
|
|
154
|
+
const overlaps = checkAllowlistOverlaps(stepAllowlist, allActiveRuns);
|
|
155
|
+
if (overlaps.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
collidingRuns: overlaps.map(o => o.runId),
|
|
159
|
+
collidingFiles: overlaps.flatMap(o => o.overlappingPatterns)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
function isOwnershipClaim(value) {
|
|
165
|
+
return typeof value !== 'string';
|
|
166
|
+
}
|
|
167
|
+
function listOwnershipConflicts(state, trackId, ownsNormalized) {
|
|
168
|
+
const conflicts = new Set();
|
|
169
|
+
const existing = Object.entries(state.file_claims);
|
|
170
|
+
for (const pattern of ownsNormalized) {
|
|
171
|
+
for (const [claimedPattern, claim] of existing) {
|
|
172
|
+
if (isOwnershipClaim(claim) && claim.track_id === trackId) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (patternsOverlap(pattern, claimedPattern)) {
|
|
176
|
+
conflicts.add(claimedPattern);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return [...conflicts];
|
|
181
|
+
}
|
|
182
|
+
export function reserveOwnershipClaims(state, trackId, ownsRaw, ownsNormalized) {
|
|
183
|
+
const normalized = [...new Set(ownsNormalized)];
|
|
184
|
+
if (normalized.length === 0) {
|
|
185
|
+
return { state, conflicts: [] };
|
|
186
|
+
}
|
|
187
|
+
const conflicts = listOwnershipConflicts(state, trackId, normalized);
|
|
188
|
+
if (conflicts.length > 0) {
|
|
189
|
+
return { state, conflicts };
|
|
190
|
+
}
|
|
191
|
+
const file_claims = { ...state.file_claims };
|
|
192
|
+
for (const pattern of normalized) {
|
|
193
|
+
file_claims[pattern] = {
|
|
194
|
+
track_id: trackId,
|
|
195
|
+
owns_raw: ownsRaw,
|
|
196
|
+
owns_normalized: normalized
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const claim_events = [
|
|
200
|
+
...(state.claim_events ?? []),
|
|
201
|
+
{
|
|
202
|
+
timestamp: new Date().toISOString(),
|
|
203
|
+
action: 'acquire',
|
|
204
|
+
track_id: trackId,
|
|
205
|
+
claims: normalized,
|
|
206
|
+
owns_raw: ownsRaw,
|
|
207
|
+
owns_normalized: normalized
|
|
208
|
+
}
|
|
209
|
+
];
|
|
210
|
+
return {
|
|
211
|
+
state: {
|
|
212
|
+
...state,
|
|
213
|
+
file_claims,
|
|
214
|
+
claim_events
|
|
215
|
+
},
|
|
216
|
+
conflicts: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
export function attachRunIdToClaims(state, trackId, runId) {
|
|
220
|
+
let updated = false;
|
|
221
|
+
const file_claims = { ...state.file_claims };
|
|
222
|
+
for (const [pattern, claim] of Object.entries(file_claims)) {
|
|
223
|
+
if (isOwnershipClaim(claim) && claim.track_id === trackId) {
|
|
224
|
+
file_claims[pattern] = { ...claim, run_id: runId };
|
|
225
|
+
updated = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!updated) {
|
|
229
|
+
return state;
|
|
230
|
+
}
|
|
231
|
+
return { ...state, file_claims };
|
|
232
|
+
}
|
|
233
|
+
export function releaseOwnershipClaims(state, trackId) {
|
|
234
|
+
const file_claims = { ...state.file_claims };
|
|
235
|
+
const released = [];
|
|
236
|
+
let firstClaim;
|
|
237
|
+
for (const [pattern, claim] of Object.entries(state.file_claims)) {
|
|
238
|
+
if (isOwnershipClaim(claim) && claim.track_id === trackId) {
|
|
239
|
+
if (!firstClaim) {
|
|
240
|
+
firstClaim = claim;
|
|
241
|
+
}
|
|
242
|
+
delete file_claims[pattern];
|
|
243
|
+
released.push(pattern);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (released.length === 0) {
|
|
247
|
+
return state;
|
|
248
|
+
}
|
|
249
|
+
const claim_events = [
|
|
250
|
+
...(state.claim_events ?? []),
|
|
251
|
+
{
|
|
252
|
+
timestamp: new Date().toISOString(),
|
|
253
|
+
action: 'release',
|
|
254
|
+
track_id: trackId,
|
|
255
|
+
run_id: firstClaim?.run_id,
|
|
256
|
+
claims: released,
|
|
257
|
+
owns_raw: firstClaim?.owns_raw ?? [],
|
|
258
|
+
owns_normalized: firstClaim?.owns_normalized ?? []
|
|
259
|
+
}
|
|
260
|
+
];
|
|
261
|
+
return {
|
|
262
|
+
...state,
|
|
263
|
+
file_claims,
|
|
264
|
+
claim_events
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Make a scheduling decision: what should the orchestrator do next?
|
|
269
|
+
*/
|
|
270
|
+
export function makeScheduleDecision(state) {
|
|
271
|
+
const policy = getEffectivePolicy(state);
|
|
272
|
+
const ownershipRequired = policy.ownership_required ?? false;
|
|
273
|
+
// Check if all tracks are done
|
|
274
|
+
const allDone = state.tracks.every((t) => t.status === 'complete' || t.status === 'stopped' || t.status === 'failed');
|
|
275
|
+
if (allDone) {
|
|
276
|
+
return { action: 'done' };
|
|
277
|
+
}
|
|
278
|
+
// Find tracks that can be launched
|
|
279
|
+
for (const track of state.tracks) {
|
|
280
|
+
// Skip tracks that are already running, complete, or failed
|
|
281
|
+
if (track.status !== 'pending' && track.status !== 'waiting') {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const step = getNextStep(track);
|
|
285
|
+
if (!step) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ownershipRequired) {
|
|
289
|
+
const ownsNormalized = step.owns_normalized ?? [];
|
|
290
|
+
if (ownsNormalized.length === 0) {
|
|
291
|
+
return {
|
|
292
|
+
action: 'blocked',
|
|
293
|
+
track_id: track.id,
|
|
294
|
+
reason: `Missing owns metadata for ${step.task_path}`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const conflicts = listOwnershipConflicts(state, track.id, ownsNormalized);
|
|
298
|
+
if (conflicts.length > 0) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Check for collisions
|
|
303
|
+
const collision = checkTrackCollision(track, step, state, state.repo_path);
|
|
304
|
+
if (!collision.ok) {
|
|
305
|
+
if (state.collision_policy === 'serialize') {
|
|
306
|
+
// Mark as waiting and continue to next track
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
else if (state.collision_policy === 'fail') {
|
|
310
|
+
return {
|
|
311
|
+
action: 'blocked',
|
|
312
|
+
track_id: track.id,
|
|
313
|
+
reason: 'File collision detected',
|
|
314
|
+
colliding_runs: collision.collidingRuns
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// 'force' policy: launch anyway
|
|
318
|
+
}
|
|
319
|
+
// This track is ready to launch
|
|
320
|
+
return {
|
|
321
|
+
action: 'launch',
|
|
322
|
+
track_id: track.id
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// No tracks ready to launch, but not all done - must be waiting
|
|
326
|
+
const waitingTracks = state.tracks.filter((t) => t.status === 'waiting');
|
|
327
|
+
if (waitingTracks.length > 0) {
|
|
328
|
+
return {
|
|
329
|
+
action: 'wait',
|
|
330
|
+
reason: `Waiting for collisions to clear: ${waitingTracks.map((t) => t.name).join(', ')}`
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// All remaining tracks must be running
|
|
334
|
+
return {
|
|
335
|
+
action: 'wait',
|
|
336
|
+
reason: 'Waiting for running tracks to complete'
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Mark a track as running with a specific run.
|
|
341
|
+
*/
|
|
342
|
+
export function startTrackRun(state, trackId, runId, runDir) {
|
|
343
|
+
const newState = { ...state };
|
|
344
|
+
newState.tracks = state.tracks.map((t) => {
|
|
345
|
+
if (t.id !== trackId)
|
|
346
|
+
return t;
|
|
347
|
+
const newSteps = [...t.steps];
|
|
348
|
+
newSteps[t.current_step] = {
|
|
349
|
+
...newSteps[t.current_step],
|
|
350
|
+
run_id: runId,
|
|
351
|
+
run_dir: runDir
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
...t,
|
|
355
|
+
steps: newSteps,
|
|
356
|
+
status: 'running'
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
newState.active_runs = { ...state.active_runs, [trackId]: runId };
|
|
360
|
+
return attachRunIdToClaims(newState, trackId, runId);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Handle a run completing for a track.
|
|
364
|
+
*/
|
|
365
|
+
export function completeTrackStep(state, trackId, result) {
|
|
366
|
+
const newState = { ...state };
|
|
367
|
+
newState.tracks = state.tracks.map((t) => {
|
|
368
|
+
if (t.id !== trackId)
|
|
369
|
+
return t;
|
|
370
|
+
const newSteps = [...t.steps];
|
|
371
|
+
newSteps[t.current_step] = {
|
|
372
|
+
...newSteps[t.current_step],
|
|
373
|
+
result
|
|
374
|
+
};
|
|
375
|
+
const nextStep = t.current_step + 1;
|
|
376
|
+
let newStatus;
|
|
377
|
+
if (result.status !== 'complete') {
|
|
378
|
+
// Run stopped or timed out
|
|
379
|
+
newStatus = 'stopped';
|
|
380
|
+
}
|
|
381
|
+
else if (nextStep >= t.steps.length) {
|
|
382
|
+
// All steps complete
|
|
383
|
+
newStatus = 'complete';
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
// More steps to go
|
|
387
|
+
newStatus = 'pending';
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
...t,
|
|
391
|
+
steps: newSteps,
|
|
392
|
+
current_step: nextStep,
|
|
393
|
+
status: newStatus,
|
|
394
|
+
error: result.status !== 'complete' ? result.stop_reason : undefined
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
// Remove from active runs
|
|
398
|
+
const { [trackId]: _, ...remainingActiveRuns } = state.active_runs;
|
|
399
|
+
newState.active_runs = remainingActiveRuns;
|
|
400
|
+
const releasedState = releaseOwnershipClaims(newState, trackId);
|
|
401
|
+
// Update overall status
|
|
402
|
+
releasedState.status = computeOverallStatus(releasedState);
|
|
403
|
+
if (releasedState.status !== 'running') {
|
|
404
|
+
releasedState.ended_at = new Date().toISOString();
|
|
405
|
+
}
|
|
406
|
+
return releasedState;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Compute overall orchestrator status from track states.
|
|
410
|
+
*/
|
|
411
|
+
function computeOverallStatus(state) {
|
|
412
|
+
const statuses = state.tracks.map((t) => t.status);
|
|
413
|
+
// If any track is running or pending, we're still running
|
|
414
|
+
if (statuses.some((s) => s === 'running' || s === 'pending' || s === 'waiting')) {
|
|
415
|
+
return 'running';
|
|
416
|
+
}
|
|
417
|
+
// If all tracks are complete, we're complete
|
|
418
|
+
if (statuses.every((s) => s === 'complete')) {
|
|
419
|
+
return 'complete';
|
|
420
|
+
}
|
|
421
|
+
// If any track failed, we failed
|
|
422
|
+
if (statuses.some((s) => s === 'failed')) {
|
|
423
|
+
return 'failed';
|
|
424
|
+
}
|
|
425
|
+
// Otherwise, we stopped (some tracks stopped but not failed)
|
|
426
|
+
return 'stopped';
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Mark a track as failed with an error.
|
|
430
|
+
*/
|
|
431
|
+
export function failTrack(state, trackId, error) {
|
|
432
|
+
const newState = { ...state };
|
|
433
|
+
newState.tracks = state.tracks.map((t) => {
|
|
434
|
+
if (t.id !== trackId)
|
|
435
|
+
return t;
|
|
436
|
+
return {
|
|
437
|
+
...t,
|
|
438
|
+
status: 'failed',
|
|
439
|
+
error
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
// Remove from active runs if present
|
|
443
|
+
const { [trackId]: _, ...remainingActiveRuns } = state.active_runs;
|
|
444
|
+
newState.active_runs = remainingActiveRuns;
|
|
445
|
+
const releasedState = releaseOwnershipClaims(newState, trackId);
|
|
446
|
+
releasedState.status = computeOverallStatus(releasedState);
|
|
447
|
+
if (releasedState.status !== 'running') {
|
|
448
|
+
releasedState.ended_at = new Date().toISOString();
|
|
449
|
+
}
|
|
450
|
+
return releasedState;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get summary statistics for display.
|
|
454
|
+
*/
|
|
455
|
+
export function getOrchestratorSummary(state) {
|
|
456
|
+
const byStatus = {
|
|
457
|
+
complete: 0,
|
|
458
|
+
running: 0,
|
|
459
|
+
pending: 0,
|
|
460
|
+
waiting: 0,
|
|
461
|
+
stopped: 0,
|
|
462
|
+
failed: 0
|
|
463
|
+
};
|
|
464
|
+
let totalSteps = 0;
|
|
465
|
+
let completedSteps = 0;
|
|
466
|
+
for (const track of state.tracks) {
|
|
467
|
+
byStatus[track.status]++;
|
|
468
|
+
totalSteps += track.steps.length;
|
|
469
|
+
completedSteps += track.steps.filter((s) => s.result?.status === 'complete').length;
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
total_tracks: state.tracks.length,
|
|
473
|
+
complete: byStatus.complete,
|
|
474
|
+
running: byStatus.running,
|
|
475
|
+
pending: byStatus.pending + byStatus.waiting,
|
|
476
|
+
stopped: byStatus.stopped,
|
|
477
|
+
failed: byStatus.failed,
|
|
478
|
+
total_steps: totalSteps,
|
|
479
|
+
completed_steps: completedSteps
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Load orchestrator state from disk.
|
|
484
|
+
* Checks both new and legacy paths, migrating if needed.
|
|
485
|
+
*/
|
|
486
|
+
export function loadOrchestratorState(orchestratorId, repoPath) {
|
|
487
|
+
// Find orchestration directory (handles migration automatically)
|
|
488
|
+
const orchDir = findOrchestrationDir(repoPath, orchestratorId);
|
|
489
|
+
if (orchDir) {
|
|
490
|
+
const statePath = path.join(orchDir, 'state.json');
|
|
491
|
+
if (fs.existsSync(statePath)) {
|
|
492
|
+
try {
|
|
493
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
494
|
+
const parsed = JSON.parse(content);
|
|
495
|
+
return orchestratorStateSchema.parse(parsed);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Save orchestrator state to disk.
|
|
506
|
+
*/
|
|
507
|
+
export function saveOrchestratorState(state, repoPath) {
|
|
508
|
+
const orchDir = getOrchestrationDir(repoPath, state.orchestrator_id);
|
|
509
|
+
fs.mkdirSync(orchDir, { recursive: true });
|
|
510
|
+
const statePath = path.join(orchDir, 'state.json');
|
|
511
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Find the latest orchestration ID.
|
|
515
|
+
* Checks both new path (.agent/orchestrations/) and legacy path (.agent/runs/orchestrations/).
|
|
516
|
+
*/
|
|
517
|
+
export function findLatestOrchestrationId(repoPath) {
|
|
518
|
+
const ids = [];
|
|
519
|
+
// Check new location: .agent/orchestrations/
|
|
520
|
+
const newOrchDir = getOrchestrationsRoot(repoPath);
|
|
521
|
+
if (fs.existsSync(newOrchDir)) {
|
|
522
|
+
for (const e of fs.readdirSync(newOrchDir, { withFileTypes: true })) {
|
|
523
|
+
if (e.isDirectory() && e.name.startsWith('orch')) {
|
|
524
|
+
ids.push(e.name);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Check legacy location: .agent/runs/orchestrations/
|
|
529
|
+
const legacyOrchDir = getLegacyOrchestrationsRoot(repoPath);
|
|
530
|
+
if (fs.existsSync(legacyOrchDir)) {
|
|
531
|
+
for (const e of fs.readdirSync(legacyOrchDir, { withFileTypes: true })) {
|
|
532
|
+
if (e.isDirectory() && e.name.startsWith('orch')) {
|
|
533
|
+
// Don't add duplicates
|
|
534
|
+
if (!ids.includes(e.name)) {
|
|
535
|
+
ids.push(e.name);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (ids.length === 0) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
// Sort and return latest
|
|
544
|
+
ids.sort().reverse();
|
|
545
|
+
return ids[0];
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Probe a run to check if it's still active or has completed.
|
|
549
|
+
* Returns the run status without blocking.
|
|
550
|
+
*/
|
|
551
|
+
export async function probeRunStatus(runId, repoPath) {
|
|
552
|
+
const runDir = path.join(getRunsRoot(repoPath), runId);
|
|
553
|
+
const statePath = path.join(runDir, 'state.json');
|
|
554
|
+
if (!fs.existsSync(statePath)) {
|
|
555
|
+
return { status: 'terminal', result: { status: 'stopped', stop_reason: 'run_not_found', elapsed_ms: 0 } };
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
559
|
+
const runState = JSON.parse(content);
|
|
560
|
+
// STOPPED is the only terminal phase (DONE was never a valid phase)
|
|
561
|
+
const isTerminal = runState.phase === 'STOPPED';
|
|
562
|
+
if (isTerminal) {
|
|
563
|
+
const isComplete = runState.stop_reason === 'complete';
|
|
564
|
+
return {
|
|
565
|
+
status: 'terminal',
|
|
566
|
+
result: {
|
|
567
|
+
status: isComplete ? 'complete' : 'stopped',
|
|
568
|
+
stop_reason: runState.stop_reason,
|
|
569
|
+
elapsed_ms: 0 // We don't track this in state.json
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
return { status: 'running' };
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return { status: 'terminal', result: { status: 'stopped', stop_reason: 'state_parse_error', elapsed_ms: 0 } };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Reconcile orchestrator state with actual run statuses.
|
|
581
|
+
*
|
|
582
|
+
* For each recorded active run:
|
|
583
|
+
* - Check if it's still running or has completed
|
|
584
|
+
* - Update state accordingly
|
|
585
|
+
*
|
|
586
|
+
* This is the critical crash-resume correctness step.
|
|
587
|
+
*/
|
|
588
|
+
export async function reconcileState(state) {
|
|
589
|
+
let newState = { ...state };
|
|
590
|
+
const reconciled = [];
|
|
591
|
+
for (const [trackId, runId] of Object.entries(state.active_runs)) {
|
|
592
|
+
const probe = await probeRunStatus(runId, state.repo_path);
|
|
593
|
+
if (probe.status === 'running') {
|
|
594
|
+
reconciled.push({ trackId, runId, status: 'still_running' });
|
|
595
|
+
// No state change needed - run is still active
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
// Run has completed - update state
|
|
599
|
+
const result = probe.result;
|
|
600
|
+
reconciled.push({
|
|
601
|
+
trackId,
|
|
602
|
+
runId,
|
|
603
|
+
status: result.status === 'complete' ? 'completed' : 'stopped',
|
|
604
|
+
result
|
|
605
|
+
});
|
|
606
|
+
newState = completeTrackStep(newState, trackId, result);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return { state: newState, reconciled };
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Check if this run should yield to another based on serialize policy.
|
|
613
|
+
*
|
|
614
|
+
* Deadlock prevention rule: later run_id yields to earlier run_id.
|
|
615
|
+
* Run IDs are timestamps (YYYYMMDDHHMMSS), so lexicographic order = time order.
|
|
616
|
+
*/
|
|
617
|
+
export function shouldYieldTo(myRunId, otherRunId) {
|
|
618
|
+
// Later run yields to earlier run
|
|
619
|
+
return myRunId > otherRunId;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* List all orchestration IDs (for status/listing commands).
|
|
623
|
+
* Checks both new and legacy paths.
|
|
624
|
+
*/
|
|
625
|
+
export function listOrchestrationIds(repoPath) {
|
|
626
|
+
const ids = [];
|
|
627
|
+
// Check new canonical path: .agent/orchestrations/
|
|
628
|
+
const newOrchDir = getOrchestrationsRoot(repoPath);
|
|
629
|
+
if (fs.existsSync(newOrchDir)) {
|
|
630
|
+
for (const e of fs.readdirSync(newOrchDir, { withFileTypes: true })) {
|
|
631
|
+
if (e.isDirectory() && e.name.startsWith('orch')) {
|
|
632
|
+
ids.push(e.name);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Check legacy path: .agent/runs/orchestrations/
|
|
637
|
+
const legacyOrchDir = getLegacyOrchestrationsRoot(repoPath);
|
|
638
|
+
if (fs.existsSync(legacyOrchDir)) {
|
|
639
|
+
for (const e of fs.readdirSync(legacyOrchDir, { withFileTypes: true })) {
|
|
640
|
+
if (e.isDirectory() && e.name.startsWith('orch') && !ids.includes(e.name)) {
|
|
641
|
+
ids.push(e.name);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return ids.sort().reverse();
|
|
646
|
+
}
|