@welluable/orch 1.1.0 → 1.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.
- package/README.md +224 -6
- package/agents/boundaries.js +26 -0
- package/agents/code-writer.js +5 -0
- package/agents/decomposer.js +62 -0
- package/agents/index.js +3 -0
- package/agents/integrator.js +41 -0
- package/agents/quick-fix.js +4 -0
- package/agents/test-writer.js +4 -0
- package/lib/agent.js +82 -6
- package/lib/commit.js +70 -0
- package/lib/config.js +115 -0
- package/lib/continue.js +132 -0
- package/lib/fanout.js +355 -0
- package/lib/file-tracker.js +89 -0
- package/lib/integrate.js +51 -0
- package/lib/job-lifecycle.js +53 -0
- package/lib/jobs.js +361 -0
- package/lib/parse-decomposition.js +33 -0
- package/lib/run-context.js +13 -1
- package/lib/stage-summary.js +55 -6
- package/lib/worktree.js +4 -2
- package/main.js +2739 -214
- package/package.json +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** @param {string} name */
|
|
4
|
+
function markerForName(name) {
|
|
5
|
+
const key = String(name || '').toLowerCase();
|
|
6
|
+
if (key === 'write') return '+';
|
|
7
|
+
if (key === 'edit' || key === 'multiedit') return '~';
|
|
8
|
+
if (key === 'delete') return '-';
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** @param {Record<string, unknown>|null|undefined} args */
|
|
13
|
+
function extractPath(args) {
|
|
14
|
+
if (!args || typeof args !== 'object') return null;
|
|
15
|
+
const p = args.path ?? args.file_path;
|
|
16
|
+
return typeof p === 'string' && p ? p : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collect Write/Edit/Delete completions into an ordered, deduped file list
|
|
21
|
+
* for live sticky lines and stage-summary `Files (N)`.
|
|
22
|
+
*/
|
|
23
|
+
export class FileTracker {
|
|
24
|
+
/** @param {{ cwd: string }} opts */
|
|
25
|
+
constructor({ cwd }) {
|
|
26
|
+
this.cwd = path.resolve(cwd);
|
|
27
|
+
/** @type {Map<string, { name: string, marker: string, path: string|null }>} */
|
|
28
|
+
this.pending = new Map();
|
|
29
|
+
/** @type {{ marker: string, path: string }[]} */
|
|
30
|
+
this.files = [];
|
|
31
|
+
/** @type {Map<string, number>} */
|
|
32
|
+
this.indexByPath = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} event
|
|
37
|
+
* @returns {{ marker: string, path: string, isNew: boolean }|null}
|
|
38
|
+
*/
|
|
39
|
+
record({ name, args, phase, callId }) {
|
|
40
|
+
if (phase === 'started') {
|
|
41
|
+
const marker = markerForName(name);
|
|
42
|
+
if (!marker) return null;
|
|
43
|
+
this.pending.set(callId, {
|
|
44
|
+
name,
|
|
45
|
+
marker,
|
|
46
|
+
path: extractPath(args),
|
|
47
|
+
});
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (phase !== 'completed') return null;
|
|
52
|
+
|
|
53
|
+
const prior = this.pending.get(callId);
|
|
54
|
+
this.pending.delete(callId);
|
|
55
|
+
|
|
56
|
+
const toolName = name || prior?.name || '';
|
|
57
|
+
const marker = markerForName(toolName) || prior?.marker || null;
|
|
58
|
+
if (!marker) return null;
|
|
59
|
+
|
|
60
|
+
const rawPath = extractPath(args) || prior?.path || null;
|
|
61
|
+
if (!rawPath) return null;
|
|
62
|
+
|
|
63
|
+
const relPath = this.#toRelative(rawPath);
|
|
64
|
+
const existingIdx = this.indexByPath.get(relPath);
|
|
65
|
+
const isNew = existingIdx === undefined;
|
|
66
|
+
|
|
67
|
+
if (isNew) {
|
|
68
|
+
this.indexByPath.set(relPath, this.files.length);
|
|
69
|
+
this.files.push({ marker, path: relPath });
|
|
70
|
+
} else {
|
|
71
|
+
this.files[existingIdx].marker = marker;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { marker, path: relPath, isNew };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns {{ marker: string, path: string }[]} */
|
|
78
|
+
getFiles() {
|
|
79
|
+
return this.files.map((f) => ({ marker: f.marker, path: f.path }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string} filePath */
|
|
83
|
+
#toRelative(filePath) {
|
|
84
|
+
if (path.isAbsolute(filePath)) {
|
|
85
|
+
return path.relative(this.cwd, filePath) || path.basename(filePath);
|
|
86
|
+
}
|
|
87
|
+
return filePath;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/lib/integrate.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFileSync as nodeExecFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
function defaultExecFile(command, args, options = {}) {
|
|
4
|
+
return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function parseLines(output) {
|
|
8
|
+
return output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sequentially merges `candidates` (in order) into `cwd` via `git merge
|
|
13
|
+
* --no-ff`, skipping branches already present in `merged`. Stops advancing on
|
|
14
|
+
* the first conflict, leaving the tree conflicted for the caller to repair or
|
|
15
|
+
* abort. Returns the per-branch results accumulated so far.
|
|
16
|
+
*/
|
|
17
|
+
export function mergeBranches({ cwd, candidates, merged = [], overlappingFiles = [], execFile = defaultExecFile }) {
|
|
18
|
+
const results = [];
|
|
19
|
+
for (const branch of candidates) {
|
|
20
|
+
if (merged.includes(branch)) {
|
|
21
|
+
results.push({ branch, status: 'skipped' });
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const output = execFile('git', ['-C', cwd, 'merge', '--no-ff', branch]);
|
|
27
|
+
results.push({ branch, status: 'merged', output });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
results.push({ branch, status: 'conflict', output: err.stderr || err.message });
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs `git merge --abort` in `cwd`. */
|
|
37
|
+
export function abortMerge({ cwd, execFile = defaultExecFile }) {
|
|
38
|
+
return execFile('git', ['-C', cwd, 'merge', '--abort']);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Runs `git diff --name-only --diff-filter=U` in `cwd`; returns the parsed path array. */
|
|
42
|
+
export function conflictedFiles({ cwd, execFile = defaultExecFile }) {
|
|
43
|
+
const output = execFile('git', ['-C', cwd, 'diff', '--name-only', '--diff-filter=U']);
|
|
44
|
+
return parseLines(output);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True if `git diff` in `cwd` still shows an unresolved `<<<<<<<` marker. */
|
|
48
|
+
export function hasConflictMarkers({ cwd, execFile = defaultExecFile }) {
|
|
49
|
+
const output = execFile('git', ['-C', cwd, 'diff']);
|
|
50
|
+
return output.includes('<<<<<<<');
|
|
51
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { generateSlug as generateSlugDefault } from './slug.js';
|
|
2
|
+
import { createRunContext as createRunContextDefault } from './run-context.js';
|
|
3
|
+
import { writeJob as writeJobDefault, jobPaths } from './jobs.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared job-allocation helper: generates a slug, creates the run directory,
|
|
7
|
+
* and writes the initial `run.json`. Used by both `runDetached` (which starts
|
|
8
|
+
* a job `"starting"` with no pid yet, since a separate child process still
|
|
9
|
+
* has to start) and the Commander action's non-detached branch (which starts
|
|
10
|
+
* a job `"running"` with `process.pid`, since there is no separate child to
|
|
11
|
+
* wait on).
|
|
12
|
+
*/
|
|
13
|
+
export function allocateJob({
|
|
14
|
+
cwd,
|
|
15
|
+
prompt,
|
|
16
|
+
agent,
|
|
17
|
+
maxRounds = null,
|
|
18
|
+
state = 'starting',
|
|
19
|
+
pid = null,
|
|
20
|
+
parent = null,
|
|
21
|
+
role = null,
|
|
22
|
+
workerId = null,
|
|
23
|
+
generateSlug = generateSlugDefault,
|
|
24
|
+
createRunContext = createRunContextDefault,
|
|
25
|
+
writeJob = writeJobDefault,
|
|
26
|
+
}) {
|
|
27
|
+
const slug = generateSlug();
|
|
28
|
+
const runContext = createRunContext({ cwd, slug });
|
|
29
|
+
const record = {
|
|
30
|
+
slug,
|
|
31
|
+
task: prompt,
|
|
32
|
+
agent,
|
|
33
|
+
maxRounds,
|
|
34
|
+
cwd,
|
|
35
|
+
pauseRequested: false,
|
|
36
|
+
branch: null,
|
|
37
|
+
worktree: null,
|
|
38
|
+
startedAt: new Date().toISOString(),
|
|
39
|
+
finishedAt: null,
|
|
40
|
+
exitCode: null,
|
|
41
|
+
logPath: jobPaths(cwd, slug).logPath,
|
|
42
|
+
pid,
|
|
43
|
+
state,
|
|
44
|
+
phase: null,
|
|
45
|
+
stage: null,
|
|
46
|
+
round: null,
|
|
47
|
+
parent,
|
|
48
|
+
role,
|
|
49
|
+
workerId,
|
|
50
|
+
};
|
|
51
|
+
writeJob(cwd, slug, record);
|
|
52
|
+
return { slug, runContext, record };
|
|
53
|
+
}
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Compact outcome written onto `run.json` with every terminal state transition.
|
|
10
|
+
* Shared so all call sites (pipelines, shutdown, reconcile) keep one shape.
|
|
11
|
+
*/
|
|
12
|
+
export function buildLastOutcome({
|
|
13
|
+
state,
|
|
14
|
+
phase = null,
|
|
15
|
+
stage = null,
|
|
16
|
+
round = null,
|
|
17
|
+
exitCode = null,
|
|
18
|
+
finishedAt,
|
|
19
|
+
task = null,
|
|
20
|
+
summary = '',
|
|
21
|
+
error = null,
|
|
22
|
+
}) {
|
|
23
|
+
return {
|
|
24
|
+
state,
|
|
25
|
+
phase: phase ?? null,
|
|
26
|
+
stage: stage ?? null,
|
|
27
|
+
round: round ?? null,
|
|
28
|
+
exitCode: exitCode ?? null,
|
|
29
|
+
finishedAt,
|
|
30
|
+
task: task ?? null,
|
|
31
|
+
summary: summary ?? '',
|
|
32
|
+
error: error ?? null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
|
|
37
|
+
export function jobPaths(cwd, slug) {
|
|
38
|
+
const dir = path.join(path.resolve(cwd), '.orch', slug);
|
|
39
|
+
return {
|
|
40
|
+
dir,
|
|
41
|
+
runJsonPath: path.join(dir, 'run.json'),
|
|
42
|
+
lockPath: path.join(dir, '.run.lock'),
|
|
43
|
+
logPath: path.join(dir, 'orch.log'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function atomicWriteJson(dir, filePath, data) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
const tmpPath = path.join(
|
|
50
|
+
dir,
|
|
51
|
+
`.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
52
|
+
);
|
|
53
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
54
|
+
fs.renameSync(tmpPath, filePath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Atomically (write-temp + rename) writes a job record to `run.json`. */
|
|
58
|
+
export function writeJob(cwd, slug, record) {
|
|
59
|
+
const { dir, runJsonPath } = jobPaths(cwd, slug);
|
|
60
|
+
atomicWriteJson(dir, runJsonPath, record);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads and parses `run.json`; `null` if missing; throws on invalid JSON. */
|
|
64
|
+
export function readJob(cwd, slug) {
|
|
65
|
+
const { runJsonPath } = jobPaths(cwd, slug);
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(runJsonPath, 'utf8');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err.code === 'ENOENT') return null;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
return JSON.parse(content);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `process.kill(pid, 0)` wrapped in try/catch; never throws. */
|
|
77
|
+
export function isPidAlive(pid) {
|
|
78
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sleepSync(ms) {
|
|
88
|
+
const view = new Int32Array(new SharedArrayBuffer(4));
|
|
89
|
+
Atomics.wait(view, 0, 0, ms);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Acquires `.run.lock` via exclusive create, busy-waiting on contention.
|
|
98
|
+
* A lock whose owner pid is no longer alive is treated as stale and removed.
|
|
99
|
+
*/
|
|
100
|
+
function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
for (;;) {
|
|
103
|
+
try {
|
|
104
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
105
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
return;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.code !== 'EEXIST') throw err;
|
|
110
|
+
|
|
111
|
+
let ownerPid = null;
|
|
112
|
+
try {
|
|
113
|
+
ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
|
|
114
|
+
} catch {
|
|
115
|
+
// Lock file mid-write or briefly unreadable; treat as contention.
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ownerPid != null && !isPidAlive(ownerPid)) {
|
|
119
|
+
try {
|
|
120
|
+
fs.unlinkSync(lockPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// Another process may have removed it first; retry.
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Date.now() - start > timeoutMs) {
|
|
128
|
+
throw new Error(`patchJob: timed out waiting for lock ${lockPath}`);
|
|
129
|
+
}
|
|
130
|
+
sleepSync(retryMs);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
|
|
137
|
+
* `(current) => partialPatch` function) over the latest record, atomically
|
|
138
|
+
* writes, unlocks, and returns the updated record.
|
|
139
|
+
*/
|
|
140
|
+
export function patchJob(cwd, slug, patchFnOrObject) {
|
|
141
|
+
const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
acquireLock(lockPath);
|
|
144
|
+
try {
|
|
145
|
+
const current = readJob(cwd, slug) ?? {};
|
|
146
|
+
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
|
|
147
|
+
const updated = { ...current, ...patch };
|
|
148
|
+
atomicWriteJson(dir, runJsonPath, updated);
|
|
149
|
+
return updated;
|
|
150
|
+
} finally {
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(lockPath);
|
|
153
|
+
} catch {
|
|
154
|
+
// Already gone; nothing to clean up.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* If `record` is in an active live state (running/pausing/paused) and its
|
|
161
|
+
* pid is dead, atomically rewrites it to `crashed` and returns the updated
|
|
162
|
+
* record. Otherwise returns `record` unchanged.
|
|
163
|
+
*/
|
|
164
|
+
export function reconcileJob(cwd, slug, record) {
|
|
165
|
+
if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
|
|
166
|
+
if (isPidAlive(record.pid)) return record;
|
|
167
|
+
return patchJob(cwd, slug, (current) => {
|
|
168
|
+
const finishedAt = new Date().toISOString();
|
|
169
|
+
return {
|
|
170
|
+
state: 'crashed',
|
|
171
|
+
finishedAt,
|
|
172
|
+
exitCode: null,
|
|
173
|
+
lastOutcome: buildLastOutcome({
|
|
174
|
+
state: 'crashed',
|
|
175
|
+
phase: current.phase,
|
|
176
|
+
stage: current.stage,
|
|
177
|
+
round: current.round,
|
|
178
|
+
exitCode: null,
|
|
179
|
+
finishedAt,
|
|
180
|
+
task: current.task,
|
|
181
|
+
summary: '',
|
|
182
|
+
error: null,
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reopen an existing terminal job for `orch continue`. Patches in place —
|
|
190
|
+
* never allocates a new slug/directory. Bumps `continuation`, appends
|
|
191
|
+
* `continuations[]`, clears live `lastOutcome`, resets to running/research.
|
|
192
|
+
*/
|
|
193
|
+
export function reopenJob(cwd, slug, { task, agent, maxRounds, pid, prior, startedAt } = {}) {
|
|
194
|
+
const existing = readJob(cwd, slug);
|
|
195
|
+
if (!existing) throw new Error(`reopenJob: unknown job ${slug}`);
|
|
196
|
+
|
|
197
|
+
const started = startedAt ?? new Date().toISOString();
|
|
198
|
+
const continuation = (existing.continuation ?? 1) + 1;
|
|
199
|
+
const continuations = Array.isArray(existing.continuations)
|
|
200
|
+
? [...existing.continuations]
|
|
201
|
+
: [];
|
|
202
|
+
continuations.push({ n: continuation, task, startedAt: started, prior: prior ?? null });
|
|
203
|
+
|
|
204
|
+
return patchJob(cwd, slug, {
|
|
205
|
+
task,
|
|
206
|
+
agent,
|
|
207
|
+
maxRounds,
|
|
208
|
+
pid,
|
|
209
|
+
startedAt: started,
|
|
210
|
+
state: 'running',
|
|
211
|
+
phase: 'research',
|
|
212
|
+
stage: null,
|
|
213
|
+
round: null,
|
|
214
|
+
finishedAt: null,
|
|
215
|
+
exitCode: null,
|
|
216
|
+
pauseRequested: false,
|
|
217
|
+
lastOutcome: null,
|
|
218
|
+
continuation,
|
|
219
|
+
continuations,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every `run.json` under `<cwd>/.orch/*`, reconciled, most-recent-first by `startedAt`. */
|
|
224
|
+
export function listJobs(cwd) {
|
|
225
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
226
|
+
if (!fs.existsSync(orchDir)) return [];
|
|
227
|
+
|
|
228
|
+
const slugs = fs.readdirSync(orchDir, { withFileTypes: true })
|
|
229
|
+
.filter((entry) => entry.isDirectory())
|
|
230
|
+
.map((entry) => entry.name);
|
|
231
|
+
|
|
232
|
+
const jobs = [];
|
|
233
|
+
for (const slug of slugs) {
|
|
234
|
+
const record = readJob(cwd, slug);
|
|
235
|
+
if (!record) continue;
|
|
236
|
+
jobs.push(reconcileJob(cwd, slug, record));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
240
|
+
return jobs;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Cooperative pause point: no-op if `pauseRequested` is falsy. Otherwise
|
|
245
|
+
* patches to `paused`, polls `run.json` (no lock/fd held) until
|
|
246
|
+
* `pauseRequested` clears, then patches back to `running`.
|
|
247
|
+
*/
|
|
248
|
+
export async function checkpointPause(cwd, slug, { pollIntervalMs = 500 } = {}) {
|
|
249
|
+
const record = readJob(cwd, slug);
|
|
250
|
+
if (!record?.pauseRequested) return;
|
|
251
|
+
|
|
252
|
+
patchJob(cwd, slug, { state: 'paused' });
|
|
253
|
+
|
|
254
|
+
for (;;) {
|
|
255
|
+
await sleep(pollIntervalMs);
|
|
256
|
+
const current = readJob(cwd, slug);
|
|
257
|
+
if (!current?.pauseRequested) break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
patchJob(cwd, slug, { state: 'running' });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The pure operation behind `orch pause <slug>`. */
|
|
264
|
+
export function requestPause(cwd, slug) {
|
|
265
|
+
const record = readJob(cwd, slug);
|
|
266
|
+
if (!record) throw new Error(`requestPause: unknown job ${slug}`);
|
|
267
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
268
|
+
throw new Error(`requestPause: job ${slug} is in terminal state ${record.state}`);
|
|
269
|
+
}
|
|
270
|
+
if (record.state === 'pausing' || record.state === 'paused') return record;
|
|
271
|
+
|
|
272
|
+
const patch = { pauseRequested: true };
|
|
273
|
+
if (record.state === 'running') patch.state = 'pausing';
|
|
274
|
+
return patchJob(cwd, slug, patch);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The pure operation behind `orch resume <slug>`. */
|
|
278
|
+
export function requestResume(cwd, slug) {
|
|
279
|
+
const record = readJob(cwd, slug);
|
|
280
|
+
if (!record) throw new Error(`requestResume: unknown job ${slug}`);
|
|
281
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
282
|
+
throw new Error(`requestResume: job ${slug} is in terminal state ${record.state}`);
|
|
283
|
+
}
|
|
284
|
+
return patchJob(cwd, slug, { pauseRequested: false, state: 'running' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The pure operation behind `orch stop <slug>`: signals a live pid (leaving
|
|
289
|
+
* the eventual `stopped` write to the child's own `shutdown()`), or
|
|
290
|
+
* reconciles a dead one to `crashed`. `kill` is injectable for tests.
|
|
291
|
+
*/
|
|
292
|
+
export function stopJob(cwd, slug, { kill = (pid, signal) => process.kill(pid, signal) } = {}) {
|
|
293
|
+
const record = readJob(cwd, slug);
|
|
294
|
+
if (!record) throw new Error(`stopJob: unknown job ${slug}`);
|
|
295
|
+
|
|
296
|
+
if (TERMINAL_STATES.includes(record.state)) {
|
|
297
|
+
return { action: 'already-terminal', record };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (isPidAlive(record.pid)) {
|
|
301
|
+
kill(record.pid, 'SIGTERM');
|
|
302
|
+
return { action: 'signaled', record };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const reconciled = reconcileJob(cwd, slug, record);
|
|
306
|
+
if (reconciled.state === 'crashed' && record.state !== 'crashed') {
|
|
307
|
+
return { action: 'crashed', record: reconciled };
|
|
308
|
+
}
|
|
309
|
+
return { action: 'already-terminal', record: reconciled };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const LIVE_PAUSE_STATES = ['running', 'pausing', 'paused'];
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parent → children pause: `requestPause` on the parent, then the same pause
|
|
316
|
+
* write on every child with `parent === parentSlug` in a live pause state.
|
|
317
|
+
* Returns how many children were cascade-targeted (CLI prints this count).
|
|
318
|
+
*/
|
|
319
|
+
export function cascadePause(cwd, parentSlug) {
|
|
320
|
+
requestPause(cwd, parentSlug);
|
|
321
|
+
let childrenSignaled = 0;
|
|
322
|
+
for (const job of listJobs(cwd)) {
|
|
323
|
+
if (job.parent !== parentSlug) continue;
|
|
324
|
+
if (!LIVE_PAUSE_STATES.includes(job.state)) continue;
|
|
325
|
+
requestPause(cwd, job.slug);
|
|
326
|
+
childrenSignaled += 1;
|
|
327
|
+
}
|
|
328
|
+
return { childrenSignaled };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Parent → children resume: `requestResume` on the parent, then cascade
|
|
333
|
+
* resume only to non-terminal children that are `paused`/`pausing`.
|
|
334
|
+
*/
|
|
335
|
+
export function cascadeResume(cwd, parentSlug) {
|
|
336
|
+
requestResume(cwd, parentSlug);
|
|
337
|
+
let childrenSignaled = 0;
|
|
338
|
+
for (const job of listJobs(cwd)) {
|
|
339
|
+
if (job.parent !== parentSlug) continue;
|
|
340
|
+
if (job.state !== 'paused' && job.state !== 'pausing') continue;
|
|
341
|
+
requestResume(cwd, job.slug);
|
|
342
|
+
childrenSignaled += 1;
|
|
343
|
+
}
|
|
344
|
+
return { childrenSignaled };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The pure operation behind `orch jobs clean`: removes every entry under
|
|
349
|
+
* `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
|
|
350
|
+
* is missing or already empty).
|
|
351
|
+
*/
|
|
352
|
+
export function cleanJobs(cwd) {
|
|
353
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
354
|
+
if (!fs.existsSync(orchDir)) return [];
|
|
355
|
+
|
|
356
|
+
const names = fs.readdirSync(orchDir);
|
|
357
|
+
for (const name of names) {
|
|
358
|
+
fs.rmSync(path.join(orchDir, name), { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
return names;
|
|
361
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/run-context.js
CHANGED
|
@@ -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);
|
package/lib/stage-summary.js
CHANGED
|
@@ -38,6 +38,34 @@ function splitSummaryIntoBullets(summary) {
|
|
|
38
38
|
.filter(Boolean);
|
|
39
39
|
}
|
|
40
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
|
+
|
|
41
69
|
/**
|
|
42
70
|
* Print a titled, bulleted block for a stage's summary paragraph, e.g.:
|
|
43
71
|
*
|
|
@@ -46,12 +74,24 @@ function splitSummaryIntoBullets(summary) {
|
|
|
46
74
|
* ──────────
|
|
47
75
|
* • Sentence one.
|
|
48
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
|
|
49
80
|
*
|
|
50
|
-
*
|
|
51
|
-
* a
|
|
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
|
|
52
89
|
*/
|
|
53
|
-
export function printStageSummary(label, summary) {
|
|
54
|
-
|
|
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;
|
|
55
95
|
|
|
56
96
|
const title = ` ${label} `;
|
|
57
97
|
const rule = '─'.repeat(title.length);
|
|
@@ -60,8 +100,17 @@ export function printStageSummary(label, summary) {
|
|
|
60
100
|
console.log(rule);
|
|
61
101
|
console.log(title);
|
|
62
102
|
console.log(rule);
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
114
|
}
|
|
66
115
|
console.log();
|
|
67
116
|
}
|