@welluable/orch 1.2.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 +25 -5
- package/lib/agent.js +18 -5
- package/lib/config.js +115 -0
- package/lib/continue.js +132 -0
- package/lib/jobs.js +79 -2
- package/main.js +620 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,10 @@ npm install -g @welluable/orch
|
|
|
51
51
|
|
|
52
52
|
Make sure an agent CLI is on your `PATH` — orch defaults to `--agent cursor`
|
|
53
53
|
(the Cursor Agent CLI, command `agent`); `claude` and `agn` are also
|
|
54
|
-
supported.
|
|
54
|
+
supported. Pin a default with `orch config --agent <name>` so you don't need
|
|
55
|
+
`--agent` on every run (local `.orch/config` overrides global
|
|
56
|
+
`~/.orch/config`; CLI `--agent` still wins). See [Requirements](#requirements)
|
|
57
|
+
for details.
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
60
|
orch "fix the typo in the README"
|
|
@@ -210,9 +213,18 @@ Usage: orch [options] [command] <task...>
|
|
|
210
213
|
at once; omit to let the coordinator choose (typically the current layer's
|
|
211
214
|
size); only meaningful with `--fan-out`.
|
|
212
215
|
- `--agent <cursor|claude|agn>` — selects the backend for the whole pipeline;
|
|
213
|
-
|
|
216
|
+
when omitted, uses local `.orch/config`, then global `~/.orch/config`, else
|
|
217
|
+
`cursor`.
|
|
214
218
|
- `-h, --help` — displays help for the command.
|
|
215
219
|
|
|
220
|
+
Config:
|
|
221
|
+
|
|
222
|
+
- `orch config` — prints the effective agent and which file(s) contributed
|
|
223
|
+
(local / global / default). Does not prompt.
|
|
224
|
+
- `orch config --agent <cursor|claude|agn> [--global|--local]` — writes the
|
|
225
|
+
default agent. Bare `--agent` (and `--global`) write `~/.orch/config`;
|
|
226
|
+
`--local` writes `<cwd>/.orch/config`. There is no `orch init`.
|
|
227
|
+
|
|
216
228
|
Job-control subcommands (see [Headless runs](#headless-runs)):
|
|
217
229
|
|
|
218
230
|
- `orch list` — lists all runs tracked under `.orch/` in the current directory.
|
|
@@ -220,7 +232,11 @@ Job-control subcommands (see [Headless runs](#headless-runs)):
|
|
|
220
232
|
recently started run.
|
|
221
233
|
- `orch pause <slug>` — requests a pause at the run's next stage-boundary
|
|
222
234
|
checkpoint.
|
|
223
|
-
- `orch resume <slug>` —
|
|
235
|
+
- `orch resume <slug>` — unpauses a paused (or pausing) run.
|
|
236
|
+
- `orch continue <slug> "new task"` — starts a new complex pipeline on a
|
|
237
|
+
finished run's existing worktree/branch (same slug). Carries prior
|
|
238
|
+
stage/failure outcome into research/plan. For fan-out workers, continue the
|
|
239
|
+
worker slug, then `orch --integrate <parent>`. Not the same as `resume`.
|
|
224
240
|
- `orch stop <slug>` — sends `SIGTERM` to a running job (or reconciles a dead
|
|
225
241
|
one to `crashed`).
|
|
226
242
|
- `orch logs <slug> [-f]` — prints a run's `orch.log`; `-f` follows it until
|
|
@@ -237,6 +253,9 @@ orch "implement the local spec" --agent agn -v
|
|
|
237
253
|
orch --ask "where is the CLI entrypoint?" --agent claude
|
|
238
254
|
orch --quick "fix the typo in the README" --agent claude
|
|
239
255
|
orch "noop" --dry-run --agent cursor
|
|
256
|
+
orch config
|
|
257
|
+
orch config --agent claude
|
|
258
|
+
orch config --agent agn --local
|
|
240
259
|
```
|
|
241
260
|
|
|
242
261
|
## Headless runs
|
|
@@ -260,7 +279,8 @@ directory's `.orch/`:
|
|
|
260
279
|
orch list # SLUG ROLE STATE PHASE AGENT STARTED DURATION PID
|
|
261
280
|
orch status swift-lagoon-49ea # full record: state, phase, branch, worktree, exit code, ...
|
|
262
281
|
orch pause swift-lagoon-49ea # request a pause at the next stage-boundary checkpoint
|
|
263
|
-
orch resume swift-lagoon-49ea #
|
|
282
|
+
orch resume swift-lagoon-49ea # unpause a paused/pausing run
|
|
283
|
+
orch continue swift-lagoon-49ea "follow-up polish" # new work on the same worktree
|
|
264
284
|
orch logs swift-lagoon-49ea -f # follow orch.log until the run finishes
|
|
265
285
|
orch stop swift-lagoon-49ea # SIGTERM the run
|
|
266
286
|
orch jobs clean # delete every tracked run under .orch/ (asks to confirm)
|
|
@@ -432,7 +452,7 @@ pkill -f 'agn ' # --agent agn
|
|
|
432
452
|
|
|
433
453
|
| Backend | `--agent` value | Status | Notes |
|
|
434
454
|
| --- | --- | --- | --- |
|
|
435
|
-
| Cursor Agent CLI | `cursor` | Supported (default) | Command `agent` on `PATH`. |
|
|
455
|
+
| Cursor Agent CLI | `cursor` | Supported (builtin default) | Command `agent` on `PATH`. Override via `orch config` or `--agent`. |
|
|
436
456
|
| Claude Code CLI | `claude` | Supported | Command `claude` on `PATH`. |
|
|
437
457
|
| agn | `agn` | Supported | Requires `npm install -g @welluable/agn-cli` (`>= 0.0.12`) and `agn init`. |
|
|
438
458
|
|
package/lib/agent.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import { formatActiveTools } from './tool-status.js';
|
|
5
|
-
import { patchJob } from './jobs.js';
|
|
5
|
+
import { patchJob, buildLastOutcome } from './jobs.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Every child process spawned by an agent, tracked so we can reap them all
|
|
@@ -67,11 +67,24 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
|
|
|
67
67
|
const jobSlug = process.env.ORCH_JOB_SLUG ?? activeJobSlug;
|
|
68
68
|
if (jobSlug) {
|
|
69
69
|
try {
|
|
70
|
-
|
|
70
|
+
const exitCode = exitCodeForSignal(signal);
|
|
71
|
+
const finishedAt = new Date().toISOString();
|
|
72
|
+
patchJob(jobCwd, jobSlug, (current) => ({
|
|
71
73
|
state: 'stopped',
|
|
72
|
-
exitCode
|
|
73
|
-
finishedAt
|
|
74
|
-
|
|
74
|
+
exitCode,
|
|
75
|
+
finishedAt,
|
|
76
|
+
lastOutcome: buildLastOutcome({
|
|
77
|
+
state: 'stopped',
|
|
78
|
+
phase: current.phase,
|
|
79
|
+
stage: current.stage,
|
|
80
|
+
round: current.round,
|
|
81
|
+
exitCode,
|
|
82
|
+
finishedAt,
|
|
83
|
+
task: current.task,
|
|
84
|
+
summary: '',
|
|
85
|
+
error: null,
|
|
86
|
+
}),
|
|
87
|
+
}));
|
|
75
88
|
} catch {
|
|
76
89
|
// Best-effort: don't let a job-state write failure block shutdown.
|
|
77
90
|
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const VALID_AGENTS = new Set(['cursor', 'claude', 'agn']);
|
|
6
|
+
|
|
7
|
+
/** Absolute path to the global orch config file. */
|
|
8
|
+
export function globalConfigPath({ homedir = os.homedir() } = {}) {
|
|
9
|
+
return path.join(homedir, '.orch', 'config');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Absolute path to the project-local orch config file under `cwd`. */
|
|
13
|
+
export function localConfigPath(cwd) {
|
|
14
|
+
return path.join(cwd, '.orch', 'config');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read a config file. Missing file → `{}`. Bad JSON, unreadable file, or
|
|
19
|
+
* invalid `agent` → throws with a message suitable for `Error: …` on stderr.
|
|
20
|
+
* Unknown keys are ignored; `agent` is case-sensitive.
|
|
21
|
+
*/
|
|
22
|
+
export function loadConfig(configPath, displayPath = configPath) {
|
|
23
|
+
if (!fs.existsSync(configPath)) return {};
|
|
24
|
+
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`could not parse ${displayPath}: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`could not parse ${displayPath}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
40
|
+
throw new Error(`could not parse ${displayPath}: expected a JSON object`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!Object.prototype.hasOwnProperty.call(data, 'agent') || data.agent === undefined) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!VALID_AGENTS.has(data.agent)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", or "agn")`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { agent: data.agent };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Effective agent for a run: `--agent` > local > global > `cursor`.
|
|
58
|
+
* Reads local then global when `cliAgent` is omitted; throws on bad files.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
|
|
61
|
+
if (cliAgent) return cliAgent;
|
|
62
|
+
|
|
63
|
+
const local = loadConfig(localConfigPath(cwd), '.orch/config');
|
|
64
|
+
if (local.agent) return local.agent;
|
|
65
|
+
|
|
66
|
+
const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
|
|
67
|
+
if (global.agent) return global.agent;
|
|
68
|
+
|
|
69
|
+
return 'cursor';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create parent dirs if needed, overwrite `config` with pretty-printed JSON,
|
|
74
|
+
* return the path written.
|
|
75
|
+
*/
|
|
76
|
+
export function writeConfig(configPath, { agent }) {
|
|
77
|
+
if (!VALID_AGENTS.has(agent)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", or "agn")`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
83
|
+
fs.writeFileSync(configPath, `${JSON.stringify({ agent }, null, 2)}\n`);
|
|
84
|
+
return configPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Print effective agent and which file(s) contributed (stdout). Throws on
|
|
89
|
+
* invalid existing files — same fail-fast contract as a run.
|
|
90
|
+
*/
|
|
91
|
+
export function printConfig({
|
|
92
|
+
cwd,
|
|
93
|
+
homedir = os.homedir(),
|
|
94
|
+
log = console.log,
|
|
95
|
+
} = {}) {
|
|
96
|
+
const localPath = localConfigPath(cwd);
|
|
97
|
+
const globalPath = globalConfigPath({ homedir });
|
|
98
|
+
const local = loadConfig(localPath, '.orch/config');
|
|
99
|
+
const global = loadConfig(globalPath, '~/.orch/config');
|
|
100
|
+
|
|
101
|
+
let agent = 'cursor';
|
|
102
|
+
let source = 'default (builtin)';
|
|
103
|
+
if (local.agent) {
|
|
104
|
+
agent = local.agent;
|
|
105
|
+
source = `local (${localPath})`;
|
|
106
|
+
} else if (global.agent) {
|
|
107
|
+
agent = global.agent;
|
|
108
|
+
source = `global (${globalPath})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log(`agent=${agent}`);
|
|
112
|
+
log(`source=${source}`);
|
|
113
|
+
log(`global=${global.agent ?? 'unset'} (${globalPath})`);
|
|
114
|
+
log(`local=${local.agent ?? 'unset'} (${localPath})`);
|
|
115
|
+
}
|
package/lib/continue.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJob, reconcileJob, jobPaths } from './jobs.js';
|
|
4
|
+
|
|
5
|
+
const CONTINUE_ELIGIBLE = new Set(['done', 'failed', 'stopped', 'crashed']);
|
|
6
|
+
|
|
7
|
+
/** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
|
|
8
|
+
* `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
|
|
9
|
+
function fail(message) {
|
|
10
|
+
const err = new Error(message);
|
|
11
|
+
err.name = '';
|
|
12
|
+
return err;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pure eligibility gate for `orch continue`. Reconciles first; never mutates
|
|
17
|
+
* beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
|
|
18
|
+
* (CLI wraps with `Error: ${err.message}`).
|
|
19
|
+
*/
|
|
20
|
+
export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
|
|
21
|
+
const existing = readJob(cwd, slug);
|
|
22
|
+
if (!existing) throw fail(`unknown run ${slug}`);
|
|
23
|
+
|
|
24
|
+
if (ask) throw fail('orch continue does not support --ask; use the default orch command');
|
|
25
|
+
if (quick) throw fail('orch continue does not support --quick; use the default orch command');
|
|
26
|
+
|
|
27
|
+
if (typeof task !== 'string' || !task.trim()) {
|
|
28
|
+
throw fail('task cannot be empty');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const record = reconcileJob(cwd, slug, existing);
|
|
32
|
+
|
|
33
|
+
if (!CONTINUE_ELIGIBLE.has(record.state)) {
|
|
34
|
+
throw fail(
|
|
35
|
+
`cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (record.role === 'coordinator') {
|
|
40
|
+
throw fail(
|
|
41
|
+
`cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (record.role === 'integration') {
|
|
46
|
+
const parent = record.parent ?? '<parent-slug>';
|
|
47
|
+
throw fail(
|
|
48
|
+
`cannot continue integration ${slug}; use orch --integrate ${parent}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!record.worktree || !record.branch) {
|
|
53
|
+
throw fail(`${slug} has no worktree; continue only applies to complex runs`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(record.worktree)) {
|
|
57
|
+
throw fail(`worktree missing at ${record.worktree}; cannot continue ${slug}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Snapshot prior outcome for continue reopen. Prefer `record.lastOutcome`;
|
|
65
|
+
* otherwise synthesize from terminal fields (+ best-effort status.md scrape).
|
|
66
|
+
* Never throws.
|
|
67
|
+
*/
|
|
68
|
+
export function snapshotPriorOutcome(cwd, slug, record) {
|
|
69
|
+
if (record?.lastOutcome) return record.lastOutcome;
|
|
70
|
+
|
|
71
|
+
let summary = '';
|
|
72
|
+
try {
|
|
73
|
+
const statusPath = path.join(jobPaths(cwd, slug).dir, 'status.md');
|
|
74
|
+
if (fs.existsSync(statusPath)) {
|
|
75
|
+
const content = fs.readFileSync(statusPath, 'utf8');
|
|
76
|
+
const matches = [...content.matchAll(/^- Summary:\s*(.*)$/gm)];
|
|
77
|
+
if (matches.length > 0) {
|
|
78
|
+
summary = (matches[matches.length - 1][1] || '').trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
summary = '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
state: record.state,
|
|
87
|
+
phase: record.phase ?? null,
|
|
88
|
+
stage: record.stage ?? null,
|
|
89
|
+
round: record.round ?? null,
|
|
90
|
+
exitCode: record.exitCode ?? null,
|
|
91
|
+
finishedAt: record.finishedAt ?? null,
|
|
92
|
+
task: record.task ?? null,
|
|
93
|
+
summary,
|
|
94
|
+
error: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function displayOrNone(value) {
|
|
99
|
+
if (value == null || value === '') return '(none recorded)';
|
|
100
|
+
return String(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the `[Prior run outcome]…[/Prior run outcome]` block injected into
|
|
105
|
+
* research/planner prompts on continue.
|
|
106
|
+
*/
|
|
107
|
+
export function buildPriorOutcomeText(prior, {
|
|
108
|
+
slug,
|
|
109
|
+
continuation,
|
|
110
|
+
worktreePath,
|
|
111
|
+
branch,
|
|
112
|
+
parentSlug,
|
|
113
|
+
workerId,
|
|
114
|
+
} = {}) {
|
|
115
|
+
const p = prior ?? {};
|
|
116
|
+
const lines = [
|
|
117
|
+
'[Prior run outcome]',
|
|
118
|
+
`- Continuation: this is continue ${continuation} on slug ${slug}`,
|
|
119
|
+
`- Prior state: ${displayOrNone(p.state)}`,
|
|
120
|
+
`- Prior phase: ${displayOrNone(p.phase)}`,
|
|
121
|
+
`- Prior stage: ${displayOrNone(p.stage)}`,
|
|
122
|
+
`- Prior round: ${p.round == null ? 'null' : p.round}`,
|
|
123
|
+
`- Prior task: ${displayOrNone(p.task)}`,
|
|
124
|
+
`- Summary: ${displayOrNone(p.summary)}`,
|
|
125
|
+
`- Error: ${displayOrNone(p.error)}`,
|
|
126
|
+
`- Worktree: ${worktreePath} (branch ${branch} tip is the starting point; do not assume a clean tree)`,
|
|
127
|
+
];
|
|
128
|
+
if (parentSlug) lines.push(`- Fan-out parent: ${parentSlug}`);
|
|
129
|
+
if (workerId) lines.push(`- Worker id: ${workerId}`);
|
|
130
|
+
lines.push('[/Prior run outcome]');
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
package/lib/jobs.js
CHANGED
|
@@ -5,6 +5,34 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
|
|
6
6
|
const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
|
|
7
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
|
+
|
|
8
36
|
/** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
|
|
9
37
|
export function jobPaths(cwd, slug) {
|
|
10
38
|
const dir = path.join(path.resolve(cwd), '.orch', slug);
|
|
@@ -136,10 +164,59 @@ export function patchJob(cwd, slug, patchFnOrObject) {
|
|
|
136
164
|
export function reconcileJob(cwd, slug, record) {
|
|
137
165
|
if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
|
|
138
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
|
+
|
|
139
204
|
return patchJob(cwd, slug, {
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
142
215
|
exitCode: null,
|
|
216
|
+
pauseRequested: false,
|
|
217
|
+
lastOutcome: null,
|
|
218
|
+
continuation,
|
|
219
|
+
continuations,
|
|
143
220
|
});
|
|
144
221
|
}
|
|
145
222
|
|
package/main.js
CHANGED
|
@@ -33,7 +33,14 @@ import {
|
|
|
33
33
|
stopJob,
|
|
34
34
|
cleanJobs,
|
|
35
35
|
isPidAlive,
|
|
36
|
+
reopenJob,
|
|
37
|
+
buildLastOutcome,
|
|
36
38
|
} from './lib/jobs.js';
|
|
39
|
+
import {
|
|
40
|
+
validateContinue,
|
|
41
|
+
snapshotPriorOutcome,
|
|
42
|
+
buildPriorOutcomeText,
|
|
43
|
+
} from './lib/continue.js';
|
|
37
44
|
import { askAgentArgs } from './agents/ask.js';
|
|
38
45
|
import { triageAgentArgs } from './agents/triage.js';
|
|
39
46
|
import { quickFixAgentArgs } from './agents/quick-fix.js';
|
|
@@ -67,6 +74,13 @@ import {
|
|
|
67
74
|
conflictedFiles,
|
|
68
75
|
hasConflictMarkers,
|
|
69
76
|
} from './lib/integrate.js';
|
|
77
|
+
import {
|
|
78
|
+
resolveAgent,
|
|
79
|
+
writeConfig,
|
|
80
|
+
printConfig,
|
|
81
|
+
globalConfigPath,
|
|
82
|
+
localConfigPath,
|
|
83
|
+
} from './lib/config.js';
|
|
70
84
|
|
|
71
85
|
const __filename = fileURLToPath(import.meta.url);
|
|
72
86
|
const __dirname = path.dirname(__filename);
|
|
@@ -211,6 +225,18 @@ export function formatStatus(cwd, record) {
|
|
|
211
225
|
lines.splice(1, 0, `parent: ${record.parent}`);
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
if (record.continuation > 1) {
|
|
229
|
+
const stateIdx = lines.findIndex((line) => line.startsWith('state:'));
|
|
230
|
+
lines.splice(stateIdx + 1, 0, `continuation: ${record.continuation}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (record.lastOutcome) {
|
|
234
|
+
const o = record.lastOutcome;
|
|
235
|
+
lines.push(`outcome: ${o.phase ?? '-'} / ${o.stage ?? '-'} (round ${o.round ?? '-'})`);
|
|
236
|
+
if (o.summary) lines.push(`summary: ${o.summary}`);
|
|
237
|
+
if (o.error) lines.push(`error: ${o.error}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
214
240
|
const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
|
|
215
241
|
if (fs.existsSync(statusPath)) {
|
|
216
242
|
const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
|
|
@@ -275,6 +301,28 @@ function defaultExecFile(command, args, options = {}) {
|
|
|
275
301
|
return execFileSync(command, args, { encoding: 'utf8', ...options });
|
|
276
302
|
}
|
|
277
303
|
|
|
304
|
+
/** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
|
|
305
|
+
function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
|
|
306
|
+
if (!jobSlug) return;
|
|
307
|
+
const finishedAt = new Date().toISOString();
|
|
308
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
309
|
+
state,
|
|
310
|
+
exitCode,
|
|
311
|
+
finishedAt,
|
|
312
|
+
lastOutcome: buildLastOutcome({
|
|
313
|
+
state,
|
|
314
|
+
phase: current.phase,
|
|
315
|
+
stage: current.stage,
|
|
316
|
+
round: current.round,
|
|
317
|
+
exitCode,
|
|
318
|
+
finishedAt,
|
|
319
|
+
task: task ?? current.task,
|
|
320
|
+
summary,
|
|
321
|
+
error,
|
|
322
|
+
}),
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
278
326
|
/** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
|
|
279
327
|
async function runTestLoop({
|
|
280
328
|
prompt,
|
|
@@ -729,7 +777,7 @@ export async function runPipeline(prompt, options) {
|
|
|
729
777
|
.filter(Boolean)
|
|
730
778
|
.join('\n');
|
|
731
779
|
|
|
732
|
-
await runCodeLoop({
|
|
780
|
+
const codeAccepted = await runCodeLoop({
|
|
733
781
|
prompt,
|
|
734
782
|
worktreePath: worktree.worktreePath,
|
|
735
783
|
branch: worktree.branch,
|
|
@@ -762,6 +810,7 @@ export async function runPipeline(prompt, options) {
|
|
|
762
810
|
);
|
|
763
811
|
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
764
812
|
console.log(`merge: git merge ${commitResult.branch}`);
|
|
813
|
+
console.log(`next: orch continue ${runContext.slug} "…"`);
|
|
765
814
|
} else {
|
|
766
815
|
fs.appendFileSync(
|
|
767
816
|
runContext.statusPath,
|
|
@@ -770,20 +819,29 @@ export async function runPipeline(prompt, options) {
|
|
|
770
819
|
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
771
820
|
}
|
|
772
821
|
|
|
773
|
-
|
|
822
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
823
|
+
state: 'done',
|
|
824
|
+
exitCode: 0,
|
|
825
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
826
|
+
error: null,
|
|
827
|
+
task: prompt,
|
|
828
|
+
});
|
|
774
829
|
} catch (err) {
|
|
775
830
|
console.error(`Error: ${err.message}`);
|
|
776
831
|
if (jobSlug) {
|
|
777
832
|
try {
|
|
778
|
-
patchJobFn
|
|
833
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
779
834
|
state: 'failed',
|
|
780
835
|
exitCode: 1,
|
|
781
|
-
|
|
836
|
+
summary: '',
|
|
837
|
+
error: err.message,
|
|
838
|
+
task: prompt,
|
|
782
839
|
});
|
|
783
840
|
} catch {
|
|
784
841
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
785
842
|
}
|
|
786
843
|
}
|
|
844
|
+
console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
|
|
787
845
|
process.exit(1);
|
|
788
846
|
}
|
|
789
847
|
}
|
|
@@ -846,6 +904,311 @@ export async function runDetached(prompt, options = {}) {
|
|
|
846
904
|
exit(0);
|
|
847
905
|
}
|
|
848
906
|
|
|
907
|
+
/**
|
|
908
|
+
* Continue pipeline: full complex stages on an existing worktree/branch.
|
|
909
|
+
* Skips triage and `createWorktree`. Injects prior-outcome text into
|
|
910
|
+
* research/planner only. See `.spec/continue.md`.
|
|
911
|
+
*/
|
|
912
|
+
export async function runContinuePipeline(prompt, options = {}) {
|
|
913
|
+
const verbose = Boolean(options.verbose);
|
|
914
|
+
const maxRounds = options.maxRounds ?? 5;
|
|
915
|
+
const backend = AGENT_BACKENDS[options.agent];
|
|
916
|
+
if (!backend) {
|
|
917
|
+
throw new Error(`Unknown agent backend: ${options.agent}`);
|
|
918
|
+
}
|
|
919
|
+
const AgentClass = options.AgentClass ?? backend.AgentClass;
|
|
920
|
+
const cwd = options.cwd ?? process.cwd();
|
|
921
|
+
const {
|
|
922
|
+
slug,
|
|
923
|
+
worktreePath,
|
|
924
|
+
branch,
|
|
925
|
+
role,
|
|
926
|
+
parentSlug,
|
|
927
|
+
workerId,
|
|
928
|
+
priorOutcome,
|
|
929
|
+
continuation,
|
|
930
|
+
} = options;
|
|
931
|
+
|
|
932
|
+
const createRunContextFn = options.createRunContext ?? createRunContext;
|
|
933
|
+
const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
|
|
934
|
+
const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
|
|
935
|
+
const patchWorkerFn = options.patchWorker ?? patchWorker;
|
|
936
|
+
const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
|
|
937
|
+
const execFileFn = options.execFile;
|
|
938
|
+
|
|
939
|
+
const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
|
|
940
|
+
const jobCwd = options.jobCwd ?? cwd;
|
|
941
|
+
const patchJobFn = options.patchJob ?? patchJob;
|
|
942
|
+
const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
|
|
943
|
+
const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
|
|
944
|
+
|
|
945
|
+
const jobPatch = (fields) => {
|
|
946
|
+
if (!jobSlug) return;
|
|
947
|
+
patchJobFn(jobCwd, jobSlug, fields);
|
|
948
|
+
};
|
|
949
|
+
const jobCheckpoint = async () => {
|
|
950
|
+
if (!jobSlug) return;
|
|
951
|
+
await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
if (!options.AgentClass) {
|
|
955
|
+
ensureBinaryOnPath(backend.binary, options.agent);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const priorBlock = buildPriorOutcomeText(priorOutcome, {
|
|
959
|
+
slug,
|
|
960
|
+
continuation,
|
|
961
|
+
worktreePath,
|
|
962
|
+
branch,
|
|
963
|
+
parentSlug,
|
|
964
|
+
workerId,
|
|
965
|
+
});
|
|
966
|
+
const researchPlannerPrompt = `${priorBlock}\n\nUser follow-up:\n${prompt}`;
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
const runContext = createRunContextFn({ cwd, slug });
|
|
970
|
+
|
|
971
|
+
fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
|
|
972
|
+
const prior = priorOutcome ?? {};
|
|
973
|
+
const startedIso = new Date().toISOString();
|
|
974
|
+
let continueSection = `\n## Continue ${continuation}\n\n`;
|
|
975
|
+
continueSection += `- Task: ${prompt.split('\n')[0]}\n`;
|
|
976
|
+
continueSection += `- Started: ${startedIso}\n`;
|
|
977
|
+
continueSection += `- Branch: \`${branch}\`\n`;
|
|
978
|
+
continueSection += `- Worktree: \`${worktreePath}\`\n`;
|
|
979
|
+
if (parentSlug) continueSection += `- Fan-out parent: \`${parentSlug}\`\n`;
|
|
980
|
+
if (workerId) continueSection += `- Worker id: \`${workerId}\`\n`;
|
|
981
|
+
continueSection += `\n### Prior outcome\n\n`;
|
|
982
|
+
continueSection += `- State: ${prior.state ?? '(none recorded)'}\n`;
|
|
983
|
+
continueSection += `- Phase: ${prior.phase ?? '(none recorded)'}\n`;
|
|
984
|
+
continueSection += `- Stage: ${prior.stage ?? '(none recorded)'}\n`;
|
|
985
|
+
continueSection += `- Round: ${prior.round ?? 'null'}\n`;
|
|
986
|
+
continueSection += `- Summary: ${prior.summary || '(none recorded)'}\n`;
|
|
987
|
+
if (prior.error) continueSection += `- Error: ${prior.error}\n`;
|
|
988
|
+
fs.appendFileSync(runContext.statusPath, continueSection);
|
|
989
|
+
|
|
990
|
+
jobPatch({ phase: 'research', stage: 'research', round: null });
|
|
991
|
+
const research = researchAgentArgs({
|
|
992
|
+
prompt: researchPlannerPrompt,
|
|
993
|
+
cwd: worktreePath,
|
|
994
|
+
researchPath: runContext.researchPath,
|
|
995
|
+
});
|
|
996
|
+
const researchAgent = new AgentClass(
|
|
997
|
+
research.name,
|
|
998
|
+
research.instructions,
|
|
999
|
+
research.prompt,
|
|
1000
|
+
research.options,
|
|
1001
|
+
);
|
|
1002
|
+
const researchResult = await researchAgent.run({ verbose });
|
|
1003
|
+
await jobCheckpoint();
|
|
1004
|
+
const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
|
|
1005
|
+
printStageSummary('research', researchSummary);
|
|
1006
|
+
|
|
1007
|
+
jobPatch({ phase: 'plan', stage: 'planner', round: null });
|
|
1008
|
+
const planner = plannerAgentArgs({
|
|
1009
|
+
prompt: researchPlannerPrompt,
|
|
1010
|
+
cwd: worktreePath,
|
|
1011
|
+
researchPath: runContext.researchPath,
|
|
1012
|
+
taskPath: runContext.taskPath,
|
|
1013
|
+
researchOutput: researchContent,
|
|
1014
|
+
});
|
|
1015
|
+
const plannerAgent = new AgentClass(
|
|
1016
|
+
planner.name,
|
|
1017
|
+
planner.instructions,
|
|
1018
|
+
planner.prompt,
|
|
1019
|
+
planner.options,
|
|
1020
|
+
);
|
|
1021
|
+
const plannerResult = await plannerAgent.run({ verbose });
|
|
1022
|
+
await jobCheckpoint();
|
|
1023
|
+
const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
|
|
1024
|
+
printStageSummary('planner', plannerSummary);
|
|
1025
|
+
|
|
1026
|
+
const testAccepted = await runTestLoop({
|
|
1027
|
+
prompt,
|
|
1028
|
+
worktreePath,
|
|
1029
|
+
branch,
|
|
1030
|
+
taskPath: runContext.taskPath,
|
|
1031
|
+
statusPath: runContext.statusPath,
|
|
1032
|
+
maxRounds,
|
|
1033
|
+
AgentClass,
|
|
1034
|
+
verbose,
|
|
1035
|
+
jobPatch,
|
|
1036
|
+
jobCheckpoint,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
|
|
1040
|
+
.filter(Boolean)
|
|
1041
|
+
.join('\n');
|
|
1042
|
+
|
|
1043
|
+
const codeAccepted = await runCodeLoop({
|
|
1044
|
+
prompt,
|
|
1045
|
+
worktreePath,
|
|
1046
|
+
branch,
|
|
1047
|
+
taskPath: runContext.taskPath,
|
|
1048
|
+
statusPath: runContext.statusPath,
|
|
1049
|
+
maxRounds,
|
|
1050
|
+
AgentClass,
|
|
1051
|
+
verbose,
|
|
1052
|
+
jobPatch,
|
|
1053
|
+
jobCheckpoint,
|
|
1054
|
+
acceptedVerification,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
jobPatch({ phase: 'commit', stage: 'commit', round: null });
|
|
1058
|
+
const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
|
|
1059
|
+
const worktreeChanges = collectWorktreeChangesFn({ worktreePath });
|
|
1060
|
+
printFilesChanged(worktreeChanges);
|
|
1061
|
+
const commitResult = commitWorktreeFn({
|
|
1062
|
+
worktreePath,
|
|
1063
|
+
branch,
|
|
1064
|
+
message,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (commitResult.committed) {
|
|
1068
|
+
fs.appendFileSync(
|
|
1069
|
+
runContext.statusPath,
|
|
1070
|
+
`\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
|
|
1071
|
+
);
|
|
1072
|
+
console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
|
|
1073
|
+
console.log(`merge: git merge ${commitResult.branch}`);
|
|
1074
|
+
} else {
|
|
1075
|
+
fs.appendFileSync(
|
|
1076
|
+
runContext.statusPath,
|
|
1077
|
+
`\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
|
|
1078
|
+
);
|
|
1079
|
+
console.log(`commit: no changes on ${commitResult.branch}`);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1083
|
+
let changedFiles = [];
|
|
1084
|
+
try {
|
|
1085
|
+
changedFiles = recordChangedFilesFn({
|
|
1086
|
+
repoRoot: cwd,
|
|
1087
|
+
base: undefined,
|
|
1088
|
+
branch,
|
|
1089
|
+
execFile: execFileFn,
|
|
1090
|
+
});
|
|
1091
|
+
} catch {
|
|
1092
|
+
// Best-effort.
|
|
1093
|
+
}
|
|
1094
|
+
if (commitResult.committed) {
|
|
1095
|
+
patchWorkerFn(cwd, parentSlug, workerId, {
|
|
1096
|
+
state: 'done',
|
|
1097
|
+
sha: commitResult.sha,
|
|
1098
|
+
changedFiles,
|
|
1099
|
+
});
|
|
1100
|
+
console.log(`next: orch --integrate ${parentSlug}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1105
|
+
state: 'done',
|
|
1106
|
+
exitCode: 0,
|
|
1107
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1108
|
+
error: null,
|
|
1109
|
+
task: prompt,
|
|
1110
|
+
});
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
console.error(`Error: ${err.message}`);
|
|
1113
|
+
if (role === 'worker' && parentSlug && workerId) {
|
|
1114
|
+
try {
|
|
1115
|
+
patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
|
|
1116
|
+
} catch {
|
|
1117
|
+
// Best-effort.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (jobSlug) {
|
|
1121
|
+
try {
|
|
1122
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1123
|
+
state: 'failed',
|
|
1124
|
+
exitCode: 1,
|
|
1125
|
+
summary: '',
|
|
1126
|
+
error: err.message,
|
|
1127
|
+
task: prompt,
|
|
1128
|
+
});
|
|
1129
|
+
} catch {
|
|
1130
|
+
// Best-effort.
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Detach-parent path for `orch continue`: validate, PATH-check, spawn a
|
|
1139
|
+
* `--detach`-stripped re-invocation, reopen the existing slug with the child
|
|
1140
|
+
* pid, print `started`, exit. Never runs pipeline stages itself.
|
|
1141
|
+
*/
|
|
1142
|
+
export async function runContinueDetached(slug, prompt, options = {}) {
|
|
1143
|
+
const {
|
|
1144
|
+
agent,
|
|
1145
|
+
maxRounds = 5,
|
|
1146
|
+
verbose,
|
|
1147
|
+
cwd = process.cwd(),
|
|
1148
|
+
spawn: spawnFn = spawn,
|
|
1149
|
+
exit = (code) => process.exit(code),
|
|
1150
|
+
validateContinue: validateContinueFn = validateContinue,
|
|
1151
|
+
reopenJob: reopenJobFn = reopenJob,
|
|
1152
|
+
snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
|
|
1153
|
+
} = options;
|
|
1154
|
+
|
|
1155
|
+
const backend = AGENT_BACKENDS[agent];
|
|
1156
|
+
if (!backend) {
|
|
1157
|
+
throw new Error(`Unknown agent backend: ${agent}`);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let record;
|
|
1161
|
+
try {
|
|
1162
|
+
record = validateContinueFn(cwd, slug, { task: prompt });
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.error(`Error: ${err.message}`);
|
|
1165
|
+
exit(1);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
1170
|
+
console.error(binaryMissingHint(agent));
|
|
1171
|
+
exit(1);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const prior = snapshotPriorOutcomeFn(cwd, slug, record);
|
|
1176
|
+
const { logPath } = jobPaths(cwd, slug);
|
|
1177
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1178
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
1179
|
+
|
|
1180
|
+
const childArgs = [
|
|
1181
|
+
__filename,
|
|
1182
|
+
'continue',
|
|
1183
|
+
slug,
|
|
1184
|
+
prompt,
|
|
1185
|
+
'--agent',
|
|
1186
|
+
agent,
|
|
1187
|
+
'--max-rounds',
|
|
1188
|
+
String(maxRounds),
|
|
1189
|
+
];
|
|
1190
|
+
if (verbose) childArgs.push('--verbose');
|
|
1191
|
+
|
|
1192
|
+
const child = spawnFn(process.execPath, childArgs, {
|
|
1193
|
+
cwd,
|
|
1194
|
+
env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
|
|
1195
|
+
detached: true,
|
|
1196
|
+
stdio: ['ignore', logFd, logFd],
|
|
1197
|
+
});
|
|
1198
|
+
child.unref();
|
|
1199
|
+
|
|
1200
|
+
const updated = reopenJobFn(cwd, slug, {
|
|
1201
|
+
task: prompt,
|
|
1202
|
+
agent,
|
|
1203
|
+
maxRounds,
|
|
1204
|
+
pid: child.pid,
|
|
1205
|
+
prior,
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
console.log(`started ${slug} (pid ${child.pid}, continuation ${updated.continuation})`);
|
|
1209
|
+
exit(0);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
849
1212
|
/**
|
|
850
1213
|
* The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
|
|
851
1214
|
* worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
|
|
@@ -954,7 +1317,7 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
954
1317
|
.filter(Boolean)
|
|
955
1318
|
.join('\n');
|
|
956
1319
|
|
|
957
|
-
await runCodeLoop({
|
|
1320
|
+
const codeAccepted = await runCodeLoop({
|
|
958
1321
|
prompt,
|
|
959
1322
|
worktreePath: worktree.worktreePath,
|
|
960
1323
|
branch: worktree.branch,
|
|
@@ -1009,7 +1372,13 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1009
1372
|
sha: commitResult.sha,
|
|
1010
1373
|
changedFiles,
|
|
1011
1374
|
});
|
|
1012
|
-
|
|
1375
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1376
|
+
state: 'done',
|
|
1377
|
+
exitCode: 0,
|
|
1378
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1379
|
+
error: null,
|
|
1380
|
+
task: prompt,
|
|
1381
|
+
});
|
|
1013
1382
|
} catch (err) {
|
|
1014
1383
|
console.error(`Error: ${err.message}`);
|
|
1015
1384
|
try {
|
|
@@ -1019,15 +1388,19 @@ export async function runWorkerPipeline(prompt, options = {}) {
|
|
|
1019
1388
|
}
|
|
1020
1389
|
if (jobSlug) {
|
|
1021
1390
|
try {
|
|
1022
|
-
patchJobFn
|
|
1391
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1023
1392
|
state: 'failed',
|
|
1024
1393
|
exitCode: 1,
|
|
1025
|
-
|
|
1394
|
+
summary: '',
|
|
1395
|
+
error: err.message,
|
|
1396
|
+
task: prompt,
|
|
1026
1397
|
});
|
|
1027
1398
|
} catch {
|
|
1028
1399
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
1029
1400
|
}
|
|
1030
1401
|
}
|
|
1402
|
+
console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
|
|
1403
|
+
if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
|
|
1031
1404
|
process.exit(1);
|
|
1032
1405
|
}
|
|
1033
1406
|
}
|
|
@@ -1217,7 +1590,7 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1217
1590
|
}
|
|
1218
1591
|
|
|
1219
1592
|
// --- runner-first verify loop: test-runner first, code-writer only on failure ---
|
|
1220
|
-
await runCodeLoop({
|
|
1593
|
+
const codeAccepted = await runCodeLoop({
|
|
1221
1594
|
prompt: fanout.task,
|
|
1222
1595
|
worktreePath: worktree.worktreePath,
|
|
1223
1596
|
branch: worktree.branch,
|
|
@@ -1253,7 +1626,13 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1253
1626
|
merged,
|
|
1254
1627
|
skipped,
|
|
1255
1628
|
});
|
|
1256
|
-
|
|
1629
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1630
|
+
state: 'done',
|
|
1631
|
+
exitCode: 0,
|
|
1632
|
+
summary: codeAccepted?.verdict?.summary ?? '',
|
|
1633
|
+
error: null,
|
|
1634
|
+
task: fanout.task,
|
|
1635
|
+
});
|
|
1257
1636
|
} catch (err) {
|
|
1258
1637
|
console.error(`Error: ${err.message}`);
|
|
1259
1638
|
try {
|
|
@@ -1268,10 +1647,12 @@ export async function runIntegratePipeline(options = {}) {
|
|
|
1268
1647
|
}
|
|
1269
1648
|
if (jobSlug) {
|
|
1270
1649
|
try {
|
|
1271
|
-
patchJobFn
|
|
1650
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1272
1651
|
state: 'failed',
|
|
1273
1652
|
exitCode: 1,
|
|
1274
|
-
|
|
1653
|
+
summary: '',
|
|
1654
|
+
error: err.message,
|
|
1655
|
+
task: fanout?.task,
|
|
1275
1656
|
});
|
|
1276
1657
|
} catch {
|
|
1277
1658
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
@@ -1392,11 +1773,24 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1392
1773
|
}
|
|
1393
1774
|
if (jobSlug) {
|
|
1394
1775
|
try {
|
|
1395
|
-
|
|
1776
|
+
const exitCode = exitCodeForSignal(signal);
|
|
1777
|
+
const finishedAt = new Date().toISOString();
|
|
1778
|
+
patchJobFn(jobCwd, jobSlug, (current) => ({
|
|
1396
1779
|
state: 'stopped',
|
|
1397
|
-
exitCode
|
|
1398
|
-
finishedAt
|
|
1399
|
-
|
|
1780
|
+
exitCode,
|
|
1781
|
+
finishedAt,
|
|
1782
|
+
lastOutcome: buildLastOutcome({
|
|
1783
|
+
state: 'stopped',
|
|
1784
|
+
phase: current.phase,
|
|
1785
|
+
stage: current.stage,
|
|
1786
|
+
round: current.round,
|
|
1787
|
+
exitCode,
|
|
1788
|
+
finishedAt,
|
|
1789
|
+
task: prompt,
|
|
1790
|
+
summary: '',
|
|
1791
|
+
error: null,
|
|
1792
|
+
}),
|
|
1793
|
+
}));
|
|
1400
1794
|
} catch {
|
|
1401
1795
|
// Best-effort: don't let a job-state write failure block shutdown.
|
|
1402
1796
|
}
|
|
@@ -1942,17 +2336,31 @@ export async function runFanoutPipeline(prompt, options = {}) {
|
|
|
1942
2336
|
console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
|
|
1943
2337
|
}
|
|
1944
2338
|
|
|
1945
|
-
|
|
2339
|
+
const finishedAt = new Date().toISOString();
|
|
2340
|
+
const summary = success
|
|
2341
|
+
? `fan-out complete: ${doneWorkers.length} worker${doneWorkers.length === 1 ? '' : 's'} integrated`
|
|
2342
|
+
: (failedWorkers.length > 0
|
|
2343
|
+
? `${failedWorkers.length} worker(s) failed`
|
|
2344
|
+
: 'fan-out failed');
|
|
2345
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
2346
|
+
state: success ? 'done' : 'failed',
|
|
2347
|
+
exitCode: success ? 0 : 1,
|
|
2348
|
+
summary,
|
|
2349
|
+
error: success ? null : summary,
|
|
2350
|
+
task: prompt,
|
|
2351
|
+
});
|
|
1946
2352
|
exitFn(success ? 0 : 1);
|
|
1947
2353
|
} catch (err) {
|
|
1948
2354
|
if (err instanceof FanoutInterrupted) return;
|
|
1949
2355
|
console.error(`Error: ${err.message}`);
|
|
1950
2356
|
if (jobSlug) {
|
|
1951
2357
|
try {
|
|
1952
|
-
patchJobFn
|
|
2358
|
+
patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
|
|
1953
2359
|
state: 'failed',
|
|
1954
2360
|
exitCode: 1,
|
|
1955
|
-
|
|
2361
|
+
summary: '',
|
|
2362
|
+
error: err.message,
|
|
2363
|
+
task: prompt,
|
|
1956
2364
|
});
|
|
1957
2365
|
} catch {
|
|
1958
2366
|
// Best-effort: don't let a job-state write failure mask the real error.
|
|
@@ -2093,6 +2501,17 @@ function positiveIntParser(flagName) {
|
|
|
2093
2501
|
};
|
|
2094
2502
|
}
|
|
2095
2503
|
|
|
2504
|
+
/** Resolve the effective agent (CLI > local > global > cursor) or exit 1. */
|
|
2505
|
+
function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
|
|
2506
|
+
try {
|
|
2507
|
+
return resolveAgent({ cliAgent, cwd });
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
console.error(`Error: ${err.message}`);
|
|
2510
|
+
process.exit(1);
|
|
2511
|
+
return undefined;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2096
2515
|
const program = new Command();
|
|
2097
2516
|
|
|
2098
2517
|
program
|
|
@@ -2110,9 +2529,8 @@ program
|
|
|
2110
2529
|
.option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
|
|
2111
2530
|
.option('--max-concurrency <n>', 'Optional hard ceiling on in-flight fan-out workers at once (only meaningful with --fan-out; default: coordinator chooses)', positiveIntParser('--max-concurrency'))
|
|
2112
2531
|
.addOption(
|
|
2113
|
-
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI)')
|
|
2114
|
-
.choices(['cursor', 'claude', 'agn'])
|
|
2115
|
-
.default('cursor'),
|
|
2532
|
+
new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI). Omitting uses local then global config, else cursor')
|
|
2533
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2116
2534
|
)
|
|
2117
2535
|
.addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
|
|
2118
2536
|
.addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
|
|
@@ -2126,13 +2544,18 @@ Examples:
|
|
|
2126
2544
|
$ orch --ask "where is the CLI entrypoint?" --agent claude
|
|
2127
2545
|
$ orch --quick "fix the typo in the README" --agent claude
|
|
2128
2546
|
$ orch "noop" --dry-run --agent cursor
|
|
2547
|
+
$ orch config # print effective agent
|
|
2548
|
+
$ orch config --agent claude # pin global default
|
|
2549
|
+
$ orch config --agent agn --local # pin project default
|
|
2129
2550
|
|
|
2130
2551
|
Headless runs:
|
|
2131
2552
|
$ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
|
|
2132
2553
|
$ orch list # show all tracked runs
|
|
2133
2554
|
$ orch status [slug] # show full status (defaults to most recent)
|
|
2134
2555
|
$ orch pause <slug> # request a pause at the next stage boundary
|
|
2135
|
-
$ orch resume <slug> #
|
|
2556
|
+
$ orch resume <slug> # unpause a paused/pausing run
|
|
2557
|
+
$ orch continue <slug> "new task" # new work on a finished run's worktree
|
|
2558
|
+
# (workers: same command; then re-integrate the parent)
|
|
2136
2559
|
$ orch stop <slug> # send SIGTERM to a running job
|
|
2137
2560
|
$ orch logs <slug> [-f] # print (or follow) a run's log file
|
|
2138
2561
|
|
|
@@ -2149,6 +2572,8 @@ Fan-out:
|
|
|
2149
2572
|
return;
|
|
2150
2573
|
}
|
|
2151
2574
|
|
|
2575
|
+
options.agent = resolveAgentOrExit(options.agent);
|
|
2576
|
+
|
|
2152
2577
|
if (options.fanOut) {
|
|
2153
2578
|
const conflicts = ['ask', 'quick', 'dryRun']
|
|
2154
2579
|
.filter((key) => options[key])
|
|
@@ -2235,6 +2660,178 @@ Fan-out:
|
|
|
2235
2660
|
await runPipeline(prompt, options);
|
|
2236
2661
|
});
|
|
2237
2662
|
|
|
2663
|
+
program
|
|
2664
|
+
.command('continue')
|
|
2665
|
+
.description(
|
|
2666
|
+
'Start new complex work on a finished run\'s existing worktree (not the same as resume, which only unpauses). Workers: continue the worker slug, then re-integrate the parent',
|
|
2667
|
+
)
|
|
2668
|
+
.argument('<slug>', 'Existing run slug under .orch/')
|
|
2669
|
+
.argument('<task...>', 'New task prompt for this continue iteration')
|
|
2670
|
+
.option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
|
|
2671
|
+
.option('--dry-run', 'Validate eligibility and agent PATH; do not reopen the job or run agents')
|
|
2672
|
+
.option('--ask', 'Rejected: continue does not support --ask')
|
|
2673
|
+
.option('--quick', 'Rejected: continue does not support --quick')
|
|
2674
|
+
.option('--detach', 'Run the continue in the background under the same slug')
|
|
2675
|
+
.option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
|
|
2676
|
+
.addOption(
|
|
2677
|
+
new Option('--agent <agent>', 'Agent backend: "cursor", "claude", or "agn". Omitting uses local then global config, else cursor')
|
|
2678
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2679
|
+
)
|
|
2680
|
+
.action(async (slug, taskParts, options, command) => {
|
|
2681
|
+
// Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
|
|
2682
|
+
// --max-rounds; when those flags appear after the continue arguments,
|
|
2683
|
+
// Commander attaches them to the parent. Merge via optsWithGlobals.
|
|
2684
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
2685
|
+
? command.optsWithGlobals()
|
|
2686
|
+
: { ...program.opts(), ...options };
|
|
2687
|
+
const prompt = taskParts.join(' ').trim();
|
|
2688
|
+
const cwd = process.cwd();
|
|
2689
|
+
|
|
2690
|
+
opts.agent = resolveAgentOrExit(opts.agent, cwd);
|
|
2691
|
+
|
|
2692
|
+
let record;
|
|
2693
|
+
try {
|
|
2694
|
+
record = validateContinue(cwd, slug, {
|
|
2695
|
+
task: prompt,
|
|
2696
|
+
ask: opts.ask,
|
|
2697
|
+
quick: opts.quick,
|
|
2698
|
+
});
|
|
2699
|
+
} catch (err) {
|
|
2700
|
+
console.error(`Error: ${err.message}`);
|
|
2701
|
+
process.exit(1);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
if (opts.detach) {
|
|
2706
|
+
await runContinueDetached(slug, prompt, {
|
|
2707
|
+
agent: opts.agent,
|
|
2708
|
+
maxRounds: opts.maxRounds,
|
|
2709
|
+
verbose: opts.verbose,
|
|
2710
|
+
cwd,
|
|
2711
|
+
});
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
if (opts.dryRun) {
|
|
2716
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
2717
|
+
if (!isBinaryOnPath(backend.binary)) {
|
|
2718
|
+
console.error(binaryMissingHint(opts.agent));
|
|
2719
|
+
process.exit(1);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
console.log(`dry-run: continue ${slug} ok`);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
|
|
2727
|
+
let priorOutcome;
|
|
2728
|
+
let continuation;
|
|
2729
|
+
let worktreePath = record.worktree;
|
|
2730
|
+
let branch = record.branch;
|
|
2731
|
+
let role = record.role;
|
|
2732
|
+
let parentSlug = record.parent;
|
|
2733
|
+
let workerId = record.workerId;
|
|
2734
|
+
|
|
2735
|
+
if (!alreadyReopened) {
|
|
2736
|
+
priorOutcome = snapshotPriorOutcome(cwd, slug, record);
|
|
2737
|
+
const updated = reopenJob(cwd, slug, {
|
|
2738
|
+
task: prompt,
|
|
2739
|
+
agent: opts.agent,
|
|
2740
|
+
maxRounds: opts.maxRounds,
|
|
2741
|
+
pid: process.pid,
|
|
2742
|
+
prior: priorOutcome,
|
|
2743
|
+
});
|
|
2744
|
+
continuation = updated.continuation;
|
|
2745
|
+
setJobSlug(slug);
|
|
2746
|
+
} else {
|
|
2747
|
+
const live = readJob(cwd, slug) ?? record;
|
|
2748
|
+
continuation = live.continuation ?? 2;
|
|
2749
|
+
const entries = Array.isArray(live.continuations) ? live.continuations : [];
|
|
2750
|
+
const last = entries[entries.length - 1];
|
|
2751
|
+
priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
|
|
2752
|
+
worktreePath = live.worktree ?? worktreePath;
|
|
2753
|
+
branch = live.branch ?? branch;
|
|
2754
|
+
role = live.role ?? role;
|
|
2755
|
+
parentSlug = live.parent ?? parentSlug;
|
|
2756
|
+
workerId = live.workerId ?? workerId;
|
|
2757
|
+
setJobSlug(slug);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// PATH check after reopen (foreground) so empty-PATH tests can observe the bump.
|
|
2761
|
+
const backend = AGENT_BACKENDS[opts.agent];
|
|
2762
|
+
ensureBinaryOnPath(backend.binary, opts.agent);
|
|
2763
|
+
|
|
2764
|
+
await runContinuePipeline(prompt, {
|
|
2765
|
+
agent: opts.agent,
|
|
2766
|
+
maxRounds: opts.maxRounds,
|
|
2767
|
+
verbose: opts.verbose,
|
|
2768
|
+
cwd,
|
|
2769
|
+
slug,
|
|
2770
|
+
worktreePath,
|
|
2771
|
+
branch,
|
|
2772
|
+
role,
|
|
2773
|
+
parentSlug,
|
|
2774
|
+
workerId,
|
|
2775
|
+
priorOutcome,
|
|
2776
|
+
continuation,
|
|
2777
|
+
jobSlug: slug,
|
|
2778
|
+
jobCwd: cwd,
|
|
2779
|
+
});
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
program
|
|
2783
|
+
.command('config')
|
|
2784
|
+
.description('Print or set the default agent (global ~/.orch/config or local .orch/config)')
|
|
2785
|
+
.addOption(
|
|
2786
|
+
new Option('--agent <agent>', 'Set the default agent backend')
|
|
2787
|
+
.choices(['cursor', 'claude', 'agn']),
|
|
2788
|
+
)
|
|
2789
|
+
.option('--global', 'Write the global config (~/.orch/config); default when --agent is set')
|
|
2790
|
+
.option('--local', 'Write the project-local config (.orch/config)')
|
|
2791
|
+
.action((options, command) => {
|
|
2792
|
+
// Parent also defines --agent; flags after `config` may land on the
|
|
2793
|
+
// parent. Merge so either placement works.
|
|
2794
|
+
const opts = typeof command.optsWithGlobals === 'function'
|
|
2795
|
+
? command.optsWithGlobals()
|
|
2796
|
+
: { ...program.opts(), ...options };
|
|
2797
|
+
const cwd = process.cwd();
|
|
2798
|
+
|
|
2799
|
+
if (opts.global && opts.local) {
|
|
2800
|
+
console.error('Error: --global and --local are mutually exclusive');
|
|
2801
|
+
process.exit(1);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if ((opts.global || opts.local) && !opts.agent) {
|
|
2806
|
+
console.error('Error: --global/--local require --agent (omit flags to print config)');
|
|
2807
|
+
process.exit(1);
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
if (opts.agent) {
|
|
2812
|
+
const targetPath = opts.local
|
|
2813
|
+
? localConfigPath(cwd)
|
|
2814
|
+
: globalConfigPath();
|
|
2815
|
+
try {
|
|
2816
|
+
writeConfig(targetPath, { agent: opts.agent });
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
console.error(`Error: ${err.message}`);
|
|
2819
|
+
process.exit(1);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
console.log(`wrote ${targetPath}`);
|
|
2823
|
+
process.stdout.write(`${JSON.stringify({ agent: opts.agent }, null, 2)}\n`);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
try {
|
|
2828
|
+
printConfig({ cwd });
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
console.error(`Error: ${err.message}`);
|
|
2831
|
+
process.exit(1);
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2238
2835
|
program
|
|
2239
2836
|
.command('list')
|
|
2240
2837
|
.description('List all runs (active and finished) tracked under .orch/ in this directory')
|