@matthugh1/conductor-cli 0.2.4 → 0.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/dist/{agent-spawner-BNOGEYDK.js → agent-spawner-YCGFXFXX.js} +32 -6
- package/dist/agent.js +10 -10
- package/dist/{branch-overview-RRHX3XGY.js → branch-overview-35OJQU7T.js} +3 -109
- package/dist/chunk-HXPYK5E3.js +116 -0
- package/dist/chunk-T27CR6PR.js +179 -0
- package/dist/daemon-427UNQNA.js +614 -0
- package/dist/{daemon-client-CTYOJMJP.js → daemon-client-RVY2YLMA.js} +13 -0
- package/dist/{git-hooks-RQ6WJQS4.js → git-hooks-CD25TLQV.js} +112 -14
- package/dist/{runner-prompt-MOOPKA5P.js → runner-prompt-7EUGIDWK.js} +1 -1
- package/package.json +1 -1
- package/dist/chunk-6AA726KG.js +0 -238
- package/dist/daemon-ZJDZIP3R.js +0 -607
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../../src/core/daemon-runner.ts
|
|
4
|
+
async function fillSlots(ctx, freeSlots, todayCount) {
|
|
5
|
+
let agents;
|
|
6
|
+
try {
|
|
7
|
+
agents = await ctx.client.listEnabledAgents(ctx.projectId);
|
|
8
|
+
} catch (err) {
|
|
9
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10
|
+
ctx.log(`Failed to list agents: ${msg}`);
|
|
11
|
+
ctx.counters.consecutiveFailures++;
|
|
12
|
+
ctx.counters.failedToday++;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (agents.length === 0) return;
|
|
16
|
+
const activeInitiativeIds = new Set(
|
|
17
|
+
[...ctx.activeTasks.values()].map((t) => t.initiativeId).filter((id) => id !== null)
|
|
18
|
+
);
|
|
19
|
+
let globalSlotsRemaining = Math.min(freeSlots, ctx.config.maxPerDay - todayCount);
|
|
20
|
+
for (const agent of agents) {
|
|
21
|
+
if (globalSlotsRemaining <= 0 || !ctx.running) break;
|
|
22
|
+
const agentRunning = runningCountForAgent(ctx.activeTasks, agent.id);
|
|
23
|
+
const agentCapacity = agent.maxConcurrent - agentRunning;
|
|
24
|
+
if (agentCapacity <= 0) continue;
|
|
25
|
+
const fetchLimit = Math.min(agentCapacity, globalSlotsRemaining);
|
|
26
|
+
let candidates;
|
|
27
|
+
try {
|
|
28
|
+
candidates = await ctx.client.getNextTasksForAgent(agent.id, fetchLimit);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
31
|
+
ctx.log(`Failed to fetch tasks for agent "${agent.name}": ${msg}`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
for (const task of candidates) {
|
|
35
|
+
if (globalSlotsRemaining <= 0 || !ctx.running) break;
|
|
36
|
+
if (ctx.attemptedIds.has(task.id)) continue;
|
|
37
|
+
ctx.attemptedIds.set(task.id, Date.now());
|
|
38
|
+
if (task.initiativeId !== null && activeInitiativeIds.has(task.initiativeId)) {
|
|
39
|
+
ctx.log(`Skipping "${task.title}" \u2014 another task from the same initiative is already running.`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (task.type === "deliverable" && task.deliverableId) {
|
|
43
|
+
try {
|
|
44
|
+
const delivDetail = await ctx.client.getDeliverableDetail(task.deliverableId);
|
|
45
|
+
if (delivDetail && ["feature", "enhancement", "refactor"].includes(delivDetail.type ?? "feature") && !delivDetail.prompt) {
|
|
46
|
+
ctx.log(`Skipping "${task.title}" \u2014 deliverable needs an implementation brief (prompt).`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let claimed;
|
|
53
|
+
try {
|
|
54
|
+
claimed = await ctx.client.claimTask(task.id);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
57
|
+
ctx.log(`Failed to claim task "${task.title}": ${msg}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!claimed) continue;
|
|
61
|
+
const spawned = await spawnAgentTask(ctx, claimed, agent);
|
|
62
|
+
if (spawned) {
|
|
63
|
+
globalSlotsRemaining--;
|
|
64
|
+
if (task.initiativeId !== null) {
|
|
65
|
+
activeInitiativeIds.add(task.initiativeId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function spawnAgentTask(ctx, task, agent) {
|
|
72
|
+
let worktreePath = null;
|
|
73
|
+
let initiativeTitle = null;
|
|
74
|
+
if (task.initiativeId) {
|
|
75
|
+
try {
|
|
76
|
+
const init = task.deliverableId ? await ctx.client.getInitiativeForDeliverable(task.deliverableId) : null;
|
|
77
|
+
initiativeTitle = init?.initiativeTitle ?? null;
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (ctx.config.useWorktree && task.initiativeId) {
|
|
82
|
+
try {
|
|
83
|
+
const existing = await ctx.client.getWorktreeForInitiative(ctx.projectId, task.initiativeId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
worktreePath = existing.worktreePath;
|
|
86
|
+
ctx.log(`Reusing existing worktree at ${worktreePath}`);
|
|
87
|
+
} else {
|
|
88
|
+
const { runGit } = await import("./git-wrapper-QRZYTYCZ.js");
|
|
89
|
+
const path = await import("path");
|
|
90
|
+
const slug = (initiativeTitle ?? "unknown").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
91
|
+
const branchName = slug;
|
|
92
|
+
const parentDir = path.dirname(ctx.projectPath);
|
|
93
|
+
const projectDir = path.basename(ctx.projectPath);
|
|
94
|
+
const worktreeDir = path.join(parentDir, `${projectDir}--${slug}`);
|
|
95
|
+
try {
|
|
96
|
+
await runGit(ctx.projectPath, ["rev-parse", "--verify", branchName]);
|
|
97
|
+
} catch {
|
|
98
|
+
await runGit(ctx.projectPath, ["branch", branchName]);
|
|
99
|
+
}
|
|
100
|
+
await runGit(ctx.projectPath, ["worktree", "add", worktreeDir, branchName]);
|
|
101
|
+
await ctx.client.registerWorktree({
|
|
102
|
+
projectId: ctx.projectId,
|
|
103
|
+
initiativeId: task.initiativeId,
|
|
104
|
+
branchName,
|
|
105
|
+
worktreePath: worktreeDir
|
|
106
|
+
});
|
|
107
|
+
worktreePath = worktreeDir;
|
|
108
|
+
ctx.log(`Created worktree for "${initiativeTitle}" at ${worktreePath}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
112
|
+
ctx.log(`Worktree setup failed: ${msg}`);
|
|
113
|
+
if (task.deliverableId) {
|
|
114
|
+
ctx.log("Skipping \u2014 useWorktree is enabled. Refusing to run in main repo without isolation.");
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (ctx.config.useWorktree && task.deliverableId && !task.initiativeId) {
|
|
119
|
+
ctx.log(`Skipping "${task.title}" \u2014 useWorktree is enabled but no initiative set. Refusing to run without isolation.`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const agentCwd = worktreePath ?? ctx.projectPath;
|
|
123
|
+
let run;
|
|
124
|
+
try {
|
|
125
|
+
run = await ctx.client.createRun({
|
|
126
|
+
projectId: ctx.projectId,
|
|
127
|
+
deliverableId: task.deliverableId ?? task.id,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
worktreePath: worktreePath ?? void 0
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
133
|
+
ctx.log(`Failed to create daemon run record: ${msg}`);
|
|
134
|
+
ctx.counters.consecutiveFailures++;
|
|
135
|
+
ctx.counters.failedToday++;
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await ctx.client.updateTask(task.id, { status: "running", runId: run.id });
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
const { readFile } = await import("fs/promises");
|
|
143
|
+
const { join } = await import("path");
|
|
144
|
+
let claudeMd = "";
|
|
145
|
+
try {
|
|
146
|
+
claudeMd = await readFile(join(agentCwd, "CLAUDE.md"), "utf8");
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
const { assembleTaskPrompt } = await import("./runner-prompt-7EUGIDWK.js");
|
|
150
|
+
const assembledPrompt = assembleTaskPrompt(
|
|
151
|
+
{ name: agent.name, systemPrompt: agent.systemPrompt, agentSlug: agent.slug },
|
|
152
|
+
{
|
|
153
|
+
title: task.title,
|
|
154
|
+
prompt: task.prompt,
|
|
155
|
+
deliverableId: task.deliverableId,
|
|
156
|
+
projectName: ctx.projectPath
|
|
157
|
+
},
|
|
158
|
+
claudeMd
|
|
159
|
+
);
|
|
160
|
+
ctx.log(
|
|
161
|
+
`Spawning "${agent.name}" for: "${task.title}"${worktreePath ? ` (worktree: ${worktreePath})` : ""} [${ctx.activeTasks.size + 1}/${ctx.config.maxConcurrent} slots]`
|
|
162
|
+
);
|
|
163
|
+
const { spawnAgent } = await import("./agent-spawner-YCGFXFXX.js");
|
|
164
|
+
const spawned = spawnAgent({
|
|
165
|
+
item: {
|
|
166
|
+
priority: task.priority,
|
|
167
|
+
tier: "active",
|
|
168
|
+
type: "deliverable",
|
|
169
|
+
title: task.title,
|
|
170
|
+
reason: `Agent task: ${task.title}`,
|
|
171
|
+
action: "Execute task",
|
|
172
|
+
agentRole: agent.role ?? "implementation-engineer",
|
|
173
|
+
entityId: task.deliverableId ?? task.id,
|
|
174
|
+
entityType: "deliverable"
|
|
175
|
+
},
|
|
176
|
+
projectRoot: agentCwd,
|
|
177
|
+
runId: run.id,
|
|
178
|
+
projectId: ctx.projectId,
|
|
179
|
+
deliverableId: task.deliverableId ?? void 0,
|
|
180
|
+
timeoutMs: ctx.config.timeout * 6e4,
|
|
181
|
+
client: ctx.client,
|
|
182
|
+
assembledPrompt,
|
|
183
|
+
modelOverride: agent.model !== "claude-sonnet-4-20250514" ? agent.model : void 0,
|
|
184
|
+
skipPermissions: ctx.config.skipPermissions,
|
|
185
|
+
onLine: (stream, line) => {
|
|
186
|
+
if (stream === "stderr") {
|
|
187
|
+
ctx.writeErr(line);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (spawned.pid !== null) {
|
|
192
|
+
try {
|
|
193
|
+
await ctx.client.updateRunPid(run.id, spawned.pid);
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
ctx.activeTasks.set(task.id, {
|
|
198
|
+
promise: spawned.done,
|
|
199
|
+
cancel: spawned.cancel,
|
|
200
|
+
taskId: task.id,
|
|
201
|
+
runId: run.id,
|
|
202
|
+
agentId: agent.id,
|
|
203
|
+
agentName: agent.name,
|
|
204
|
+
deliverableId: task.deliverableId,
|
|
205
|
+
initiativeId: task.initiativeId,
|
|
206
|
+
worktreePath,
|
|
207
|
+
title: task.title
|
|
208
|
+
});
|
|
209
|
+
ctx.log(
|
|
210
|
+
`Picked: "${task.title}" (P${task.priority}) \u2192 agent "${agent.name}"${initiativeTitle ? ` [${initiativeTitle}]` : ""}`
|
|
211
|
+
);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
async function handleCompletion(ctx, taskId, result) {
|
|
215
|
+
const task = ctx.activeTasks.get(taskId);
|
|
216
|
+
if (!task) return;
|
|
217
|
+
ctx.activeTasks.delete(taskId);
|
|
218
|
+
ctx.log(
|
|
219
|
+
`Agent "${task.agentName}" finished: ${result.outcome} (exit ${result.exitCode ?? "n/a"}, ${result.lineCount} lines, ${Math.round(result.durationMs / 1e3)}s)`
|
|
220
|
+
);
|
|
221
|
+
try {
|
|
222
|
+
await ctx.client.completeRun(task.runId, {
|
|
223
|
+
status: result.outcome === "completed" ? "completed" : result.outcome === "timeout" ? "timeout" : result.outcome === "cancelled" ? "cancelled" : "failed",
|
|
224
|
+
exitCode: result.exitCode ?? void 0,
|
|
225
|
+
errorMessage: result.errorMessage,
|
|
226
|
+
inputTokens: result.tokenUsage?.inputTokens,
|
|
227
|
+
outputTokens: result.tokenUsage?.outputTokens,
|
|
228
|
+
cacheReadTokens: result.tokenUsage?.cacheReadTokens,
|
|
229
|
+
cacheCreationTokens: result.tokenUsage?.cacheCreationTokens,
|
|
230
|
+
totalCostUsd: result.tokenUsage?.totalCostUsd
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
ctx.log(`Failed to update daemon run: ${msg}`);
|
|
235
|
+
}
|
|
236
|
+
if (result.tokenUsage) {
|
|
237
|
+
const t = result.tokenUsage;
|
|
238
|
+
const fmtK = (n) => n >= 1e3 ? `${Math.round(n / 1e3)}K` : String(n);
|
|
239
|
+
const totalIn = t.inputTokens + t.cacheReadTokens + t.cacheCreationTokens;
|
|
240
|
+
ctx.log(`[tokens] ${fmtK(totalIn)} in / ${fmtK(t.outputTokens)} out ($${t.totalCostUsd.toFixed(2)})`);
|
|
241
|
+
}
|
|
242
|
+
if (result.outcome === "completed") {
|
|
243
|
+
ctx.log(`Completed: "${task.title}" (agent: ${task.agentName})`);
|
|
244
|
+
ctx.counters.consecutiveFailures = 0;
|
|
245
|
+
ctx.counters.completedToday++;
|
|
246
|
+
try {
|
|
247
|
+
await ctx.client.updateTask(task.taskId, {
|
|
248
|
+
status: "completed",
|
|
249
|
+
result: `Completed successfully in ${Math.round(result.durationMs / 1e3)}s`
|
|
250
|
+
});
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
if (task.deliverableId) {
|
|
254
|
+
try {
|
|
255
|
+
const compliance = await ctx.client.checkRunCompliance(
|
|
256
|
+
task.runId,
|
|
257
|
+
task.deliverableId
|
|
258
|
+
);
|
|
259
|
+
if (compliance.passed) {
|
|
260
|
+
ctx.log(`[protocol] Full compliance \u2713 for "${task.title}"`);
|
|
261
|
+
} else {
|
|
262
|
+
ctx.log(`[protocol] Missing for "${task.title}": ${compliance.missing.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
await ctx.client.updateRunCompliance(task.runId, compliance);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
267
|
+
ctx.log(`[protocol] Compliance check failed: ${msg}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (task.worktreePath) {
|
|
271
|
+
ctx.log(`Worktree preserved at ${task.worktreePath} for reuse by next task.`);
|
|
272
|
+
}
|
|
273
|
+
} else if (result.outcome === "cancelled") {
|
|
274
|
+
ctx.log(`Cancelled: "${task.title}" (agent: ${task.agentName})`);
|
|
275
|
+
ctx.counters.failedToday++;
|
|
276
|
+
try {
|
|
277
|
+
await ctx.client.updateTask(task.taskId, { status: "cancelled" });
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
if (task.deliverableId) {
|
|
281
|
+
try {
|
|
282
|
+
await ctx.client.updateDeliverableStatus(task.deliverableId, "parked");
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (task.worktreePath) {
|
|
287
|
+
ctx.log(`Worktree preserved at ${task.worktreePath} for debugging`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const reason = result.errorMessage ?? `exit ${result.exitCode}`;
|
|
291
|
+
ctx.log(
|
|
292
|
+
`${result.outcome === "timeout" ? "Timed out" : "Failed"}: "${task.title}" (agent: ${task.agentName}) \u2014 ${reason}`
|
|
293
|
+
);
|
|
294
|
+
ctx.counters.consecutiveFailures++;
|
|
295
|
+
ctx.counters.failedToday++;
|
|
296
|
+
try {
|
|
297
|
+
await ctx.client.updateTask(task.taskId, {
|
|
298
|
+
status: "failed",
|
|
299
|
+
errorMessage: reason
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
if (task.deliverableId) {
|
|
304
|
+
try {
|
|
305
|
+
await ctx.client.updateDeliverableStatus(task.deliverableId, "parked");
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (task.worktreePath) {
|
|
310
|
+
ctx.log(`Worktree preserved at ${task.worktreePath} for debugging`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function runningCountForAgent(activeTasks, agentId) {
|
|
315
|
+
let count = 0;
|
|
316
|
+
for (const t of activeTasks.values()) {
|
|
317
|
+
if (t.agentId === agentId) count++;
|
|
318
|
+
}
|
|
319
|
+
return count;
|
|
320
|
+
}
|
|
321
|
+
var ATTEMPTED_TTL_MS = 30 * 6e4;
|
|
322
|
+
function pruneAttempted(attemptedIds, now) {
|
|
323
|
+
for (const [id, ts2] of attemptedIds) {
|
|
324
|
+
if (now - ts2 > ATTEMPTED_TTL_MS) attemptedIds.delete(id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function sleepWithCheck(ms, isRunning) {
|
|
328
|
+
const intervals = Math.ceil(ms / 1e3);
|
|
329
|
+
for (let i = 0; i < intervals && isRunning(); i++) {
|
|
330
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function ingestAndRoute(ctx) {
|
|
334
|
+
try {
|
|
335
|
+
const ingested = await ctx.client.ingestAutonomousDeliverables(ctx.projectId, ctx.projectPath);
|
|
336
|
+
if (ingested > 0) ctx.log(`Ingested ${ingested} auto-generated task(s) into task backlog`);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
ctx.log(`Warning: auto-task ingestion failed: ${msg}`);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const { routed, decisions } = await ctx.client.routePendingTasks(ctx.projectId, ctx.projectPath);
|
|
343
|
+
for (const decision of decisions) {
|
|
344
|
+
ctx.log(
|
|
345
|
+
`Routed task "${decision.taskTitle}" to ${decision.agentName} (${decision.explanation.summary}; weight=${decision.explanation.weight}; load=${decision.explanation.activeQueuedCount})`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (routed > 0) ctx.log(`Auto-routed ${routed} pending daemon task(s)`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
351
|
+
ctx.log(`Warning: daemon task routing failed: ${msg}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function resolveProject(opts, client, log2, writeErr2) {
|
|
355
|
+
try {
|
|
356
|
+
const { readProjectId } = await import("./cli-config-LEERSU5N.js");
|
|
357
|
+
const configProjectId = readProjectId();
|
|
358
|
+
if (configProjectId) {
|
|
359
|
+
log2(`Using project ID from config: ${configProjectId}`);
|
|
360
|
+
return { projectPath: opts.projectRoot, projectId: configProjectId };
|
|
361
|
+
}
|
|
362
|
+
if (opts.projectName) {
|
|
363
|
+
const project2 = await client.resolveProject({ name: opts.projectName });
|
|
364
|
+
return { projectPath: project2.path, projectId: project2.id };
|
|
365
|
+
}
|
|
366
|
+
const project = await client.resolveProject({ path: opts.projectRoot });
|
|
367
|
+
return { projectPath: project.path, projectId: project.id };
|
|
368
|
+
} catch (err) {
|
|
369
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
370
|
+
writeErr2(msg);
|
|
371
|
+
return { projectPath: null, projectId: null };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function buildConfig(opts, projectPath) {
|
|
375
|
+
let fileConfig = {};
|
|
376
|
+
try {
|
|
377
|
+
const { readConfig } = await import("./cli-config-LEERSU5N.js");
|
|
378
|
+
const config = readConfig(projectPath);
|
|
379
|
+
fileConfig = config.daemon ?? {};
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
const D = { checkInterval: 3e4, maxPerDay: 50, maxConsecutiveFailures: 3, timeout: 120, maxConcurrent: 1, skipPermissions: false };
|
|
383
|
+
return {
|
|
384
|
+
checkInterval: opts.checkInterval !== D.checkInterval ? opts.checkInterval : fileConfig.checkInterval ?? D.checkInterval,
|
|
385
|
+
maxPerDay: opts.maxPerDay !== D.maxPerDay ? opts.maxPerDay : fileConfig.maxPerDay ?? D.maxPerDay,
|
|
386
|
+
maxConsecutiveFailures: opts.maxConsecutiveFailures !== D.maxConsecutiveFailures ? opts.maxConsecutiveFailures : fileConfig.maxConsecutiveFailures ?? D.maxConsecutiveFailures,
|
|
387
|
+
timeout: opts.timeout !== D.timeout ? opts.timeout : fileConfig.timeout ?? D.timeout,
|
|
388
|
+
maxConcurrent: opts.maxConcurrent !== D.maxConcurrent ? opts.maxConcurrent : fileConfig.maxConcurrent ?? D.maxConcurrent,
|
|
389
|
+
useWorktree: !opts.noWorktree,
|
|
390
|
+
skipPermissions: opts.skipPermissions || (fileConfig.skipPermissions ?? false)
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async function pollLoop(ctx, heartbeatInterval) {
|
|
394
|
+
while (ctx.running) {
|
|
395
|
+
try {
|
|
396
|
+
pruneAttempted(ctx.attemptedIds, Date.now());
|
|
397
|
+
if (ctx.counters.consecutiveFailures >= ctx.config.maxConsecutiveFailures) {
|
|
398
|
+
ctx.log(`Stopping: ${ctx.counters.consecutiveFailures} consecutive failures reached limit.`);
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
await ingestAndRoute(ctx);
|
|
402
|
+
const freeSlots = ctx.config.maxConcurrent - ctx.activeTasks.size;
|
|
403
|
+
if (freeSlots > 0) {
|
|
404
|
+
const todayCount = await ctx.client.getQuota(ctx.projectId);
|
|
405
|
+
if (todayCount >= ctx.config.maxPerDay) {
|
|
406
|
+
ctx.log(`Daily quota reached (${todayCount}/${ctx.config.maxPerDay}). Waiting...`);
|
|
407
|
+
} else {
|
|
408
|
+
await fillSlots(ctx, freeSlots, todayCount);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (ctx.activeTasks.size > 0) {
|
|
412
|
+
const taggedPromises = [...ctx.activeTasks.entries()].map(
|
|
413
|
+
([id, task]) => task.promise.then((result) => ({ id, result }))
|
|
414
|
+
);
|
|
415
|
+
const sleepPromise = sleepWithCheck(ctx.config.checkInterval, () => ctx.running).then(() => null);
|
|
416
|
+
const completed = await Promise.race([...taggedPromises, sleepPromise]);
|
|
417
|
+
if (completed !== null) {
|
|
418
|
+
await handleCompletion(ctx, completed.id, completed.result);
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
await sleepWithCheck(ctx.config.checkInterval, () => ctx.running);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
425
|
+
ctx.log(`Unexpected error in daemon loop: ${msg}`);
|
|
426
|
+
ctx.counters.consecutiveFailures++;
|
|
427
|
+
ctx.counters.failedToday++;
|
|
428
|
+
await sleepWithCheck(ctx.config.checkInterval, () => ctx.running);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (ctx.activeTasks.size > 0) {
|
|
432
|
+
ctx.log(`Waiting for ${ctx.activeTasks.size} active task(s) to finish...`);
|
|
433
|
+
const remaining = [...ctx.activeTasks.entries()].map(
|
|
434
|
+
([id, task]) => task.promise.then((result) => ({ id, result }))
|
|
435
|
+
);
|
|
436
|
+
const results = await Promise.allSettled(remaining);
|
|
437
|
+
for (const r of results) {
|
|
438
|
+
if (r.status === "fulfilled") {
|
|
439
|
+
await handleCompletion(ctx, r.value.id, r.value.result);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
clearInterval(heartbeatInterval);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ../../src/cli/daemon.ts
|
|
447
|
+
function writeOut(text) {
|
|
448
|
+
process.stdout.write(text + "\n");
|
|
449
|
+
}
|
|
450
|
+
function writeErr(text) {
|
|
451
|
+
process.stderr.write(text + "\n");
|
|
452
|
+
}
|
|
453
|
+
function ts() {
|
|
454
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
455
|
+
}
|
|
456
|
+
function log(msg) {
|
|
457
|
+
writeOut(`[${ts()}] ${msg}`);
|
|
458
|
+
}
|
|
459
|
+
async function cmdDaemonCancel(projectRoot, projectName, jsonOutput, apiUrl, apiKey) {
|
|
460
|
+
const { existsSync, readFileSync, unlinkSync } = await import("fs");
|
|
461
|
+
const { join } = await import("path");
|
|
462
|
+
const { createDaemonClient } = await import("./daemon-client-RVY2YLMA.js");
|
|
463
|
+
const resolvedUrl = apiUrl ?? process.env.CONDUCTOR_API_URL;
|
|
464
|
+
const client = createDaemonClient(resolvedUrl, apiKey);
|
|
465
|
+
let projectId;
|
|
466
|
+
try {
|
|
467
|
+
const project = projectName ? await client.resolveProject({ name: projectName }) : await client.resolveProject({ path: projectRoot });
|
|
468
|
+
projectId = project.id;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
471
|
+
writeErr(msg);
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
const pidPath = join(projectRoot, ".conductor", `daemon-${projectId}.pid`);
|
|
475
|
+
if (!existsSync(pidPath)) {
|
|
476
|
+
if (jsonOutput) {
|
|
477
|
+
writeOut(JSON.stringify({ error: "No daemon running for this project." }));
|
|
478
|
+
} else {
|
|
479
|
+
writeErr("No daemon running for this project.");
|
|
480
|
+
}
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
|
|
484
|
+
if (!Number.isFinite(pid)) {
|
|
485
|
+
unlinkSync(pidPath);
|
|
486
|
+
writeErr("Stale PID file removed.");
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
process.kill(pid, "SIGTERM");
|
|
491
|
+
if (jsonOutput) {
|
|
492
|
+
writeOut(JSON.stringify({ cancelled: true, pid }));
|
|
493
|
+
} else {
|
|
494
|
+
writeOut(`Sent shutdown signal to daemon (PID ${pid}).`);
|
|
495
|
+
}
|
|
496
|
+
return 0;
|
|
497
|
+
} catch {
|
|
498
|
+
unlinkSync(pidPath);
|
|
499
|
+
if (jsonOutput) {
|
|
500
|
+
writeOut(JSON.stringify({ error: "Daemon process not found. Cleaned up stale PID file." }));
|
|
501
|
+
} else {
|
|
502
|
+
writeErr("Daemon process not found. Cleaned up stale PID file.");
|
|
503
|
+
}
|
|
504
|
+
return 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function cmdDaemon(opts) {
|
|
508
|
+
const { mkdirSync, writeFileSync, unlinkSync, existsSync, readFileSync } = await import("fs");
|
|
509
|
+
const { join } = await import("path");
|
|
510
|
+
const { createDaemonClient } = await import("./daemon-client-RVY2YLMA.js");
|
|
511
|
+
const resolvedUrl = opts.apiUrl ?? process.env.CONDUCTOR_API_URL;
|
|
512
|
+
const client = createDaemonClient(resolvedUrl, opts.apiKey);
|
|
513
|
+
const resolved = await resolveProject(opts, client, log, writeErr);
|
|
514
|
+
if (!resolved.projectPath) return 1;
|
|
515
|
+
const { projectPath, projectId } = resolved;
|
|
516
|
+
const conductorDir = join(projectPath, ".conductor");
|
|
517
|
+
mkdirSync(conductorDir, { recursive: true });
|
|
518
|
+
const pidPath = join(conductorDir, `daemon-${projectId}.pid`);
|
|
519
|
+
if (existsSync(pidPath)) {
|
|
520
|
+
const existingPid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
|
|
521
|
+
if (Number.isFinite(existingPid)) {
|
|
522
|
+
try {
|
|
523
|
+
process.kill(existingPid, 0);
|
|
524
|
+
writeErr(`A daemon is already running for this project (PID ${existingPid}).`);
|
|
525
|
+
writeErr("Run: conductor daemon cancel --project <name>");
|
|
526
|
+
return 1;
|
|
527
|
+
} catch {
|
|
528
|
+
log("Cleaned up stale PID file from a previous daemon.");
|
|
529
|
+
unlinkSync(pidPath);
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
unlinkSync(pidPath);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
writeFileSync(pidPath, String(process.pid), "utf8");
|
|
536
|
+
const daemonConfig = await buildConfig(opts, projectPath);
|
|
537
|
+
const activeTasks = /* @__PURE__ */ new Map();
|
|
538
|
+
const counters = { consecutiveFailures: 0, completedToday: 0, failedToday: 0 };
|
|
539
|
+
let running = true;
|
|
540
|
+
const ctx = {
|
|
541
|
+
client,
|
|
542
|
+
config: daemonConfig,
|
|
543
|
+
projectPath,
|
|
544
|
+
projectId,
|
|
545
|
+
activeTasks,
|
|
546
|
+
attemptedIds: /* @__PURE__ */ new Map(),
|
|
547
|
+
counters,
|
|
548
|
+
get running() {
|
|
549
|
+
return running;
|
|
550
|
+
},
|
|
551
|
+
log,
|
|
552
|
+
writeErr
|
|
553
|
+
};
|
|
554
|
+
const shutdown = () => {
|
|
555
|
+
log("Shutting down daemon gracefully...");
|
|
556
|
+
running = false;
|
|
557
|
+
for (const task of activeTasks.values()) task.cancel();
|
|
558
|
+
};
|
|
559
|
+
process.on("SIGINT", shutdown);
|
|
560
|
+
process.on("SIGTERM", shutdown);
|
|
561
|
+
try {
|
|
562
|
+
await client.upsertHeartbeat({
|
|
563
|
+
projectId,
|
|
564
|
+
pid: process.pid,
|
|
565
|
+
state: "idle",
|
|
566
|
+
config: { ...daemonConfig },
|
|
567
|
+
stats: { completedToday: 0, failedToday: 0 }
|
|
568
|
+
});
|
|
569
|
+
} catch (err) {
|
|
570
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
571
|
+
writeErr(`Failed to write initial heartbeat: ${msg}`);
|
|
572
|
+
unlinkSync(pidPath);
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const purged = await client.purgeOldRuns(30);
|
|
577
|
+
if (purged > 0) log(`Cleaned up ${purged} daemon run(s) older than 30 days`);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
580
|
+
log(`Warning: cleanup of old runs failed: ${msg}`);
|
|
581
|
+
}
|
|
582
|
+
log(`Daemon started for project: ${projectPath}`);
|
|
583
|
+
log(`PID: ${process.pid} | Poll: ${daemonConfig.checkInterval}ms | Daily: ${daemonConfig.maxPerDay} | Slots: ${daemonConfig.maxConcurrent}`);
|
|
584
|
+
const heartbeatInterval = setInterval(async () => {
|
|
585
|
+
try {
|
|
586
|
+
const firstActive = activeTasks.values().next().value;
|
|
587
|
+
await client.upsertHeartbeat({
|
|
588
|
+
projectId,
|
|
589
|
+
pid: process.pid,
|
|
590
|
+
state: activeTasks.size > 0 ? "executing" : "idle",
|
|
591
|
+
currentDeliverableId: firstActive?.deliverableId ?? void 0,
|
|
592
|
+
currentRunId: firstActive?.runId,
|
|
593
|
+
config: { ...daemonConfig },
|
|
594
|
+
stats: { completedToday: counters.completedToday, failedToday: counters.failedToday, activeCount: activeTasks.size }
|
|
595
|
+
});
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
}, 3e4);
|
|
599
|
+
await pollLoop(ctx, heartbeatInterval);
|
|
600
|
+
log("Daemon stopped.");
|
|
601
|
+
try {
|
|
602
|
+
await client.clearHeartbeat(projectId);
|
|
603
|
+
} catch {
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
unlinkSync(pidPath);
|
|
607
|
+
} catch {
|
|
608
|
+
}
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
export {
|
|
612
|
+
cmdDaemon,
|
|
613
|
+
cmdDaemonCancel
|
|
614
|
+
};
|
|
@@ -426,6 +426,19 @@ function createDaemonClient(baseUrl = "https://conductor-297703646986.europe-wes
|
|
|
426
426
|
"updateTask"
|
|
427
427
|
);
|
|
428
428
|
return task;
|
|
429
|
+
},
|
|
430
|
+
// ── Protocol compliance methods ────────────────────────────────────
|
|
431
|
+
async checkRunCompliance(runId, deliverableId) {
|
|
432
|
+
const res = await get(`/api/daemon/runs/${runId}/compliance`, {
|
|
433
|
+
deliverableId
|
|
434
|
+
});
|
|
435
|
+
return jsonBody(res, "checkRunCompliance");
|
|
436
|
+
},
|
|
437
|
+
async updateRunCompliance(runId, compliance) {
|
|
438
|
+
const res = await patch(`/api/daemon/runs/${runId}`, {
|
|
439
|
+
protocolCompliance: compliance
|
|
440
|
+
});
|
|
441
|
+
await assertOk(res, "updateRunCompliance");
|
|
429
442
|
}
|
|
430
443
|
};
|
|
431
444
|
}
|