@caseyharalson/orrery 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HELP.md +76 -14
- package/README.md +9 -8
- package/lib/cli/commands/orchestrate.js +15 -2
- package/lib/cli/commands/resume.js +93 -20
- package/lib/cli/commands/status.js +72 -13
- package/lib/orchestration/agent-invoker.js +24 -22
- package/lib/orchestration/config.js +1 -24
- package/lib/orchestration/index.js +625 -26
- package/lib/utils/git.js +43 -0
- package/lib/utils/lock.js +81 -16
- package/lib/utils/paths.js +20 -4
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - When complete, a PR is created and orchestrator returns to source branch
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
const { execSync } = require("child_process");
|
|
16
17
|
const fs = require("fs");
|
|
17
18
|
const path = require("path");
|
|
18
19
|
const YAML = require("yaml");
|
|
@@ -49,8 +50,10 @@ const {
|
|
|
49
50
|
commit,
|
|
50
51
|
createPullRequest,
|
|
51
52
|
deriveBranchName,
|
|
53
|
+
derivePlanId,
|
|
52
54
|
hasUncommittedChanges,
|
|
53
55
|
addWorktree,
|
|
56
|
+
addWorktreeExistingBranch,
|
|
54
57
|
removeWorktree,
|
|
55
58
|
getCommitRange,
|
|
56
59
|
cherryPick,
|
|
@@ -264,7 +267,8 @@ async function orchestrate(options = {}) {
|
|
|
264
267
|
verbose: Boolean(options.verbose),
|
|
265
268
|
resume: Boolean(options.resume),
|
|
266
269
|
review: options.review,
|
|
267
|
-
parallel: options.parallel
|
|
270
|
+
parallel: options.parallel,
|
|
271
|
+
onComplete: options.onComplete || null
|
|
268
272
|
};
|
|
269
273
|
|
|
270
274
|
config.logging.streamOutput = normalizedOptions.verbose;
|
|
@@ -279,7 +283,48 @@ async function orchestrate(options = {}) {
|
|
|
279
283
|
);
|
|
280
284
|
}
|
|
281
285
|
|
|
282
|
-
//
|
|
286
|
+
// Per-plan worktree mode: when --plan is specified without --resume,
|
|
287
|
+
// use per-plan lock and worktree isolation for concurrent execution
|
|
288
|
+
if (
|
|
289
|
+
normalizedOptions.plan &&
|
|
290
|
+
!normalizedOptions.resume &&
|
|
291
|
+
!normalizedOptions.dryRun
|
|
292
|
+
) {
|
|
293
|
+
await processPlanInWorktree(normalizedOptions);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Per-plan worktree resume: when --plan + --resume, check for existing worktree
|
|
298
|
+
if (
|
|
299
|
+
normalizedOptions.plan &&
|
|
300
|
+
normalizedOptions.resume &&
|
|
301
|
+
!normalizedOptions.dryRun
|
|
302
|
+
) {
|
|
303
|
+
const plansDir = getPlansDir();
|
|
304
|
+
const resolved = resolvePlanFile(normalizedOptions.plan, plansDir);
|
|
305
|
+
if (resolved) {
|
|
306
|
+
const planId = derivePlanId(path.basename(resolved));
|
|
307
|
+
const worktreePath = path.join(REPO_ROOT, ".worktrees", `plan-${planId}`);
|
|
308
|
+
if (fs.existsSync(worktreePath)) {
|
|
309
|
+
const plan = loadPlan(resolved);
|
|
310
|
+
const completedDir = getCompletedDir();
|
|
311
|
+
const reportsDir = getReportsDir();
|
|
312
|
+
await resumeInWorktree(
|
|
313
|
+
resolved,
|
|
314
|
+
plan,
|
|
315
|
+
planId,
|
|
316
|
+
worktreePath,
|
|
317
|
+
completedDir,
|
|
318
|
+
reportsDir,
|
|
319
|
+
normalizedOptions.onComplete
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// No worktree found — fall through to existing global-lock resume path
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Acquire global execution lock (skip for dry-run)
|
|
283
328
|
if (!normalizedOptions.dryRun) {
|
|
284
329
|
const lockResult = acquireLock();
|
|
285
330
|
if (!lockResult.acquired) {
|
|
@@ -392,7 +437,8 @@ async function orchestrate(options = {}) {
|
|
|
392
437
|
completedDir,
|
|
393
438
|
reportsDir,
|
|
394
439
|
sourceBranch,
|
|
395
|
-
normalizedOptions.plan
|
|
440
|
+
normalizedOptions.plan,
|
|
441
|
+
normalizedOptions.onComplete
|
|
396
442
|
);
|
|
397
443
|
return;
|
|
398
444
|
}
|
|
@@ -410,7 +456,8 @@ async function orchestrate(options = {}) {
|
|
|
410
456
|
sourceBranch,
|
|
411
457
|
completedDir,
|
|
412
458
|
reportsDir,
|
|
413
|
-
parallelEnabled
|
|
459
|
+
parallelEnabled,
|
|
460
|
+
normalizedOptions.onComplete
|
|
414
461
|
);
|
|
415
462
|
|
|
416
463
|
if (result.isComplete && result.isSuccessful) {
|
|
@@ -443,6 +490,172 @@ async function orchestrate(options = {}) {
|
|
|
443
490
|
}
|
|
444
491
|
}
|
|
445
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Resume a plan inside its existing worktree.
|
|
495
|
+
* @param {string} planFile - Resolved plan file path
|
|
496
|
+
* @param {Object} plan - Loaded plan object
|
|
497
|
+
* @param {string} planId - Plan ID for locking
|
|
498
|
+
* @param {string} worktreePath - Path to existing worktree
|
|
499
|
+
* @param {string} completedDir - Directory for completed plans
|
|
500
|
+
* @param {string} reportsDir - Directory for reports
|
|
501
|
+
*/
|
|
502
|
+
async function resumeInWorktree(
|
|
503
|
+
planFile,
|
|
504
|
+
plan,
|
|
505
|
+
planId,
|
|
506
|
+
worktreePath,
|
|
507
|
+
completedDir,
|
|
508
|
+
reportsDir,
|
|
509
|
+
onComplete
|
|
510
|
+
) {
|
|
511
|
+
const planFileName = path.basename(planFile);
|
|
512
|
+
|
|
513
|
+
// Acquire per-plan lock
|
|
514
|
+
const lockResult = acquireLock(planId, { worktreePath });
|
|
515
|
+
if (!lockResult.acquired) {
|
|
516
|
+
console.error(`Cannot resume plan "${planId}": ${lockResult.reason}`);
|
|
517
|
+
process.exitCode = 1;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const cleanupLock = () => {
|
|
522
|
+
releaseLock(planId);
|
|
523
|
+
process.exit();
|
|
524
|
+
};
|
|
525
|
+
process.on("SIGINT", cleanupLock);
|
|
526
|
+
process.on("SIGTERM", cleanupLock);
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
process.env.ORRERY_REPO_ROOT = REPO_ROOT;
|
|
530
|
+
|
|
531
|
+
console.log(`Found plan: ${planFileName}`);
|
|
532
|
+
|
|
533
|
+
if (plan.isComplete()) {
|
|
534
|
+
console.log("\nPlan is already complete (no pending steps).");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const pendingSteps = plan.steps.filter((s) => s.status === "pending");
|
|
539
|
+
const inProgressSteps = plan.steps.filter(
|
|
540
|
+
(s) => s.status === "in_progress"
|
|
541
|
+
);
|
|
542
|
+
console.log(`Pending steps: ${pendingSteps.length}`);
|
|
543
|
+
if (inProgressSteps.length > 0) {
|
|
544
|
+
console.log(
|
|
545
|
+
`In-progress steps (will be retried): ${inProgressSteps.length}`
|
|
546
|
+
);
|
|
547
|
+
for (const step of inProgressSteps) {
|
|
548
|
+
step.status = "pending";
|
|
549
|
+
}
|
|
550
|
+
savePlan(plan);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
console.log("\nResuming plan execution in worktree...\n");
|
|
554
|
+
|
|
555
|
+
const parallelEnabled = resolveParallelEnabled(undefined);
|
|
556
|
+
if (parallelEnabled) {
|
|
557
|
+
config.concurrency.maxParallel = resolveParallelMax();
|
|
558
|
+
console.log(
|
|
559
|
+
`Parallel mode enabled (max ${config.concurrency.maxParallel} concurrent agents)`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const ctx = { workingDir: worktreePath, mainRepoRoot: REPO_ROOT };
|
|
564
|
+
await processPlan(planFile, completedDir, reportsDir, parallelEnabled, ctx);
|
|
565
|
+
|
|
566
|
+
// Reload and check final state
|
|
567
|
+
plan = loadPlan(planFile);
|
|
568
|
+
const isComplete = plan.isComplete();
|
|
569
|
+
|
|
570
|
+
if (isComplete) {
|
|
571
|
+
archivePlan(planFile, plan, completedDir);
|
|
572
|
+
|
|
573
|
+
const workCommit = commit(
|
|
574
|
+
`chore: complete plan ${planFileName}`,
|
|
575
|
+
[],
|
|
576
|
+
worktreePath
|
|
577
|
+
);
|
|
578
|
+
if (workCommit) {
|
|
579
|
+
console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const sourceBranch = plan.metadata.source_branch || "main";
|
|
583
|
+
const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
|
|
584
|
+
const prBody = generatePRBody(plan);
|
|
585
|
+
const prInfo = createPullRequest(
|
|
586
|
+
prTitle,
|
|
587
|
+
prBody,
|
|
588
|
+
sourceBranch,
|
|
589
|
+
worktreePath
|
|
590
|
+
);
|
|
591
|
+
logPullRequestInfo(prInfo);
|
|
592
|
+
|
|
593
|
+
const isSuccessful = plan.isSuccessful();
|
|
594
|
+
runOnCompleteHook(
|
|
595
|
+
onComplete,
|
|
596
|
+
{
|
|
597
|
+
planName: planFileName,
|
|
598
|
+
planFile,
|
|
599
|
+
outcome: isSuccessful ? "success" : "partial",
|
|
600
|
+
workBranch: plan.metadata.work_branch || "",
|
|
601
|
+
sourceBranch,
|
|
602
|
+
prUrl: prInfo.url || "",
|
|
603
|
+
stepsTotal: plan.steps.length,
|
|
604
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
605
|
+
.length,
|
|
606
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
607
|
+
},
|
|
608
|
+
worktreePath
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// Clean up worktree on success
|
|
612
|
+
try {
|
|
613
|
+
removeWorktree(worktreePath, REPO_ROOT);
|
|
614
|
+
console.log(`Cleaned up worktree: ${worktreePath}`);
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.error(`Failed to clean up worktree: ${err.message}`);
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
const progressCommit = commit(
|
|
620
|
+
`wip: progress on plan ${planFileName}`,
|
|
621
|
+
[],
|
|
622
|
+
worktreePath
|
|
623
|
+
);
|
|
624
|
+
if (progressCommit) {
|
|
625
|
+
console.log(
|
|
626
|
+
`Committed work-in-progress (${progressCommit.slice(0, 7)})`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
runOnCompleteHook(
|
|
631
|
+
onComplete,
|
|
632
|
+
{
|
|
633
|
+
planName: planFileName,
|
|
634
|
+
planFile,
|
|
635
|
+
outcome: "incomplete",
|
|
636
|
+
workBranch: plan.metadata.work_branch || "",
|
|
637
|
+
sourceBranch: plan.metadata.source_branch || "main",
|
|
638
|
+
prUrl: "",
|
|
639
|
+
stepsTotal: plan.steps.length,
|
|
640
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
641
|
+
.length,
|
|
642
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
643
|
+
},
|
|
644
|
+
worktreePath
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
console.log(
|
|
648
|
+
"\nPlan still has pending steps. Run --resume again to continue."
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
console.log("\n=== Resume Complete ===");
|
|
653
|
+
} finally {
|
|
654
|
+
delete process.env.ORRERY_REPO_ROOT;
|
|
655
|
+
releaseLock(planId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
446
659
|
/**
|
|
447
660
|
* Handle resume mode: find and continue plan for current branch
|
|
448
661
|
*/
|
|
@@ -451,7 +664,8 @@ async function handleResumeMode(
|
|
|
451
664
|
completedDir,
|
|
452
665
|
reportsDir,
|
|
453
666
|
currentBranch,
|
|
454
|
-
planFileArg
|
|
667
|
+
planFileArg,
|
|
668
|
+
onComplete
|
|
455
669
|
) {
|
|
456
670
|
console.log("=== Resume Mode ===\n");
|
|
457
671
|
|
|
@@ -478,6 +692,25 @@ async function handleResumeMode(
|
|
|
478
692
|
process.exit(1);
|
|
479
693
|
}
|
|
480
694
|
|
|
695
|
+
// Check for existing worktree for this plan
|
|
696
|
+
const planId = derivePlanId(path.basename(resolved));
|
|
697
|
+
const worktreePath = path.join(REPO_ROOT, ".worktrees", `plan-${planId}`);
|
|
698
|
+
if (fs.existsSync(worktreePath)) {
|
|
699
|
+
// Resume in worktree mode
|
|
700
|
+
console.log(`Found existing worktree: ${worktreePath}`);
|
|
701
|
+
console.log(`Using specified plan: ${path.basename(resolved)}\n`);
|
|
702
|
+
await resumeInWorktree(
|
|
703
|
+
resolved,
|
|
704
|
+
matchingPlan,
|
|
705
|
+
planId,
|
|
706
|
+
worktreePath,
|
|
707
|
+
completedDir,
|
|
708
|
+
reportsDir,
|
|
709
|
+
onComplete
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
481
714
|
if (matchingPlan.metadata.work_branch !== currentBranch) {
|
|
482
715
|
console.error(
|
|
483
716
|
`Plan expects branch '${matchingPlan.metadata.work_branch}' but you are on '${currentBranch}'.`
|
|
@@ -585,6 +818,26 @@ async function handleResumeMode(
|
|
|
585
818
|
const prBody = generatePRBody(matchingPlan);
|
|
586
819
|
const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
|
|
587
820
|
logPullRequestInfo(prInfo);
|
|
821
|
+
|
|
822
|
+
const isSuccessful = matchingPlan.isSuccessful();
|
|
823
|
+
runOnCompleteHook(
|
|
824
|
+
onComplete,
|
|
825
|
+
{
|
|
826
|
+
planName: planFileName,
|
|
827
|
+
planFile: matchingPlanFile,
|
|
828
|
+
outcome: isSuccessful ? "success" : "partial",
|
|
829
|
+
workBranch: matchingPlan.metadata.work_branch || currentBranch,
|
|
830
|
+
sourceBranch,
|
|
831
|
+
prUrl: prInfo.url || "",
|
|
832
|
+
stepsTotal: matchingPlan.steps.length,
|
|
833
|
+
stepsCompleted: matchingPlan.steps.filter(
|
|
834
|
+
(s) => s.status === "complete"
|
|
835
|
+
).length,
|
|
836
|
+
stepsBlocked: matchingPlan.steps.filter((s) => s.status === "blocked")
|
|
837
|
+
.length
|
|
838
|
+
},
|
|
839
|
+
REPO_ROOT
|
|
840
|
+
);
|
|
588
841
|
} else {
|
|
589
842
|
const progressCommit = commit(
|
|
590
843
|
`wip: progress on plan ${planFileName}`,
|
|
@@ -594,6 +847,26 @@ async function handleResumeMode(
|
|
|
594
847
|
if (progressCommit) {
|
|
595
848
|
console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
|
|
596
849
|
}
|
|
850
|
+
|
|
851
|
+
runOnCompleteHook(
|
|
852
|
+
onComplete,
|
|
853
|
+
{
|
|
854
|
+
planName: planFileName,
|
|
855
|
+
planFile: matchingPlanFile,
|
|
856
|
+
outcome: "incomplete",
|
|
857
|
+
workBranch: matchingPlan.metadata.work_branch || currentBranch,
|
|
858
|
+
sourceBranch: matchingPlan.metadata.source_branch || "main",
|
|
859
|
+
prUrl: "",
|
|
860
|
+
stepsTotal: matchingPlan.steps.length,
|
|
861
|
+
stepsCompleted: matchingPlan.steps.filter(
|
|
862
|
+
(s) => s.status === "complete"
|
|
863
|
+
).length,
|
|
864
|
+
stepsBlocked: matchingPlan.steps.filter((s) => s.status === "blocked")
|
|
865
|
+
.length
|
|
866
|
+
},
|
|
867
|
+
REPO_ROOT
|
|
868
|
+
);
|
|
869
|
+
|
|
597
870
|
console.log(
|
|
598
871
|
"\nPlan still has pending steps. Run --resume again to continue."
|
|
599
872
|
);
|
|
@@ -602,6 +875,235 @@ async function handleResumeMode(
|
|
|
602
875
|
console.log("\n=== Resume Complete ===");
|
|
603
876
|
}
|
|
604
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Process a single plan in an isolated git worktree.
|
|
880
|
+
* Uses per-plan locking to allow concurrent execution of multiple plans.
|
|
881
|
+
* @param {Object} normalizedOptions - Normalized orchestration options
|
|
882
|
+
*/
|
|
883
|
+
async function processPlanInWorktree(normalizedOptions) {
|
|
884
|
+
const plansDir = getPlansDir();
|
|
885
|
+
const completedDir = getCompletedDir();
|
|
886
|
+
const reportsDir = getReportsDir();
|
|
887
|
+
|
|
888
|
+
// Handle parallel mode configuration
|
|
889
|
+
const parallelEnabled = resolveParallelEnabled(normalizedOptions.parallel);
|
|
890
|
+
if (parallelEnabled) {
|
|
891
|
+
config.concurrency.maxParallel = resolveParallelMax();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Resolve the plan file
|
|
895
|
+
const resolvedPlanFile = resolvePlanFile(normalizedOptions.plan, plansDir);
|
|
896
|
+
if (!resolvedPlanFile) {
|
|
897
|
+
console.error(`Plan file not found: ${normalizedOptions.plan}`);
|
|
898
|
+
process.exitCode = 1;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const completedNames = getCompletedPlanNames(completedDir);
|
|
903
|
+
const planFileName = path.basename(resolvedPlanFile);
|
|
904
|
+
if (completedNames.has(planFileName)) {
|
|
905
|
+
console.log(`Plan already completed: ${planFileName}`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Derive plan ID and work branch
|
|
910
|
+
const planId = derivePlanId(planFileName);
|
|
911
|
+
const workBranch = deriveBranchName(planFileName);
|
|
912
|
+
|
|
913
|
+
// Acquire per-plan lock
|
|
914
|
+
const worktreesDir = path.join(REPO_ROOT, ".worktrees");
|
|
915
|
+
const worktreePath = path.join(worktreesDir, `plan-${planId}`);
|
|
916
|
+
|
|
917
|
+
const lockResult = acquireLock(planId, { worktreePath });
|
|
918
|
+
if (!lockResult.acquired) {
|
|
919
|
+
console.error(`Cannot start plan "${planId}": ${lockResult.reason}`);
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Clean up per-plan lock on signals
|
|
925
|
+
const cleanupLock = () => {
|
|
926
|
+
releaseLock(planId);
|
|
927
|
+
process.exit();
|
|
928
|
+
};
|
|
929
|
+
process.on("SIGINT", cleanupLock);
|
|
930
|
+
process.on("SIGTERM", cleanupLock);
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
console.log("=== Plan Orchestrator Starting (worktree mode) ===\n");
|
|
934
|
+
console.log(`Plan: ${planFileName}`);
|
|
935
|
+
console.log(`Plan ID: ${planId}`);
|
|
936
|
+
console.log(`Work branch: ${workBranch}`);
|
|
937
|
+
|
|
938
|
+
// Set ORRERY_REPO_ROOT so path resolution works from inside the worktree
|
|
939
|
+
process.env.ORRERY_REPO_ROOT = REPO_ROOT;
|
|
940
|
+
|
|
941
|
+
const sourceBranch = getCurrentBranch(REPO_ROOT);
|
|
942
|
+
console.log(`Source branch: ${sourceBranch}\n`);
|
|
943
|
+
|
|
944
|
+
// Check for uncommitted changes in the main repo
|
|
945
|
+
if (hasUncommittedChanges(REPO_ROOT)) {
|
|
946
|
+
console.error(
|
|
947
|
+
"Error: Uncommitted changes detected. Please commit or stash before running orchestrator."
|
|
948
|
+
);
|
|
949
|
+
process.exitCode = 1;
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Update plan metadata (in the plan file, not committed to source branch)
|
|
954
|
+
let plan = loadPlan(resolvedPlanFile);
|
|
955
|
+
plan.metadata.source_branch = sourceBranch;
|
|
956
|
+
plan.metadata.work_branch = workBranch;
|
|
957
|
+
savePlan(plan);
|
|
958
|
+
|
|
959
|
+
// Create work branch if needed
|
|
960
|
+
if (!branchExists(workBranch, REPO_ROOT)) {
|
|
961
|
+
console.log(`Creating work branch: ${workBranch}`);
|
|
962
|
+
createBranch(workBranch, REPO_ROOT);
|
|
963
|
+
// Switch back to source branch — the worktree will use the work branch
|
|
964
|
+
checkoutBranch(sourceBranch, REPO_ROOT);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Create or reuse plan worktree
|
|
968
|
+
if (!fs.existsSync(worktreesDir)) {
|
|
969
|
+
fs.mkdirSync(worktreesDir, { recursive: true });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (fs.existsSync(worktreePath)) {
|
|
973
|
+
console.log(`Reusing existing worktree: ${worktreePath}`);
|
|
974
|
+
} else {
|
|
975
|
+
console.log(`Creating worktree: ${worktreePath}`);
|
|
976
|
+
addWorktreeExistingBranch(worktreePath, workBranch, REPO_ROOT);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Build execution context
|
|
980
|
+
const ctx = { workingDir: worktreePath, mainRepoRoot: REPO_ROOT };
|
|
981
|
+
|
|
982
|
+
if (parallelEnabled) {
|
|
983
|
+
console.log(
|
|
984
|
+
`Parallel mode enabled (max ${config.concurrency.maxParallel} concurrent agents)`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
console.log(`\nWorking directory: ${worktreePath}\n`);
|
|
989
|
+
|
|
990
|
+
// Process the plan inside the worktree
|
|
991
|
+
await processPlan(
|
|
992
|
+
resolvedPlanFile,
|
|
993
|
+
completedDir,
|
|
994
|
+
reportsDir,
|
|
995
|
+
parallelEnabled,
|
|
996
|
+
ctx
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
// Reload and check final state
|
|
1000
|
+
plan = loadPlan(resolvedPlanFile);
|
|
1001
|
+
const isComplete = plan.isComplete();
|
|
1002
|
+
|
|
1003
|
+
if (isComplete) {
|
|
1004
|
+
const isSuccessful = plan.isSuccessful();
|
|
1005
|
+
|
|
1006
|
+
// Archive the plan
|
|
1007
|
+
archivePlan(resolvedPlanFile, plan, completedDir);
|
|
1008
|
+
|
|
1009
|
+
// Commit all work branch changes (inside worktree)
|
|
1010
|
+
const workCommit = commit(
|
|
1011
|
+
`chore: complete plan ${planFileName}`,
|
|
1012
|
+
[],
|
|
1013
|
+
worktreePath
|
|
1014
|
+
);
|
|
1015
|
+
if (workCommit) {
|
|
1016
|
+
console.log(`Committed plan completion (${workCommit.slice(0, 7)})`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Generate PR info
|
|
1020
|
+
const prTitle = `Plan: ${planFileName.replace(/\.ya?ml$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")}`;
|
|
1021
|
+
const prBody = generatePRBody(plan);
|
|
1022
|
+
const prInfo = createPullRequest(
|
|
1023
|
+
prTitle,
|
|
1024
|
+
prBody,
|
|
1025
|
+
sourceBranch,
|
|
1026
|
+
worktreePath
|
|
1027
|
+
);
|
|
1028
|
+
logPullRequestInfo(prInfo);
|
|
1029
|
+
|
|
1030
|
+
// Run on-complete hook before worktree cleanup
|
|
1031
|
+
runOnCompleteHook(
|
|
1032
|
+
normalizedOptions.onComplete,
|
|
1033
|
+
{
|
|
1034
|
+
planName: planFileName,
|
|
1035
|
+
planFile: resolvedPlanFile,
|
|
1036
|
+
outcome: isSuccessful ? "success" : "partial",
|
|
1037
|
+
workBranch,
|
|
1038
|
+
sourceBranch,
|
|
1039
|
+
prUrl: prInfo.url || "",
|
|
1040
|
+
stepsTotal: plan.steps.length,
|
|
1041
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
1042
|
+
.length,
|
|
1043
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
1044
|
+
},
|
|
1045
|
+
worktreePath
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
// Clean up worktree on success
|
|
1049
|
+
try {
|
|
1050
|
+
removeWorktree(worktreePath, REPO_ROOT);
|
|
1051
|
+
console.log(`Cleaned up worktree: ${worktreePath}`);
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
console.error(`Failed to clean up worktree: ${err.message}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (isSuccessful) {
|
|
1057
|
+
console.log("\n=== Plan Complete (success) ===");
|
|
1058
|
+
} else {
|
|
1059
|
+
console.log("\n=== Plan Complete (partial — some steps blocked) ===");
|
|
1060
|
+
}
|
|
1061
|
+
} else {
|
|
1062
|
+
// Plan not complete — commit WIP and preserve worktree
|
|
1063
|
+
const progressCommit = commit(
|
|
1064
|
+
`wip: progress on plan ${planFileName}`,
|
|
1065
|
+
[],
|
|
1066
|
+
worktreePath
|
|
1067
|
+
);
|
|
1068
|
+
if (progressCommit) {
|
|
1069
|
+
console.log(
|
|
1070
|
+
`Committed work-in-progress (${progressCommit.slice(0, 7)})`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
runOnCompleteHook(
|
|
1075
|
+
normalizedOptions.onComplete,
|
|
1076
|
+
{
|
|
1077
|
+
planName: planFileName,
|
|
1078
|
+
planFile: resolvedPlanFile,
|
|
1079
|
+
outcome: "incomplete",
|
|
1080
|
+
workBranch,
|
|
1081
|
+
sourceBranch,
|
|
1082
|
+
prUrl: "",
|
|
1083
|
+
stepsTotal: plan.steps.length,
|
|
1084
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
1085
|
+
.length,
|
|
1086
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
1087
|
+
},
|
|
1088
|
+
worktreePath
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
console.log(
|
|
1092
|
+
"\nPlan not complete. Worktree preserved for later continuation."
|
|
1093
|
+
);
|
|
1094
|
+
console.log(`Worktree: ${worktreePath}`);
|
|
1095
|
+
console.log("\nTo continue:");
|
|
1096
|
+
console.log(" 1. Fix the blocked steps (orrery status)");
|
|
1097
|
+
console.log(
|
|
1098
|
+
` 2. Run 'orrery resume --plan ${normalizedOptions.plan}' to continue`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
} finally {
|
|
1102
|
+
delete process.env.ORRERY_REPO_ROOT;
|
|
1103
|
+
releaseLock(planId);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
605
1107
|
/**
|
|
606
1108
|
* Process a single plan with branch management
|
|
607
1109
|
* @param {string} planFile - Path to the plan file
|
|
@@ -616,7 +1118,8 @@ async function processPlanWithBranching(
|
|
|
616
1118
|
sourceBranch,
|
|
617
1119
|
completedDir,
|
|
618
1120
|
reportsDir,
|
|
619
|
-
parallelEnabled = false
|
|
1121
|
+
parallelEnabled = false,
|
|
1122
|
+
onComplete = null
|
|
620
1123
|
) {
|
|
621
1124
|
const planFileName = path.basename(planFile);
|
|
622
1125
|
console.log(`\n--- Processing: ${planFileName} ---\n`);
|
|
@@ -683,6 +1186,23 @@ async function processPlanWithBranching(
|
|
|
683
1186
|
const prInfo = createPullRequest(prTitle, prBody, sourceBranch, REPO_ROOT);
|
|
684
1187
|
logPullRequestInfo(prInfo);
|
|
685
1188
|
|
|
1189
|
+
runOnCompleteHook(
|
|
1190
|
+
onComplete,
|
|
1191
|
+
{
|
|
1192
|
+
planName: planFileName,
|
|
1193
|
+
planFile,
|
|
1194
|
+
outcome: isSuccessful ? "success" : "partial",
|
|
1195
|
+
workBranch,
|
|
1196
|
+
sourceBranch,
|
|
1197
|
+
prUrl: prInfo.url || "",
|
|
1198
|
+
stepsTotal: plan.steps.length,
|
|
1199
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
1200
|
+
.length,
|
|
1201
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
1202
|
+
},
|
|
1203
|
+
REPO_ROOT
|
|
1204
|
+
);
|
|
1205
|
+
|
|
686
1206
|
return { isComplete: true, isSuccessful, workBranch };
|
|
687
1207
|
} else {
|
|
688
1208
|
// Plan not complete (still has pending steps or was interrupted)
|
|
@@ -695,6 +1215,24 @@ async function processPlanWithBranching(
|
|
|
695
1215
|
if (progressCommit) {
|
|
696
1216
|
console.log(`Committed work-in-progress (${progressCommit.slice(0, 7)})`);
|
|
697
1217
|
}
|
|
1218
|
+
|
|
1219
|
+
runOnCompleteHook(
|
|
1220
|
+
onComplete,
|
|
1221
|
+
{
|
|
1222
|
+
planName: planFileName,
|
|
1223
|
+
planFile,
|
|
1224
|
+
outcome: "incomplete",
|
|
1225
|
+
workBranch,
|
|
1226
|
+
sourceBranch,
|
|
1227
|
+
prUrl: "",
|
|
1228
|
+
stepsTotal: plan.steps.length,
|
|
1229
|
+
stepsCompleted: plan.steps.filter((s) => s.status === "complete")
|
|
1230
|
+
.length,
|
|
1231
|
+
stepsBlocked: plan.steps.filter((s) => s.status === "blocked").length
|
|
1232
|
+
},
|
|
1233
|
+
REPO_ROOT
|
|
1234
|
+
);
|
|
1235
|
+
|
|
698
1236
|
console.log(
|
|
699
1237
|
"\nPlan not complete. Work branch preserved for later continuation."
|
|
700
1238
|
);
|
|
@@ -772,13 +1310,18 @@ function generatePRBody(plan) {
|
|
|
772
1310
|
* @param {string} completedDir - Directory for completed plans
|
|
773
1311
|
* @param {string} reportsDir - Directory for reports
|
|
774
1312
|
* @param {boolean} parallelEnabled - Whether parallel execution with worktrees is enabled
|
|
1313
|
+
* @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context for worktree mode
|
|
775
1314
|
*/
|
|
776
1315
|
async function processPlan(
|
|
777
1316
|
planFile,
|
|
778
1317
|
completedDir,
|
|
779
1318
|
reportsDir,
|
|
780
|
-
parallelEnabled = false
|
|
1319
|
+
parallelEnabled = false,
|
|
1320
|
+
ctx
|
|
781
1321
|
) {
|
|
1322
|
+
if (!ctx) {
|
|
1323
|
+
ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
|
|
1324
|
+
}
|
|
782
1325
|
let plan = loadPlan(planFile);
|
|
783
1326
|
const activeAgents = []; // Array of {handle, stepIds, tempPlanFile, worktreeInfo?}
|
|
784
1327
|
|
|
@@ -815,22 +1358,23 @@ async function processPlan(
|
|
|
815
1358
|
tracker
|
|
816
1359
|
);
|
|
817
1360
|
plan = loadPlan(planFile);
|
|
818
|
-
await mergeWorktreeCommits(results,
|
|
1361
|
+
await mergeWorktreeCommits(results, ctx.workingDir);
|
|
819
1362
|
} else {
|
|
820
1363
|
// Serial mode: wait for one
|
|
821
1364
|
const { stepIds, parsedResults } = await waitForAgentCompletion(
|
|
822
1365
|
planFile,
|
|
823
1366
|
activeAgents,
|
|
824
1367
|
reportsDir,
|
|
825
|
-
tracker
|
|
1368
|
+
tracker,
|
|
1369
|
+
ctx
|
|
826
1370
|
);
|
|
827
1371
|
plan = loadPlan(planFile);
|
|
828
1372
|
|
|
829
|
-
if (hasUncommittedChanges(
|
|
1373
|
+
if (hasUncommittedChanges(ctx.workingDir)) {
|
|
830
1374
|
const commitMsg =
|
|
831
1375
|
parsedResults[0]?.commitMessage ||
|
|
832
1376
|
`feat: complete step(s) ${stepIds.join(", ")}`;
|
|
833
|
-
const commitSha = commit(commitMsg, [],
|
|
1377
|
+
const commitSha = commit(commitMsg, [], ctx.workingDir);
|
|
834
1378
|
if (commitSha) {
|
|
835
1379
|
console.log(
|
|
836
1380
|
`Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
|
|
@@ -876,7 +1420,8 @@ async function processPlan(
|
|
|
876
1420
|
activeAgents,
|
|
877
1421
|
tracker,
|
|
878
1422
|
useWorktree,
|
|
879
|
-
parallel.length > 1 // skipLogging for parallel batches
|
|
1423
|
+
parallel.length > 1, // skipLogging for parallel batches
|
|
1424
|
+
ctx
|
|
880
1425
|
);
|
|
881
1426
|
}
|
|
882
1427
|
}
|
|
@@ -889,7 +1434,15 @@ async function processPlan(
|
|
|
889
1434
|
) {
|
|
890
1435
|
break;
|
|
891
1436
|
}
|
|
892
|
-
await startSteps(
|
|
1437
|
+
await startSteps(
|
|
1438
|
+
planFile,
|
|
1439
|
+
[step.id],
|
|
1440
|
+
activeAgents,
|
|
1441
|
+
tracker,
|
|
1442
|
+
false,
|
|
1443
|
+
false,
|
|
1444
|
+
ctx
|
|
1445
|
+
);
|
|
893
1446
|
}
|
|
894
1447
|
|
|
895
1448
|
// If we started any agents, wait for completion
|
|
@@ -908,22 +1461,23 @@ async function processPlan(
|
|
|
908
1461
|
tracker
|
|
909
1462
|
);
|
|
910
1463
|
plan = loadPlan(planFile);
|
|
911
|
-
await mergeWorktreeCommits(results,
|
|
1464
|
+
await mergeWorktreeCommits(results, ctx.workingDir);
|
|
912
1465
|
} else {
|
|
913
1466
|
// Serial execution: wait for one agent at a time
|
|
914
1467
|
const { stepIds, parsedResults } = await waitForAgentCompletion(
|
|
915
1468
|
planFile,
|
|
916
1469
|
activeAgents,
|
|
917
1470
|
reportsDir,
|
|
918
|
-
tracker
|
|
1471
|
+
tracker,
|
|
1472
|
+
ctx
|
|
919
1473
|
);
|
|
920
1474
|
plan = loadPlan(planFile);
|
|
921
1475
|
|
|
922
|
-
if (hasUncommittedChanges(
|
|
1476
|
+
if (hasUncommittedChanges(ctx.workingDir)) {
|
|
923
1477
|
const commitMsg =
|
|
924
1478
|
parsedResults[0]?.commitMessage ||
|
|
925
1479
|
`feat: complete step(s) ${stepIds.join(", ")}`;
|
|
926
|
-
const commitSha = commit(commitMsg, [],
|
|
1480
|
+
const commitSha = commit(commitMsg, [], ctx.workingDir);
|
|
927
1481
|
if (commitSha) {
|
|
928
1482
|
console.log(
|
|
929
1483
|
`Committed: ${commitMsg.split("\n")[0]} (${commitSha.slice(0, 7)})`
|
|
@@ -945,6 +1499,8 @@ async function processPlan(
|
|
|
945
1499
|
* @param {Object[]} activeAgents - Array of active agent handles
|
|
946
1500
|
* @param {ProgressTracker} tracker - Progress tracker instance
|
|
947
1501
|
* @param {boolean} useWorktree - Whether to use a git worktree for isolation
|
|
1502
|
+
* @param {boolean} skipLogging - Whether to skip logging step start
|
|
1503
|
+
* @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context
|
|
948
1504
|
*/
|
|
949
1505
|
async function startSteps(
|
|
950
1506
|
planFile,
|
|
@@ -952,8 +1508,13 @@ async function startSteps(
|
|
|
952
1508
|
activeAgents,
|
|
953
1509
|
tracker,
|
|
954
1510
|
useWorktree = false,
|
|
955
|
-
skipLogging = false
|
|
1511
|
+
skipLogging = false,
|
|
1512
|
+
ctx
|
|
956
1513
|
) {
|
|
1514
|
+
if (!ctx) {
|
|
1515
|
+
ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
957
1518
|
// Log step start with progress info (unless skipped for parallel batch)
|
|
958
1519
|
if (!skipLogging) {
|
|
959
1520
|
tracker.logStepStart(stepIds);
|
|
@@ -971,13 +1532,15 @@ async function startSteps(
|
|
|
971
1532
|
const condensedPlan = generateCondensedPlan(plan, stepIds);
|
|
972
1533
|
const tempPlanFile = writeCondensedPlan(condensedPlan, planFile, stepIds);
|
|
973
1534
|
|
|
974
|
-
// Determine working directory (worktree or
|
|
975
|
-
let workingDir =
|
|
1535
|
+
// Determine working directory (worktree or ctx.workingDir)
|
|
1536
|
+
let workingDir = ctx.workingDir;
|
|
976
1537
|
let worktreeInfo = null;
|
|
977
1538
|
|
|
978
1539
|
if (useWorktree) {
|
|
1540
|
+
// Step-level worktrees are created relative to the main repo root,
|
|
1541
|
+
// not the plan worktree, to avoid nesting issues
|
|
979
1542
|
const branchName = `worktree-${stepIds.join("-")}-${Date.now()}`;
|
|
980
|
-
const worktreesDir = path.join(
|
|
1543
|
+
const worktreesDir = path.join(ctx.mainRepoRoot, ".worktrees");
|
|
981
1544
|
|
|
982
1545
|
// Ensure .worktrees directory exists
|
|
983
1546
|
if (!fs.existsSync(worktreesDir)) {
|
|
@@ -987,7 +1550,7 @@ async function startSteps(
|
|
|
987
1550
|
const worktreePath = path.join(worktreesDir, branchName);
|
|
988
1551
|
|
|
989
1552
|
try {
|
|
990
|
-
addWorktree(worktreePath, branchName, "HEAD",
|
|
1553
|
+
addWorktree(worktreePath, branchName, "HEAD", ctx.mainRepoRoot);
|
|
991
1554
|
workingDir = worktreePath;
|
|
992
1555
|
worktreeInfo = { path: worktreePath, branch: branchName };
|
|
993
1556
|
console.log(`Created worktree for ${stepIds.join(",")}: ${worktreePath}`);
|
|
@@ -1040,14 +1603,19 @@ async function startSteps(
|
|
|
1040
1603
|
* @param {Object[]} activeAgents - Array of active agent handles
|
|
1041
1604
|
* @param {string} reportsDir - Directory for reports
|
|
1042
1605
|
* @param {ProgressTracker} tracker - Progress tracker instance
|
|
1606
|
+
* @param {{workingDir: string, mainRepoRoot: string}} [ctx] - Execution context
|
|
1043
1607
|
* @returns {{stepIds: string[], parsedResults: Object[]}} Completed step IDs and parsed results
|
|
1044
1608
|
*/
|
|
1045
1609
|
async function waitForAgentCompletion(
|
|
1046
1610
|
planFile,
|
|
1047
1611
|
activeAgents,
|
|
1048
1612
|
reportsDir,
|
|
1049
|
-
tracker
|
|
1613
|
+
tracker,
|
|
1614
|
+
ctx
|
|
1050
1615
|
) {
|
|
1616
|
+
if (!ctx) {
|
|
1617
|
+
ctx = { workingDir: REPO_ROOT, mainRepoRoot: REPO_ROOT };
|
|
1618
|
+
}
|
|
1051
1619
|
if (activeAgents.length === 0) return { stepIds: [], parsedResults: [] };
|
|
1052
1620
|
|
|
1053
1621
|
// Wait for any agent to complete
|
|
@@ -1112,7 +1680,7 @@ async function waitForAgentCompletion(
|
|
|
1112
1680
|
config,
|
|
1113
1681
|
tempPlanFile,
|
|
1114
1682
|
[stepId],
|
|
1115
|
-
|
|
1683
|
+
ctx.workingDir,
|
|
1116
1684
|
{ stepId, timeoutMs: resolveAgentTimeout() }
|
|
1117
1685
|
);
|
|
1118
1686
|
|
|
@@ -1167,7 +1735,7 @@ async function waitForAgentCompletion(
|
|
|
1167
1735
|
planFile,
|
|
1168
1736
|
[stepId],
|
|
1169
1737
|
reviewResult.feedback,
|
|
1170
|
-
|
|
1738
|
+
ctx.workingDir,
|
|
1171
1739
|
{
|
|
1172
1740
|
stepId,
|
|
1173
1741
|
stepIds: [stepId],
|
|
@@ -1394,6 +1962,37 @@ function writeReport(reportsDir, planFile, report) {
|
|
|
1394
1962
|
console.log(`Report written: ${fileName}`);
|
|
1395
1963
|
}
|
|
1396
1964
|
|
|
1965
|
+
/**
|
|
1966
|
+
* Run the on-complete hook command with plan context as environment variables.
|
|
1967
|
+
* Failures are logged but never thrown.
|
|
1968
|
+
* @param {string} command - Shell command to execute
|
|
1969
|
+
* @param {Object} context - Plan context for environment variables
|
|
1970
|
+
* @param {string} cwd - Working directory to run the command in
|
|
1971
|
+
*/
|
|
1972
|
+
function runOnCompleteHook(command, context, cwd) {
|
|
1973
|
+
if (!command) return;
|
|
1974
|
+
|
|
1975
|
+
const env = {
|
|
1976
|
+
...process.env,
|
|
1977
|
+
ORRERY_PLAN_NAME: String(context.planName || ""),
|
|
1978
|
+
ORRERY_PLAN_FILE: String(context.planFile || ""),
|
|
1979
|
+
ORRERY_PLAN_OUTCOME: String(context.outcome || ""),
|
|
1980
|
+
ORRERY_WORK_BRANCH: String(context.workBranch || ""),
|
|
1981
|
+
ORRERY_SOURCE_BRANCH: String(context.sourceBranch || ""),
|
|
1982
|
+
ORRERY_PR_URL: String(context.prUrl || ""),
|
|
1983
|
+
ORRERY_STEPS_TOTAL: String(context.stepsTotal || 0),
|
|
1984
|
+
ORRERY_STEPS_COMPLETED: String(context.stepsCompleted || 0),
|
|
1985
|
+
ORRERY_STEPS_BLOCKED: String(context.stepsBlocked || 0)
|
|
1986
|
+
};
|
|
1987
|
+
|
|
1988
|
+
try {
|
|
1989
|
+
console.log(`\nRunning on-complete hook: ${command}`);
|
|
1990
|
+
execSync(command, { cwd, env, stdio: "inherit", timeout: 60000 });
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
console.error(`on-complete hook failed: ${err.message}`);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1397
1996
|
/**
|
|
1398
1997
|
* Archive a completed plan
|
|
1399
1998
|
*/
|
|
@@ -1421,4 +2020,4 @@ if (require.main === module) {
|
|
|
1421
2020
|
});
|
|
1422
2021
|
}
|
|
1423
2022
|
|
|
1424
|
-
module.exports = { orchestrate };
|
|
2023
|
+
module.exports = { orchestrate, runOnCompleteHook };
|