@caseyharalson/orrery 0.13.1 → 0.14.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.
@@ -93,6 +93,7 @@ module.exports = function registerResumeCommand(program) {
93
93
 
94
94
  let planFile;
95
95
  let plan;
96
+ let hasWorktree = false;
96
97
 
97
98
  if (options.plan) {
98
99
  // Resolve plan path (same pattern as status.js)
@@ -134,7 +135,7 @@ module.exports = function registerResumeCommand(program) {
134
135
  ".worktrees",
135
136
  `plan-${planId}`
136
137
  );
137
- const hasWorktree = require("fs").existsSync(worktreePath);
138
+ hasWorktree = fs.existsSync(worktreePath);
138
139
 
139
140
  if (!hasWorktree) {
140
141
  // Verify current branch matches plan's work_branch
@@ -258,17 +259,21 @@ module.exports = function registerResumeCommand(program) {
258
259
  console.log(` ${colorize("pending", "yellow")} ${step.id}`);
259
260
  }
260
261
 
261
- // 7. Commit the plan file changes
262
- const planName = planFileName.replace(/\.ya?ml$/, "");
263
- const commitMessage = `chore: unblock steps in ${planName}`;
264
- const commitHash = commit(commitMessage, [planFile], process.cwd());
262
+ // 7. Commit the plan file changes (only when not using a worktree,
263
+ // since the plan file lives in the main repo's .agent-work/, not
264
+ // inside the worktree's git tree)
265
+ if (!hasWorktree) {
266
+ const planName = planFileName.replace(/\.ya?ml$/, "");
267
+ const commitMessage = `chore: unblock steps in ${planName}`;
268
+ const commitHash = commit(commitMessage, [planFile], process.cwd());
265
269
 
266
- if (commitHash) {
267
- console.log(
268
- `\nCommitted: ${commitMessage} (${commitHash.slice(0, 7)})`
269
- );
270
- } else {
271
- console.log("\n(no changes to commit)");
270
+ if (commitHash) {
271
+ console.log(
272
+ `\nCommitted: ${commitMessage} (${commitHash.slice(0, 7)})`
273
+ );
274
+ } else {
275
+ console.log("\n(no changes to commit)");
276
+ }
272
277
  }
273
278
 
274
279
  // 8. Resume orchestration
@@ -0,0 +1,146 @@
1
+ const path = require("path");
2
+
3
+ const { derivePlanId } = require("../../utils/git");
4
+ const {
5
+ getLockStatus,
6
+ listPlanLocks,
7
+ readLock,
8
+ isOrreryProcess
9
+ } = require("../../utils/lock");
10
+ const { requestStop, clearStopSignal } = require("../../utils/stop-signal");
11
+
12
+ function supportsColor() {
13
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
14
+ }
15
+
16
+ function colorize(text, color) {
17
+ if (!supportsColor()) return text;
18
+ const colors = {
19
+ green: "\x1b[32m",
20
+ yellow: "\x1b[33m",
21
+ red: "\x1b[31m",
22
+ reset: "\x1b[0m"
23
+ };
24
+ return `${colors[color] || ""}${text}${colors.reset}`;
25
+ }
26
+
27
+ /**
28
+ * Send SIGTERM to a process after verifying it's an orrery process.
29
+ * @param {number} pid - Process ID
30
+ * @param {string} label - Label for log messages
31
+ * @returns {boolean} - Whether the signal was sent
32
+ */
33
+ function killProcess(pid, label) {
34
+ if (!isOrreryProcess(pid)) {
35
+ console.log(
36
+ ` ${colorize("skipped", "yellow")} ${label} — PID ${pid} is not an orrery process`
37
+ );
38
+ return false;
39
+ }
40
+
41
+ try {
42
+ process.kill(pid, "SIGTERM");
43
+ console.log(
44
+ ` ${colorize("stopped", "green")} ${label} (PID ${pid}, sent SIGTERM)`
45
+ );
46
+ return true;
47
+ } catch (err) {
48
+ if (err.code === "ESRCH") {
49
+ console.log(
50
+ ` ${colorize("skipped", "yellow")} ${label} — PID ${pid} already exited`
51
+ );
52
+ } else {
53
+ console.log(` ${colorize("failed", "red")} ${label} — ${err.message}`);
54
+ }
55
+ return false;
56
+ }
57
+ }
58
+
59
+ module.exports = function registerStopCommand(program) {
60
+ program
61
+ .command("stop")
62
+ .description("Stop running orchestrations")
63
+ .option("--plan <file>", "Stop a specific plan by name or file")
64
+ .option(
65
+ "--graceful",
66
+ "Finish current step(s) then stop (instead of immediate SIGTERM)"
67
+ )
68
+ .action((options) => {
69
+ const graceful = options.graceful || false;
70
+
71
+ if (options.plan) {
72
+ // Stop a specific plan
73
+ const planBasename = path.basename(options.plan);
74
+ const planId = derivePlanId(planBasename);
75
+
76
+ const lock = readLock(planId);
77
+ if (!lock) {
78
+ console.log(`No active execution found for plan "${planId}".`);
79
+ return;
80
+ }
81
+
82
+ const status = getLockStatus(planId);
83
+ if (status.stale) {
84
+ console.log(
85
+ `Lock for plan "${planId}" is stale (PID ${lock.pid} no longer running).`
86
+ );
87
+ return;
88
+ }
89
+
90
+ if (!status.locked) {
91
+ console.log(`No active execution found for plan "${planId}".`);
92
+ return;
93
+ }
94
+
95
+ if (graceful) {
96
+ requestStop(planId);
97
+ console.log(
98
+ `Graceful stop requested for plan "${planId}" — will stop after current step(s) finish.`
99
+ );
100
+ } else {
101
+ killProcess(lock.pid, `plan "${planId}"`);
102
+ clearStopSignal(planId);
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Stop all running plans
108
+ const planLocks = listPlanLocks();
109
+ const activeLocks = planLocks.filter((l) => l.active);
110
+
111
+ // Also check global lock
112
+ const globalStatus = getLockStatus();
113
+ const globalLock = globalStatus.locked ? readLock() : null;
114
+
115
+ if (activeLocks.length === 0 && !globalLock) {
116
+ console.log("No active orchestrations found.");
117
+ return;
118
+ }
119
+
120
+ if (graceful) {
121
+ // Write signal files for all active plans + global
122
+ if (globalLock) {
123
+ requestStop();
124
+ console.log(
125
+ "Graceful stop requested for global execution — will stop after current step(s) finish."
126
+ );
127
+ }
128
+ for (const pl of activeLocks) {
129
+ requestStop(pl.planId);
130
+ console.log(
131
+ `Graceful stop requested for plan "${pl.planId}" — will stop after current step(s) finish.`
132
+ );
133
+ }
134
+ } else {
135
+ // Immediate stop via SIGTERM
136
+ if (globalLock) {
137
+ killProcess(globalLock.pid, "global execution");
138
+ clearStopSignal();
139
+ }
140
+ for (const pl of activeLocks) {
141
+ killProcess(pl.pid, `plan "${pl.planId}"`);
142
+ clearStopSignal(pl.planId);
143
+ }
144
+ }
145
+ });
146
+ };
package/lib/cli/index.js CHANGED
@@ -7,6 +7,7 @@ const registerInstallDevcontainer = require("./commands/install-devcontainer");
7
7
  const registerOrchestrate = require("./commands/orchestrate");
8
8
  const registerStatus = require("./commands/status");
9
9
  const registerResume = require("./commands/resume");
10
+ const registerStop = require("./commands/stop");
10
11
  const registerValidatePlan = require("./commands/validate-plan");
11
12
  const registerIngestPlan = require("./commands/ingest-plan");
12
13
  const registerManual = require("./commands/manual");
@@ -34,6 +35,7 @@ function buildProgram() {
34
35
  registerOrchestrate(program);
35
36
  registerStatus(program);
36
37
  registerResume(program);
38
+ registerStop(program);
37
39
  registerValidatePlan(program);
38
40
  registerIngestPlan(program);
39
41
  registerManual(program);
@@ -77,6 +77,7 @@ const {
77
77
 
78
78
  const { ProgressTracker } = require("./progress-tracker");
79
79
  const { acquireLock, releaseLock } = require("../utils/lock");
80
+ const { isStopRequested, clearStopSignal } = require("../utils/stop-signal");
80
81
 
81
82
  const REPO_ROOT = process.cwd();
82
83
 
@@ -333,8 +334,9 @@ async function orchestrate(options = {}) {
333
334
  return;
334
335
  }
335
336
 
336
- // Clean up lock on signals
337
+ // Clean up lock and stop signals on signals
337
338
  const cleanupLock = () => {
339
+ clearStopSignal();
338
340
  releaseLock();
339
341
  process.exit();
340
342
  };
@@ -519,6 +521,8 @@ async function resumeInWorktree(
519
521
  }
520
522
 
521
523
  const cleanupLock = () => {
524
+ clearStopSignal(planId);
525
+ clearStopSignal();
522
526
  releaseLock(planId);
523
527
  process.exit();
524
528
  };
@@ -921,8 +925,10 @@ async function processPlanInWorktree(normalizedOptions) {
921
925
  return;
922
926
  }
923
927
 
924
- // Clean up per-plan lock on signals
928
+ // Clean up per-plan lock and stop signals on signals
925
929
  const cleanupLock = () => {
930
+ clearStopSignal(planId);
931
+ clearStopSignal();
926
932
  releaseLock(planId);
927
933
  process.exit();
928
934
  };
@@ -1341,7 +1347,16 @@ async function processPlan(
1341
1347
  }
1342
1348
 
1343
1349
  // Main execution loop
1350
+ const loopPlanId = derivePlanId(path.basename(planFile));
1344
1351
  while (!plan.isComplete()) {
1352
+ // Check for graceful stop signal
1353
+ if (isStopRequested(loopPlanId) || isStopRequested()) {
1354
+ console.log("\nStop signal received — stopping after current step(s).");
1355
+ clearStopSignal(loopPlanId);
1356
+ clearStopSignal();
1357
+ break;
1358
+ }
1359
+
1345
1360
  // Get steps ready to execute
1346
1361
  const readySteps = getReadySteps(plan);
1347
1362
 
@@ -0,0 +1,51 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { getWorkDir } = require("./paths");
5
+
6
+ /**
7
+ * Get the path to the stop signal file.
8
+ * @param {string} [planId] - Optional plan ID for per-plan signals
9
+ * @returns {string} - Path to the signal file
10
+ */
11
+ function getStopSignalPath(planId) {
12
+ const fileName = planId ? `stop-${planId}.signal` : "stop.signal";
13
+ return path.join(getWorkDir(), fileName);
14
+ }
15
+
16
+ /**
17
+ * Write a stop signal file to request graceful stop.
18
+ * @param {string} [planId] - Optional plan ID for per-plan signals
19
+ */
20
+ function requestStop(planId) {
21
+ const signalPath = getStopSignalPath(planId);
22
+ fs.writeFileSync(signalPath, new Date().toISOString() + "\n");
23
+ }
24
+
25
+ /**
26
+ * Check if a stop signal has been requested.
27
+ * @param {string} [planId] - Optional plan ID for per-plan signals
28
+ * @returns {boolean}
29
+ */
30
+ function isStopRequested(planId) {
31
+ return fs.existsSync(getStopSignalPath(planId));
32
+ }
33
+
34
+ /**
35
+ * Remove the stop signal file.
36
+ * @param {string} [planId] - Optional plan ID for per-plan signals
37
+ */
38
+ function clearStopSignal(planId) {
39
+ try {
40
+ fs.unlinkSync(getStopSignalPath(planId));
41
+ } catch {
42
+ // Ignore if file doesn't exist
43
+ }
44
+ }
45
+
46
+ module.exports = {
47
+ getStopSignalPath,
48
+ requestStop,
49
+ isStopRequested,
50
+ clearStopSignal
51
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caseyharalson/orrery",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Workflow planning and orchestration CLI for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "Casey Haralson",