@chuckssmith/agentloom 0.6.0 → 0.8.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/dist/cli.js +1 -0
- package/dist/commands/crew.js +88 -27
- package/dist/commands/status.js +30 -8
- package/dist/team/queue.js +22 -20
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
loom crew [N] "<task>" Spawn N parallel workers on a task
|
|
17
17
|
loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
|
|
18
18
|
loom crew --dry-run [N] "<task>" Preview decomposed subtasks without launching
|
|
19
|
+
loom crew --serial [N] "<task>" Run workers sequentially (each sees prior results)
|
|
19
20
|
loom watch Live tail all worker logs (Ctrl+C to stop)
|
|
20
21
|
loom stop Kill all background workers (SIGTERM)
|
|
21
22
|
loom stop <workerId> Kill one worker
|
package/dist/commands/crew.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync, spawn, spawnSync } from 'child_process';
|
|
2
2
|
import { writeFile, mkdir, open } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
4
5
|
import { parseWorkerSpec, initSession, writeContextSnapshot, decomposeTasks, } from '../team/orchestrator.js';
|
|
5
6
|
import { STATE_DIR } from '../state/session.js';
|
|
6
7
|
const hasTmux = () => {
|
|
@@ -67,7 +68,8 @@ export async function crew(args) {
|
|
|
67
68
|
process.exit(1);
|
|
68
69
|
}
|
|
69
70
|
const dryRun = args.includes('--dry-run');
|
|
70
|
-
const
|
|
71
|
+
const serial = args.includes('--serial');
|
|
72
|
+
const filteredArgs = args.filter(a => a !== '--dry-run' && a !== '--serial');
|
|
71
73
|
const { specs, task } = parseWorkerSpec(filteredArgs);
|
|
72
74
|
const totalWorkers = specs.reduce((sum, s) => sum + s.count, 0);
|
|
73
75
|
const slug = task.slice(0, 30).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
@@ -90,25 +92,78 @@ export async function crew(args) {
|
|
|
90
92
|
return;
|
|
91
93
|
}
|
|
92
94
|
const useTmux = hasTmux() && !isWSL() && process.stdout.isTTY;
|
|
93
|
-
|
|
95
|
+
const mode = serial ? 'serial' : useTmux ? 'tmux' : 'background processes';
|
|
96
|
+
console.log(`Mode: ${mode}\n`);
|
|
94
97
|
const session = await initSession(task, totalWorkers);
|
|
95
98
|
const contextPath = await writeContextSnapshot(slug, task);
|
|
96
99
|
const tasks = await decomposeTasks(task, specs);
|
|
97
100
|
console.log(`Session: ${session.id}`);
|
|
98
101
|
console.log(`Tasks: ${tasks.length} created`);
|
|
99
102
|
console.log(`Context: ${contextPath}\n`);
|
|
100
|
-
if (
|
|
103
|
+
if (serial) {
|
|
104
|
+
await launchSerial(session.id, specs, tasks, contextPath);
|
|
105
|
+
console.log(`\nAll workers finished. Run: loom collect`);
|
|
106
|
+
}
|
|
107
|
+
else if (useTmux) {
|
|
101
108
|
await launchTmux(session.id, specs, tasks, contextPath);
|
|
109
|
+
console.log(`\nWorkers launched. Monitor with:`);
|
|
110
|
+
console.log(` loom status`);
|
|
111
|
+
console.log(` loom stop (kill all workers)`);
|
|
102
112
|
}
|
|
103
113
|
else {
|
|
104
114
|
await launchBackground(session.id, specs, tasks, contextPath);
|
|
115
|
+
console.log(`\nWorkers launched. Monitor with:`);
|
|
116
|
+
console.log(` loom status`);
|
|
117
|
+
console.log(` loom watch`);
|
|
118
|
+
console.log(` loom stop (kill all workers)`);
|
|
105
119
|
}
|
|
106
|
-
console.log(`\nWorkers launched. Monitor with:`);
|
|
107
|
-
console.log(` loom status`);
|
|
108
|
-
console.log(` loom watch`);
|
|
109
|
-
console.log(` loom stop (kill all workers)`);
|
|
110
120
|
console.log(`State dir: ${STATE_DIR}/`);
|
|
111
121
|
}
|
|
122
|
+
async function launchSerial(sessionId, specs, tasks, contextPath) {
|
|
123
|
+
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
124
|
+
let workerIdx = 0;
|
|
125
|
+
for (const spec of specs) {
|
|
126
|
+
for (let i = 0; i < spec.count; i++) {
|
|
127
|
+
const workerId = `w${String(workerIdx).padStart(2, '0')}`;
|
|
128
|
+
const subtask = tasks[workerIdx]?.description ?? tasks[0]?.description ?? '';
|
|
129
|
+
const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
|
|
130
|
+
workerIdx++;
|
|
131
|
+
// Each worker receives results from all previous workers via the context file
|
|
132
|
+
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
|
|
133
|
+
const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
|
|
134
|
+
await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
|
|
135
|
+
console.log(` → Worker ${workerId} (${agentType}) starting...`);
|
|
136
|
+
const claudeArgs = [
|
|
137
|
+
'--print',
|
|
138
|
+
...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
139
|
+
'-p',
|
|
140
|
+
prompt,
|
|
141
|
+
];
|
|
142
|
+
// Run synchronously — block until this worker is done before starting the next
|
|
143
|
+
const result = spawnSync('claude', claudeArgs, {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
timeout: 30 * 60 * 1000, // 30 min max per worker
|
|
146
|
+
env: { ...process.env, AGENTLOOM_WORKER_ID: workerId, AGENTLOOM_SESSION: sessionId },
|
|
147
|
+
});
|
|
148
|
+
const output = (result.stdout ?? '') + (result.stderr ?? '');
|
|
149
|
+
await writeFile(logFile, output);
|
|
150
|
+
if (result.status !== 0) {
|
|
151
|
+
const resultFile = join(STATE_DIR, 'workers', `${workerId}-result.md`);
|
|
152
|
+
await writeFile(resultFile, `# Error\n\nWorker exited with code ${result.status ?? 'unknown'}\n\n${output.slice(-500)}`);
|
|
153
|
+
console.log(` ✗ Worker ${workerId} failed (exit ${result.status ?? '?'})`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// If worker didn't write its own result file, write a placeholder
|
|
157
|
+
const resultFile = join(STATE_DIR, 'workers', `${workerId}-result.md`);
|
|
158
|
+
if (!existsSync(resultFile)) {
|
|
159
|
+
const lastLines = output.trim().split('\n').slice(-20).join('\n');
|
|
160
|
+
await writeFile(resultFile, `# Result\n\n${lastLines}`);
|
|
161
|
+
}
|
|
162
|
+
console.log(` ✓ Worker ${workerId} done`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
112
167
|
async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
113
168
|
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
114
169
|
let workerIdx = 0;
|
|
@@ -122,11 +177,13 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
122
177
|
const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
|
|
123
178
|
const pidFile = join(STATE_DIR, 'workers', `${workerId}.pid`);
|
|
124
179
|
await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
180
|
+
// Build args declaratively — no positional splicing
|
|
181
|
+
const claudeArgs = [
|
|
182
|
+
'--print',
|
|
183
|
+
...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
184
|
+
'-p',
|
|
185
|
+
prompt,
|
|
186
|
+
];
|
|
130
187
|
const log = await open(logFile, 'w');
|
|
131
188
|
const child = spawn('claude', claudeArgs, {
|
|
132
189
|
detached: true,
|
|
@@ -170,17 +227,20 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
170
227
|
const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
|
|
171
228
|
workerIdx++;
|
|
172
229
|
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
|
|
173
|
-
|
|
174
|
-
const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.
|
|
175
|
-
|
|
176
|
-
|
|
230
|
+
const promptFile = join(STATE_DIR, 'workers', `${workerId}-prompt.md`);
|
|
231
|
+
const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.mjs`);
|
|
232
|
+
await writeFile(promptFile, prompt);
|
|
233
|
+
// Node.js runner — JSON.stringify safely encodes all values, no shell expansion possible
|
|
177
234
|
await writeFile(scriptFile, [
|
|
178
|
-
'
|
|
179
|
-
`
|
|
180
|
-
`
|
|
181
|
-
`
|
|
182
|
-
`
|
|
183
|
-
`
|
|
235
|
+
`import { readFileSync } from 'fs'`,
|
|
236
|
+
`import { spawnSync } from 'child_process'`,
|
|
237
|
+
`process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
|
|
238
|
+
`process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
|
|
239
|
+
`const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
|
|
240
|
+
`const args = ['--print', ${!READ_ONLY_ROLES.has(agentType) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
|
|
241
|
+
`const r = spawnSync('claude', args, { stdio: 'inherit' })`,
|
|
242
|
+
`console.log('[worker done]')`,
|
|
243
|
+
`process.exit(r.status ?? 0)`,
|
|
184
244
|
].join('\n'));
|
|
185
245
|
if (workerIdx > 1) {
|
|
186
246
|
try {
|
|
@@ -191,11 +251,12 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
191
251
|
// Non-fatal — continue with remaining workers even if layout fails
|
|
192
252
|
}
|
|
193
253
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
254
|
+
// Use spawnSync (no shell) so the scriptFile path is passed as a literal argument.
|
|
255
|
+
// Escape single quotes in the path for the shell inside the tmux pane.
|
|
256
|
+
const shellSafePath = scriptFile.replace(/'/g, "'\\''");
|
|
257
|
+
const sendResult = spawnSync('tmux', ['send-keys', '-t', tmuxSession, `node '${shellSafePath}'`, 'Enter'], { stdio: 'ignore' });
|
|
258
|
+
if (sendResult.status !== 0) {
|
|
259
|
+
console.error(` ✗ Worker ${workerId}: failed to send tmux keys`);
|
|
199
260
|
continue;
|
|
200
261
|
}
|
|
201
262
|
console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched in tmux pane`);
|
package/dist/commands/status.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { readSession, readTasks, STATE_DIR } from '../state/session.js';
|
|
2
2
|
import { existsSync, statSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { readdir } from 'fs/promises';
|
|
5
|
-
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10
|
|
4
|
+
import { readdir, readFile } from 'fs/promises';
|
|
5
|
+
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 min with no log growth AND no live PID = stale
|
|
6
|
+
function isProcessAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
export async function status() {
|
|
7
16
|
if (!existsSync(STATE_DIR)) {
|
|
8
17
|
console.log('No active session. Run: loom crew "<task>"');
|
|
@@ -42,21 +51,34 @@ export async function status() {
|
|
|
42
51
|
console.log(` [${workerId}] done ✓`);
|
|
43
52
|
continue;
|
|
44
53
|
}
|
|
45
|
-
// Check
|
|
54
|
+
// Check PID liveness first — a quiet log doesn't mean a dead worker
|
|
55
|
+
const pidPath = join(workersDir, `${workerId}.pid`);
|
|
56
|
+
let pidAlive = false;
|
|
57
|
+
if (existsSync(pidPath)) {
|
|
58
|
+
const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
|
|
59
|
+
if (!isNaN(pid))
|
|
60
|
+
pidAlive = isProcessAlive(pid);
|
|
61
|
+
}
|
|
46
62
|
const logStat = statSync(logPath);
|
|
47
63
|
const msSinceWrite = now - logStat.mtimeMs;
|
|
48
|
-
const isStale = msSinceWrite > STALE_THRESHOLD_MS;
|
|
49
64
|
const logSize = logStat.size;
|
|
50
|
-
if (logSize === 0) {
|
|
65
|
+
if (logSize === 0 && pidAlive) {
|
|
66
|
+
console.log(` [${workerId}] starting... (pid alive)`);
|
|
67
|
+
}
|
|
68
|
+
else if (logSize === 0) {
|
|
51
69
|
console.log(` [${workerId}] starting...`);
|
|
52
70
|
}
|
|
53
|
-
else if (
|
|
71
|
+
else if (pidAlive) {
|
|
72
|
+
const secs = Math.round(msSinceWrite / 1000);
|
|
73
|
+
console.log(` [${workerId}] running (pid alive, last log ${secs}s ago)`);
|
|
74
|
+
}
|
|
75
|
+
else if (msSinceWrite > STALE_THRESHOLD_MS) {
|
|
54
76
|
const mins = Math.round(msSinceWrite / 60000);
|
|
55
|
-
console.log(` [${workerId}] STALE — no activity for ${mins}m
|
|
77
|
+
console.log(` [${workerId}] STALE — pid dead, no log activity for ${mins}m`);
|
|
56
78
|
}
|
|
57
79
|
else {
|
|
58
80
|
const secs = Math.round(msSinceWrite / 1000);
|
|
59
|
-
console.log(` [${workerId}]
|
|
81
|
+
console.log(` [${workerId}] stopped? — pid dead, last log ${secs}s ago`);
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
const allDone = logFiles.every(f => existsSync(join(workersDir, f.replace('.log', '-result.md'))));
|
package/dist/team/queue.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { readdir, readFile, rename, writeFile } from 'fs/promises';
|
|
1
|
+
import { readdir, readFile, rename, writeFile, stat, unlink } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { STATE_DIR } from '../state/session.js';
|
|
4
4
|
const TASKS_DIR = join(STATE_DIR, 'tasks');
|
|
5
|
-
const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
5
|
+
const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
6
6
|
// Recover tasks whose worker crashed before completing.
|
|
7
|
-
// Finds -claimed- files older than CLAIM_TTL_MS and
|
|
7
|
+
// Finds -claimed- files older than CLAIM_TTL_MS and re-queues them as -pending.
|
|
8
8
|
export async function recoverStaleClaims() {
|
|
9
9
|
let recovered = 0;
|
|
10
10
|
let files;
|
|
@@ -19,24 +19,21 @@ export async function recoverStaleClaims() {
|
|
|
19
19
|
for (const file of claimed) {
|
|
20
20
|
const filePath = join(TASKS_DIR, file);
|
|
21
21
|
try {
|
|
22
|
-
const { mtimeMs } = await
|
|
22
|
+
const { mtimeMs } = await stat(filePath);
|
|
23
23
|
if (now - mtimeMs < CLAIM_TTL_MS)
|
|
24
24
|
continue;
|
|
25
|
-
// Parse task id from filename: {id}-claimed-{workerId}.json
|
|
26
25
|
const taskId = file.split('-claimed-')[0];
|
|
27
26
|
if (!taskId)
|
|
28
27
|
continue;
|
|
29
|
-
const pendingPath = join(TASKS_DIR, `${taskId}-pending.json`);
|
|
30
|
-
// Re-read the file and reset status before writing back as pending
|
|
31
28
|
const task = JSON.parse(await readFile(filePath, 'utf8'));
|
|
32
29
|
task.status = 'pending';
|
|
33
30
|
delete task.workerId;
|
|
34
31
|
delete task.claimedAt;
|
|
32
|
+
const pendingPath = join(TASKS_DIR, `${taskId}-pending.json`);
|
|
33
|
+
// Write the reset task to the pending path, then remove the stale claimed file.
|
|
34
|
+
// (Do NOT rename claimed→pending: that would overwrite our fresh write with stale data.)
|
|
35
35
|
await writeFile(pendingPath, JSON.stringify(task, null, 2));
|
|
36
|
-
await
|
|
37
|
-
// If the write succeeded but rename fails (e.g. destination now exists from another
|
|
38
|
-
// recovery run), leave it — the pending file was already written
|
|
39
|
-
});
|
|
36
|
+
await unlink(filePath).catch(() => { });
|
|
40
37
|
recovered++;
|
|
41
38
|
}
|
|
42
39
|
catch {
|
|
@@ -61,11 +58,8 @@ export async function claimTask(workerId) {
|
|
|
61
58
|
const newFile = file.replace('-pending.json', `-claimed-${workerId}.json`);
|
|
62
59
|
const newPath = join(TASKS_DIR, newFile);
|
|
63
60
|
try {
|
|
64
|
-
// Prepare
|
|
65
|
-
|
|
66
|
-
// re-enters the pending pool rather than being stuck as claimed with stale data.
|
|
67
|
-
const raw = await readFile(oldPath, 'utf8');
|
|
68
|
-
const task = JSON.parse(raw);
|
|
61
|
+
// Prepare updated task object BEFORE the rename.
|
|
62
|
+
const task = JSON.parse(await readFile(oldPath, 'utf8'));
|
|
69
63
|
task.status = 'claimed';
|
|
70
64
|
task.workerId = workerId;
|
|
71
65
|
task.claimedAt = new Date().toISOString();
|
|
@@ -78,12 +72,20 @@ export async function claimTask(workerId) {
|
|
|
78
72
|
catch (writeErr) {
|
|
79
73
|
// Rename succeeded but write failed — roll back so the task isn't orphaned
|
|
80
74
|
await rename(newPath, oldPath).catch(() => { });
|
|
81
|
-
|
|
75
|
+
// Log genuine I/O errors (disk full, permissions) — these are not race conditions
|
|
76
|
+
process.stderr.write(`[agentloom] claimTask writeFile failed for ${file}: ${writeErr}\n`);
|
|
77
|
+
// Continue to next task rather than crashing — another task may succeed
|
|
78
|
+
continue;
|
|
82
79
|
}
|
|
83
80
|
return task;
|
|
84
81
|
}
|
|
85
|
-
catch {
|
|
86
|
-
//
|
|
82
|
+
catch (err) {
|
|
83
|
+
// ENOENT/EPERM = another worker claimed it first — expected, try next file
|
|
84
|
+
// Any other error is unexpected; log and skip
|
|
85
|
+
const code = err.code;
|
|
86
|
+
if (code !== 'ENOENT' && code !== 'EPERM' && code !== 'EACCES') {
|
|
87
|
+
process.stderr.write(`[agentloom] claimTask unexpected error for ${file}: ${err}\n`);
|
|
88
|
+
}
|
|
87
89
|
continue;
|
|
88
90
|
}
|
|
89
91
|
}
|
|
@@ -101,7 +103,7 @@ export async function completeTask(task, result) {
|
|
|
101
103
|
await rename(claimedFile, doneFile);
|
|
102
104
|
}
|
|
103
105
|
catch {
|
|
104
|
-
//
|
|
106
|
+
// Double-complete or missing claimed file — write directly to done path
|
|
105
107
|
await writeFile(doneFile, JSON.stringify(task, null, 2)).catch(() => { });
|
|
106
108
|
}
|
|
107
109
|
}
|