@welluable/orch 1.0.0 → 1.2.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/lib/jobs.js ADDED
@@ -0,0 +1,284 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
6
+ const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
7
+
8
+ /** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
9
+ export function jobPaths(cwd, slug) {
10
+ const dir = path.join(path.resolve(cwd), '.orch', slug);
11
+ return {
12
+ dir,
13
+ runJsonPath: path.join(dir, 'run.json'),
14
+ lockPath: path.join(dir, '.run.lock'),
15
+ logPath: path.join(dir, 'orch.log'),
16
+ };
17
+ }
18
+
19
+ function atomicWriteJson(dir, filePath, data) {
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ const tmpPath = path.join(
22
+ dir,
23
+ `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
24
+ );
25
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
26
+ fs.renameSync(tmpPath, filePath);
27
+ }
28
+
29
+ /** Atomically (write-temp + rename) writes a job record to `run.json`. */
30
+ export function writeJob(cwd, slug, record) {
31
+ const { dir, runJsonPath } = jobPaths(cwd, slug);
32
+ atomicWriteJson(dir, runJsonPath, record);
33
+ }
34
+
35
+ /** Reads and parses `run.json`; `null` if missing; throws on invalid JSON. */
36
+ export function readJob(cwd, slug) {
37
+ const { runJsonPath } = jobPaths(cwd, slug);
38
+ let content;
39
+ try {
40
+ content = fs.readFileSync(runJsonPath, 'utf8');
41
+ } catch (err) {
42
+ if (err.code === 'ENOENT') return null;
43
+ throw err;
44
+ }
45
+ return JSON.parse(content);
46
+ }
47
+
48
+ /** `process.kill(pid, 0)` wrapped in try/catch; never throws. */
49
+ export function isPidAlive(pid) {
50
+ if (!Number.isInteger(pid) || pid <= 0) return false;
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function sleepSync(ms) {
60
+ const view = new Int32Array(new SharedArrayBuffer(4));
61
+ Atomics.wait(view, 0, 0, ms);
62
+ }
63
+
64
+ function sleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+
68
+ /**
69
+ * Acquires `.run.lock` via exclusive create, busy-waiting on contention.
70
+ * A lock whose owner pid is no longer alive is treated as stale and removed.
71
+ */
72
+ function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
73
+ const start = Date.now();
74
+ for (;;) {
75
+ try {
76
+ const fd = fs.openSync(lockPath, 'wx');
77
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
78
+ fs.closeSync(fd);
79
+ return;
80
+ } catch (err) {
81
+ if (err.code !== 'EEXIST') throw err;
82
+
83
+ let ownerPid = null;
84
+ try {
85
+ ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
86
+ } catch {
87
+ // Lock file mid-write or briefly unreadable; treat as contention.
88
+ }
89
+
90
+ if (ownerPid != null && !isPidAlive(ownerPid)) {
91
+ try {
92
+ fs.unlinkSync(lockPath);
93
+ } catch {
94
+ // Another process may have removed it first; retry.
95
+ }
96
+ continue;
97
+ }
98
+
99
+ if (Date.now() - start > timeoutMs) {
100
+ throw new Error(`patchJob: timed out waiting for lock ${lockPath}`);
101
+ }
102
+ sleepSync(retryMs);
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
109
+ * `(current) => partialPatch` function) over the latest record, atomically
110
+ * writes, unlocks, and returns the updated record.
111
+ */
112
+ export function patchJob(cwd, slug, patchFnOrObject) {
113
+ const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
114
+ fs.mkdirSync(dir, { recursive: true });
115
+ acquireLock(lockPath);
116
+ try {
117
+ const current = readJob(cwd, slug) ?? {};
118
+ const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
119
+ const updated = { ...current, ...patch };
120
+ atomicWriteJson(dir, runJsonPath, updated);
121
+ return updated;
122
+ } finally {
123
+ try {
124
+ fs.unlinkSync(lockPath);
125
+ } catch {
126
+ // Already gone; nothing to clean up.
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * If `record` is in an active live state (running/pausing/paused) and its
133
+ * pid is dead, atomically rewrites it to `crashed` and returns the updated
134
+ * record. Otherwise returns `record` unchanged.
135
+ */
136
+ export function reconcileJob(cwd, slug, record) {
137
+ if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
138
+ if (isPidAlive(record.pid)) return record;
139
+ return patchJob(cwd, slug, {
140
+ state: 'crashed',
141
+ finishedAt: new Date().toISOString(),
142
+ exitCode: null,
143
+ });
144
+ }
145
+
146
+ /** Every `run.json` under `<cwd>/.orch/*`, reconciled, most-recent-first by `startedAt`. */
147
+ export function listJobs(cwd) {
148
+ const orchDir = path.join(path.resolve(cwd), '.orch');
149
+ if (!fs.existsSync(orchDir)) return [];
150
+
151
+ const slugs = fs.readdirSync(orchDir, { withFileTypes: true })
152
+ .filter((entry) => entry.isDirectory())
153
+ .map((entry) => entry.name);
154
+
155
+ const jobs = [];
156
+ for (const slug of slugs) {
157
+ const record = readJob(cwd, slug);
158
+ if (!record) continue;
159
+ jobs.push(reconcileJob(cwd, slug, record));
160
+ }
161
+
162
+ jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
163
+ return jobs;
164
+ }
165
+
166
+ /**
167
+ * Cooperative pause point: no-op if `pauseRequested` is falsy. Otherwise
168
+ * patches to `paused`, polls `run.json` (no lock/fd held) until
169
+ * `pauseRequested` clears, then patches back to `running`.
170
+ */
171
+ export async function checkpointPause(cwd, slug, { pollIntervalMs = 500 } = {}) {
172
+ const record = readJob(cwd, slug);
173
+ if (!record?.pauseRequested) return;
174
+
175
+ patchJob(cwd, slug, { state: 'paused' });
176
+
177
+ for (;;) {
178
+ await sleep(pollIntervalMs);
179
+ const current = readJob(cwd, slug);
180
+ if (!current?.pauseRequested) break;
181
+ }
182
+
183
+ patchJob(cwd, slug, { state: 'running' });
184
+ }
185
+
186
+ /** The pure operation behind `orch pause <slug>`. */
187
+ export function requestPause(cwd, slug) {
188
+ const record = readJob(cwd, slug);
189
+ if (!record) throw new Error(`requestPause: unknown job ${slug}`);
190
+ if (TERMINAL_STATES.includes(record.state)) {
191
+ throw new Error(`requestPause: job ${slug} is in terminal state ${record.state}`);
192
+ }
193
+ if (record.state === 'pausing' || record.state === 'paused') return record;
194
+
195
+ const patch = { pauseRequested: true };
196
+ if (record.state === 'running') patch.state = 'pausing';
197
+ return patchJob(cwd, slug, patch);
198
+ }
199
+
200
+ /** The pure operation behind `orch resume <slug>`. */
201
+ export function requestResume(cwd, slug) {
202
+ const record = readJob(cwd, slug);
203
+ if (!record) throw new Error(`requestResume: unknown job ${slug}`);
204
+ if (TERMINAL_STATES.includes(record.state)) {
205
+ throw new Error(`requestResume: job ${slug} is in terminal state ${record.state}`);
206
+ }
207
+ return patchJob(cwd, slug, { pauseRequested: false, state: 'running' });
208
+ }
209
+
210
+ /**
211
+ * The pure operation behind `orch stop <slug>`: signals a live pid (leaving
212
+ * the eventual `stopped` write to the child's own `shutdown()`), or
213
+ * reconciles a dead one to `crashed`. `kill` is injectable for tests.
214
+ */
215
+ export function stopJob(cwd, slug, { kill = (pid, signal) => process.kill(pid, signal) } = {}) {
216
+ const record = readJob(cwd, slug);
217
+ if (!record) throw new Error(`stopJob: unknown job ${slug}`);
218
+
219
+ if (TERMINAL_STATES.includes(record.state)) {
220
+ return { action: 'already-terminal', record };
221
+ }
222
+
223
+ if (isPidAlive(record.pid)) {
224
+ kill(record.pid, 'SIGTERM');
225
+ return { action: 'signaled', record };
226
+ }
227
+
228
+ const reconciled = reconcileJob(cwd, slug, record);
229
+ if (reconciled.state === 'crashed' && record.state !== 'crashed') {
230
+ return { action: 'crashed', record: reconciled };
231
+ }
232
+ return { action: 'already-terminal', record: reconciled };
233
+ }
234
+
235
+ const LIVE_PAUSE_STATES = ['running', 'pausing', 'paused'];
236
+
237
+ /**
238
+ * Parent → children pause: `requestPause` on the parent, then the same pause
239
+ * write on every child with `parent === parentSlug` in a live pause state.
240
+ * Returns how many children were cascade-targeted (CLI prints this count).
241
+ */
242
+ export function cascadePause(cwd, parentSlug) {
243
+ requestPause(cwd, parentSlug);
244
+ let childrenSignaled = 0;
245
+ for (const job of listJobs(cwd)) {
246
+ if (job.parent !== parentSlug) continue;
247
+ if (!LIVE_PAUSE_STATES.includes(job.state)) continue;
248
+ requestPause(cwd, job.slug);
249
+ childrenSignaled += 1;
250
+ }
251
+ return { childrenSignaled };
252
+ }
253
+
254
+ /**
255
+ * Parent → children resume: `requestResume` on the parent, then cascade
256
+ * resume only to non-terminal children that are `paused`/`pausing`.
257
+ */
258
+ export function cascadeResume(cwd, parentSlug) {
259
+ requestResume(cwd, parentSlug);
260
+ let childrenSignaled = 0;
261
+ for (const job of listJobs(cwd)) {
262
+ if (job.parent !== parentSlug) continue;
263
+ if (job.state !== 'paused' && job.state !== 'pausing') continue;
264
+ requestResume(cwd, job.slug);
265
+ childrenSignaled += 1;
266
+ }
267
+ return { childrenSignaled };
268
+ }
269
+
270
+ /**
271
+ * The pure operation behind `orch jobs clean`: removes every entry under
272
+ * `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
273
+ * is missing or already empty).
274
+ */
275
+ export function cleanJobs(cwd) {
276
+ const orchDir = path.join(path.resolve(cwd), '.orch');
277
+ if (!fs.existsSync(orchDir)) return [];
278
+
279
+ const names = fs.readdirSync(orchDir);
280
+ for (const name of names) {
281
+ fs.rmSync(path.join(orchDir, name), { recursive: true, force: true });
282
+ }
283
+ return names;
284
+ }
@@ -0,0 +1,33 @@
1
+ /** Parse decomposer agent final message as JSON. Returns null on any failure. */
2
+ export function parseDecomposition(result) {
3
+ if (typeof result !== 'string') return null;
4
+
5
+ const trimmed = result.trim();
6
+ if (!trimmed) return null;
7
+
8
+ const tryParse = (text) => {
9
+ try {
10
+ return JSON.parse(text);
11
+ } catch {
12
+ return null;
13
+ }
14
+ };
15
+
16
+ let parsed = tryParse(trimmed);
17
+ if (parsed && typeof parsed === 'object') return parsed;
18
+
19
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
20
+ if (fenceMatch) {
21
+ parsed = tryParse(fenceMatch[1].trim());
22
+ if (parsed && typeof parsed === 'object') return parsed;
23
+ }
24
+
25
+ const start = trimmed.indexOf('{');
26
+ const end = trimmed.lastIndexOf('}');
27
+ if (start !== -1 && end > start) {
28
+ parsed = tryParse(trimmed.slice(start, end + 1));
29
+ if (parsed && typeof parsed === 'object') return parsed;
30
+ }
31
+
32
+ return null;
33
+ }
@@ -8,10 +8,22 @@ import { generateSlug as defaultGenerateSlug } from './slug.js';
8
8
  * generated directory already exists; throws once attempts are exhausted
9
9
  * rather than reusing or repairing an existing directory.
10
10
  */
11
- export function createRunContext({ cwd, generateSlug = defaultGenerateSlug, maxAttempts = 5 } = {}) {
11
+ export function createRunContext({ cwd, slug, generateSlug = defaultGenerateSlug, maxAttempts = 5 } = {}) {
12
12
  const absCwd = path.resolve(cwd);
13
13
  const orchDir = path.join(absCwd, '.orch');
14
14
 
15
+ if (slug) {
16
+ const artifactDir = path.join(orchDir, slug);
17
+ fs.mkdirSync(artifactDir, { recursive: true });
18
+ return {
19
+ slug,
20
+ artifactDir,
21
+ researchPath: path.join(artifactDir, 'research.md'),
22
+ taskPath: path.join(artifactDir, 'task.md'),
23
+ statusPath: path.join(artifactDir, 'status.md'),
24
+ };
25
+ }
26
+
15
27
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
16
28
  const slug = generateSlug();
17
29
  const artifactDir = path.join(orchDir, slug);
@@ -24,11 +24,93 @@ export function splitStageSummary(raw) {
24
24
  }
25
25
 
26
26
  /**
27
- * Print a blank line followed by `[label] summary: <summary>`. No-op when
28
- * summary is empty so stages that return no summary don't print a broken line.
27
+ * Split a one-paragraph summary into sentence-sized bullet lines. Splits at
28
+ * a `.`/`!`/`?` only when it's followed by whitespace and then a capital
29
+ * letter or digit, so periods inside filenames/versions (e.g. "status.md")
30
+ * don't cause a false split.
29
31
  */
30
- export function printStageSummary(label, summary) {
31
- if (!summary) return;
32
+ function splitSummaryIntoBullets(summary) {
33
+ const normalized = summary.replace(/\s+/g, ' ').trim();
34
+ if (!normalized) return [];
35
+ return normalized
36
+ .split(/(?<=[.!?])\s+(?=[A-Z0-9])/)
37
+ .map((sentence) => sentence.trim())
38
+ .filter(Boolean);
39
+ }
40
+
41
+ const FILES_NOTES_HEADER_RE = /^\s*files\s*:?\s*$/i;
42
+
43
+ /**
44
+ * Pull an optional trailing `Files:` block (one `<path>: <one-line note>`
45
+ * per line) off the end of a stage summary, so writer agents can attach a
46
+ * short description to each file they touched. Returns the prose with that
47
+ * block removed plus a `path -> note` map; when no `Files:` line is present
48
+ * the summary passes through unchanged and the map is empty.
49
+ */
50
+ function extractFileNotes(summary) {
51
+ const lines = summary.split('\n');
52
+ const headerIdx = lines.findIndex((line) => FILES_NOTES_HEADER_RE.test(line));
53
+ if (headerIdx === -1) return { prose: summary, notes: new Map() };
54
+
55
+ const notes = new Map();
56
+ for (const line of lines.slice(headerIdx + 1)) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed) continue;
59
+ const sep = trimmed.indexOf(':');
60
+ if (sep === -1) continue;
61
+ const notePath = trimmed.slice(0, sep).trim();
62
+ const note = trimmed.slice(sep + 1).trim();
63
+ if (notePath && note) notes.set(notePath, note);
64
+ }
65
+
66
+ return { prose: lines.slice(0, headerIdx).join('\n').trim(), notes };
67
+ }
68
+
69
+ /**
70
+ * Print a titled, bulleted block for a stage's summary paragraph, e.g.:
71
+ *
72
+ * ──────────
73
+ * triage
74
+ * ──────────
75
+ * • Sentence one.
76
+ * • Sentence two.
77
+ * Files (2)
78
+ * ~ lib/agent.js - wired the tracker into onToolEvent
79
+ * + lib/file-tracker.js - added the ordered/deduped file collector
80
+ *
81
+ * A trailing `Files:` block in `summary` (one `<path>: <note>` per line)
82
+ * supplies the per-file note; a file with no matching note prints as before
83
+ * (marker + path only). Prints when prose or files are nonempty; both
84
+ * empty → no-op.
85
+ *
86
+ * @param {string} label
87
+ * @param {string} summary
88
+ * @param {{ marker: string, path: string }[]=} files
89
+ */
90
+ export function printStageSummary(label, summary, files) {
91
+ const { prose, notes } = extractFileNotes(summary || '');
92
+ const hasSummary = Boolean(prose);
93
+ const hasFiles = Array.isArray(files) && files.length > 0;
94
+ if (!hasSummary && !hasFiles) return;
95
+
96
+ const title = ` ${label} `;
97
+ const rule = '─'.repeat(title.length);
98
+
99
+ console.log();
100
+ console.log(rule);
101
+ console.log(title);
102
+ console.log(rule);
103
+ if (hasSummary) {
104
+ for (const bullet of splitSummaryIntoBullets(prose)) {
105
+ console.log(` • ${bullet}`);
106
+ }
107
+ }
108
+ if (hasFiles) {
109
+ console.log(` Files (${files.length})`);
110
+ for (const { marker, path: filePath } of files) {
111
+ const note = notes.get(filePath);
112
+ console.log(` ${marker} ${filePath}${note ? ` - ${note}` : ''}`);
113
+ }
114
+ }
32
115
  console.log();
33
- console.log(`[${label}] summary: ${summary}`);
34
116
  }
package/lib/worktree.js CHANGED
@@ -22,7 +22,7 @@ function runGit(execFile, args) {
22
22
  * `execFile` is injectable and defaults to a `child_process.execFileSync`
23
23
  * wrapper.
24
24
  */
25
- export function createWorktree({ cwd, slug, execFile = defaultExecFile }) {
25
+ export function createWorktree({ cwd, slug, execFile = defaultExecFile, base }) {
26
26
  const repoRoot = runGit(execFile, ['-C', cwd, 'rev-parse', '--show-toplevel']).trim();
27
27
 
28
28
  const worktreePath = `${path.join(path.dirname(repoRoot), path.basename(repoRoot))}-${slug}`;
@@ -32,7 +32,9 @@ export function createWorktree({ cwd, slug, execFile = defaultExecFile }) {
32
32
  throw new Error(`createWorktree: refusing to overwrite existing path ${worktreePath}`);
33
33
  }
34
34
 
35
- runGit(execFile, ['-C', repoRoot, 'worktree', 'add', '-b', branch, worktreePath]);
35
+ const args = ['-C', repoRoot, 'worktree', 'add', '-b', branch, worktreePath];
36
+ if (base) args.push(base);
37
+ runGit(execFile, args);
36
38
 
37
39
  return { repoRoot, worktreePath, branch };
38
40
  }