@chuckssmith/agentloom 0.5.0 → 0.7.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 +6 -0
- package/dist/commands/crew.js +82 -19
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +50 -0
- package/dist/team/queue.d.ts +1 -0
- package/dist/team/queue.js +105 -25
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { logs } from './commands/logs.js';
|
|
|
6
6
|
import { collect } from './commands/collect.js';
|
|
7
7
|
import { reset } from './commands/reset.js';
|
|
8
8
|
import { watch } from './commands/watch.js';
|
|
9
|
+
import { stop } from './commands/stop.js';
|
|
9
10
|
const [, , command, ...args] = process.argv;
|
|
10
11
|
const usage = `
|
|
11
12
|
agentloom (loom) — workflow layer for Claude Code
|
|
@@ -16,6 +17,8 @@ Usage:
|
|
|
16
17
|
loom crew 2:explore "<task>" Spawn typed workers (explore/plan/code-reviewer)
|
|
17
18
|
loom crew --dry-run [N] "<task>" Preview decomposed subtasks without launching
|
|
18
19
|
loom watch Live tail all worker logs (Ctrl+C to stop)
|
|
20
|
+
loom stop Kill all background workers (SIGTERM)
|
|
21
|
+
loom stop <workerId> Kill one worker
|
|
19
22
|
loom status Show active crew session + stale worker detection
|
|
20
23
|
loom logs Show worker output summary
|
|
21
24
|
loom logs <workerId> Show full log for a specific worker
|
|
@@ -62,6 +65,9 @@ switch (command) {
|
|
|
62
65
|
case 'collect':
|
|
63
66
|
await collect(args);
|
|
64
67
|
break;
|
|
68
|
+
case 'stop':
|
|
69
|
+
await stop(args);
|
|
70
|
+
break;
|
|
65
71
|
case 'reset':
|
|
66
72
|
await reset(args);
|
|
67
73
|
break;
|
package/dist/commands/crew.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync, spawn } from 'child_process';
|
|
1
|
+
import { execSync, spawn, spawnSync } from 'child_process';
|
|
2
2
|
import { writeFile, mkdir, open } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { parseWorkerSpec, initSession, writeContextSnapshot, decomposeTasks, } from '../team/orchestrator.js';
|
|
@@ -13,17 +13,18 @@ const hasTmux = () => {
|
|
|
13
13
|
}
|
|
14
14
|
};
|
|
15
15
|
const isWSL = () => process.platform === 'linux' && !!process.env.WSL_DISTRO_NAME;
|
|
16
|
-
//
|
|
16
|
+
// Roles that must NOT receive --dangerously-skip-permissions
|
|
17
|
+
const READ_ONLY_ROLES = new Set(['explore', 'plan', 'code-reviewer']);
|
|
17
18
|
const AGENT_ROLE = {
|
|
18
|
-
'explore': `Your role is EXPLORER. You are read-only
|
|
19
|
+
'explore': `Your role is EXPLORER. You are read-only — do not modify, create, or delete any files.
|
|
19
20
|
- Map out the relevant code, files, and structure
|
|
20
21
|
- Document what exists, how it connects, and what's notable
|
|
21
22
|
- Your output feeds the other workers — be thorough and specific`,
|
|
22
|
-
'plan': `Your role is PLANNER. You are read-only
|
|
23
|
+
'plan': `Your role is PLANNER. You are read-only — do not modify, create, or delete any files.
|
|
23
24
|
- Reason about the best approach to the subtask
|
|
24
25
|
- Identify risks, dependencies, and open questions
|
|
25
26
|
- Produce a concrete, ordered action plan other workers can execute`,
|
|
26
|
-
'code-reviewer': `Your role is CODE REVIEWER. You are read-only
|
|
27
|
+
'code-reviewer': `Your role is CODE REVIEWER. You are read-only — do not modify, create, or delete any files.
|
|
27
28
|
- Audit the relevant code for correctness, security, and quality
|
|
28
29
|
- Flag specific lines, patterns, or logic that are problematic
|
|
29
30
|
- Assign severity (critical / high / medium / low) to each finding`,
|
|
@@ -88,14 +89,15 @@ export async function crew(args) {
|
|
|
88
89
|
console.log('Run without --dry-run to launch workers.');
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
+
const useTmux = hasTmux() && !isWSL() && process.stdout.isTTY;
|
|
93
|
+
console.log(`Mode: ${useTmux ? 'tmux' : 'background processes'}\n`);
|
|
92
94
|
const session = await initSession(task, totalWorkers);
|
|
93
95
|
const contextPath = await writeContextSnapshot(slug, task);
|
|
94
96
|
const tasks = await decomposeTasks(task, specs);
|
|
95
97
|
console.log(`Session: ${session.id}`);
|
|
96
98
|
console.log(`Tasks: ${tasks.length} created`);
|
|
97
99
|
console.log(`Context: ${contextPath}\n`);
|
|
98
|
-
if (
|
|
100
|
+
if (useTmux) {
|
|
99
101
|
await launchTmux(session.id, specs, tasks, contextPath);
|
|
100
102
|
}
|
|
101
103
|
else {
|
|
@@ -103,8 +105,8 @@ export async function crew(args) {
|
|
|
103
105
|
}
|
|
104
106
|
console.log(`\nWorkers launched. Monitor with:`);
|
|
105
107
|
console.log(` loom status`);
|
|
106
|
-
console.log(` loom
|
|
107
|
-
console.log(` loom
|
|
108
|
+
console.log(` loom watch`);
|
|
109
|
+
console.log(` loom stop (kill all workers)`);
|
|
108
110
|
console.log(`State dir: ${STATE_DIR}/`);
|
|
109
111
|
}
|
|
110
112
|
async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
@@ -118,22 +120,50 @@ async function launchBackground(sessionId, specs, tasks, contextPath) {
|
|
|
118
120
|
workerIdx++;
|
|
119
121
|
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
|
|
120
122
|
const logFile = join(STATE_DIR, 'workers', `${workerId}.log`);
|
|
123
|
+
const pidFile = join(STATE_DIR, 'workers', `${workerId}.pid`);
|
|
121
124
|
await writeFile(join(STATE_DIR, 'workers', `${workerId}-prompt.md`), prompt);
|
|
125
|
+
// Build args declaratively — no positional splicing
|
|
126
|
+
const claudeArgs = [
|
|
127
|
+
'--print',
|
|
128
|
+
...(!READ_ONLY_ROLES.has(agentType) ? ['--dangerously-skip-permissions'] : []),
|
|
129
|
+
'-p',
|
|
130
|
+
prompt,
|
|
131
|
+
];
|
|
122
132
|
const log = await open(logFile, 'w');
|
|
123
|
-
const child = spawn('claude',
|
|
133
|
+
const child = spawn('claude', claudeArgs, {
|
|
124
134
|
detached: true,
|
|
125
135
|
stdio: ['ignore', log.fd, log.fd],
|
|
126
136
|
env: { ...process.env, AGENTLOOM_WORKER_ID: workerId, AGENTLOOM_SESSION: sessionId },
|
|
127
137
|
});
|
|
128
|
-
child.on('
|
|
138
|
+
child.on('error', async (err) => {
|
|
139
|
+
await writeFile(join(STATE_DIR, 'workers', `${workerId}-result.md`), `# Launch Error\n\nFailed to start worker: ${err.message}\n`).catch(() => { });
|
|
140
|
+
log.close().catch(() => { });
|
|
141
|
+
});
|
|
142
|
+
child.on('close', () => { log.close().catch(() => { }); });
|
|
143
|
+
if (child.pid != null) {
|
|
144
|
+
await writeFile(pidFile, String(child.pid));
|
|
145
|
+
}
|
|
129
146
|
child.unref();
|
|
130
|
-
console.log(` ✓ Worker ${workerId} (${agentType}) launched [pid ${child.pid}] → ${logFile}`);
|
|
147
|
+
console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched [pid ${child.pid ?? '?'}] → ${logFile}`);
|
|
131
148
|
}
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
135
152
|
const tmuxSession = `loom-${sessionId}`;
|
|
136
|
-
|
|
153
|
+
// Check for session name collision
|
|
154
|
+
const existing = spawnSync('tmux', ['has-session', '-t', tmuxSession], { stdio: 'ignore' });
|
|
155
|
+
if (existing.status === 0) {
|
|
156
|
+
console.error(`tmux session "${tmuxSession}" already exists. Run: tmux kill-session -t ${tmuxSession}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
execSync(`tmux new-session -d -s ${tmuxSession} -x 220 -y 50`);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
console.error(`Failed to create tmux session: ${err instanceof Error ? err.message : err}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
await mkdir(join(STATE_DIR, 'workers'), { recursive: true });
|
|
137
167
|
let workerIdx = 0;
|
|
138
168
|
for (const spec of specs) {
|
|
139
169
|
for (let i = 0; i < spec.count; i++) {
|
|
@@ -142,14 +172,47 @@ async function launchTmux(sessionId, specs, tasks, contextPath) {
|
|
|
142
172
|
const agentType = tasks[workerIdx]?.agentType ?? spec.agentType;
|
|
143
173
|
workerIdx++;
|
|
144
174
|
const prompt = buildWorkerPrompt(subtask, contextPath, sessionId, workerId, agentType);
|
|
175
|
+
const promptFile = join(STATE_DIR, 'workers', `${workerId}-prompt.md`);
|
|
176
|
+
const scriptFile = join(STATE_DIR, 'workers', `${workerId}-run.mjs`);
|
|
177
|
+
await writeFile(promptFile, prompt);
|
|
178
|
+
// Node.js runner — JSON.stringify safely encodes all values, no shell expansion possible
|
|
179
|
+
await writeFile(scriptFile, [
|
|
180
|
+
`import { readFileSync } from 'fs'`,
|
|
181
|
+
`import { spawnSync } from 'child_process'`,
|
|
182
|
+
`process.env.AGENTLOOM_WORKER_ID = ${JSON.stringify(workerId)}`,
|
|
183
|
+
`process.env.AGENTLOOM_SESSION = ${JSON.stringify(sessionId)}`,
|
|
184
|
+
`const prompt = readFileSync(${JSON.stringify(promptFile)}, 'utf8')`,
|
|
185
|
+
`const args = ['--print', ${!READ_ONLY_ROLES.has(agentType) ? `'--dangerously-skip-permissions', ` : ``}'${'-p'}', prompt]`,
|
|
186
|
+
`const r = spawnSync('claude', args, { stdio: 'inherit' })`,
|
|
187
|
+
`console.log('[worker done]')`,
|
|
188
|
+
`process.exit(r.status ?? 0)`,
|
|
189
|
+
].join('\n'));
|
|
145
190
|
if (workerIdx > 1) {
|
|
146
|
-
|
|
147
|
-
|
|
191
|
+
try {
|
|
192
|
+
execSync(`tmux split-window -h -t ${tmuxSession}`);
|
|
193
|
+
execSync(`tmux select-layout -t ${tmuxSession} tiled`);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Non-fatal — continue with remaining workers even if layout fails
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Use spawnSync (no shell) so the scriptFile path is passed as a literal argument.
|
|
200
|
+
// Escape single quotes in the path for the shell inside the tmux pane.
|
|
201
|
+
const shellSafePath = scriptFile.replace(/'/g, "'\\''");
|
|
202
|
+
const sendResult = spawnSync('tmux', ['send-keys', '-t', tmuxSession, `node '${shellSafePath}'`, 'Enter'], { stdio: 'ignore' });
|
|
203
|
+
if (sendResult.status !== 0) {
|
|
204
|
+
console.error(` ✗ Worker ${workerId}: failed to send tmux keys`);
|
|
205
|
+
continue;
|
|
148
206
|
}
|
|
149
|
-
|
|
150
|
-
execSync(`tmux send-keys -t ${tmuxSession} "${cmd}" Enter`);
|
|
151
|
-
console.log(` ✓ Worker ${workerId} (${agentType}) launched in tmux pane`);
|
|
207
|
+
console.log(` ✓ Worker ${workerId} (${agentType})${READ_ONLY_ROLES.has(agentType) ? ' [read-only]' : ''} launched in tmux pane`);
|
|
152
208
|
}
|
|
153
209
|
}
|
|
154
|
-
|
|
210
|
+
// Attach only in interactive terminals
|
|
211
|
+
if (process.stdout.isTTY) {
|
|
212
|
+
spawnSync('tmux', ['attach-session', '-t', tmuxSession], { stdio: 'inherit' });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
console.log(`\nTmux session: ${tmuxSession}`);
|
|
216
|
+
console.log(`Attach with: tmux attach-session -t ${tmuxSession}`);
|
|
217
|
+
}
|
|
155
218
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stop(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile, readdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { STATE_DIR } from '../state/session.js';
|
|
5
|
+
const WORKERS_DIR = join(STATE_DIR, 'workers');
|
|
6
|
+
export async function stop(args) {
|
|
7
|
+
if (!existsSync(WORKERS_DIR)) {
|
|
8
|
+
console.log('No active session.');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const targetId = args[0]; // optional: stop a single worker
|
|
12
|
+
const files = await readdir(WORKERS_DIR);
|
|
13
|
+
const pidFiles = files
|
|
14
|
+
.filter(f => f.endsWith('.pid'))
|
|
15
|
+
.filter(f => !targetId || f === `${targetId}.pid`)
|
|
16
|
+
.sort();
|
|
17
|
+
if (pidFiles.length === 0) {
|
|
18
|
+
console.log(targetId ? `No PID file found for ${targetId}.` : 'No worker PID files found.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let killed = 0;
|
|
22
|
+
let notFound = 0;
|
|
23
|
+
for (const pidFile of pidFiles) {
|
|
24
|
+
const workerId = pidFile.replace('.pid', '');
|
|
25
|
+
const pidPath = join(WORKERS_DIR, pidFile);
|
|
26
|
+
const pid = parseInt(await readFile(pidPath, 'utf8').catch(() => ''), 10);
|
|
27
|
+
if (!pid || isNaN(pid)) {
|
|
28
|
+
console.log(` [${workerId}] no valid PID`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 'SIGTERM');
|
|
33
|
+
killed++;
|
|
34
|
+
console.log(` [${workerId}] killed (pid ${pid})`);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err instanceof Error && 'code' in err && err.code === 'ESRCH') {
|
|
38
|
+
notFound++;
|
|
39
|
+
console.log(` [${workerId}] not running (pid ${pid} not found)`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(` [${workerId}] error: ${err instanceof Error ? err.message : err}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(`\n${killed} killed, ${notFound} already stopped.`);
|
|
47
|
+
if (killed > 0) {
|
|
48
|
+
console.log('State preserved. Run: loom reset --force to clear it.');
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/team/queue.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Task } from '../state/session.js';
|
|
2
|
+
export declare function recoverStaleClaims(): Promise<number>;
|
|
2
3
|
export declare function claimTask(workerId: string): Promise<Task | null>;
|
|
3
4
|
export declare function completeTask(task: Task, result: string): Promise<void>;
|
|
4
5
|
export declare function failTask(task: Task, error: string): Promise<void>;
|
package/dist/team/queue.js
CHANGED
|
@@ -1,62 +1,142 @@
|
|
|
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
|
-
import { existsSync } from 'fs';
|
|
4
3
|
import { STATE_DIR } from '../state/session.js';
|
|
5
4
|
const TASKS_DIR = join(STATE_DIR, 'tasks');
|
|
5
|
+
const CLAIM_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
6
|
+
// Recover tasks whose worker crashed before completing.
|
|
7
|
+
// Finds -claimed- files older than CLAIM_TTL_MS and re-queues them as -pending.
|
|
8
|
+
export async function recoverStaleClaims() {
|
|
9
|
+
let recovered = 0;
|
|
10
|
+
let files;
|
|
11
|
+
try {
|
|
12
|
+
files = await readdir(TASKS_DIR);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const claimed = files.filter(f => f.includes('-claimed-'));
|
|
19
|
+
for (const file of claimed) {
|
|
20
|
+
const filePath = join(TASKS_DIR, file);
|
|
21
|
+
try {
|
|
22
|
+
const { mtimeMs } = await stat(filePath);
|
|
23
|
+
if (now - mtimeMs < CLAIM_TTL_MS)
|
|
24
|
+
continue;
|
|
25
|
+
const taskId = file.split('-claimed-')[0];
|
|
26
|
+
if (!taskId)
|
|
27
|
+
continue;
|
|
28
|
+
const task = JSON.parse(await readFile(filePath, 'utf8'));
|
|
29
|
+
task.status = 'pending';
|
|
30
|
+
delete task.workerId;
|
|
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
|
+
await writeFile(pendingPath, JSON.stringify(task, null, 2));
|
|
36
|
+
await unlink(filePath).catch(() => { });
|
|
37
|
+
recovered++;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Skip files we can't read/stat — don't crash the recovery pass
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return recovered;
|
|
44
|
+
}
|
|
6
45
|
export async function claimTask(workerId) {
|
|
7
|
-
|
|
46
|
+
// Self-heal: recover any stale claimed tasks before scanning for pending ones
|
|
47
|
+
await recoverStaleClaims();
|
|
48
|
+
let files;
|
|
49
|
+
try {
|
|
50
|
+
files = await readdir(TASKS_DIR);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
8
53
|
return null;
|
|
9
|
-
|
|
54
|
+
}
|
|
10
55
|
const pending = files.filter(f => f.endsWith('-pending.json'));
|
|
11
56
|
for (const file of pending) {
|
|
12
57
|
const oldPath = join(TASKS_DIR, file);
|
|
13
|
-
const task = JSON.parse(await readFile(oldPath, 'utf8'));
|
|
14
58
|
const newFile = file.replace('-pending.json', `-claimed-${workerId}.json`);
|
|
15
59
|
const newPath = join(TASKS_DIR, newFile);
|
|
16
|
-
// Atomic rename = claim. First writer wins.
|
|
17
60
|
try {
|
|
18
|
-
|
|
61
|
+
// Prepare updated task object BEFORE the rename.
|
|
62
|
+
const task = JSON.parse(await readFile(oldPath, 'utf8'));
|
|
19
63
|
task.status = 'claimed';
|
|
20
64
|
task.workerId = workerId;
|
|
21
65
|
task.claimedAt = new Date().toISOString();
|
|
22
|
-
|
|
66
|
+
const updated = JSON.stringify(task, null, 2);
|
|
67
|
+
// Atomic claim — first writer wins
|
|
68
|
+
await rename(oldPath, newPath);
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(newPath, updated);
|
|
71
|
+
}
|
|
72
|
+
catch (writeErr) {
|
|
73
|
+
// Rename succeeded but write failed — roll back so the task isn't orphaned
|
|
74
|
+
await rename(newPath, oldPath).catch(() => { });
|
|
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;
|
|
79
|
+
}
|
|
23
80
|
return task;
|
|
24
81
|
}
|
|
25
|
-
catch {
|
|
26
|
-
//
|
|
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
|
+
}
|
|
27
89
|
continue;
|
|
28
90
|
}
|
|
29
91
|
}
|
|
30
92
|
return null;
|
|
31
93
|
}
|
|
32
94
|
export async function completeTask(task, result) {
|
|
33
|
-
const
|
|
34
|
-
const
|
|
95
|
+
const workerId = task.workerId ?? 'unknown';
|
|
96
|
+
const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${workerId}.json`);
|
|
97
|
+
const doneFile = join(TASKS_DIR, `${task.id}-done-${workerId}.json`);
|
|
35
98
|
task.status = 'done';
|
|
36
99
|
task.result = result;
|
|
37
100
|
task.completedAt = new Date().toISOString();
|
|
38
|
-
|
|
39
|
-
|
|
101
|
+
try {
|
|
102
|
+
await writeFile(claimedFile, JSON.stringify(task, null, 2));
|
|
103
|
+
await rename(claimedFile, doneFile);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Double-complete or missing claimed file — write directly to done path
|
|
107
|
+
await writeFile(doneFile, JSON.stringify(task, null, 2)).catch(() => { });
|
|
108
|
+
}
|
|
40
109
|
}
|
|
41
110
|
export async function failTask(task, error) {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
111
|
+
const workerId = task.workerId ?? 'unknown';
|
|
112
|
+
const claimedFile = join(TASKS_DIR, `${task.id}-claimed-${workerId}.json`);
|
|
113
|
+
const failedFile = join(TASKS_DIR, `${task.id}-failed-${workerId}.json`);
|
|
44
114
|
task.status = 'failed';
|
|
45
115
|
task.error = error;
|
|
46
116
|
task.completedAt = new Date().toISOString();
|
|
47
|
-
|
|
48
|
-
|
|
117
|
+
try {
|
|
118
|
+
await writeFile(claimedFile, JSON.stringify(task, null, 2));
|
|
119
|
+
await rename(claimedFile, failedFile);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
await writeFile(failedFile, JSON.stringify(task, null, 2)).catch(() => { });
|
|
123
|
+
}
|
|
49
124
|
}
|
|
50
125
|
export async function pendingCount() {
|
|
51
|
-
|
|
126
|
+
try {
|
|
127
|
+
const files = await readdir(TASKS_DIR);
|
|
128
|
+
return files.filter(f => f.endsWith('-pending.json')).length;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
52
131
|
return 0;
|
|
53
|
-
|
|
54
|
-
return files.filter(f => f.includes('-pending.json')).length;
|
|
132
|
+
}
|
|
55
133
|
}
|
|
56
134
|
export async function allDone() {
|
|
57
|
-
|
|
135
|
+
try {
|
|
136
|
+
const files = await readdir(TASKS_DIR);
|
|
137
|
+
return !files.some(f => f.endsWith('-pending.json') || f.includes('-claimed-'));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
58
140
|
return true;
|
|
59
|
-
|
|
60
|
-
const active = files.filter(f => f.includes('-pending.json') || f.includes('-claimed-'));
|
|
61
|
-
return active.length === 0;
|
|
141
|
+
}
|
|
62
142
|
}
|