@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
package/lib/utils/git.js
CHANGED
|
@@ -239,6 +239,22 @@ function getUncommittedDiff(cwd, files) {
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Derive a plan ID from a plan filename (for lock/worktree naming).
|
|
244
|
+
* @param {string} planFileName - Plan filename (e.g., "2026-01-11-add-dummy-script.yaml")
|
|
245
|
+
* @returns {string} - Plan ID (e.g., "add-dummy-script")
|
|
246
|
+
*/
|
|
247
|
+
function derivePlanId(planFileName) {
|
|
248
|
+
let name = planFileName.replace(/\.ya?ml$/, "");
|
|
249
|
+
name = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
250
|
+
name = name
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
253
|
+
.replace(/-+/g, "-")
|
|
254
|
+
.replace(/^-|-$/g, "");
|
|
255
|
+
return name;
|
|
256
|
+
}
|
|
257
|
+
|
|
242
258
|
/**
|
|
243
259
|
* Derive a branch name from a plan filename
|
|
244
260
|
* @param {string} planFileName - Plan filename (e.g., "2026-01-11-add-dummy-script.yaml")
|
|
@@ -341,6 +357,30 @@ function listWorktrees(cwd) {
|
|
|
341
357
|
return worktrees;
|
|
342
358
|
}
|
|
343
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Get the main (first) worktree root path.
|
|
362
|
+
* Useful for finding the true repo root from inside a linked worktree.
|
|
363
|
+
* @param {string} cwd - Working directory (can be inside any worktree)
|
|
364
|
+
* @returns {string} - Absolute path to the main worktree
|
|
365
|
+
*/
|
|
366
|
+
function getMainRepoRoot(cwd) {
|
|
367
|
+
const worktrees = listWorktrees(cwd);
|
|
368
|
+
if (worktrees.length === 0) {
|
|
369
|
+
throw new Error("No worktrees found");
|
|
370
|
+
}
|
|
371
|
+
return worktrees[0].worktree;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Add a worktree for an existing branch (without creating a new branch).
|
|
376
|
+
* @param {string} worktreePath - Path where the worktree will be created
|
|
377
|
+
* @param {string} branchName - Existing branch to check out in the worktree
|
|
378
|
+
* @param {string} cwd - Working directory of the main repository
|
|
379
|
+
*/
|
|
380
|
+
function addWorktreeExistingBranch(worktreePath, branchName, cwd) {
|
|
381
|
+
git(`worktree add "${worktreePath}" ${branchName}`, cwd);
|
|
382
|
+
}
|
|
383
|
+
|
|
344
384
|
/**
|
|
345
385
|
* Get commits between two refs
|
|
346
386
|
* @param {string} fromRef - Starting reference (exclusive)
|
|
@@ -403,11 +443,14 @@ module.exports = {
|
|
|
403
443
|
hasUncommittedChanges,
|
|
404
444
|
getUncommittedDiff,
|
|
405
445
|
deriveBranchName,
|
|
446
|
+
derivePlanId,
|
|
406
447
|
stash,
|
|
407
448
|
stashPop,
|
|
408
449
|
addWorktree,
|
|
450
|
+
addWorktreeExistingBranch,
|
|
409
451
|
removeWorktree,
|
|
410
452
|
listWorktrees,
|
|
453
|
+
getMainRepoRoot,
|
|
411
454
|
getCommitRange,
|
|
412
455
|
cherryPick,
|
|
413
456
|
cherryPickAbort,
|
package/lib/utils/lock.js
CHANGED
|
@@ -6,8 +6,14 @@ const { getWorkDir } = require("./paths");
|
|
|
6
6
|
|
|
7
7
|
const LOCK_FILE = "exec.lock";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Get the lock file path.
|
|
11
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
12
|
+
* @returns {string} - Path to the lock file
|
|
13
|
+
*/
|
|
14
|
+
function getLockPath(planId) {
|
|
15
|
+
const fileName = planId ? `exec-${planId}.lock` : LOCK_FILE;
|
|
16
|
+
return path.join(getWorkDir(), fileName);
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
/**
|
|
@@ -56,10 +62,11 @@ function isOrreryProcess(pid) {
|
|
|
56
62
|
|
|
57
63
|
/**
|
|
58
64
|
* Read and parse the lock file.
|
|
59
|
-
* @
|
|
65
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
66
|
+
* @returns {{pid: number, startedAt: string, command: string, planId?: string, worktreePath?: string}|null}
|
|
60
67
|
*/
|
|
61
|
-
function readLock() {
|
|
62
|
-
const lockPath = getLockPath();
|
|
68
|
+
function readLock(planId) {
|
|
69
|
+
const lockPath = getLockPath(planId);
|
|
63
70
|
try {
|
|
64
71
|
const content = fs.readFileSync(lockPath, "utf8");
|
|
65
72
|
return JSON.parse(content);
|
|
@@ -70,18 +77,24 @@ function readLock() {
|
|
|
70
77
|
|
|
71
78
|
/**
|
|
72
79
|
* Attempt to acquire the execution lock.
|
|
80
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
81
|
+
* @param {{worktreePath?: string}} [extras] - Extra fields to store in the lock
|
|
73
82
|
* @returns {{acquired: boolean, reason?: string, pid?: number}}
|
|
74
83
|
*/
|
|
75
|
-
function acquireLock() {
|
|
76
|
-
const lockPath = getLockPath();
|
|
84
|
+
function acquireLock(planId, extras) {
|
|
85
|
+
const lockPath = getLockPath(planId);
|
|
77
86
|
const lockData = {
|
|
78
87
|
pid: process.pid,
|
|
79
88
|
startedAt: new Date().toISOString(),
|
|
80
89
|
command: process.argv.slice(2).join(" ")
|
|
81
90
|
};
|
|
91
|
+
if (planId) lockData.planId = planId;
|
|
92
|
+
if (extras && extras.worktreePath) {
|
|
93
|
+
lockData.worktreePath = extras.worktreePath;
|
|
94
|
+
}
|
|
82
95
|
|
|
83
96
|
// Check for existing lock
|
|
84
|
-
const existing = readLock();
|
|
97
|
+
const existing = readLock(planId);
|
|
85
98
|
if (existing) {
|
|
86
99
|
const running = isProcessRunning(existing.pid);
|
|
87
100
|
if (running && isOrreryProcess(existing.pid)) {
|
|
@@ -109,7 +122,7 @@ function acquireLock() {
|
|
|
109
122
|
} catch (err) {
|
|
110
123
|
if (err.code === "EEXIST") {
|
|
111
124
|
// Race condition — another process acquired between check and write
|
|
112
|
-
const raceWinner = readLock();
|
|
125
|
+
const raceWinner = readLock(planId);
|
|
113
126
|
return {
|
|
114
127
|
acquired: false,
|
|
115
128
|
reason: `Another orrery process just started (PID ${raceWinner?.pid || "unknown"})`,
|
|
@@ -125,10 +138,11 @@ function acquireLock() {
|
|
|
125
138
|
|
|
126
139
|
/**
|
|
127
140
|
* Release the execution lock (only if owned by current process).
|
|
141
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
128
142
|
*/
|
|
129
|
-
function releaseLock() {
|
|
130
|
-
const lockPath = getLockPath();
|
|
131
|
-
const existing = readLock();
|
|
143
|
+
function releaseLock(planId) {
|
|
144
|
+
const lockPath = getLockPath(planId);
|
|
145
|
+
const existing = readLock(planId);
|
|
132
146
|
|
|
133
147
|
if (existing && existing.pid === process.pid) {
|
|
134
148
|
try {
|
|
@@ -141,10 +155,11 @@ function releaseLock() {
|
|
|
141
155
|
|
|
142
156
|
/**
|
|
143
157
|
* Get the current lock status (read-only).
|
|
144
|
-
* @
|
|
158
|
+
* @param {string} [planId] - Optional plan ID for per-plan locks
|
|
159
|
+
* @returns {{locked: boolean, pid?: number, startedAt?: string, stale: boolean, planId?: string, worktreePath?: string}}
|
|
145
160
|
*/
|
|
146
|
-
function getLockStatus() {
|
|
147
|
-
const existing = readLock();
|
|
161
|
+
function getLockStatus(planId) {
|
|
162
|
+
const existing = readLock(planId);
|
|
148
163
|
|
|
149
164
|
if (!existing) {
|
|
150
165
|
return { locked: false, stale: false };
|
|
@@ -153,18 +168,68 @@ function getLockStatus() {
|
|
|
153
168
|
const running = isProcessRunning(existing.pid);
|
|
154
169
|
const isOrrery = running && isOrreryProcess(existing.pid);
|
|
155
170
|
|
|
156
|
-
|
|
171
|
+
const status = {
|
|
157
172
|
locked: isOrrery,
|
|
158
173
|
pid: existing.pid,
|
|
159
174
|
startedAt: existing.startedAt,
|
|
160
175
|
stale: !isOrrery
|
|
161
176
|
};
|
|
177
|
+
if (existing.planId) status.planId = existing.planId;
|
|
178
|
+
if (existing.worktreePath) status.worktreePath = existing.worktreePath;
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all per-plan locks in the work directory.
|
|
184
|
+
* @returns {Array<{planId: string, pid: number, startedAt: string, active: boolean, stale: boolean, worktreePath?: string}>}
|
|
185
|
+
*/
|
|
186
|
+
function listPlanLocks() {
|
|
187
|
+
const workDir = getWorkDir();
|
|
188
|
+
const results = [];
|
|
189
|
+
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(workDir);
|
|
193
|
+
} catch {
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const match = entry.match(/^exec-(.+)\.lock$/);
|
|
199
|
+
if (!match) continue;
|
|
200
|
+
|
|
201
|
+
const planId = match[1];
|
|
202
|
+
const lockPath = path.join(workDir, entry);
|
|
203
|
+
let lockData;
|
|
204
|
+
try {
|
|
205
|
+
lockData = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
206
|
+
} catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const running = isProcessRunning(lockData.pid);
|
|
211
|
+
const active = running && isOrreryProcess(lockData.pid);
|
|
212
|
+
|
|
213
|
+
const info = {
|
|
214
|
+
planId,
|
|
215
|
+
pid: lockData.pid,
|
|
216
|
+
startedAt: lockData.startedAt,
|
|
217
|
+
active,
|
|
218
|
+
stale: !active
|
|
219
|
+
};
|
|
220
|
+
if (lockData.worktreePath) info.worktreePath = lockData.worktreePath;
|
|
221
|
+
results.push(info);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return results;
|
|
162
225
|
}
|
|
163
226
|
|
|
164
227
|
module.exports = {
|
|
165
228
|
acquireLock,
|
|
166
229
|
releaseLock,
|
|
167
230
|
getLockStatus,
|
|
231
|
+
listPlanLocks,
|
|
232
|
+
readLock,
|
|
168
233
|
isProcessRunning,
|
|
169
234
|
isOrreryProcess
|
|
170
235
|
};
|
package/lib/utils/paths.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require("fs");
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
|
|
5
5
|
const WORK_DIR_ENV = "ORRERY_WORK_DIR";
|
|
6
|
+
const REPO_ROOT_ENV = "ORRERY_REPO_ROOT";
|
|
6
7
|
|
|
7
8
|
function ensureDir(dirPath) {
|
|
8
9
|
if (!fs.existsSync(dirPath)) {
|
|
@@ -11,13 +12,28 @@ function ensureDir(dirPath) {
|
|
|
11
12
|
return dirPath;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Get the effective repo root for path resolution.
|
|
17
|
+
* Uses ORRERY_REPO_ROOT if set (when running inside a worktree), otherwise process.cwd().
|
|
18
|
+
* @returns {string} - Resolved repo root path
|
|
19
|
+
*/
|
|
20
|
+
function getEffectiveRoot() {
|
|
21
|
+
const envRoot = process.env[REPO_ROOT_ENV];
|
|
22
|
+
if (envRoot && envRoot.trim()) {
|
|
23
|
+
return path.resolve(envRoot.trim());
|
|
24
|
+
}
|
|
25
|
+
return path.resolve(process.cwd());
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/**
|
|
15
29
|
* Generate a deterministic project identifier from the current working directory.
|
|
16
30
|
* Format: <sanitized-basename>-<hash8>
|
|
31
|
+
* When ORRERY_REPO_ROOT is set (inside a worktree), uses that instead of cwd
|
|
32
|
+
* so the project ID stays consistent.
|
|
17
33
|
* @returns {string} - Project identifier
|
|
18
34
|
*/
|
|
19
35
|
function getProjectId() {
|
|
20
|
-
const cwd =
|
|
36
|
+
const cwd = getEffectiveRoot();
|
|
21
37
|
const basename = path.basename(cwd) || "root";
|
|
22
38
|
const sanitized = basename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
23
39
|
const hash = crypto
|
|
@@ -34,7 +50,7 @@ function getWorkDir() {
|
|
|
34
50
|
const projectScoped = path.join(envDir.trim(), getProjectId());
|
|
35
51
|
return ensureDir(projectScoped);
|
|
36
52
|
}
|
|
37
|
-
return ensureDir(path.join(
|
|
53
|
+
return ensureDir(path.join(getEffectiveRoot(), ".agent-work"));
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
function getPlansDir() {
|
|
@@ -55,8 +71,8 @@ function getTempDir() {
|
|
|
55
71
|
|
|
56
72
|
function isWorkDirExternal() {
|
|
57
73
|
const workDir = path.resolve(getWorkDir());
|
|
58
|
-
const
|
|
59
|
-
return !workDir.startsWith(
|
|
74
|
+
const root = getEffectiveRoot();
|
|
75
|
+
return !workDir.startsWith(root + path.sep) && workDir !== root;
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
module.exports = {
|