@detergent-software/conductor 0.0.0-stage → 1.0.0-dev.2
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/CHANGELOG.md +31 -0
- package/README.md +131 -2
- package/build/claude.js +102 -0
- package/build/claude.js.map +1 -0
- package/build/cli.js +450 -0
- package/build/cli.js.map +1 -0
- package/build/client.js +64 -0
- package/build/client.js.map +1 -0
- package/build/conditions.js +198 -0
- package/build/conditions.js.map +1 -0
- package/build/config.js +74 -0
- package/build/config.js.map +1 -0
- package/build/daemon.js +127 -0
- package/build/daemon.js.map +1 -0
- package/build/errors.js +17 -0
- package/build/errors.js.map +1 -0
- package/build/jsonc.js +24 -0
- package/build/jsonc.js.map +1 -0
- package/build/lock.js +64 -0
- package/build/lock.js.map +1 -0
- package/build/log.js +43 -0
- package/build/log.js.map +1 -0
- package/build/manager.js +614 -0
- package/build/manager.js.map +1 -0
- package/build/paths.js +16 -0
- package/build/paths.js.map +1 -0
- package/build/profile.js +74 -0
- package/build/profile.js.map +1 -0
- package/build/registry.js +33 -0
- package/build/registry.js.map +1 -0
- package/build/resolve.js +28 -0
- package/build/resolve.js.map +1 -0
- package/build/run.js +52 -0
- package/build/run.js.map +1 -0
- package/build/server.js +227 -0
- package/build/server.js.map +1 -0
- package/build/spend.js +134 -0
- package/build/spend.js.map +1 -0
- package/build/startup.js +78 -0
- package/build/startup.js.map +1 -0
- package/build/store.js +528 -0
- package/build/store.js.map +1 -0
- package/build/tab.js +26 -0
- package/build/tab.js.map +1 -0
- package/build/token.js +58 -0
- package/build/token.js.map +1 -0
- package/build/upgrade.js +93 -0
- package/build/upgrade.js.map +1 -0
- package/build/wait.js +149 -0
- package/build/wait.js.map +1 -0
- package/build/watchers/ado.js +167 -0
- package/build/watchers/ado.js.map +1 -0
- package/build/watchers/github.js +82 -0
- package/build/watchers/github.js.map +1 -0
- package/build/watchers/health.js +42 -0
- package/build/watchers/health.js.map +1 -0
- package/build/watchers/index.js +226 -0
- package/build/watchers/index.js.map +1 -0
- package/build/watchers/linear-key.js +56 -0
- package/build/watchers/linear-key.js.map +1 -0
- package/build/watchers/linear.js +218 -0
- package/build/watchers/linear.js.map +1 -0
- package/build/watchers/npm.js +35 -0
- package/build/watchers/npm.js.map +1 -0
- package/build/watchers/source.js +94 -0
- package/build/watchers/source.js.map +1 -0
- package/build/worktrees.js +143 -0
- package/build/worktrees.js.map +1 -0
- package/package.json +56 -5
package/build/manager.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isLive, isStarting, permissionPrompt, turnEnded } from "./claude.js";
|
|
5
|
+
import { formatCondition, parseCondition, uncheckable } from "./conditions.js";
|
|
6
|
+
import { ConductorError } from "./errors.js";
|
|
7
|
+
import { processAlive as defaultProcessAlive } from "./lock.js";
|
|
8
|
+
import { resolveOpenWait } from "./resolve.js";
|
|
9
|
+
import { findTranscript, localDay, projectSlug, readUsage, transcriptFiles } from "./spend.js";
|
|
10
|
+
import { ACTIVE_STATUSES, waitSchema, workerSchema, } from "./store.js";
|
|
11
|
+
// A worker as GET /fleet shows it.
|
|
12
|
+
export const fleetWorkerSchema = workerSchema.extend({
|
|
13
|
+
// launching, starting, working, idle, waiting on <x>, parked, stopped, no session
|
|
14
|
+
activity: z.string(),
|
|
15
|
+
spend: z.object({ today: z.number(), total: z.number() }),
|
|
16
|
+
waits: z.array(waitSchema),
|
|
17
|
+
});
|
|
18
|
+
export const PROBE_PREFIX = 'probe-';
|
|
19
|
+
export const PROBE_PROMPT = 'This is a conductor probe session started by a test run. It works no issue: reply "probe ready" and wait.';
|
|
20
|
+
// conduct-atk starts workers this way; a resume keeps both (ATK-309's open check).
|
|
21
|
+
export const PERMISSION_MODE = 'auto';
|
|
22
|
+
export const DISALLOWED_TOOLS = ['EnterPlanMode', 'ExitPlanMode'];
|
|
23
|
+
const ITEM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
24
|
+
const STOP_WAIT_MS = 30_000;
|
|
25
|
+
// A park that failed (the process outlived `claude stop`) is tried again this much later.
|
|
26
|
+
const PARK_RETRY_MS = 5 * 60_000;
|
|
27
|
+
const TRANSCRIPT_SCAN_MS = 60_000;
|
|
28
|
+
// The statuses a sweep looks at; a launching worker belongs to its launch.
|
|
29
|
+
const SWEPT = ['running', 'parked', 'stopped-unexpectedly'];
|
|
30
|
+
// The resume text: facts only, as a user turn (design note, section 4), such as
|
|
31
|
+
// `[conductor] event: time 2026-10-10T22:05:00.000Z reached 22:05`. The worker re-reads the source.
|
|
32
|
+
export function eventText(waits) {
|
|
33
|
+
const facts = waits.map((w) => `${w.resolution ?? w.condition} ${clock(new Date(w.resolvedAt ?? w.createdAt))}`);
|
|
34
|
+
return `[conductor] event: ${facts.join('; ')}`;
|
|
35
|
+
}
|
|
36
|
+
export function sessionManager(deps) {
|
|
37
|
+
const { claude, config, log, openTab, store, worktrees } = deps;
|
|
38
|
+
const now = deps.now ?? (() => new Date());
|
|
39
|
+
const alive = deps.processAlive ?? defaultProcessAlive;
|
|
40
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
41
|
+
// Workers with an operation in flight (launch, park, resume, retire, attach): a sweep leaves them alone.
|
|
42
|
+
const busy = new Set();
|
|
43
|
+
const tasks = new Set();
|
|
44
|
+
const stalls = new Map();
|
|
45
|
+
const transcripts = new Map();
|
|
46
|
+
const transcriptScans = new Map();
|
|
47
|
+
let sessions = [];
|
|
48
|
+
// Until the first sweep, nothing is known about any session.
|
|
49
|
+
let listed = false;
|
|
50
|
+
let sweeping = null;
|
|
51
|
+
let timer;
|
|
52
|
+
const event = (worker, kind, data = {}) => {
|
|
53
|
+
const entry = { data: { name: worker.name, worker: worker.id, ...data }, kind, project: worker.project, subject: worker.item };
|
|
54
|
+
store.appendEvent(entry);
|
|
55
|
+
};
|
|
56
|
+
const update = (worker, patch) => store.updateWorker(worker.id, patch);
|
|
57
|
+
const track = (task) => {
|
|
58
|
+
tasks.add(task);
|
|
59
|
+
void task.finally(() => tasks.delete(task)).catch(() => { });
|
|
60
|
+
return task;
|
|
61
|
+
};
|
|
62
|
+
const project = (name) => {
|
|
63
|
+
const projects = store.listProjects();
|
|
64
|
+
const found = name === undefined && projects.length === 1 ? projects[0] : projects.find((p) => p.name === name);
|
|
65
|
+
if (!found) {
|
|
66
|
+
throw new ConductorError(name === undefined ? 'name the project' : `no project "${name}"`, [
|
|
67
|
+
`registered: ${projects.map((p) => p.name).join(', ') || 'none'}`,
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
if (!found.profile)
|
|
71
|
+
throw new ConductorError(`project "${found.name}" has a problem: ${found.problem ?? 'no profile'}`);
|
|
72
|
+
if (!found.enabled)
|
|
73
|
+
throw new ConductorError(`project "${found.name}" is disabled`);
|
|
74
|
+
return { ...found, profile: found.profile };
|
|
75
|
+
};
|
|
76
|
+
// Where a worker's commands run: its project's main checkout, where conduct-atk starts them too.
|
|
77
|
+
const rootOf = (worker) => store.listProjects().find((p) => p.name === worker.project)?.root ?? worker.worktree ?? process.cwd();
|
|
78
|
+
const sessionOf = (worker) => sessions.find((s) => (worker.sessionId !== null && s.sessionId === worker.sessionId) || (worker.handle !== null && s.id === worker.handle));
|
|
79
|
+
const minutesSince = (iso) => (now().getTime() - Date.parse(iso)) / 60_000;
|
|
80
|
+
// `claude stop`, then wait until its process has exited: the listing can drop the pid first.
|
|
81
|
+
const stopAndWait = async (handle, pid) => {
|
|
82
|
+
await claude.stop(handle);
|
|
83
|
+
if (pid === undefined)
|
|
84
|
+
return true;
|
|
85
|
+
for (let waited = 0; alive(pid); waited += 250) {
|
|
86
|
+
if (waited >= STOP_WAIT_MS)
|
|
87
|
+
return false;
|
|
88
|
+
await sleep(250);
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
const step = async (steps, name, action) => {
|
|
93
|
+
try {
|
|
94
|
+
await action();
|
|
95
|
+
steps.push({ ok: true, step: name });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
steps.push({ error: messageOf(error), ok: false, step: name });
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// ---------- launch ----------
|
|
104
|
+
const launchWorker = async (worker, root, profile, prompt) => {
|
|
105
|
+
let current = worker;
|
|
106
|
+
let stage = 'create worktree';
|
|
107
|
+
try {
|
|
108
|
+
const worktree = await worktrees.create(root, worker.name);
|
|
109
|
+
current = update(current, { branch: await worktrees.branchOf(worktree), worktree });
|
|
110
|
+
stage = 'check skills';
|
|
111
|
+
const problem = await worktrees.checkSkills(worktree, [profile.skills.work, profile.skills.verify]);
|
|
112
|
+
if (problem)
|
|
113
|
+
throw new ConductorError(problem);
|
|
114
|
+
stage = 'start session';
|
|
115
|
+
const handle = await claude.launch({
|
|
116
|
+
cwd: root,
|
|
117
|
+
disallowedTools: DISALLOWED_TOOLS,
|
|
118
|
+
name: worker.name,
|
|
119
|
+
permissionMode: PERMISSION_MODE,
|
|
120
|
+
prompt,
|
|
121
|
+
worktree: worker.name,
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
sessions = await claude.agents();
|
|
125
|
+
listed = true;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// The next sweep lists it; until then the worker has no session id.
|
|
129
|
+
}
|
|
130
|
+
const session = sessions.find((s) => s.id === handle);
|
|
131
|
+
current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
|
|
132
|
+
event(current, 'worker.launched', { handle, sessionId: current.sessionId, worktree });
|
|
133
|
+
if (profile.workers.tab) {
|
|
134
|
+
const tab = await openTab({
|
|
135
|
+
cwd: root,
|
|
136
|
+
handle,
|
|
137
|
+
title: worker.name,
|
|
138
|
+
window: `${worker.probe ? 'probe' : 'Conductor'} · ${worker.project}`,
|
|
139
|
+
});
|
|
140
|
+
if (tab)
|
|
141
|
+
event(current, 'worker.tab-failed', { error: tab });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
// A worktree with no session would read as in flight; one with work in it is kept.
|
|
146
|
+
const cleanup = current.worktree ? await removeIfClean(root, current.worktree) : {};
|
|
147
|
+
current = update(current, { status: 'failed' });
|
|
148
|
+
event(current, 'worker.launch-failed', { error: messageOf(error), step: stage, ...cleanup });
|
|
149
|
+
log.log('warn', 'launch failed', { error: messageOf(error), step: stage, worker: worker.id });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const removeIfClean = async (root, worktree) => {
|
|
153
|
+
try {
|
|
154
|
+
const work = await worktrees.unfinishedWork(worktree);
|
|
155
|
+
if (work)
|
|
156
|
+
return { kept: worktree, ...work };
|
|
157
|
+
await worktrees.remove(root, worktree);
|
|
158
|
+
return { removed: worktree };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return { removeError: messageOf(error) };
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// ---------- the sweep ----------
|
|
165
|
+
// The clock: the sweep is its timer. The other kinds are the watchers'.
|
|
166
|
+
const resolveTimeWaits = () => {
|
|
167
|
+
for (const wait of store.openWaits()) {
|
|
168
|
+
const at = timeOf(wait.condition);
|
|
169
|
+
if (at === null || now().getTime() < at.getTime())
|
|
170
|
+
continue;
|
|
171
|
+
resolveOpenWait(store, wait, `time ${at.toISOString()} reached`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
// A park that fails is reported once per spell and tried again after PARK_RETRY_MS, so one
|
|
175
|
+
// stuck process doesn't hold up every sweep for STOP_WAIT_MS.
|
|
176
|
+
const park = async (worker, session, waits) => {
|
|
177
|
+
if (worker.handle === null)
|
|
178
|
+
return;
|
|
179
|
+
const state = stalls.get(worker.id) ?? {};
|
|
180
|
+
if (state.parkFailed && now().getTime() < state.parkFailed.retryAt)
|
|
181
|
+
return;
|
|
182
|
+
let error = null;
|
|
183
|
+
try {
|
|
184
|
+
if (!(await stopAndWait(worker.handle, session.pid))) {
|
|
185
|
+
error = `pid ${session.pid} still runs ${STOP_WAIT_MS / 1000} s after claude stop`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (stopError) {
|
|
189
|
+
error = messageOf(stopError);
|
|
190
|
+
}
|
|
191
|
+
if (error !== null) {
|
|
192
|
+
if (!state.parkFailed)
|
|
193
|
+
event(worker, 'worker.park-failed', { error, retryMinutes: PARK_RETRY_MS / 60_000 });
|
|
194
|
+
state.parkFailed = { retryAt: now().getTime() + PARK_RETRY_MS };
|
|
195
|
+
stalls.set(worker.id, state);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const parked = update(worker, { status: 'parked', stoppedAt: now().toISOString() });
|
|
199
|
+
stalls.delete(worker.id);
|
|
200
|
+
event(parked, 'worker.parked', { waits: waits.map((w) => w.condition) });
|
|
201
|
+
};
|
|
202
|
+
const resume = async (worker, resolved, session) => {
|
|
203
|
+
if (worker.sessionId === null || worker.stoppedAt === null)
|
|
204
|
+
return;
|
|
205
|
+
// Let the stop settle: a resume straight after it has started a copy instead.
|
|
206
|
+
if (now().getTime() - Date.parse(worker.stoppedAt) < config.settleSeconds * 1000)
|
|
207
|
+
return;
|
|
208
|
+
const text = eventText(resolved);
|
|
209
|
+
const { copy } = await claude.resume({ cwd: session?.cwd ?? worker.worktree ?? rootOf(worker), sessionId: worker.sessionId, text });
|
|
210
|
+
if (copy) {
|
|
211
|
+
const steps = [];
|
|
212
|
+
await step(steps, 'stop copy', () => claude.stop(copy));
|
|
213
|
+
await step(steps, 'rm copy', () => claude.rm(copy));
|
|
214
|
+
const again = update(worker, { stoppedAt: now().toISOString() });
|
|
215
|
+
event(again, 'worker.resume-failed', { copy, error: 'the session still ran, so claude started a copy; retried on a later sweep', steps });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
store.deliverWaits(resolved.map((w) => w.id));
|
|
219
|
+
const running = update(worker, { attachedAt: null, status: 'running', stoppedAt: null });
|
|
220
|
+
event(running, 'worker.resumed', { text, waits: resolved.map((w) => w.id) });
|
|
221
|
+
};
|
|
222
|
+
const checkStalls = (worker, session, waiting) => {
|
|
223
|
+
const state = stalls.get(worker.id) ?? {};
|
|
224
|
+
const prompt = permissionPrompt(session);
|
|
225
|
+
if (prompt === null)
|
|
226
|
+
state.permission = undefined;
|
|
227
|
+
else {
|
|
228
|
+
if (state.permission?.waitingFor !== prompt)
|
|
229
|
+
state.permission = { reported: false, since: now().toISOString(), waitingFor: prompt };
|
|
230
|
+
const minutes = minutesSince(state.permission.since);
|
|
231
|
+
if (!state.permission.reported && minutes >= config.permissionMinutes) {
|
|
232
|
+
state.permission.reported = true;
|
|
233
|
+
event(worker, 'worker.stalled', { kind: 'permission', minutes: Math.floor(minutes), since: state.permission.since, waitingFor: prompt });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// An attached worker is Jason's while he has it open.
|
|
237
|
+
if (!turnEnded(session) || waiting || worker.attachedAt !== null)
|
|
238
|
+
state.idle = undefined;
|
|
239
|
+
else {
|
|
240
|
+
state.idle ??= { reported: false, since: now().toISOString() };
|
|
241
|
+
const minutes = minutesSince(state.idle.since);
|
|
242
|
+
if (!state.idle.reported && minutes >= config.idleMinutes) {
|
|
243
|
+
state.idle.reported = true;
|
|
244
|
+
event(worker, 'worker.stalled', { kind: 'idle', minutes: Math.floor(minutes), since: state.idle.since });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
stalls.set(worker.id, state);
|
|
248
|
+
};
|
|
249
|
+
// The daemon can't see the tab, so attached means "Jason may be in it" only while the session is
|
|
250
|
+
// in use. Once its turn has been over `idleMinutes`, the worker is the daemon's again, and its
|
|
251
|
+
// waits park and resume it as usual.
|
|
252
|
+
const releaseAttached = (worker, session) => {
|
|
253
|
+
const state = stalls.get(worker.id) ?? {};
|
|
254
|
+
stalls.set(worker.id, state);
|
|
255
|
+
if (!turnEnded(session)) {
|
|
256
|
+
state.attachedIdleSince = undefined;
|
|
257
|
+
return worker;
|
|
258
|
+
}
|
|
259
|
+
state.attachedIdleSince ??= now().toISOString();
|
|
260
|
+
const minutes = minutesSince(state.attachedIdleSince);
|
|
261
|
+
if (minutes < config.idleMinutes)
|
|
262
|
+
return worker;
|
|
263
|
+
state.attachedIdleSince = undefined;
|
|
264
|
+
const released = update(worker, { attachedAt: null });
|
|
265
|
+
event(released, 'worker.detached', { idleMinutes: Math.floor(minutes) });
|
|
266
|
+
return released;
|
|
267
|
+
};
|
|
268
|
+
const sweepWorker = async (worker) => {
|
|
269
|
+
let current = worker;
|
|
270
|
+
const session = sessionOf(current);
|
|
271
|
+
// The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
|
|
272
|
+
if (session && (current.sessionId !== session.sessionId || current.handle !== session.id)) {
|
|
273
|
+
current = update(current, { handle: session.id, sessionId: session.sessionId });
|
|
274
|
+
}
|
|
275
|
+
const waits = store.waitsOf(current.id);
|
|
276
|
+
const resolved = waits.filter((w) => w.resolvedAt !== null);
|
|
277
|
+
// A listed pid can outlive its process for a moment after a stop.
|
|
278
|
+
const live = session !== undefined && isLive(session) && (session.pid === undefined || alive(session.pid));
|
|
279
|
+
if (current.status === 'running') {
|
|
280
|
+
if (!live) {
|
|
281
|
+
stalls.delete(current.id);
|
|
282
|
+
const gone = update(current, { status: 'stopped-unexpectedly', stoppedAt: now().toISOString() });
|
|
283
|
+
event(gone, 'worker.stopped-unexpectedly', { session: session ? { id: session.id, state: session.state } : null });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (current.attachedAt !== null)
|
|
287
|
+
current = releaseAttached(current, session);
|
|
288
|
+
checkStalls(current, session, waits.length > 0);
|
|
289
|
+
// A wait that resolved before the turn ended parks the worker too; the resume carries it.
|
|
290
|
+
if (waits.length > 0 && turnEnded(session) && current.attachedAt === null)
|
|
291
|
+
await park(current, session, waits);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Parked, or stopped unexpectedly, and running again: someone woke it outside the daemon.
|
|
295
|
+
if (live && !isStarting(session)) {
|
|
296
|
+
const running = update(current, { status: 'running', stoppedAt: null });
|
|
297
|
+
event(running, 'worker.running-again', { was: current.status });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (current.status === 'parked' && resolved.length > 0)
|
|
301
|
+
await resume(current, resolved, session);
|
|
302
|
+
};
|
|
303
|
+
// The transcript is under the slug of the session's cwd, which is usually its worktree. Failing
|
|
304
|
+
// that, every project directory is scanned, at most once a minute per worker.
|
|
305
|
+
const transcriptOf = (worker, sessionId) => {
|
|
306
|
+
const known = transcripts.get(worker.id);
|
|
307
|
+
if (known !== undefined)
|
|
308
|
+
return known;
|
|
309
|
+
const cwd = sessionOf(worker)?.cwd ?? worker.worktree;
|
|
310
|
+
const direct = cwd ? join(deps.projectsDir, projectSlug(cwd), `${sessionId}.jsonl`) : null;
|
|
311
|
+
let found = direct !== null && existsSync(direct) ? direct : null;
|
|
312
|
+
if (found === null) {
|
|
313
|
+
const scannedAt = transcriptScans.get(worker.id);
|
|
314
|
+
if (scannedAt !== undefined && now().getTime() - scannedAt < TRANSCRIPT_SCAN_MS)
|
|
315
|
+
return null;
|
|
316
|
+
transcriptScans.set(worker.id, now().getTime());
|
|
317
|
+
found = findTranscript(deps.projectsDir, sessionId);
|
|
318
|
+
}
|
|
319
|
+
if (found !== null)
|
|
320
|
+
transcripts.set(worker.id, found);
|
|
321
|
+
return found;
|
|
322
|
+
};
|
|
323
|
+
const readSpend = (worker) => {
|
|
324
|
+
if (worker.sessionId === null)
|
|
325
|
+
return;
|
|
326
|
+
const main = transcriptOf(worker, worker.sessionId);
|
|
327
|
+
if (main === null)
|
|
328
|
+
return;
|
|
329
|
+
for (const file of transcriptFiles(main)) {
|
|
330
|
+
const { byDay, cursor } = readUsage(file, store.transcriptCursor(file) ?? { bytes: 0, recentIds: [] });
|
|
331
|
+
// The spend and the cursor move together, so a crash between them can't count usage twice.
|
|
332
|
+
store.transaction(() => {
|
|
333
|
+
for (const [day, usage] of byDay)
|
|
334
|
+
store.addSpend({ day, item: worker.item, project: worker.project, usage, worker: worker.id });
|
|
335
|
+
store.saveTranscript(file, worker.id, cursor);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
// Never rejects: it runs on a timer, and an unhandled rejection would end the daemon.
|
|
340
|
+
const sweepOnce = async () => {
|
|
341
|
+
const listedAt = now().toISOString();
|
|
342
|
+
try {
|
|
343
|
+
sessions = await claude.agents();
|
|
344
|
+
listed = true;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
log.log('warn', 'claude agents failed; sweep skipped', { error: messageOf(error) });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
resolveTimeWaits();
|
|
352
|
+
for (const { id } of store.listWorkers(SWEPT)) {
|
|
353
|
+
if (busy.has(id))
|
|
354
|
+
continue;
|
|
355
|
+
busy.add(id);
|
|
356
|
+
try {
|
|
357
|
+
// Read again: a park can take seconds, and meanwhile a retire or an attach may have
|
|
358
|
+
// changed this worker. One changed after the listing waits for the next sweep, whose
|
|
359
|
+
// listing shows its session as it is now.
|
|
360
|
+
const worker = store.getWorker(id);
|
|
361
|
+
if (!worker || !SWEPT.includes(worker.status) || worker.updatedAt > listedAt)
|
|
362
|
+
continue;
|
|
363
|
+
await sweepWorker(worker);
|
|
364
|
+
readSpend(store.getWorker(id) ?? worker);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
log.log('error', 'sweep failed for a worker', { error: messageOf(error), worker: id });
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
busy.delete(id);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
log.log('error', 'sweep failed', { error: messageOf(error) });
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
// A launch the daemon was stopped in can have made its worktree and even started its session.
|
|
379
|
+
// A session by the worker's name is adopted; otherwise the launch failed, and a clean worktree
|
|
380
|
+
// it left is removed.
|
|
381
|
+
const recoverLaunches = async (workers) => {
|
|
382
|
+
if (workers.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
const listing = await claude.agents().catch(() => null);
|
|
385
|
+
for (const worker of workers) {
|
|
386
|
+
try {
|
|
387
|
+
const session = listing?.find((s) => s.name === worker.name && isLive(s));
|
|
388
|
+
if (session) {
|
|
389
|
+
event(update(worker, { handle: session.id, sessionId: session.sessionId, status: 'running' }), 'worker.launch-recovered', {
|
|
390
|
+
handle: session.id,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const root = rootOf(worker);
|
|
395
|
+
const worktree = worker.worktree ?? join(root, '.claude', 'worktrees', worker.name);
|
|
396
|
+
const cleanup = listing !== null && existsSync(worktree) ? await removeIfClean(root, worktree) : {};
|
|
397
|
+
store.closeWaits(worker.id, 'launch failed');
|
|
398
|
+
event(update(worker, { status: 'failed' }), 'worker.launch-failed', {
|
|
399
|
+
error: 'the daemon stopped during the launch',
|
|
400
|
+
step: 'interrupted',
|
|
401
|
+
...cleanup,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
log.log('error', 'could not recover an interrupted launch', { error: messageOf(error), worker: worker.id });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
// ---------- the manager ----------
|
|
410
|
+
const activeWorker = (id) => {
|
|
411
|
+
const worker = store.getWorker(id);
|
|
412
|
+
if (!worker || !ACTIVE_STATUSES.includes(worker.status))
|
|
413
|
+
throw new ConductorError(`no worker ${id} in flight`);
|
|
414
|
+
return worker;
|
|
415
|
+
};
|
|
416
|
+
const manager = {
|
|
417
|
+
addWait: (workerId, condition) => {
|
|
418
|
+
const worker = activeWorker(workerId);
|
|
419
|
+
if (worker.status === 'launching')
|
|
420
|
+
throw new ConductorError(`${worker.name} is still launching`);
|
|
421
|
+
const parsed = parseCondition(condition);
|
|
422
|
+
const problem = uncheckable(parsed, store.listProjects().find((p) => p.name === worker.project)?.profile ?? null);
|
|
423
|
+
if (problem) {
|
|
424
|
+
throw new ConductorError(problem, [`the conductor reads ${worker.project}'s profile as registered; after changing it, run \`conduct project add\` again`]);
|
|
425
|
+
}
|
|
426
|
+
// Stored in its normal full form, so the watchers and `conduct status` read one spelling.
|
|
427
|
+
const wait = store.addWait(worker.id, formatCondition(parsed));
|
|
428
|
+
// A new wait hands an attached worker back to the daemon: it parks when its turn ends.
|
|
429
|
+
const current = worker.attachedAt === null ? worker : update(worker, { attachedAt: null });
|
|
430
|
+
event(current, 'wait.added', { condition: wait.condition, wait: wait.id });
|
|
431
|
+
return wait;
|
|
432
|
+
},
|
|
433
|
+
attach: async (workerId) => {
|
|
434
|
+
const worker = activeWorker(workerId);
|
|
435
|
+
if (busy.has(worker.id) || worker.status === 'launching')
|
|
436
|
+
throw new ConductorError(`${worker.name} is busy; try again shortly`);
|
|
437
|
+
const { handle, sessionId } = worker;
|
|
438
|
+
if (handle === null || sessionId === null)
|
|
439
|
+
throw new ConductorError(`${worker.name} has no session`);
|
|
440
|
+
busy.add(worker.id);
|
|
441
|
+
try {
|
|
442
|
+
let current = worker;
|
|
443
|
+
const resumed = current.status !== 'running';
|
|
444
|
+
if (resumed) {
|
|
445
|
+
const settle = current.stoppedAt === null ? 0 : config.settleSeconds * 1000 - (now().getTime() - Date.parse(current.stoppedAt));
|
|
446
|
+
if (settle > 0)
|
|
447
|
+
await sleep(settle);
|
|
448
|
+
// No event text: the session wakes idle and keeps running when the tab closes.
|
|
449
|
+
const { copy } = await claude.resume({ cwd: sessionOf(current)?.cwd ?? current.worktree ?? rootOf(current), sessionId, text: null });
|
|
450
|
+
if (copy) {
|
|
451
|
+
await claude.stop(copy).catch(() => { });
|
|
452
|
+
await claude.rm(copy).catch(() => { });
|
|
453
|
+
throw new ConductorError(`${current.name} still ran, so claude started a copy (removed); try again shortly`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
|
|
457
|
+
const tab = await openTab({
|
|
458
|
+
cwd: rootOf(current),
|
|
459
|
+
handle,
|
|
460
|
+
title: current.name,
|
|
461
|
+
window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
|
|
462
|
+
});
|
|
463
|
+
event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
|
|
464
|
+
return { resumed, tab, worker: current };
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
busy.delete(worker.id);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
fleet: () => {
|
|
471
|
+
const spend = store.spendByWorker(localDay(now()));
|
|
472
|
+
return store.listWorkers(ACTIVE_STATUSES).map((worker) => ({
|
|
473
|
+
...worker,
|
|
474
|
+
activity: listed ? activityOf(worker, sessionOf(worker)) : 'not listed yet',
|
|
475
|
+
spend: spend.get(worker.id) ?? { today: 0, total: 0 },
|
|
476
|
+
waits: store.waitsOf(worker.id),
|
|
477
|
+
}));
|
|
478
|
+
},
|
|
479
|
+
idle: async () => {
|
|
480
|
+
while (tasks.size > 0 || sweeping)
|
|
481
|
+
await Promise.allSettled([...tasks, sweeping]);
|
|
482
|
+
},
|
|
483
|
+
launch: (request) => {
|
|
484
|
+
if (!ITEM.test(request.item))
|
|
485
|
+
throw new ConductorError(`"${request.item}" is not an item id`);
|
|
486
|
+
const target = project(request.project);
|
|
487
|
+
const name = `${request.probe ? PROBE_PREFIX : ''}${request.item}`;
|
|
488
|
+
const prompt = request.prompt ??
|
|
489
|
+
(request.probe
|
|
490
|
+
? PROBE_PROMPT
|
|
491
|
+
: `/${target.profile.skills.work} ${request.item} --conducted${request.conductor ? ` --conductor ${request.conductor}` : ''}`);
|
|
492
|
+
const worker = store.insertWorker({ item: request.item, name, probe: request.probe === true, project: target.name });
|
|
493
|
+
event(worker, 'worker.launching', { probe: worker.probe });
|
|
494
|
+
busy.add(worker.id);
|
|
495
|
+
void track(launchWorker(worker, target.root, target.profile, prompt).finally(() => busy.delete(worker.id)));
|
|
496
|
+
return worker;
|
|
497
|
+
},
|
|
498
|
+
retire: async (workerId, { force = false } = {}) => {
|
|
499
|
+
const worker = activeWorker(workerId);
|
|
500
|
+
if (busy.has(worker.id) || worker.status === 'launching') {
|
|
501
|
+
return { outcome: 'refused', reason: 'still launching, or another operation runs: try again shortly', worker };
|
|
502
|
+
}
|
|
503
|
+
busy.add(worker.id);
|
|
504
|
+
try {
|
|
505
|
+
// Fresh: the last sweep's listing can be ten seconds old.
|
|
506
|
+
const session = (await claude.agents().catch(() => sessions)).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
|
|
507
|
+
const refuse = (reason, work) => {
|
|
508
|
+
event(worker, 'worker.retire-refused', { reason, ...work });
|
|
509
|
+
return { outcome: 'refused', reason, ...(work ? { work } : {}), worker };
|
|
510
|
+
};
|
|
511
|
+
// Removing the worktree while `claude --bg` still sets the session up there strands it.
|
|
512
|
+
if (session && isStarting(session))
|
|
513
|
+
return refuse('its session is still starting: try again shortly');
|
|
514
|
+
// A turn that runs can write between the check below and the stop.
|
|
515
|
+
if (session && isLive(session) && !turnEnded(session) && !force) {
|
|
516
|
+
return refuse('its turn is still running: retire once it ends, or retire --force');
|
|
517
|
+
}
|
|
518
|
+
const work = worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
|
|
519
|
+
if (work && !force)
|
|
520
|
+
return refuse('unfinished work: commit and push it, or retire --force to throw it away', work);
|
|
521
|
+
const root = store.listProjects().find((p) => p.name === worker.project)?.root;
|
|
522
|
+
const steps = [];
|
|
523
|
+
const handle = worker.handle;
|
|
524
|
+
if (handle !== null && session?.pid !== undefined) {
|
|
525
|
+
const stopped = await step(steps, 'stop', async () => {
|
|
526
|
+
if (!(await stopAndWait(handle, session.pid)))
|
|
527
|
+
throw new Error(`pid ${session.pid} still runs after claude stop`);
|
|
528
|
+
});
|
|
529
|
+
// Nothing is removed from under a session that still runs.
|
|
530
|
+
if (!stopped) {
|
|
531
|
+
event(worker, 'worker.retire-partial', { forced: force && work !== null, steps });
|
|
532
|
+
return { outcome: 'partial', steps, worker };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (worker.worktree !== null) {
|
|
536
|
+
const worktree = worker.worktree;
|
|
537
|
+
await step(steps, 'remove worktree', async () => {
|
|
538
|
+
if (!root)
|
|
539
|
+
throw new Error(`project ${worker.project} is not registered`);
|
|
540
|
+
await worktrees.remove(root, worktree);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
readSpend(worker); // the last of it, before the session goes
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
log.log('warn', 'spend not read at retire', { error: messageOf(error), worker: worker.id });
|
|
548
|
+
}
|
|
549
|
+
if (handle !== null)
|
|
550
|
+
await step(steps, 'rm', () => claude.rm(handle));
|
|
551
|
+
const ok = steps.every((s) => s.ok);
|
|
552
|
+
const current = ok ? update(worker, { status: 'exited', stoppedAt: now().toISOString() }) : worker;
|
|
553
|
+
if (ok)
|
|
554
|
+
store.closeWaits(worker.id, 'worker retired');
|
|
555
|
+
stalls.delete(worker.id);
|
|
556
|
+
event(current, ok ? 'worker.retired' : 'worker.retire-partial', { forced: force && work !== null, steps });
|
|
557
|
+
return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
busy.delete(worker.id);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
start: () => {
|
|
564
|
+
const interrupted = store.listWorkers(['launching']).filter((w) => !busy.has(w.id));
|
|
565
|
+
interrupted.forEach((w) => busy.add(w.id));
|
|
566
|
+
void track(recoverLaunches(interrupted).finally(() => interrupted.forEach((w) => busy.delete(w.id))));
|
|
567
|
+
timer = setInterval(() => {
|
|
568
|
+
manager.sweep().catch((error) => log.log('error', 'sweep failed', { error: messageOf(error) }));
|
|
569
|
+
}, config.pollSeconds * 1000);
|
|
570
|
+
timer.unref();
|
|
571
|
+
},
|
|
572
|
+
stop: async () => {
|
|
573
|
+
clearInterval(timer);
|
|
574
|
+
await sweeping;
|
|
575
|
+
},
|
|
576
|
+
sweep: () => {
|
|
577
|
+
sweeping ??= track(sweepOnce()).finally(() => (sweeping = null));
|
|
578
|
+
return sweeping;
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
return manager;
|
|
582
|
+
}
|
|
583
|
+
function activityOf(worker, session) {
|
|
584
|
+
if (worker.status === 'launching')
|
|
585
|
+
return 'launching';
|
|
586
|
+
if (worker.status === 'parked')
|
|
587
|
+
return 'parked';
|
|
588
|
+
if (!session)
|
|
589
|
+
return 'no session';
|
|
590
|
+
if (isStarting(session))
|
|
591
|
+
return 'starting';
|
|
592
|
+
if (!isLive(session))
|
|
593
|
+
return 'stopped';
|
|
594
|
+
const prompt = session.status === 'waiting' ? (session.waitingFor ?? 'input') : null;
|
|
595
|
+
if (prompt)
|
|
596
|
+
return `waiting on ${prompt}`;
|
|
597
|
+
return turnEnded(session) ? 'idle' : 'working';
|
|
598
|
+
}
|
|
599
|
+
// HH:MM, local.
|
|
600
|
+
function clock(at) {
|
|
601
|
+
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
|
|
602
|
+
}
|
|
603
|
+
function messageOf(error) {
|
|
604
|
+
if (error instanceof ConductorError)
|
|
605
|
+
return [error.message, ...error.details].join(': ');
|
|
606
|
+
return error instanceof Error ? error.message : String(error);
|
|
607
|
+
}
|
|
608
|
+
function timeOf(condition) {
|
|
609
|
+
if (!condition.startsWith('time:'))
|
|
610
|
+
return null;
|
|
611
|
+
const at = new Date(condition.slice('time:'.length));
|
|
612
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
613
|
+
}
|
|
614
|
+
//# sourceMappingURL=manager.js.map
|