@welluable/orch 1.2.0 → 1.4.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 +110 -14
- package/agents/adjust.js +66 -0
- package/agents/ask.js +3 -5
- package/agents/boundaries.js +3 -4
- package/agents/code-writer.js +3 -5
- package/agents/decomposer.js +5 -5
- package/agents/index.js +2 -0
- package/agents/integrator.js +3 -4
- package/agents/planner.js +3 -5
- package/agents/quick-fix.js +3 -5
- package/agents/research.js +3 -5
- package/agents/seq-decomposer.js +58 -0
- package/agents/summary-footer.js +23 -0
- package/agents/test-critic.js +5 -6
- package/agents/test-runner.js +5 -6
- package/agents/test-writer.js +3 -5
- package/agents/triage.js +5 -6
- package/lib/agent-agn.js +4 -2
- package/lib/agent-cursor.js +4 -2
- package/lib/agent-opencode.js +177 -0
- package/lib/agent.js +165 -14
- package/lib/config.js +190 -0
- package/lib/continue.js +150 -0
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +209 -7
- package/lib/notify.js +71 -0
- package/lib/resume.js +264 -0
- package/lib/seq.js +281 -0
- package/lib/stage-summary.js +24 -0
- package/lib/tool-status.js +53 -6
- package/main.js +3116 -360
- package/package.json +1 -1
package/lib/continue.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJob, reconcileJob, jobPaths } from './jobs.js';
|
|
4
|
+
import { readSeq } from './seq.js';
|
|
5
|
+
|
|
6
|
+
const CONTINUE_ELIGIBLE = new Set(['done']);
|
|
7
|
+
const FAILURE_TERMINALS = new Set(['failed', 'stopped', 'crashed']);
|
|
8
|
+
|
|
9
|
+
/** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
|
|
10
|
+
* `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
|
|
11
|
+
function fail(message) {
|
|
12
|
+
const err = new Error(message);
|
|
13
|
+
err.name = '';
|
|
14
|
+
return err;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure eligibility gate for `orch continue`. Reconciles first; never mutates
|
|
19
|
+
* beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
|
|
20
|
+
* (CLI wraps with `Error: ${err.message}`).
|
|
21
|
+
*
|
|
22
|
+
* Continue is follow-up work on primarily `done` jobs. Terminal failures
|
|
23
|
+
* refuse by default and point at `orch resume` (see `.spec/resume.md`).
|
|
24
|
+
*/
|
|
25
|
+
export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
|
|
26
|
+
const existing = readJob(cwd, slug);
|
|
27
|
+
if (!existing) throw fail(`unknown run ${slug}`);
|
|
28
|
+
|
|
29
|
+
if (ask) throw fail('orch continue does not support --ask; use the default orch command');
|
|
30
|
+
if (quick) throw fail('orch continue does not support --quick; use the default orch command');
|
|
31
|
+
|
|
32
|
+
if (typeof task !== 'string' || !task.trim()) {
|
|
33
|
+
throw fail('task cannot be empty');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const record = reconcileJob(cwd, slug, existing);
|
|
37
|
+
|
|
38
|
+
if (record.role === 'coordinator') {
|
|
39
|
+
if (readSeq(cwd, slug)) {
|
|
40
|
+
throw fail(
|
|
41
|
+
`cannot continue coordinator ${slug}; continue each failed unit slug, then orch --seq-continue ${slug} or orch resume ${slug}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
throw fail(
|
|
45
|
+
`cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (record.role === 'integration') {
|
|
50
|
+
const parent = record.parent ?? '<parent-slug>';
|
|
51
|
+
throw fail(
|
|
52
|
+
`cannot continue integration ${slug}; use orch --integrate ${parent}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (FAILURE_TERMINALS.has(record.state)) {
|
|
57
|
+
const phase = record.phase ?? record.lastOutcome?.phase ?? '?';
|
|
58
|
+
const stage = record.stage ?? record.lastOutcome?.stage ?? '?';
|
|
59
|
+
throw fail(
|
|
60
|
+
`${slug} is ${record.state} at ${phase}/${stage};\nuse: orch resume ${slug}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!CONTINUE_ELIGIBLE.has(record.state)) {
|
|
65
|
+
throw fail(
|
|
66
|
+
`cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!record.worktree || !record.branch) {
|
|
71
|
+
throw fail(`${slug} has no worktree; continue only applies to complex runs`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(record.worktree)) {
|
|
75
|
+
throw fail(`worktree missing at ${record.worktree}; cannot continue ${slug}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return record;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Snapshot prior outcome for continue reopen. Prefer `record.lastOutcome`;
|
|
83
|
+
* otherwise synthesize from terminal fields (+ best-effort status.md scrape).
|
|
84
|
+
* Never throws.
|
|
85
|
+
*/
|
|
86
|
+
export function snapshotPriorOutcome(cwd, slug, record) {
|
|
87
|
+
if (record?.lastOutcome) return record.lastOutcome;
|
|
88
|
+
|
|
89
|
+
let summary = '';
|
|
90
|
+
try {
|
|
91
|
+
const statusPath = path.join(jobPaths(cwd, slug).dir, 'status.md');
|
|
92
|
+
if (fs.existsSync(statusPath)) {
|
|
93
|
+
const content = fs.readFileSync(statusPath, 'utf8');
|
|
94
|
+
const matches = [...content.matchAll(/^- Summary:\s*(.*)$/gm)];
|
|
95
|
+
if (matches.length > 0) {
|
|
96
|
+
summary = (matches[matches.length - 1][1] || '').trim();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
summary = '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
state: record.state,
|
|
105
|
+
phase: record.phase ?? null,
|
|
106
|
+
stage: record.stage ?? null,
|
|
107
|
+
round: record.round ?? null,
|
|
108
|
+
exitCode: record.exitCode ?? null,
|
|
109
|
+
finishedAt: record.finishedAt ?? null,
|
|
110
|
+
task: record.task ?? null,
|
|
111
|
+
summary,
|
|
112
|
+
error: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function displayOrNone(value) {
|
|
117
|
+
if (value == null || value === '') return '(none recorded)';
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the `[Prior run outcome]…[/Prior run outcome]` block injected into
|
|
123
|
+
* research/planner prompts on continue.
|
|
124
|
+
*/
|
|
125
|
+
export function buildPriorOutcomeText(prior, {
|
|
126
|
+
slug,
|
|
127
|
+
continuation,
|
|
128
|
+
worktreePath,
|
|
129
|
+
branch,
|
|
130
|
+
parentSlug,
|
|
131
|
+
workerId,
|
|
132
|
+
} = {}) {
|
|
133
|
+
const p = prior ?? {};
|
|
134
|
+
const lines = [
|
|
135
|
+
'[Prior run outcome]',
|
|
136
|
+
`- Continuation: this is continue ${continuation} on slug ${slug}`,
|
|
137
|
+
`- Prior state: ${displayOrNone(p.state)}`,
|
|
138
|
+
`- Prior phase: ${displayOrNone(p.phase)}`,
|
|
139
|
+
`- Prior stage: ${displayOrNone(p.stage)}`,
|
|
140
|
+
`- Prior round: ${p.round == null ? 'null' : p.round}`,
|
|
141
|
+
`- Prior task: ${displayOrNone(p.task)}`,
|
|
142
|
+
`- Summary: ${displayOrNone(p.summary)}`,
|
|
143
|
+
`- Error: ${displayOrNone(p.error)}`,
|
|
144
|
+
`- Worktree: ${worktreePath} (branch ${branch} tip is the starting point; do not assume a clean tree)`,
|
|
145
|
+
];
|
|
146
|
+
if (parentSlug) lines.push(`- Fan-out parent: ${parentSlug}`);
|
|
147
|
+
if (workerId) lines.push(`- Worker id: ${workerId}`);
|
|
148
|
+
lines.push('[/Prior run outcome]');
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure failure.log section formatting + append. No jobs imports — safe for
|
|
6
|
+
* both lib/agent.js (in-process buffer flush) and lib/jobs.js (crashed
|
|
7
|
+
* header-only write) without circular dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Relative pointer for status / lastOutcome.error (matches resume.md sample). */
|
|
11
|
+
export function failureLogPointer(slug) {
|
|
12
|
+
return `see .orch/${slug}/failure.log`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtField(label, value) {
|
|
16
|
+
const rendered = value == null || value === '' ? '' : String(value);
|
|
17
|
+
return `${label}:${' '.repeat(Math.max(1, 11 - label.length))}${rendered}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build one `=== orch failure ===` section (header + stage verbose + optional
|
|
22
|
+
* prior summaries). `stageVerbose` may be empty (crashed / header-only).
|
|
23
|
+
*/
|
|
24
|
+
export function formatFailureSection({
|
|
25
|
+
slug,
|
|
26
|
+
state,
|
|
27
|
+
phase = null,
|
|
28
|
+
stage = null,
|
|
29
|
+
round = null,
|
|
30
|
+
exitCode = null,
|
|
31
|
+
finishedAt,
|
|
32
|
+
task = null,
|
|
33
|
+
error = null,
|
|
34
|
+
stageVerbose = '',
|
|
35
|
+
priorStages = [],
|
|
36
|
+
}) {
|
|
37
|
+
const stageLabel = stage ?? 'unknown';
|
|
38
|
+
const lines = [
|
|
39
|
+
'=== orch failure ===',
|
|
40
|
+
fmtField('slug', slug),
|
|
41
|
+
fmtField('state', state),
|
|
42
|
+
fmtField('phase', phase),
|
|
43
|
+
fmtField('stage', stage),
|
|
44
|
+
fmtField('round', round),
|
|
45
|
+
fmtField('exitCode', exitCode),
|
|
46
|
+
fmtField('finishedAt', finishedAt),
|
|
47
|
+
fmtField('task', task),
|
|
48
|
+
fmtField('error', error),
|
|
49
|
+
'',
|
|
50
|
+
`=== stage verbose (${stageLabel}) ===`,
|
|
51
|
+
stageVerbose.endsWith('\n') || stageVerbose === '' ? stageVerbose : `${stageVerbose}\n`,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
if (priorStages.length > 0) {
|
|
55
|
+
lines.push('=== prior stage summaries (best-effort) ===');
|
|
56
|
+
for (const prior of priorStages) {
|
|
57
|
+
const label = prior.stage ?? prior.phase ?? 'prior';
|
|
58
|
+
lines.push(`--- ${label} ---`);
|
|
59
|
+
const body = prior.verbose ?? '';
|
|
60
|
+
lines.push(body.endsWith('\n') || body === '' ? body : `${body}\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Append a section to `failureLogPath`, creating the file/dir if needed. */
|
|
68
|
+
export function appendFailureLog(failureLogPath, section) {
|
|
69
|
+
fs.mkdirSync(path.dirname(failureLogPath), { recursive: true });
|
|
70
|
+
const prefix = fs.existsSync(failureLogPath) ? '\n' : '';
|
|
71
|
+
fs.appendFileSync(failureLogPath, prefix + section, 'utf8');
|
|
72
|
+
}
|
package/lib/file-tracker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { toolPath } from './tool-status.js';
|
|
2
3
|
|
|
3
4
|
/** @param {string} name */
|
|
4
5
|
function markerForName(name) {
|
|
@@ -9,11 +10,42 @@ function markerForName(name) {
|
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Parse OpenCode `apply_patch` marker lines into file-trail entries.
|
|
15
|
+
* @param {unknown} patchText
|
|
16
|
+
* @returns {{ marker: string, path: string }[]}
|
|
17
|
+
*/
|
|
18
|
+
export function parseApplyPatchPaths(patchText) {
|
|
19
|
+
if (typeof patchText !== 'string' || !patchText) return [];
|
|
20
|
+
|
|
21
|
+
/** @type {{ marker: string, path: string }[]} */
|
|
22
|
+
const entries = [];
|
|
23
|
+
for (const line of patchText.split('\n')) {
|
|
24
|
+
const add = line.match(/^\*\*\* Add File:\s*(.+?)\s*$/);
|
|
25
|
+
if (add) {
|
|
26
|
+
entries.push({ marker: '+', path: add[1] });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const update = line.match(/^\*\*\* Update File:\s*(.+?)\s*$/);
|
|
30
|
+
if (update) {
|
|
31
|
+
entries.push({ marker: '~', path: update[1] });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const del = line.match(/^\*\*\* Delete File:\s*(.+?)\s*$/);
|
|
35
|
+
if (del) {
|
|
36
|
+
entries.push({ marker: '-', path: del[1] });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const move = line.match(/^\*\*\* Move to:\s*(.+?)\s*$/);
|
|
40
|
+
if (move) {
|
|
41
|
+
// Destination only; drop the preceding Update File source entry.
|
|
42
|
+
if (entries.length > 0 && entries[entries.length - 1].marker === '~') {
|
|
43
|
+
entries.pop();
|
|
44
|
+
}
|
|
45
|
+
entries.push({ marker: '~', path: move[1] });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
/**
|
|
@@ -43,7 +75,7 @@ export class FileTracker {
|
|
|
43
75
|
this.pending.set(callId, {
|
|
44
76
|
name,
|
|
45
77
|
marker,
|
|
46
|
-
path:
|
|
78
|
+
path: toolPath(args),
|
|
47
79
|
});
|
|
48
80
|
return null;
|
|
49
81
|
}
|
|
@@ -57,9 +89,38 @@ export class FileTracker {
|
|
|
57
89
|
const marker = markerForName(toolName) || prior?.marker || null;
|
|
58
90
|
if (!marker) return null;
|
|
59
91
|
|
|
60
|
-
const rawPath =
|
|
92
|
+
const rawPath = toolPath(args) || prior?.path || null;
|
|
61
93
|
if (!rawPath) return null;
|
|
62
94
|
|
|
95
|
+
return this.#upsert(marker, rawPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Record paths from a completed OpenCode `apply_patch` into the trail.
|
|
100
|
+
* @param {unknown} patchText
|
|
101
|
+
* @returns {{ marker: string, path: string, isNew: boolean }[]}
|
|
102
|
+
*/
|
|
103
|
+
recordApplyPatch(patchText) {
|
|
104
|
+
/** @type {{ marker: string, path: string, isNew: boolean }[]} */
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const entry of parseApplyPatchPaths(patchText)) {
|
|
107
|
+
const result = this.#upsert(entry.marker, entry.path);
|
|
108
|
+
if (result) out.push(result);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {{ marker: string, path: string }[]} */
|
|
114
|
+
getFiles() {
|
|
115
|
+
return this.files.map((f) => ({ marker: f.marker, path: f.path }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} marker
|
|
120
|
+
* @param {string} rawPath
|
|
121
|
+
* @returns {{ marker: string, path: string, isNew: boolean }}
|
|
122
|
+
*/
|
|
123
|
+
#upsert(marker, rawPath) {
|
|
63
124
|
const relPath = this.#toRelative(rawPath);
|
|
64
125
|
const existingIdx = this.indexByPath.get(relPath);
|
|
65
126
|
const isNew = existingIdx === undefined;
|
|
@@ -74,11 +135,6 @@ export class FileTracker {
|
|
|
74
135
|
return { marker, path: relPath, isNew };
|
|
75
136
|
}
|
|
76
137
|
|
|
77
|
-
/** @returns {{ marker: string, path: string }[]} */
|
|
78
|
-
getFiles() {
|
|
79
|
-
return this.files.map((f) => ({ marker: f.marker, path: f.path }));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
138
|
/** @param {string} filePath */
|
|
83
139
|
#toRelative(filePath) {
|
|
84
140
|
if (path.isAbsolute(filePath)) {
|
package/lib/jobs.js
CHANGED
|
@@ -1,10 +1,73 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
appendFailureLog,
|
|
6
|
+
failureLogPointer,
|
|
7
|
+
formatFailureSection,
|
|
8
|
+
} from './failure-log.js';
|
|
9
|
+
import { getNotifyEnabled, notifyJob as defaultNotifyJob } from './notify.js';
|
|
4
10
|
|
|
5
11
|
const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
|
|
6
12
|
const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
|
|
7
13
|
|
|
14
|
+
/** Injectable notify hook for tests; default is `notifyJob` from lib/notify.js. */
|
|
15
|
+
let notifyJobHook = defaultNotifyJob;
|
|
16
|
+
|
|
17
|
+
/** Override the notify helper used on terminal state transitions (tests). */
|
|
18
|
+
export function setNotifyJob(fn) {
|
|
19
|
+
notifyJobHook = typeof fn === 'function' ? fn : defaultNotifyJob;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Restore the default notify hook. */
|
|
23
|
+
export function resetNotifyHooks() {
|
|
24
|
+
notifyJobHook = defaultNotifyJob;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function maybeNotifyTerminalTransition(previousState, updated) {
|
|
28
|
+
if (!getNotifyEnabled()) return;
|
|
29
|
+
if (!TERMINAL_STATES.includes(updated.state)) return;
|
|
30
|
+
if (TERMINAL_STATES.includes(previousState)) return;
|
|
31
|
+
try {
|
|
32
|
+
notifyJobHook({
|
|
33
|
+
slug: updated.slug,
|
|
34
|
+
state: updated.state,
|
|
35
|
+
task: updated.task,
|
|
36
|
+
enabled: true,
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
// Notification failures must never affect job writes.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compact outcome written onto `run.json` with every terminal state transition.
|
|
45
|
+
* Shared so all call sites (pipelines, shutdown, reconcile) keep one shape.
|
|
46
|
+
*/
|
|
47
|
+
export function buildLastOutcome({
|
|
48
|
+
state,
|
|
49
|
+
phase = null,
|
|
50
|
+
stage = null,
|
|
51
|
+
round = null,
|
|
52
|
+
exitCode = null,
|
|
53
|
+
finishedAt,
|
|
54
|
+
task = null,
|
|
55
|
+
summary = '',
|
|
56
|
+
error = null,
|
|
57
|
+
}) {
|
|
58
|
+
return {
|
|
59
|
+
state,
|
|
60
|
+
phase: phase ?? null,
|
|
61
|
+
stage: stage ?? null,
|
|
62
|
+
round: round ?? null,
|
|
63
|
+
exitCode: exitCode ?? null,
|
|
64
|
+
finishedAt,
|
|
65
|
+
task: task ?? null,
|
|
66
|
+
summary: summary ?? '',
|
|
67
|
+
error: error ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
8
71
|
/** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
|
|
9
72
|
export function jobPaths(cwd, slug) {
|
|
10
73
|
const dir = path.join(path.resolve(cwd), '.orch', slug);
|
|
@@ -13,6 +76,7 @@ export function jobPaths(cwd, slug) {
|
|
|
13
76
|
runJsonPath: path.join(dir, 'run.json'),
|
|
14
77
|
lockPath: path.join(dir, '.run.lock'),
|
|
15
78
|
logPath: path.join(dir, 'orch.log'),
|
|
79
|
+
failureLogPath: path.join(dir, 'failure.log'),
|
|
16
80
|
};
|
|
17
81
|
}
|
|
18
82
|
|
|
@@ -104,20 +168,57 @@ function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
|
|
|
104
168
|
}
|
|
105
169
|
}
|
|
106
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Minimal valid job record used when `patchJob` rehydrates a missing
|
|
173
|
+
* `run.json`. Mirrors `allocateJob` defaults and always includes `slug`.
|
|
174
|
+
*/
|
|
175
|
+
function rehydrateJobRecord(cwd, slug) {
|
|
176
|
+
return {
|
|
177
|
+
slug,
|
|
178
|
+
task: null,
|
|
179
|
+
agent: null,
|
|
180
|
+
maxRounds: null,
|
|
181
|
+
cwd: path.resolve(cwd),
|
|
182
|
+
pauseRequested: false,
|
|
183
|
+
branch: null,
|
|
184
|
+
worktree: null,
|
|
185
|
+
startedAt: new Date().toISOString(),
|
|
186
|
+
finishedAt: null,
|
|
187
|
+
exitCode: null,
|
|
188
|
+
logPath: jobPaths(cwd, slug).logPath,
|
|
189
|
+
pid: null,
|
|
190
|
+
state: 'starting',
|
|
191
|
+
phase: null,
|
|
192
|
+
stage: null,
|
|
193
|
+
round: null,
|
|
194
|
+
parent: null,
|
|
195
|
+
role: null,
|
|
196
|
+
workerId: null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
107
200
|
/**
|
|
108
201
|
* Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
|
|
109
202
|
* `(current) => partialPatch` function) over the latest record, atomically
|
|
110
|
-
* writes, unlocks, and returns the updated record.
|
|
203
|
+
* writes, unlocks, and returns the updated record. When `run.json` is
|
|
204
|
+
* missing, rehydrates a minimal valid base (always including `slug`) before
|
|
205
|
+
* merging — never writes a slug-less stub from `{}`.
|
|
111
206
|
*/
|
|
112
207
|
export function patchJob(cwd, slug, patchFnOrObject) {
|
|
113
208
|
const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
|
|
114
209
|
fs.mkdirSync(dir, { recursive: true });
|
|
115
210
|
acquireLock(lockPath);
|
|
116
211
|
try {
|
|
117
|
-
|
|
212
|
+
let current = readJob(cwd, slug);
|
|
213
|
+
if (!current) {
|
|
214
|
+
console.warn(`patchJob: rehydrating missing run.json for ${slug}`);
|
|
215
|
+
current = rehydrateJobRecord(cwd, slug);
|
|
216
|
+
}
|
|
217
|
+
const previousState = current.state;
|
|
118
218
|
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
|
|
119
|
-
const updated = { ...current, ...patch };
|
|
219
|
+
const updated = { ...current, ...patch, slug };
|
|
120
220
|
atomicWriteJson(dir, runJsonPath, updated);
|
|
221
|
+
maybeNotifyTerminalTransition(previousState, updated);
|
|
121
222
|
return updated;
|
|
122
223
|
} finally {
|
|
123
224
|
try {
|
|
@@ -136,10 +237,78 @@ export function patchJob(cwd, slug, patchFnOrObject) {
|
|
|
136
237
|
export function reconcileJob(cwd, slug, record) {
|
|
137
238
|
if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
|
|
138
239
|
if (isPidAlive(record.pid)) return record;
|
|
240
|
+
return patchJob(cwd, slug, (current) => {
|
|
241
|
+
const finishedAt = new Date().toISOString();
|
|
242
|
+
const { failureLogPath } = jobPaths(cwd, slug);
|
|
243
|
+
// Header-only: out-of-process crash has no in-memory stage buffer.
|
|
244
|
+
appendFailureLog(
|
|
245
|
+
failureLogPath,
|
|
246
|
+
formatFailureSection({
|
|
247
|
+
slug,
|
|
248
|
+
state: 'crashed',
|
|
249
|
+
phase: current.phase,
|
|
250
|
+
stage: current.stage,
|
|
251
|
+
round: current.round,
|
|
252
|
+
exitCode: null,
|
|
253
|
+
finishedAt,
|
|
254
|
+
task: current.task,
|
|
255
|
+
error: 'process died',
|
|
256
|
+
stageVerbose: '',
|
|
257
|
+
priorStages: [],
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
state: 'crashed',
|
|
262
|
+
finishedAt,
|
|
263
|
+
exitCode: null,
|
|
264
|
+
failureLogPath,
|
|
265
|
+
lastOutcome: buildLastOutcome({
|
|
266
|
+
state: 'crashed',
|
|
267
|
+
phase: current.phase,
|
|
268
|
+
stage: current.stage,
|
|
269
|
+
round: current.round,
|
|
270
|
+
exitCode: null,
|
|
271
|
+
finishedAt,
|
|
272
|
+
task: current.task,
|
|
273
|
+
summary: '',
|
|
274
|
+
error: failureLogPointer(slug),
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reopen an existing terminal job for `orch continue`. Patches in place —
|
|
282
|
+
* never allocates a new slug/directory. Bumps `continuation`, appends
|
|
283
|
+
* `continuations[]`, clears live `lastOutcome`, resets to running/research.
|
|
284
|
+
*/
|
|
285
|
+
export function reopenJob(cwd, slug, { task, agent, maxRounds, pid, prior, startedAt } = {}) {
|
|
286
|
+
const existing = readJob(cwd, slug);
|
|
287
|
+
if (!existing) throw new Error(`reopenJob: unknown job ${slug}`);
|
|
288
|
+
|
|
289
|
+
const started = startedAt ?? new Date().toISOString();
|
|
290
|
+
const continuation = (existing.continuation ?? 1) + 1;
|
|
291
|
+
const continuations = Array.isArray(existing.continuations)
|
|
292
|
+
? [...existing.continuations]
|
|
293
|
+
: [];
|
|
294
|
+
continuations.push({ n: continuation, task, startedAt: started, prior: prior ?? null });
|
|
295
|
+
|
|
139
296
|
return patchJob(cwd, slug, {
|
|
140
|
-
|
|
141
|
-
|
|
297
|
+
task,
|
|
298
|
+
agent,
|
|
299
|
+
maxRounds,
|
|
300
|
+
pid,
|
|
301
|
+
startedAt: started,
|
|
302
|
+
state: 'running',
|
|
303
|
+
phase: 'research',
|
|
304
|
+
stage: null,
|
|
305
|
+
round: null,
|
|
306
|
+
finishedAt: null,
|
|
142
307
|
exitCode: null,
|
|
308
|
+
pauseRequested: false,
|
|
309
|
+
lastOutcome: null,
|
|
310
|
+
continuation,
|
|
311
|
+
continuations,
|
|
143
312
|
});
|
|
144
313
|
}
|
|
145
314
|
|
|
@@ -156,7 +325,9 @@ export function listJobs(cwd) {
|
|
|
156
325
|
for (const slug of slugs) {
|
|
157
326
|
const record = readJob(cwd, slug);
|
|
158
327
|
if (!record) continue;
|
|
159
|
-
|
|
328
|
+
// Directory name is the authority when run.json omits/corrupts slug.
|
|
329
|
+
const normalized = { ...record, slug: record.slug ?? slug };
|
|
330
|
+
jobs.push(reconcileJob(cwd, slug, normalized));
|
|
160
331
|
}
|
|
161
332
|
|
|
162
333
|
jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
|
@@ -267,15 +438,46 @@ export function cascadeResume(cwd, parentSlug) {
|
|
|
267
438
|
return { childrenSignaled };
|
|
268
439
|
}
|
|
269
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Slugs of jobs in an active live state whose pid is still alive.
|
|
443
|
+
* Used by `cleanJobs` / `orch jobs clean` to refuse wiping live runs.
|
|
444
|
+
*/
|
|
445
|
+
export function liveSlugsBlockingClean(cwd) {
|
|
446
|
+
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
447
|
+
if (!fs.existsSync(orchDir)) return [];
|
|
448
|
+
|
|
449
|
+
const live = [];
|
|
450
|
+
for (const entry of fs.readdirSync(orchDir, { withFileTypes: true })) {
|
|
451
|
+
if (!entry.isDirectory()) continue;
|
|
452
|
+
const record = readJob(cwd, entry.name);
|
|
453
|
+
if (!record) continue;
|
|
454
|
+
if (!ACTIVE_LIVE_STATES.includes(record.state)) continue;
|
|
455
|
+
if (!isPidAlive(record.pid)) continue;
|
|
456
|
+
live.push(record.slug ?? entry.name);
|
|
457
|
+
}
|
|
458
|
+
return live;
|
|
459
|
+
}
|
|
460
|
+
|
|
270
461
|
/**
|
|
271
462
|
* The pure operation behind `orch jobs clean`: removes every entry under
|
|
272
463
|
* `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
|
|
273
|
-
* is missing or already empty).
|
|
464
|
+
* is missing or already empty). Refuses (throws) when any job is in an
|
|
465
|
+
* active live state (`running`/`pausing`/`paused`) with an alive pid —
|
|
466
|
+
* caller must `orch stop <slug>` first. Dead-pid "live" states may still
|
|
467
|
+
* be cleaned.
|
|
274
468
|
*/
|
|
275
469
|
export function cleanJobs(cwd) {
|
|
276
470
|
const orchDir = path.join(path.resolve(cwd), '.orch');
|
|
277
471
|
if (!fs.existsSync(orchDir)) return [];
|
|
278
472
|
|
|
473
|
+
const live = liveSlugsBlockingClean(cwd);
|
|
474
|
+
if (live.length > 0) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`cannot clean while live jobs exist: ${live.join(', ')}. ` +
|
|
477
|
+
`Stop them first with: orch stop <slug>`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
279
481
|
const names = fs.readdirSync(orchDir);
|
|
280
482
|
for (const name of names) {
|
|
281
483
|
fs.rmSync(path.join(orchDir, name), { recursive: true, force: true });
|
package/lib/notify.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const TERMINAL_STATES = new Set(['done', 'failed', 'stopped', 'crashed']);
|
|
4
|
+
|
|
5
|
+
/** Process-level gate; default off so unit tests that patch jobs stay silent. */
|
|
6
|
+
let notifyEnabled = false;
|
|
7
|
+
|
|
8
|
+
/** Sets whether terminal job transitions should fire desktop notifications. */
|
|
9
|
+
export function setNotifyEnabled(enabled) {
|
|
10
|
+
notifyEnabled = Boolean(enabled);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Current process-level notify gate. */
|
|
14
|
+
export function getNotifyEnabled() {
|
|
15
|
+
return notifyEnabled;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shortTask(task) {
|
|
19
|
+
if (task == null) return '';
|
|
20
|
+
const text = String(task).trim();
|
|
21
|
+
if (!text) return '';
|
|
22
|
+
if (text.length <= 80) return text;
|
|
23
|
+
return `${text.slice(0, 80)}…`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildBody(state, task) {
|
|
27
|
+
const short = shortTask(task);
|
|
28
|
+
return short ? `${state} — ${short}` : state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fire-and-forget desktop notification for a terminal job state.
|
|
33
|
+
* Inject `spawn` / `platform` in tests. Never throws.
|
|
34
|
+
*/
|
|
35
|
+
export function notifyJob({
|
|
36
|
+
slug,
|
|
37
|
+
state,
|
|
38
|
+
task,
|
|
39
|
+
enabled,
|
|
40
|
+
spawn: spawnFn = defaultSpawn,
|
|
41
|
+
platform = process.platform,
|
|
42
|
+
} = {}) {
|
|
43
|
+
try {
|
|
44
|
+
if (enabled === false) return;
|
|
45
|
+
if (!TERMINAL_STATES.has(state)) return;
|
|
46
|
+
|
|
47
|
+
const title = `orch · ${slug}`;
|
|
48
|
+
const body = buildBody(state, task);
|
|
49
|
+
|
|
50
|
+
if (platform === 'darwin') {
|
|
51
|
+
const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
|
|
52
|
+
const child = spawnFn('osascript', ['-e', script], {
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
detached: true,
|
|
55
|
+
});
|
|
56
|
+
child.unref?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (platform === 'linux') {
|
|
61
|
+
const child = spawnFn('notify-send', ['--', title, body], {
|
|
62
|
+
stdio: 'ignore',
|
|
63
|
+
detached: true,
|
|
64
|
+
});
|
|
65
|
+
child.unref?.();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort: never fatal.
|
|
70
|
+
}
|
|
71
|
+
}
|