@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. 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
+ }