@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
package/lib/commit.js
CHANGED
|
@@ -33,3 +33,73 @@ export function commitWorktree({ worktreePath, branch, message, execFile = defau
|
|
|
33
33
|
|
|
34
34
|
return { committed: true, sha, branch };
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stage the dirty worktree (so untracked files show as `A`) and return
|
|
39
|
+
* cached name-status + shortstat vs worktree `HEAD`. Returns null when clean
|
|
40
|
+
* or when git cannot run against the path (display-only rollup must not abort
|
|
41
|
+
* the pipeline on stub/missing worktrees).
|
|
42
|
+
*
|
|
43
|
+
* @param {{ worktreePath: string, execFile?: Function }} opts
|
|
44
|
+
* @returns {{ files: { status: string, path: string }[], shortstat: string }|null}
|
|
45
|
+
*/
|
|
46
|
+
export function collectWorktreeChanges({ worktreePath, execFile = defaultExecFile }) {
|
|
47
|
+
try {
|
|
48
|
+
const status = runGit(execFile, ['-C', worktreePath, 'status', '--porcelain']);
|
|
49
|
+
if (status.trim() === '') {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
runGit(execFile, ['-C', worktreePath, 'add', '-A']);
|
|
54
|
+
const nameStatus = runGit(execFile, [
|
|
55
|
+
'-C', worktreePath, 'diff', '--cached', '--name-status', 'HEAD',
|
|
56
|
+
]);
|
|
57
|
+
const shortstatRaw = runGit(execFile, [
|
|
58
|
+
'-C', worktreePath, 'diff', '--cached', '--shortstat', 'HEAD',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const files = nameStatus
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((line) => line.trimEnd())
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map((line) => {
|
|
66
|
+
const tab = line.indexOf('\t');
|
|
67
|
+
if (tab === -1) return { status: line.trim(), path: '' };
|
|
68
|
+
return {
|
|
69
|
+
status: line.slice(0, tab).trim(),
|
|
70
|
+
path: line.slice(tab + 1),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { files, shortstat: shortstatRaw.trim() };
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Print the titled `files changed` rollup block. No-op for null/empty.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ files: { status: string, path: string }[], shortstat: string }|null|undefined} changes
|
|
84
|
+
* @param {{ log?: (line: string) => void }} [opts]
|
|
85
|
+
*/
|
|
86
|
+
export function printFilesChanged(changes, { log = console.log } = {}) {
|
|
87
|
+
if (!changes || !Array.isArray(changes.files) || changes.files.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const title = ' files changed ';
|
|
92
|
+
const rule = '─'.repeat(title.length);
|
|
93
|
+
|
|
94
|
+
log('');
|
|
95
|
+
log(rule);
|
|
96
|
+
log(title);
|
|
97
|
+
log(rule);
|
|
98
|
+
for (const { status, path: filePath } of changes.files) {
|
|
99
|
+
log(` ${status} ${filePath}`);
|
|
100
|
+
}
|
|
101
|
+
if (changes.shortstat) {
|
|
102
|
+
log(` ${changes.shortstat}`);
|
|
103
|
+
}
|
|
104
|
+
log('');
|
|
105
|
+
}
|
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/fanout.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { execFileSync as nodeExecFileSync } from 'node:child_process';
|
|
5
|
+
import { isPidAlive } from './jobs.js';
|
|
6
|
+
|
|
7
|
+
const SCAFFOLD_PREREGISTER_TEXT = 'pre-register every shared registry, barrel, and route-table entry wired to stubs so they never need to touch those files.';
|
|
8
|
+
|
|
9
|
+
/** Absolute paths for a fan-out's on-disk state under `<cwd>/.orch/<parentSlug>/`. */
|
|
10
|
+
function fanoutPaths(cwd, parentSlug) {
|
|
11
|
+
const dir = path.join(path.resolve(cwd), '.orch', parentSlug);
|
|
12
|
+
return {
|
|
13
|
+
dir,
|
|
14
|
+
fanoutJsonPath: path.join(dir, 'fanout.json'),
|
|
15
|
+
lockPath: path.join(dir, '.fanout.lock'),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicWriteJson(dir, filePath, data) {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
const tmpPath = path.join(
|
|
22
|
+
dir,
|
|
23
|
+
`.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
24
|
+
);
|
|
25
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
26
|
+
fs.renameSync(tmpPath, filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleepSync(ms) {
|
|
30
|
+
const view = new Int32Array(new SharedArrayBuffer(4));
|
|
31
|
+
Atomics.wait(view, 0, 0, ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Acquires `.fanout.lock` via exclusive create, busy-waiting on contention.
|
|
36
|
+
* A lock whose owner pid is no longer alive is treated as stale and removed.
|
|
37
|
+
*/
|
|
38
|
+
function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
43
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
|
|
44
|
+
fs.closeSync(fd);
|
|
45
|
+
return;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code !== 'EEXIST') throw err;
|
|
48
|
+
|
|
49
|
+
let ownerPid = null;
|
|
50
|
+
try {
|
|
51
|
+
ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
|
|
52
|
+
} catch {
|
|
53
|
+
// Lock file mid-write or briefly unreadable; treat as contention.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (ownerPid != null && !isPidAlive(ownerPid)) {
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(lockPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// Another process may have removed it first; retry.
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Date.now() - start > timeoutMs) {
|
|
66
|
+
throw new Error(`patchWorker/patchIntegration: timed out waiting for lock ${lockPath}`);
|
|
67
|
+
}
|
|
68
|
+
sleepSync(retryMs);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads and parses `fanout.json`; `null` if missing; throws on invalid JSON. */
|
|
74
|
+
export function readFanout(cwd, parentSlug) {
|
|
75
|
+
const { fanoutJsonPath } = fanoutPaths(cwd, parentSlug);
|
|
76
|
+
let content;
|
|
77
|
+
try {
|
|
78
|
+
content = fs.readFileSync(fanoutJsonPath, 'utf8');
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.code === 'ENOENT') return null;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return JSON.parse(content);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Atomically (write-temp + rename) writes the full fan-out document. */
|
|
87
|
+
export function writeFanout(cwd, parentSlug, data) {
|
|
88
|
+
const { dir, fanoutJsonPath } = fanoutPaths(cwd, parentSlug);
|
|
89
|
+
atomicWriteJson(dir, fanoutJsonPath, data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Locks, re-reads the latest document, shallow-merges `patchFnOrObject` (an
|
|
94
|
+
* object, or a `(currentWorker) => partialPatch` function) onto the matching
|
|
95
|
+
* entry in `workers[]`, atomically writes the whole document back, unlocks,
|
|
96
|
+
* and returns the updated full document.
|
|
97
|
+
*/
|
|
98
|
+
export function patchWorker(cwd, parentSlug, workerId, patchFnOrObject) {
|
|
99
|
+
const { dir, fanoutJsonPath, lockPath } = fanoutPaths(cwd, parentSlug);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
acquireLock(lockPath);
|
|
102
|
+
try {
|
|
103
|
+
const current = readFanout(cwd, parentSlug);
|
|
104
|
+
const workers = current.workers.map((worker) => {
|
|
105
|
+
if (worker.id !== workerId) return worker;
|
|
106
|
+
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(worker) : patchFnOrObject;
|
|
107
|
+
return { ...worker, ...patch };
|
|
108
|
+
});
|
|
109
|
+
const updated = { ...current, workers };
|
|
110
|
+
atomicWriteJson(dir, fanoutJsonPath, updated);
|
|
111
|
+
return updated;
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
fs.unlinkSync(lockPath);
|
|
115
|
+
} catch {
|
|
116
|
+
// Already gone; nothing to clean up.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Same lock/read/merge/write/unlock shape as `patchWorker`, applied to the
|
|
123
|
+
* top-level `integration` object.
|
|
124
|
+
*/
|
|
125
|
+
export function patchIntegration(cwd, parentSlug, patchFnOrObject) {
|
|
126
|
+
const { dir, fanoutJsonPath, lockPath } = fanoutPaths(cwd, parentSlug);
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
acquireLock(lockPath);
|
|
129
|
+
try {
|
|
130
|
+
const current = readFanout(cwd, parentSlug);
|
|
131
|
+
const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current.integration) : patchFnOrObject;
|
|
132
|
+
const updated = { ...current, integration: { ...current.integration, ...patch } };
|
|
133
|
+
atomicWriteJson(dir, fanoutJsonPath, updated);
|
|
134
|
+
return updated;
|
|
135
|
+
} finally {
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(lockPath);
|
|
138
|
+
} catch {
|
|
139
|
+
// Already gone; nothing to clean up.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pathsOverlap(a, b) {
|
|
145
|
+
if (a === b) return true;
|
|
146
|
+
const aDir = a.endsWith('/') ? a : `${a}/`;
|
|
147
|
+
const bDir = b.endsWith('/') ? b : `${b}/`;
|
|
148
|
+
return b.startsWith(aDir) || a.startsWith(bDir);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ownsOverlap(ownsA, ownsB) {
|
|
152
|
+
for (const a of ownsA) {
|
|
153
|
+
for (const b of ownsB) {
|
|
154
|
+
if (pathsOverlap(a, b)) return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasDependencyCycle(workers) {
|
|
161
|
+
const byId = new Map(workers.map((w) => [w.id, w]));
|
|
162
|
+
const WHITE = 0;
|
|
163
|
+
const GRAY = 1;
|
|
164
|
+
const BLACK = 2;
|
|
165
|
+
const color = new Map(workers.map((w) => [w.id, WHITE]));
|
|
166
|
+
|
|
167
|
+
function visit(id) {
|
|
168
|
+
color.set(id, GRAY);
|
|
169
|
+
const worker = byId.get(id);
|
|
170
|
+
for (const dep of worker.dependsOn || []) {
|
|
171
|
+
const depColor = color.get(dep);
|
|
172
|
+
if (depColor === GRAY) return true;
|
|
173
|
+
if (depColor === WHITE && visit(dep)) return true;
|
|
174
|
+
}
|
|
175
|
+
color.set(id, BLACK);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const worker of workers) {
|
|
180
|
+
if (color.get(worker.id) === WHITE && visit(worker.id)) return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Returns a violation list for a decomposition (empty array = valid). */
|
|
186
|
+
export function validateDecomposition(decomposition, { maxWorkers } = {}) {
|
|
187
|
+
const violations = [];
|
|
188
|
+
const workers = decomposition?.workers ?? [];
|
|
189
|
+
|
|
190
|
+
if (workers.length < 2) {
|
|
191
|
+
violations.push('fewer than two workers; not decomposable');
|
|
192
|
+
}
|
|
193
|
+
if (typeof maxWorkers === 'number' && workers.length > maxWorkers) {
|
|
194
|
+
violations.push(`too many workers: ${workers.length} exceeds maxWorkers ${maxWorkers}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ids = new Set(workers.map((w) => w.id));
|
|
198
|
+
let hasUnknownDep = false;
|
|
199
|
+
for (const worker of workers) {
|
|
200
|
+
if (!worker.owns || worker.owns.length === 0) {
|
|
201
|
+
violations.push(`worker ${worker.id} has no owns`);
|
|
202
|
+
}
|
|
203
|
+
if (!worker.area) {
|
|
204
|
+
violations.push(`worker ${worker.id} has no area`);
|
|
205
|
+
}
|
|
206
|
+
for (const dep of worker.dependsOn || []) {
|
|
207
|
+
if (!ids.has(dep)) {
|
|
208
|
+
violations.push(`worker ${worker.id} depends on unknown id ${dep}`);
|
|
209
|
+
hasUnknownDep = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const cyclic = !hasUnknownDep && hasDependencyCycle(workers);
|
|
215
|
+
if (cyclic) {
|
|
216
|
+
violations.push('dependsOn graph has a cycle');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!hasUnknownDep && !cyclic) {
|
|
220
|
+
const layers = planLayers(workers);
|
|
221
|
+
for (const layer of layers) {
|
|
222
|
+
const layerWorkers = layer.map((id) => workers.find((w) => w.id === id));
|
|
223
|
+
for (let i = 0; i < layerWorkers.length; i += 1) {
|
|
224
|
+
for (let j = i + 1; j < layerWorkers.length; j += 1) {
|
|
225
|
+
if (ownsOverlap(layerWorkers[i].owns || [], layerWorkers[j].owns || [])) {
|
|
226
|
+
violations.push(
|
|
227
|
+
`workers ${layerWorkers[i].id} and ${layerWorkers[j].id} have overlapping owns in the same layer`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const scaffolds = workers.filter((w) => w.scaffold);
|
|
236
|
+
if (scaffolds.length > 1) {
|
|
237
|
+
violations.push('more than one worker marked scaffold');
|
|
238
|
+
}
|
|
239
|
+
for (const scaffold of scaffolds) {
|
|
240
|
+
if (scaffold.dependsOn && scaffold.dependsOn.length > 0) {
|
|
241
|
+
violations.push(`scaffold worker ${scaffold.id} has dependencies`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return violations;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Topological layering over `dependsOn`: layer 0 is every worker with no
|
|
250
|
+
* dependencies, each subsequent layer is workers whose deps are all already
|
|
251
|
+
* placed. Preserves the input array's relative order within each layer.
|
|
252
|
+
*/
|
|
253
|
+
export function planLayers(workers) {
|
|
254
|
+
const remaining = new Set(workers.map((w) => w.id));
|
|
255
|
+
const done = new Set();
|
|
256
|
+
const layers = [];
|
|
257
|
+
|
|
258
|
+
while (remaining.size > 0) {
|
|
259
|
+
const layer = [];
|
|
260
|
+
for (const worker of workers) {
|
|
261
|
+
if (!remaining.has(worker.id)) continue;
|
|
262
|
+
const deps = worker.dependsOn || [];
|
|
263
|
+
if (deps.every((dep) => done.has(dep))) {
|
|
264
|
+
layer.push(worker.id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (layer.length === 0) break;
|
|
268
|
+
for (const id of layer) {
|
|
269
|
+
remaining.delete(id);
|
|
270
|
+
done.add(id);
|
|
271
|
+
}
|
|
272
|
+
layers.push(layer);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return layers;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Coordinator concurrency rule: layer size, capped when `maxConcurrency` is a number. */
|
|
279
|
+
export function chooseConcurrency({ layerSize, maxConcurrency }) {
|
|
280
|
+
if (typeof maxConcurrency !== 'number') return layerSize;
|
|
281
|
+
return Math.min(layerSize, maxConcurrency);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Thin per-worker prompt: subtask, area, sibling titles; no `owns`, no `boundaries.md`. */
|
|
285
|
+
export function buildWorkerEnvelope({ subtask, area, scaffold, siblingTitles }) {
|
|
286
|
+
const siblings = (siblingTitles || []).join(', ');
|
|
287
|
+
const lines = [
|
|
288
|
+
subtask,
|
|
289
|
+
'',
|
|
290
|
+
`This is one worker in a parallel orch run. Sibling workers are handling: ${siblings}. Keep your changes within ${area} and do not refactor or reorganize anything outside it.`,
|
|
291
|
+
];
|
|
292
|
+
lines.push(
|
|
293
|
+
scaffold
|
|
294
|
+
? `Parallel workers will follow; ${SCAFFOLD_PREREGISTER_TEXT}`
|
|
295
|
+
: 'Shared types, interfaces, and stubs already exist on your base commit — use them as they are rather than redefining them.',
|
|
296
|
+
);
|
|
297
|
+
return lines.join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Thin integration prompt: ordered branches, overlapping paths or "none"; no `owns`. */
|
|
301
|
+
export function buildIntegrationEnvelope({ task, branches, overlappingFiles }) {
|
|
302
|
+
const branchList = (branches || []).join(', ');
|
|
303
|
+
const overlapList = overlappingFiles && overlappingFiles.length > 0 ? overlappingFiles.join(', ') : 'none';
|
|
304
|
+
return [
|
|
305
|
+
`Combine the completed worker branches for "${task}" into one coherent branch and make the full test suite pass.`,
|
|
306
|
+
'',
|
|
307
|
+
`Branches to merge, in order: ${branchList}. Files more than one worker changed: ${overlapList}. Resolve merge fallout only — do not redesign or reimplement what the workers built. Shared types, interfaces, and stubs already exist on the base commit.`,
|
|
308
|
+
].join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function defaultExecFile(command, args, options = {}) {
|
|
312
|
+
return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Runs `git diff --name-only <base>..<branch>` and returns the parsed file-path array. */
|
|
316
|
+
export function recordChangedFiles({ repoRoot, base, branch, execFile = defaultExecFile }) {
|
|
317
|
+
const output = execFile('git', ['-C', repoRoot, 'diff', '--name-only', `${base}..${branch}`]);
|
|
318
|
+
return output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* For every file appearing in ≥2 workers' `changedFiles`, appends that path
|
|
323
|
+
* onto each of those workers' `overlaps` arrays (mutated in place) and
|
|
324
|
+
* returns the deduped union of overlapping paths.
|
|
325
|
+
*/
|
|
326
|
+
export function detectOverlaps(workers) {
|
|
327
|
+
const countByFile = new Map();
|
|
328
|
+
for (const worker of workers) {
|
|
329
|
+
for (const file of new Set(worker.changedFiles || [])) {
|
|
330
|
+
countByFile.set(file, (countByFile.get(file) || 0) + 1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const union = [];
|
|
335
|
+
for (const [file, count] of countByFile.entries()) {
|
|
336
|
+
if (count >= 2) union.push(file);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const worker of workers) {
|
|
340
|
+
const changed = new Set(worker.changedFiles || []);
|
|
341
|
+
for (const file of union) {
|
|
342
|
+
if (changed.has(file) && !worker.overlaps.includes(file)) {
|
|
343
|
+
worker.overlaps.push(file);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return union;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Appends the pre-register wording to a scaffold worker's subtask if not already present. */
|
|
352
|
+
export function ensureScaffoldSubtask(text) {
|
|
353
|
+
if (text && /pre-regist/i.test(text)) return text;
|
|
354
|
+
return `${text} Pre-register shared registries, barrels, or route tables so parallel workers can fill in bodies without touching those files.`;
|
|
355
|
+
}
|