@lumi.ai/runner 0.5.5 → 0.5.7
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 +433 -25
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,9 +7,9 @@ import { Command } from "commander";
|
|
|
7
7
|
import os3 from "node:os";
|
|
8
8
|
import {
|
|
9
9
|
collection as collection5,
|
|
10
|
-
deleteField,
|
|
10
|
+
deleteField as deleteField2,
|
|
11
11
|
doc as doc7,
|
|
12
|
-
getDoc as
|
|
12
|
+
getDoc as getDoc6,
|
|
13
13
|
getDocs as getDocs4,
|
|
14
14
|
onSnapshot as onSnapshot2,
|
|
15
15
|
orderBy as orderBy4,
|
|
@@ -231,6 +231,159 @@ function jobTarget(job) {
|
|
|
231
231
|
return null;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ../shared/dist/jobProgress.js
|
|
235
|
+
var MAX_JOB_STEPS = 10;
|
|
236
|
+
var MAX_STEP_LABEL = 80;
|
|
237
|
+
var MAX_STEP_DETAIL = 120;
|
|
238
|
+
function clampStepText(text, max) {
|
|
239
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
240
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}\u2026`;
|
|
241
|
+
}
|
|
242
|
+
function firstLine(text) {
|
|
243
|
+
return text.split("\n").find((l) => l.trim()) ?? "";
|
|
244
|
+
}
|
|
245
|
+
function step(kind, label, detail) {
|
|
246
|
+
return detail ? { kind, label, detail } : { kind, label };
|
|
247
|
+
}
|
|
248
|
+
function str(input, key) {
|
|
249
|
+
if (!input || typeof input !== "object")
|
|
250
|
+
return void 0;
|
|
251
|
+
const value = input[key];
|
|
252
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
253
|
+
}
|
|
254
|
+
function basename(path5) {
|
|
255
|
+
const parts = path5.split(/[\\/]/).filter(Boolean);
|
|
256
|
+
return parts[parts.length - 1] ?? path5;
|
|
257
|
+
}
|
|
258
|
+
function hostOf(url) {
|
|
259
|
+
try {
|
|
260
|
+
return new URL(url).hostname;
|
|
261
|
+
} catch {
|
|
262
|
+
return "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
var WORKSPACE_PREFIX = "mcp__workspace__";
|
|
266
|
+
var MCP_PREFIX = "mcp__";
|
|
267
|
+
var WORKSPACE_LABELS = {
|
|
268
|
+
ship_info: "Checking the Ship",
|
|
269
|
+
agent_list: "Looking up the crew",
|
|
270
|
+
task_list: "Looking through the board",
|
|
271
|
+
task_get: "Reading a task",
|
|
272
|
+
task_create: "Creating a task",
|
|
273
|
+
task_update_status: "Moving a task",
|
|
274
|
+
task_assign: "Assigning a task",
|
|
275
|
+
task_relate: "Linking two tasks",
|
|
276
|
+
task_comment: "Commenting on a task",
|
|
277
|
+
approval_request: "Asking for approval",
|
|
278
|
+
approval_decide: "Answering an approval",
|
|
279
|
+
run_report: "Writing its run report",
|
|
280
|
+
knowledge_get: "Reading the knowledge base",
|
|
281
|
+
knowledge_write: "Writing to the knowledge base",
|
|
282
|
+
media_get: "Opening an attachment",
|
|
283
|
+
media_attach: "Attaching a file",
|
|
284
|
+
memory_write: "Updating its notes",
|
|
285
|
+
chat_get: "Reading the conversation",
|
|
286
|
+
chat_send: "Writing its reply"
|
|
287
|
+
};
|
|
288
|
+
var BUILTIN_LABELS = {
|
|
289
|
+
Bash: "Running a command",
|
|
290
|
+
Read: "Reading a file",
|
|
291
|
+
Write: "Writing a file",
|
|
292
|
+
Edit: "Editing a file",
|
|
293
|
+
MultiEdit: "Editing a file",
|
|
294
|
+
Glob: "Looking for files",
|
|
295
|
+
Grep: "Searching the code",
|
|
296
|
+
WebSearch: "Searching the web",
|
|
297
|
+
WebFetch: "Reading a web page",
|
|
298
|
+
TodoWrite: "Planning its next steps",
|
|
299
|
+
Task: "Delegating to a sub-agent"
|
|
300
|
+
};
|
|
301
|
+
function workspaceDetail(tool, input) {
|
|
302
|
+
switch (tool) {
|
|
303
|
+
case "task_list":
|
|
304
|
+
return str(input, "status") ?? str(input, "query");
|
|
305
|
+
case "task_get":
|
|
306
|
+
return str(input, "taskId");
|
|
307
|
+
case "task_create":
|
|
308
|
+
return str(input, "title");
|
|
309
|
+
case "task_update_status":
|
|
310
|
+
return str(input, "status");
|
|
311
|
+
case "task_relate":
|
|
312
|
+
return str(input, "blockedBy") ? "blocked by" : str(input, "waitingOn") ? "waiting on" : void 0;
|
|
313
|
+
case "task_comment": {
|
|
314
|
+
const content = str(input, "content");
|
|
315
|
+
return content ? firstLine(content) : void 0;
|
|
316
|
+
}
|
|
317
|
+
case "approval_request":
|
|
318
|
+
return str(input, "action") ?? str(input, "summary");
|
|
319
|
+
case "knowledge_get":
|
|
320
|
+
return str(input, "slug") ?? str(input, "find") ?? "the catalog";
|
|
321
|
+
case "knowledge_write":
|
|
322
|
+
return str(input, "slug");
|
|
323
|
+
case "media_get":
|
|
324
|
+
return str(input, "mediaId");
|
|
325
|
+
case "media_attach":
|
|
326
|
+
return str(input, "name");
|
|
327
|
+
default:
|
|
328
|
+
return void 0;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function builtinDetail(tool, input) {
|
|
332
|
+
switch (tool) {
|
|
333
|
+
case "Bash":
|
|
334
|
+
return str(input, "command");
|
|
335
|
+
case "Read":
|
|
336
|
+
case "Write":
|
|
337
|
+
case "Edit":
|
|
338
|
+
case "MultiEdit": {
|
|
339
|
+
const path5 = str(input, "file_path");
|
|
340
|
+
return path5 ? basename(path5) : void 0;
|
|
341
|
+
}
|
|
342
|
+
case "Glob":
|
|
343
|
+
case "Grep":
|
|
344
|
+
return str(input, "pattern");
|
|
345
|
+
case "WebSearch":
|
|
346
|
+
return str(input, "query");
|
|
347
|
+
case "WebFetch": {
|
|
348
|
+
const url = str(input, "url");
|
|
349
|
+
return url ? hostOf(url) || void 0 : void 0;
|
|
350
|
+
}
|
|
351
|
+
case "Task":
|
|
352
|
+
return str(input, "description");
|
|
353
|
+
default:
|
|
354
|
+
return void 0;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function describeToolUse(name, input, opts) {
|
|
358
|
+
if (name.startsWith(WORKSPACE_PREFIX)) {
|
|
359
|
+
const tool = name.slice(WORKSPACE_PREFIX.length);
|
|
360
|
+
return step("tool", WORKSPACE_LABELS[tool] ?? "Working", workspaceDetail(tool, input));
|
|
361
|
+
}
|
|
362
|
+
if (name.startsWith(MCP_PREFIX)) {
|
|
363
|
+
const rest = name.slice(MCP_PREFIX.length);
|
|
364
|
+
const split = rest.indexOf("__");
|
|
365
|
+
const key = split < 0 ? rest : rest.slice(0, split);
|
|
366
|
+
const tool = split < 0 ? void 0 : rest.slice(split + 2);
|
|
367
|
+
return step("tool", `Using ${opts?.mcpNames?.[key] ?? key}`, tool);
|
|
368
|
+
}
|
|
369
|
+
const label = BUILTIN_LABELS[name];
|
|
370
|
+
if (label)
|
|
371
|
+
return step("tool", label, builtinDetail(name, input));
|
|
372
|
+
return step("tool", "Working");
|
|
373
|
+
}
|
|
374
|
+
function describeAssistantText(text) {
|
|
375
|
+
return step("saying", "Working it out", firstLine(text));
|
|
376
|
+
}
|
|
377
|
+
function pushJobStep(progress, step2, max = MAX_JOB_STEPS) {
|
|
378
|
+
const steps = [...progress?.steps ?? [], step2];
|
|
379
|
+
return {
|
|
380
|
+
seq: (progress?.seq ?? 0) + 1,
|
|
381
|
+
updatedAt: step2.at,
|
|
382
|
+
// Keep the NEWEST `max`, oldest first — a reader renders top to bottom.
|
|
383
|
+
steps: steps.slice(Math.max(0, steps.length - max))
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
234
387
|
// ../shared/dist/knowledge.js
|
|
235
388
|
var KNOWLEDGE_CATALOG_MAX_CHARS = 1500;
|
|
236
389
|
var KNOWLEDGE_CATALOG_SUMMARIES = 8;
|
|
@@ -581,7 +734,7 @@ function mcpUrl(config2) {
|
|
|
581
734
|
}
|
|
582
735
|
|
|
583
736
|
// src/version.ts
|
|
584
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
737
|
+
var RUNNER_VERSION = true ? "0.5.7" : "0.0.0-dev";
|
|
585
738
|
|
|
586
739
|
// src/auth.ts
|
|
587
740
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1416,6 +1569,31 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
1416
1569
|
import fs3 from "node:fs";
|
|
1417
1570
|
import os2 from "node:os";
|
|
1418
1571
|
import path3 from "node:path";
|
|
1572
|
+
|
|
1573
|
+
// src/engines/claudeEvents.ts
|
|
1574
|
+
function stepsFromClaudeEvent(event, opts) {
|
|
1575
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
1576
|
+
if (type === "assistant") {
|
|
1577
|
+
const message = event.message;
|
|
1578
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
1579
|
+
const steps = [];
|
|
1580
|
+
for (const block of content) {
|
|
1581
|
+
if (!block || typeof block !== "object") continue;
|
|
1582
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
1583
|
+
steps.push(describeToolUse(block.name, block.input, { mcpNames: opts?.mcpNames }));
|
|
1584
|
+
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
1585
|
+
steps.push({ kind: "thinking", label: "Thinking" });
|
|
1586
|
+
} else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
1587
|
+
steps.push(describeAssistantText(block.text));
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return steps;
|
|
1591
|
+
}
|
|
1592
|
+
if (type === "result") return [{ kind: "done", label: "Finished" }];
|
|
1593
|
+
return [];
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// src/engines/claude.ts
|
|
1419
1597
|
function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
1420
1598
|
const result = resultEvent ?? {};
|
|
1421
1599
|
const u = result.usage ?? {};
|
|
@@ -1559,6 +1737,8 @@ async function runSession(input, bin, dirs) {
|
|
|
1559
1737
|
GIT_CONFIG_VALUE_0: "https://github.com/"
|
|
1560
1738
|
} : {}
|
|
1561
1739
|
};
|
|
1740
|
+
const mcpNames = {};
|
|
1741
|
+
for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
|
|
1562
1742
|
const startedAt = Date.now();
|
|
1563
1743
|
const lines = [];
|
|
1564
1744
|
const stderrLines = [];
|
|
@@ -1591,6 +1771,9 @@ async function runSession(input, bin, dirs) {
|
|
|
1591
1771
|
const event = JSON.parse(line);
|
|
1592
1772
|
if (event.type === "result") resultEvent = event;
|
|
1593
1773
|
if (event.type === "assistant") input.log("claude: assistant turn");
|
|
1774
|
+
if (input.onStep) {
|
|
1775
|
+
for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
|
|
1776
|
+
}
|
|
1594
1777
|
} catch {
|
|
1595
1778
|
}
|
|
1596
1779
|
}
|
|
@@ -1864,7 +2047,9 @@ function selectDispatch(input) {
|
|
|
1864
2047
|
import {
|
|
1865
2048
|
addDoc,
|
|
1866
2049
|
collection as collection4,
|
|
2050
|
+
deleteField,
|
|
1867
2051
|
doc as doc6,
|
|
2052
|
+
getDoc as getDoc5,
|
|
1868
2053
|
getDocs as getDocs3,
|
|
1869
2054
|
limit as fsLimit,
|
|
1870
2055
|
orderBy as orderBy3,
|
|
@@ -1915,6 +2100,16 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
1915
2100
|
endedAt: now,
|
|
1916
2101
|
usage: u,
|
|
1917
2102
|
transcriptPath: input.transcriptPath,
|
|
2103
|
+
// §15.36. The live strip dies with the run — the transcript is the record, and a settled job
|
|
2104
|
+
// still carrying steps would show a spinner over work that finished.
|
|
2105
|
+
//
|
|
2106
|
+
// `stopRequestedAt` is cleared on EVERY terminal path, including a plain `done`: the marker
|
|
2107
|
+
// is self-consuming (§15.13's `startAt` shape), and leaving one behind on a job that
|
|
2108
|
+
// succeeded anyway is what would let the watchdog's stop branch re-examine settled jobs
|
|
2109
|
+
// forever. Deleting an absent field is not an affected key, so both are legal whether or not
|
|
2110
|
+
// anything ever set them.
|
|
2111
|
+
progress: deleteField(),
|
|
2112
|
+
stopRequestedAt: deleteField(),
|
|
1918
2113
|
...input.error ? { error: input.error.slice(0, 1500) } : {},
|
|
1919
2114
|
// Omitted rather than written empty, so a job that had none looks exactly like every job
|
|
1920
2115
|
// written before §15.31 — there is nothing to migrate and nothing to read defensively.
|
|
@@ -1947,7 +2142,9 @@ async function requeueForRetry(db, shipId, job, error) {
|
|
|
1947
2142
|
tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
1948
2143
|
status: "queued",
|
|
1949
2144
|
attempt: job.attempt + 1,
|
|
1950
|
-
error: error.slice(0, 1500)
|
|
2145
|
+
error: error.slice(0, 1500),
|
|
2146
|
+
// The steps describe an attempt that is over; the next one starts its own stream.
|
|
2147
|
+
progress: deleteField()
|
|
1951
2148
|
});
|
|
1952
2149
|
});
|
|
1953
2150
|
}
|
|
@@ -1956,7 +2153,15 @@ async function releaseJob(db, shipId, job, reason) {
|
|
|
1956
2153
|
status: "queued",
|
|
1957
2154
|
runnerId: "",
|
|
1958
2155
|
startedAt: 0,
|
|
1959
|
-
error: reason.slice(0, 1500)
|
|
2156
|
+
error: reason.slice(0, 1500),
|
|
2157
|
+
// §15.36: cleared here too, not only on the terminal paths. A released job sits `queued`
|
|
2158
|
+
// waiting to be re-claimed, and steps left behind would show a live-looking strip describing a
|
|
2159
|
+
// session that is no longer running anywhere.
|
|
2160
|
+
//
|
|
2161
|
+
// `stopRequestedAt` deliberately SURVIVES a release. The job goes back on the queue still
|
|
2162
|
+
// carrying the request, so whichever daemon claims it next honours the stop instead of running
|
|
2163
|
+
// work somebody already asked to end.
|
|
2164
|
+
progress: deleteField()
|
|
1960
2165
|
});
|
|
1961
2166
|
}
|
|
1962
2167
|
async function markTaskFailed(db, shipId, job, error, statuses) {
|
|
@@ -1979,6 +2184,36 @@ ${error.slice(0, 800)}
|
|
|
1979
2184
|
});
|
|
1980
2185
|
});
|
|
1981
2186
|
}
|
|
2187
|
+
async function markTaskStopped(db, shipId, job, stoppedBy) {
|
|
2188
|
+
const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
|
|
2189
|
+
const who = await actorName(db, shipId, stoppedBy);
|
|
2190
|
+
await addDoc(collection4(taskRef, COLLECTIONS.activity), {
|
|
2191
|
+
author: { type: "agent", id: job.agentId },
|
|
2192
|
+
createdAt: Date.now(),
|
|
2193
|
+
kind: "comment",
|
|
2194
|
+
content: `This run was stopped by ${who} before it finished. The task is unchanged.`
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
async function actorName(db, shipId, actorId) {
|
|
2198
|
+
try {
|
|
2199
|
+
const snap = await getDoc5(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
|
|
2200
|
+
const m = snap.data();
|
|
2201
|
+
return m?.displayName || m?.email || actorId;
|
|
2202
|
+
} catch {
|
|
2203
|
+
return actorId;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
async function markChatStopped(db, shipId, job, stoppedBy) {
|
|
2207
|
+
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2208
|
+
const who = await actorName(db, shipId, stoppedBy);
|
|
2209
|
+
const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
|
|
2210
|
+
await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
|
|
2211
|
+
author: { type: "agent", id: job.agentId },
|
|
2212
|
+
content,
|
|
2213
|
+
chars: content.length,
|
|
2214
|
+
createdAt: Date.now()
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
1982
2217
|
async function markChatFailed(db, shipId, job, error) {
|
|
1983
2218
|
const shipRef = doc6(db, COLLECTIONS.ships, shipId);
|
|
1984
2219
|
const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
|
|
@@ -2033,11 +2268,113 @@ async function ensureChatReply(db, shipId, job, resultText) {
|
|
|
2033
2268
|
return resultText.trim() ? "posted-final-text" : "posted-silence-note";
|
|
2034
2269
|
}
|
|
2035
2270
|
|
|
2271
|
+
// src/jobs/progress.ts
|
|
2272
|
+
var PROGRESS_FLUSH_MS = 3e3;
|
|
2273
|
+
var PROGRESS_DENIALS_BEFORE_PROBE = 2;
|
|
2274
|
+
function redactStep(step2, secrets) {
|
|
2275
|
+
const label = clampStepText(redactTranscript(step2.label, secrets), MAX_STEP_LABEL);
|
|
2276
|
+
const base = { at: step2.at, kind: step2.kind, label };
|
|
2277
|
+
if (step2.detail === void 0) return base;
|
|
2278
|
+
const detail = clampStepText(redactTranscript(step2.detail, secrets), MAX_STEP_DETAIL);
|
|
2279
|
+
return detail ? { ...base, detail } : base;
|
|
2280
|
+
}
|
|
2281
|
+
function errorCode(error) {
|
|
2282
|
+
return error?.code ?? "";
|
|
2283
|
+
}
|
|
2284
|
+
function isDenied(error) {
|
|
2285
|
+
const code = errorCode(error);
|
|
2286
|
+
return code === "permission-denied" || code.endsWith("/permission-denied");
|
|
2287
|
+
}
|
|
2288
|
+
function isUnwritable(error) {
|
|
2289
|
+
const code = errorCode(error);
|
|
2290
|
+
return code === "invalid-argument" || code.endsWith("/invalid-argument");
|
|
2291
|
+
}
|
|
2292
|
+
function createProgressWriter(deps) {
|
|
2293
|
+
const now = deps.now ?? Date.now;
|
|
2294
|
+
const flushMs = deps.flushMs ?? PROGRESS_FLUSH_MS;
|
|
2295
|
+
let progress;
|
|
2296
|
+
let pending = [];
|
|
2297
|
+
let timer = null;
|
|
2298
|
+
let lastWriteAt = 0;
|
|
2299
|
+
let inFlight = false;
|
|
2300
|
+
let denials = 0;
|
|
2301
|
+
let closed = false;
|
|
2302
|
+
let loggedFailure = false;
|
|
2303
|
+
function flush() {
|
|
2304
|
+
if (timer) {
|
|
2305
|
+
clearTimeout(timer);
|
|
2306
|
+
timer = null;
|
|
2307
|
+
}
|
|
2308
|
+
if (closed || inFlight || pending.length === 0) return;
|
|
2309
|
+
const secrets = deps.secrets();
|
|
2310
|
+
for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets));
|
|
2311
|
+
pending = [];
|
|
2312
|
+
lastWriteAt = now();
|
|
2313
|
+
inFlight = true;
|
|
2314
|
+
const snapshot = progress;
|
|
2315
|
+
let written;
|
|
2316
|
+
try {
|
|
2317
|
+
written = deps.write(snapshot);
|
|
2318
|
+
} catch (e) {
|
|
2319
|
+
written = Promise.reject(e);
|
|
2320
|
+
}
|
|
2321
|
+
void written.then(() => {
|
|
2322
|
+
denials = 0;
|
|
2323
|
+
}).catch((e) => {
|
|
2324
|
+
if (isDenied(e)) {
|
|
2325
|
+
denials += 1;
|
|
2326
|
+
if (denials >= PROGRESS_DENIALS_BEFORE_PROBE) {
|
|
2327
|
+
closed = true;
|
|
2328
|
+
deps.onDenied();
|
|
2329
|
+
}
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
denials = 0;
|
|
2333
|
+
if (isUnwritable(e)) closed = true;
|
|
2334
|
+
if (!loggedFailure) {
|
|
2335
|
+
loggedFailure = true;
|
|
2336
|
+
deps.log(
|
|
2337
|
+
`progress write failed${closed ? " \u2014 live steps are off for this job" : " (further ones silent)"}: ${e instanceof Error ? e.message : e}`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
}).finally(() => {
|
|
2341
|
+
inFlight = false;
|
|
2342
|
+
if (pending.length > 0 && !closed) schedule();
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
function schedule() {
|
|
2346
|
+
if (closed || timer) return;
|
|
2347
|
+
const due = lastWriteAt + flushMs - now();
|
|
2348
|
+
if (due <= 0 && !inFlight) {
|
|
2349
|
+
flush();
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
timer = setTimeout(flush, Math.max(due, 0));
|
|
2353
|
+
timer.unref?.();
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
push(step2) {
|
|
2357
|
+
if (closed) return;
|
|
2358
|
+
pending.push({ ...step2, at: now() });
|
|
2359
|
+
schedule();
|
|
2360
|
+
},
|
|
2361
|
+
stop() {
|
|
2362
|
+
closed = true;
|
|
2363
|
+
if (timer) {
|
|
2364
|
+
clearTimeout(timer);
|
|
2365
|
+
timer = null;
|
|
2366
|
+
}
|
|
2367
|
+
pending = [];
|
|
2368
|
+
}
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2036
2372
|
// src/daemon.ts
|
|
2037
2373
|
var HEARTBEAT_MS = 3e4;
|
|
2038
2374
|
var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
2039
2375
|
var MAX_ATTEMPTS = 2;
|
|
2040
2376
|
var SHUTDOWN_GRACE_MS = 1e4;
|
|
2377
|
+
var STOP_POLL_MS = 5e3;
|
|
2041
2378
|
async function startDaemon() {
|
|
2042
2379
|
const moved = migrateLegacyDir();
|
|
2043
2380
|
let config2 = requireConfig();
|
|
@@ -2102,7 +2439,7 @@ async function startDaemon() {
|
|
|
2102
2439
|
try {
|
|
2103
2440
|
for (const shipId of [...serving]) {
|
|
2104
2441
|
try {
|
|
2105
|
-
const snap = await
|
|
2442
|
+
const snap = await getDoc6(shipRunnerRef(shipId));
|
|
2106
2443
|
if (!snap.exists()) {
|
|
2107
2444
|
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
2108
2445
|
continue;
|
|
@@ -2424,6 +2761,23 @@ async function startDaemon() {
|
|
|
2424
2761
|
...job.workflowName ? { workflowName: job.workflowName } : {},
|
|
2425
2762
|
startedAt: Date.now()
|
|
2426
2763
|
};
|
|
2764
|
+
const stopPoll = setInterval(() => {
|
|
2765
|
+
void (async () => {
|
|
2766
|
+
if (slot.stop) return;
|
|
2767
|
+
try {
|
|
2768
|
+
const snap = await getDoc6(
|
|
2769
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2770
|
+
);
|
|
2771
|
+
const fresh = snap.data();
|
|
2772
|
+
if (!fresh?.stopRequestedAt || slot.stop) return;
|
|
2773
|
+
slot.stop = { at: fresh.stopRequestedAt, by: fresh.stopRequestedBy?.id ?? "someone" };
|
|
2774
|
+
log2(`Job ${job.id}: stop requested \u2014 ending the session.`);
|
|
2775
|
+
slot.abort.abort();
|
|
2776
|
+
} catch {
|
|
2777
|
+
}
|
|
2778
|
+
})();
|
|
2779
|
+
}, STOP_POLL_MS);
|
|
2780
|
+
stopPoll.unref();
|
|
2427
2781
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2428
2782
|
void heartbeat();
|
|
2429
2783
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
@@ -2450,6 +2804,37 @@ async function startDaemon() {
|
|
|
2450
2804
|
let githubToken;
|
|
2451
2805
|
let extraMcpServers = [];
|
|
2452
2806
|
let statuses = DEFAULT_TASK_STATUSES;
|
|
2807
|
+
let knownSecrets = [];
|
|
2808
|
+
const progress = createProgressWriter({
|
|
2809
|
+
write: (p) => updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
2810
|
+
progress: p
|
|
2811
|
+
}),
|
|
2812
|
+
secrets: () => knownSecrets,
|
|
2813
|
+
// Two consecutive DENIALS mean this job is no longer ours to write — a force-stop took it, or
|
|
2814
|
+
// the machine was revoked. The §15.30 door: the news arrives as `permission-denied`.
|
|
2815
|
+
//
|
|
2816
|
+
// A PROBE RATHER THAN A BARE ABORT, and it is not optional. If a deploy lands the daemon
|
|
2817
|
+
// before the rules, every progress write is denied on a perfectly healthy job, and aborting
|
|
2818
|
+
// on that signal alone would kill live work for the length of the rollout. One read settles
|
|
2819
|
+
// which of the two it is.
|
|
2820
|
+
onDenied: () => {
|
|
2821
|
+
void (async () => {
|
|
2822
|
+
try {
|
|
2823
|
+
const snap = await getDoc6(
|
|
2824
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2825
|
+
);
|
|
2826
|
+
const fresh = snap.data();
|
|
2827
|
+
if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
|
|
2828
|
+
log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
|
|
2829
|
+
slot.abort.abort();
|
|
2830
|
+
}
|
|
2831
|
+
} catch {
|
|
2832
|
+
}
|
|
2833
|
+
})();
|
|
2834
|
+
},
|
|
2835
|
+
log: log2
|
|
2836
|
+
});
|
|
2837
|
+
progress.push({ kind: "start", label: "Starting up" });
|
|
2453
2838
|
try {
|
|
2454
2839
|
const secrets = await loadSecrets(shipId);
|
|
2455
2840
|
const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }) };
|
|
@@ -2503,6 +2888,15 @@ async function startDaemon() {
|
|
|
2503
2888
|
throw e;
|
|
2504
2889
|
}
|
|
2505
2890
|
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
2891
|
+
knownSecrets = [
|
|
2892
|
+
idToken,
|
|
2893
|
+
secrets?.claudeToken,
|
|
2894
|
+
githubToken,
|
|
2895
|
+
secrets?.githubPat,
|
|
2896
|
+
// §15.31. BOTH the composed header value and the bare credential — see
|
|
2897
|
+
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
2898
|
+
...mcpSecretsToRedact(extraMcpServers)
|
|
2899
|
+
];
|
|
2506
2900
|
const session = await getDriver(engineId).run({
|
|
2507
2901
|
prompt,
|
|
2508
2902
|
agent,
|
|
@@ -2515,17 +2909,13 @@ async function startDaemon() {
|
|
|
2515
2909
|
githubToken,
|
|
2516
2910
|
timeoutMs: JOB_TIMEOUT_MS,
|
|
2517
2911
|
signal: slot.abort.signal,
|
|
2518
|
-
log: log2
|
|
2912
|
+
log: log2,
|
|
2913
|
+
// §15.36. The driver emits Crew's step vocabulary; the writer coalesces, redacts and
|
|
2914
|
+
// persists. A throw here must never reach the driver's stdout loop, which is why `push`
|
|
2915
|
+
// swallows everything.
|
|
2916
|
+
onStep: (step2) => progress.push(step2)
|
|
2519
2917
|
});
|
|
2520
|
-
transcript = redactTranscript(session.transcript,
|
|
2521
|
-
idToken,
|
|
2522
|
-
secrets?.claudeToken,
|
|
2523
|
-
githubToken,
|
|
2524
|
-
secrets?.githubPat,
|
|
2525
|
-
// §15.31. BOTH the composed header value and the bare credential — see
|
|
2526
|
-
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
2527
|
-
...mcpSecretsToRedact(extraMcpServers)
|
|
2528
|
-
]);
|
|
2918
|
+
transcript = redactTranscript(session.transcript, knownSecrets);
|
|
2529
2919
|
usage = session.usage;
|
|
2530
2920
|
if (session.limit && getEngine(engineId).usageWindows) {
|
|
2531
2921
|
sessionLimit = session.limit;
|
|
@@ -2545,11 +2935,29 @@ async function startDaemon() {
|
|
|
2545
2935
|
transient = isTransientFirestoreError(e);
|
|
2546
2936
|
}
|
|
2547
2937
|
wake?.release();
|
|
2938
|
+
progress.stop();
|
|
2939
|
+
clearInterval(stopPoll);
|
|
2548
2940
|
try {
|
|
2549
2941
|
if (!serving.has(shipId)) {
|
|
2550
2942
|
log2(
|
|
2551
2943
|
`Job ${job.id} stopped \u2014 Ship ${shipId} removed this machine mid-run. The Ship has put the job back on its own queue; nothing more is written from here.`
|
|
2552
2944
|
);
|
|
2945
|
+
} else if (slot.stop && failure) {
|
|
2946
|
+
const transcriptPath = transcript ? await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript) : "";
|
|
2947
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, {
|
|
2948
|
+
status: "stopped",
|
|
2949
|
+
usage,
|
|
2950
|
+
transcriptPath,
|
|
2951
|
+
mcpServers: extraMcpServers.map((s) => s.key)
|
|
2952
|
+
});
|
|
2953
|
+
const by = slot.stop.by;
|
|
2954
|
+
if (target.kind === "chat") {
|
|
2955
|
+
await markChatStopped(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, by);
|
|
2956
|
+
} else {
|
|
2957
|
+
await markTaskStopped(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }, by);
|
|
2958
|
+
}
|
|
2959
|
+
log2(`Job ${job.id} STOPPED by ${by} (attempt ${job.attempt}).`);
|
|
2960
|
+
notify("Crew job stopped", `${targetLabel} was stopped before it finished.`);
|
|
2553
2961
|
} else if (shuttingDown) {
|
|
2554
2962
|
await releaseJob(
|
|
2555
2963
|
sess(shipId).fb.db,
|
|
@@ -2690,8 +3098,8 @@ async function startDaemon() {
|
|
|
2690
3098
|
{
|
|
2691
3099
|
status: "offline",
|
|
2692
3100
|
lastSeenAt: now,
|
|
2693
|
-
currentJob:
|
|
2694
|
-
currentJobs:
|
|
3101
|
+
currentJob: deleteField2(),
|
|
3102
|
+
currentJobs: deleteField2()
|
|
2695
3103
|
},
|
|
2696
3104
|
{ merge: true }
|
|
2697
3105
|
);
|
|
@@ -2951,7 +3359,7 @@ function setParallel(config2, value, ship2) {
|
|
|
2951
3359
|
|
|
2952
3360
|
// src/cli/commands/doctor.ts
|
|
2953
3361
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2954
|
-
import { collection as collection6, doc as doc8, getDoc as
|
|
3362
|
+
import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
|
|
2955
3363
|
|
|
2956
3364
|
// src/service.ts
|
|
2957
3365
|
import { spawnSync } from "node:child_process";
|
|
@@ -3370,7 +3778,7 @@ async function checkShips(config2) {
|
|
|
3370
3778
|
continue;
|
|
3371
3779
|
}
|
|
3372
3780
|
try {
|
|
3373
|
-
const snap = await
|
|
3781
|
+
const snap = await getDoc7(
|
|
3374
3782
|
doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
|
|
3375
3783
|
);
|
|
3376
3784
|
if (!snap.exists()) {
|
|
@@ -3795,7 +4203,7 @@ async function runUninstall(options) {
|
|
|
3795
4203
|
}
|
|
3796
4204
|
|
|
3797
4205
|
// src/cli/commands/ship.ts
|
|
3798
|
-
import { doc as doc9, getDoc as
|
|
4206
|
+
import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
|
|
3799
4207
|
async function listMyShips() {
|
|
3800
4208
|
const config2 = requireConfig();
|
|
3801
4209
|
const ids = Object.keys(config2.shipKeys ?? {});
|
|
@@ -3803,7 +4211,7 @@ async function listMyShips() {
|
|
|
3803
4211
|
ids.map(async (id) => {
|
|
3804
4212
|
try {
|
|
3805
4213
|
const { fb } = await openShipSession(id);
|
|
3806
|
-
const snap = await
|
|
4214
|
+
const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
|
|
3807
4215
|
return { id, ...snap.data() };
|
|
3808
4216
|
} catch {
|
|
3809
4217
|
return null;
|
|
@@ -3930,7 +4338,7 @@ import {
|
|
|
3930
4338
|
collection as collection7,
|
|
3931
4339
|
doc as doc10,
|
|
3932
4340
|
getCountFromServer as getCountFromServer2,
|
|
3933
|
-
getDoc as
|
|
4341
|
+
getDoc as getDoc9,
|
|
3934
4342
|
getDocs as getDocs6,
|
|
3935
4343
|
query as query5,
|
|
3936
4344
|
where as where5
|
|
@@ -3968,7 +4376,7 @@ async function runStatus() {
|
|
|
3968
4376
|
continue;
|
|
3969
4377
|
}
|
|
3970
4378
|
const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
|
|
3971
|
-
const mirrorSnap = await
|
|
4379
|
+
const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
|
|
3972
4380
|
const mirror = mirrorSnap.data();
|
|
3973
4381
|
let queued = 0;
|
|
3974
4382
|
try {
|
|
@@ -3979,7 +4387,7 @@ async function runStatus() {
|
|
|
3979
4387
|
} catch {
|
|
3980
4388
|
queued = 0;
|
|
3981
4389
|
}
|
|
3982
|
-
const usageSnap = await
|
|
4390
|
+
const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
|
|
3983
4391
|
const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
|
|
3984
4392
|
const now = Date.now();
|
|
3985
4393
|
const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
|
|
6
6
|
"//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",
|