@lumi.ai/runner 0.5.5 → 0.5.6
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 +400 -16
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ 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
12
|
getDoc as getDoc5,
|
|
13
13
|
getDocs as getDocs4,
|
|
@@ -231,6 +231,164 @@ 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 str(input, key) {
|
|
246
|
+
if (!input || typeof input !== "object")
|
|
247
|
+
return void 0;
|
|
248
|
+
const value = input[key];
|
|
249
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
250
|
+
}
|
|
251
|
+
function basename(path5) {
|
|
252
|
+
const parts = path5.split(/[\\/]/).filter(Boolean);
|
|
253
|
+
return parts[parts.length - 1] ?? path5;
|
|
254
|
+
}
|
|
255
|
+
function hostOf(url) {
|
|
256
|
+
try {
|
|
257
|
+
return new URL(url).hostname;
|
|
258
|
+
} catch {
|
|
259
|
+
return "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
var WORKSPACE_PREFIX = "mcp__workspace__";
|
|
263
|
+
var MCP_PREFIX = "mcp__";
|
|
264
|
+
var WORKSPACE_LABELS = {
|
|
265
|
+
ship_info: "Checking the Ship",
|
|
266
|
+
agent_list: "Looking up the crew",
|
|
267
|
+
task_list: "Looking through the board",
|
|
268
|
+
task_get: "Reading a task",
|
|
269
|
+
task_create: "Creating a task",
|
|
270
|
+
task_update_status: "Moving a task",
|
|
271
|
+
task_assign: "Assigning a task",
|
|
272
|
+
task_relate: "Linking two tasks",
|
|
273
|
+
task_comment: "Commenting on a task",
|
|
274
|
+
approval_request: "Asking for approval",
|
|
275
|
+
approval_decide: "Answering an approval",
|
|
276
|
+
run_report: "Writing its run report",
|
|
277
|
+
knowledge_get: "Reading the knowledge base",
|
|
278
|
+
knowledge_write: "Writing to the knowledge base",
|
|
279
|
+
media_get: "Opening an attachment",
|
|
280
|
+
media_attach: "Attaching a file",
|
|
281
|
+
memory_write: "Updating its notes",
|
|
282
|
+
chat_get: "Reading the conversation",
|
|
283
|
+
chat_send: "Writing its reply"
|
|
284
|
+
};
|
|
285
|
+
var BUILTIN_LABELS = {
|
|
286
|
+
Bash: "Running a command",
|
|
287
|
+
Read: "Reading a file",
|
|
288
|
+
Write: "Writing a file",
|
|
289
|
+
Edit: "Editing a file",
|
|
290
|
+
MultiEdit: "Editing a file",
|
|
291
|
+
Glob: "Looking for files",
|
|
292
|
+
Grep: "Searching the code",
|
|
293
|
+
WebSearch: "Searching the web",
|
|
294
|
+
WebFetch: "Reading a web page",
|
|
295
|
+
TodoWrite: "Planning its next steps",
|
|
296
|
+
Task: "Delegating to a sub-agent"
|
|
297
|
+
};
|
|
298
|
+
function workspaceDetail(tool, input) {
|
|
299
|
+
switch (tool) {
|
|
300
|
+
case "task_list":
|
|
301
|
+
return str(input, "status") ?? str(input, "query");
|
|
302
|
+
case "task_get":
|
|
303
|
+
return str(input, "taskId");
|
|
304
|
+
case "task_create":
|
|
305
|
+
return str(input, "title");
|
|
306
|
+
case "task_update_status":
|
|
307
|
+
return str(input, "status");
|
|
308
|
+
case "task_relate":
|
|
309
|
+
return str(input, "blockedBy") ? "blocked by" : str(input, "waitingOn") ? "waiting on" : void 0;
|
|
310
|
+
case "task_comment": {
|
|
311
|
+
const content = str(input, "content");
|
|
312
|
+
return content ? firstLine(content) : void 0;
|
|
313
|
+
}
|
|
314
|
+
case "approval_request":
|
|
315
|
+
return str(input, "action") ?? str(input, "summary");
|
|
316
|
+
case "knowledge_get":
|
|
317
|
+
return str(input, "slug") ?? str(input, "find") ?? "the catalog";
|
|
318
|
+
case "knowledge_write":
|
|
319
|
+
return str(input, "slug");
|
|
320
|
+
case "media_get":
|
|
321
|
+
return str(input, "mediaId");
|
|
322
|
+
case "media_attach":
|
|
323
|
+
return str(input, "name");
|
|
324
|
+
default:
|
|
325
|
+
return void 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function builtinDetail(tool, input) {
|
|
329
|
+
switch (tool) {
|
|
330
|
+
case "Bash":
|
|
331
|
+
return str(input, "command");
|
|
332
|
+
case "Read":
|
|
333
|
+
case "Write":
|
|
334
|
+
case "Edit":
|
|
335
|
+
case "MultiEdit": {
|
|
336
|
+
const path5 = str(input, "file_path");
|
|
337
|
+
return path5 ? basename(path5) : void 0;
|
|
338
|
+
}
|
|
339
|
+
case "Glob":
|
|
340
|
+
case "Grep":
|
|
341
|
+
return str(input, "pattern");
|
|
342
|
+
case "WebSearch":
|
|
343
|
+
return str(input, "query");
|
|
344
|
+
case "WebFetch": {
|
|
345
|
+
const url = str(input, "url");
|
|
346
|
+
return url ? hostOf(url) || void 0 : void 0;
|
|
347
|
+
}
|
|
348
|
+
case "Task":
|
|
349
|
+
return str(input, "description");
|
|
350
|
+
default:
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function describeToolUse(name, input, opts) {
|
|
355
|
+
if (name.startsWith(WORKSPACE_PREFIX)) {
|
|
356
|
+
const tool = name.slice(WORKSPACE_PREFIX.length);
|
|
357
|
+
return {
|
|
358
|
+
kind: "tool",
|
|
359
|
+
label: WORKSPACE_LABELS[tool] ?? "Working",
|
|
360
|
+
detail: workspaceDetail(tool, input)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (name.startsWith(MCP_PREFIX)) {
|
|
364
|
+
const rest = name.slice(MCP_PREFIX.length);
|
|
365
|
+
const split = rest.indexOf("__");
|
|
366
|
+
const key = split < 0 ? rest : rest.slice(0, split);
|
|
367
|
+
const tool = split < 0 ? void 0 : rest.slice(split + 2);
|
|
368
|
+
return {
|
|
369
|
+
kind: "tool",
|
|
370
|
+
label: `Using ${opts?.mcpNames?.[key] ?? key}`,
|
|
371
|
+
detail: tool || void 0
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const label = BUILTIN_LABELS[name];
|
|
375
|
+
if (label)
|
|
376
|
+
return { kind: "tool", label, detail: builtinDetail(name, input) };
|
|
377
|
+
return { kind: "tool", label: "Working" };
|
|
378
|
+
}
|
|
379
|
+
function describeAssistantText(text) {
|
|
380
|
+
return { kind: "saying", label: "Working it out", detail: firstLine(text) || void 0 };
|
|
381
|
+
}
|
|
382
|
+
function pushJobStep(progress, step, max = MAX_JOB_STEPS) {
|
|
383
|
+
const steps = [...progress?.steps ?? [], step];
|
|
384
|
+
return {
|
|
385
|
+
seq: (progress?.seq ?? 0) + 1,
|
|
386
|
+
updatedAt: step.at,
|
|
387
|
+
// Keep the NEWEST `max`, oldest first — a reader renders top to bottom.
|
|
388
|
+
steps: steps.slice(Math.max(0, steps.length - max))
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
234
392
|
// ../shared/dist/knowledge.js
|
|
235
393
|
var KNOWLEDGE_CATALOG_MAX_CHARS = 1500;
|
|
236
394
|
var KNOWLEDGE_CATALOG_SUMMARIES = 8;
|
|
@@ -581,7 +739,7 @@ function mcpUrl(config2) {
|
|
|
581
739
|
}
|
|
582
740
|
|
|
583
741
|
// src/version.ts
|
|
584
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
742
|
+
var RUNNER_VERSION = true ? "0.5.6" : "0.0.0-dev";
|
|
585
743
|
|
|
586
744
|
// src/auth.ts
|
|
587
745
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1416,6 +1574,31 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
1416
1574
|
import fs3 from "node:fs";
|
|
1417
1575
|
import os2 from "node:os";
|
|
1418
1576
|
import path3 from "node:path";
|
|
1577
|
+
|
|
1578
|
+
// src/engines/claudeEvents.ts
|
|
1579
|
+
function stepsFromClaudeEvent(event, opts) {
|
|
1580
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
1581
|
+
if (type === "assistant") {
|
|
1582
|
+
const message = event.message;
|
|
1583
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
1584
|
+
const steps = [];
|
|
1585
|
+
for (const block of content) {
|
|
1586
|
+
if (!block || typeof block !== "object") continue;
|
|
1587
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
1588
|
+
steps.push(describeToolUse(block.name, block.input, { mcpNames: opts?.mcpNames }));
|
|
1589
|
+
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
1590
|
+
steps.push({ kind: "thinking", label: "Thinking" });
|
|
1591
|
+
} else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
1592
|
+
steps.push(describeAssistantText(block.text));
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return steps;
|
|
1596
|
+
}
|
|
1597
|
+
if (type === "result") return [{ kind: "done", label: "Finished" }];
|
|
1598
|
+
return [];
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// src/engines/claude.ts
|
|
1419
1602
|
function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
1420
1603
|
const result = resultEvent ?? {};
|
|
1421
1604
|
const u = result.usage ?? {};
|
|
@@ -1559,6 +1742,8 @@ async function runSession(input, bin, dirs) {
|
|
|
1559
1742
|
GIT_CONFIG_VALUE_0: "https://github.com/"
|
|
1560
1743
|
} : {}
|
|
1561
1744
|
};
|
|
1745
|
+
const mcpNames = {};
|
|
1746
|
+
for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
|
|
1562
1747
|
const startedAt = Date.now();
|
|
1563
1748
|
const lines = [];
|
|
1564
1749
|
const stderrLines = [];
|
|
@@ -1591,6 +1776,9 @@ async function runSession(input, bin, dirs) {
|
|
|
1591
1776
|
const event = JSON.parse(line);
|
|
1592
1777
|
if (event.type === "result") resultEvent = event;
|
|
1593
1778
|
if (event.type === "assistant") input.log("claude: assistant turn");
|
|
1779
|
+
if (input.onStep) {
|
|
1780
|
+
for (const step of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step);
|
|
1781
|
+
}
|
|
1594
1782
|
} catch {
|
|
1595
1783
|
}
|
|
1596
1784
|
}
|
|
@@ -1864,6 +2052,7 @@ function selectDispatch(input) {
|
|
|
1864
2052
|
import {
|
|
1865
2053
|
addDoc,
|
|
1866
2054
|
collection as collection4,
|
|
2055
|
+
deleteField,
|
|
1867
2056
|
doc as doc6,
|
|
1868
2057
|
getDocs as getDocs3,
|
|
1869
2058
|
limit as fsLimit,
|
|
@@ -1915,6 +2104,16 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
1915
2104
|
endedAt: now,
|
|
1916
2105
|
usage: u,
|
|
1917
2106
|
transcriptPath: input.transcriptPath,
|
|
2107
|
+
// §15.36. The live strip dies with the run — the transcript is the record, and a settled job
|
|
2108
|
+
// still carrying steps would show a spinner over work that finished.
|
|
2109
|
+
//
|
|
2110
|
+
// `stopRequestedAt` is cleared on EVERY terminal path, including a plain `done`: the marker
|
|
2111
|
+
// is self-consuming (§15.13's `startAt` shape), and leaving one behind on a job that
|
|
2112
|
+
// succeeded anyway is what would let the watchdog's stop branch re-examine settled jobs
|
|
2113
|
+
// forever. Deleting an absent field is not an affected key, so both are legal whether or not
|
|
2114
|
+
// anything ever set them.
|
|
2115
|
+
progress: deleteField(),
|
|
2116
|
+
stopRequestedAt: deleteField(),
|
|
1918
2117
|
...input.error ? { error: input.error.slice(0, 1500) } : {},
|
|
1919
2118
|
// Omitted rather than written empty, so a job that had none looks exactly like every job
|
|
1920
2119
|
// written before §15.31 — there is nothing to migrate and nothing to read defensively.
|
|
@@ -1947,7 +2146,9 @@ async function requeueForRetry(db, shipId, job, error) {
|
|
|
1947
2146
|
tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
1948
2147
|
status: "queued",
|
|
1949
2148
|
attempt: job.attempt + 1,
|
|
1950
|
-
error: error.slice(0, 1500)
|
|
2149
|
+
error: error.slice(0, 1500),
|
|
2150
|
+
// The steps describe an attempt that is over; the next one starts its own stream.
|
|
2151
|
+
progress: deleteField()
|
|
1951
2152
|
});
|
|
1952
2153
|
});
|
|
1953
2154
|
}
|
|
@@ -1956,7 +2157,15 @@ async function releaseJob(db, shipId, job, reason) {
|
|
|
1956
2157
|
status: "queued",
|
|
1957
2158
|
runnerId: "",
|
|
1958
2159
|
startedAt: 0,
|
|
1959
|
-
error: reason.slice(0, 1500)
|
|
2160
|
+
error: reason.slice(0, 1500),
|
|
2161
|
+
// §15.36: cleared here too, not only on the terminal paths. A released job sits `queued`
|
|
2162
|
+
// waiting to be re-claimed, and steps left behind would show a live-looking strip describing a
|
|
2163
|
+
// session that is no longer running anywhere.
|
|
2164
|
+
//
|
|
2165
|
+
// `stopRequestedAt` deliberately SURVIVES a release. The job goes back on the queue still
|
|
2166
|
+
// carrying the request, so whichever daemon claims it next honours the stop instead of running
|
|
2167
|
+
// work somebody already asked to end.
|
|
2168
|
+
progress: deleteField()
|
|
1960
2169
|
});
|
|
1961
2170
|
}
|
|
1962
2171
|
async function markTaskFailed(db, shipId, job, error, statuses) {
|
|
@@ -1979,6 +2188,25 @@ ${error.slice(0, 800)}
|
|
|
1979
2188
|
});
|
|
1980
2189
|
});
|
|
1981
2190
|
}
|
|
2191
|
+
async function markTaskStopped(db, shipId, job, stoppedBy) {
|
|
2192
|
+
const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
|
|
2193
|
+
await addDoc(collection4(taskRef, COLLECTIONS.activity), {
|
|
2194
|
+
author: { type: "agent", id: job.agentId },
|
|
2195
|
+
createdAt: Date.now(),
|
|
2196
|
+
kind: "comment",
|
|
2197
|
+
content: `This run was stopped by ${stoppedBy} before it finished. The task is unchanged.`
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
async function markChatStopped(db, shipId, job, stoppedBy) {
|
|
2201
|
+
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2202
|
+
const content = `Stopped by ${stoppedBy} before I finished. Write again to start a fresh run.`;
|
|
2203
|
+
await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
|
|
2204
|
+
author: { type: "agent", id: job.agentId },
|
|
2205
|
+
content,
|
|
2206
|
+
chars: content.length,
|
|
2207
|
+
createdAt: Date.now()
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
1982
2210
|
async function markChatFailed(db, shipId, job, error) {
|
|
1983
2211
|
const shipRef = doc6(db, COLLECTIONS.ships, shipId);
|
|
1984
2212
|
const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
|
|
@@ -2033,11 +2261,96 @@ async function ensureChatReply(db, shipId, job, resultText) {
|
|
|
2033
2261
|
return resultText.trim() ? "posted-final-text" : "posted-silence-note";
|
|
2034
2262
|
}
|
|
2035
2263
|
|
|
2264
|
+
// src/jobs/progress.ts
|
|
2265
|
+
var PROGRESS_FLUSH_MS = 3e3;
|
|
2266
|
+
var PROGRESS_DENIALS_BEFORE_PROBE = 2;
|
|
2267
|
+
function redactStep(step, secrets) {
|
|
2268
|
+
const label = clampStepText(redactTranscript(step.label, secrets), MAX_STEP_LABEL);
|
|
2269
|
+
if (step.detail === void 0) return { ...step, label };
|
|
2270
|
+
const detail = clampStepText(redactTranscript(step.detail, secrets), MAX_STEP_DETAIL);
|
|
2271
|
+
return detail ? { ...step, label, detail } : { at: step.at, kind: step.kind, label };
|
|
2272
|
+
}
|
|
2273
|
+
function isDenied(error) {
|
|
2274
|
+
const code = error?.code ?? "";
|
|
2275
|
+
return code === "permission-denied" || code.endsWith("/permission-denied");
|
|
2276
|
+
}
|
|
2277
|
+
function createProgressWriter(deps) {
|
|
2278
|
+
const now = deps.now ?? Date.now;
|
|
2279
|
+
const flushMs = deps.flushMs ?? PROGRESS_FLUSH_MS;
|
|
2280
|
+
let progress;
|
|
2281
|
+
let pending = [];
|
|
2282
|
+
let timer = null;
|
|
2283
|
+
let lastWriteAt = 0;
|
|
2284
|
+
let inFlight = false;
|
|
2285
|
+
let denials = 0;
|
|
2286
|
+
let closed = false;
|
|
2287
|
+
let loggedFailure = false;
|
|
2288
|
+
function flush() {
|
|
2289
|
+
if (timer) {
|
|
2290
|
+
clearTimeout(timer);
|
|
2291
|
+
timer = null;
|
|
2292
|
+
}
|
|
2293
|
+
if (closed || inFlight || pending.length === 0) return;
|
|
2294
|
+
const secrets = deps.secrets();
|
|
2295
|
+
for (const step of pending) progress = pushJobStep(progress, redactStep(step, secrets));
|
|
2296
|
+
pending = [];
|
|
2297
|
+
lastWriteAt = now();
|
|
2298
|
+
inFlight = true;
|
|
2299
|
+
const snapshot = progress;
|
|
2300
|
+
void deps.write(snapshot).then(() => {
|
|
2301
|
+
denials = 0;
|
|
2302
|
+
}).catch((e) => {
|
|
2303
|
+
if (isDenied(e)) {
|
|
2304
|
+
denials += 1;
|
|
2305
|
+
if (denials >= PROGRESS_DENIALS_BEFORE_PROBE) {
|
|
2306
|
+
closed = true;
|
|
2307
|
+
deps.onDenied();
|
|
2308
|
+
}
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
denials = 0;
|
|
2312
|
+
if (!loggedFailure) {
|
|
2313
|
+
loggedFailure = true;
|
|
2314
|
+
deps.log(`progress write failed (further ones silent): ${e instanceof Error ? e.message : e}`);
|
|
2315
|
+
}
|
|
2316
|
+
}).finally(() => {
|
|
2317
|
+
inFlight = false;
|
|
2318
|
+
if (pending.length > 0 && !closed) schedule();
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
function schedule() {
|
|
2322
|
+
if (closed || timer) return;
|
|
2323
|
+
const due = lastWriteAt + flushMs - now();
|
|
2324
|
+
if (due <= 0 && !inFlight) {
|
|
2325
|
+
flush();
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
timer = setTimeout(flush, Math.max(due, 0));
|
|
2329
|
+
timer.unref?.();
|
|
2330
|
+
}
|
|
2331
|
+
return {
|
|
2332
|
+
push(step) {
|
|
2333
|
+
if (closed) return;
|
|
2334
|
+
pending.push({ ...step, at: now() });
|
|
2335
|
+
schedule();
|
|
2336
|
+
},
|
|
2337
|
+
stop() {
|
|
2338
|
+
closed = true;
|
|
2339
|
+
if (timer) {
|
|
2340
|
+
clearTimeout(timer);
|
|
2341
|
+
timer = null;
|
|
2342
|
+
}
|
|
2343
|
+
pending = [];
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2036
2348
|
// src/daemon.ts
|
|
2037
2349
|
var HEARTBEAT_MS = 3e4;
|
|
2038
2350
|
var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
2039
2351
|
var MAX_ATTEMPTS = 2;
|
|
2040
2352
|
var SHUTDOWN_GRACE_MS = 1e4;
|
|
2353
|
+
var STOP_POLL_MS = 5e3;
|
|
2041
2354
|
async function startDaemon() {
|
|
2042
2355
|
const moved = migrateLegacyDir();
|
|
2043
2356
|
let config2 = requireConfig();
|
|
@@ -2424,6 +2737,23 @@ async function startDaemon() {
|
|
|
2424
2737
|
...job.workflowName ? { workflowName: job.workflowName } : {},
|
|
2425
2738
|
startedAt: Date.now()
|
|
2426
2739
|
};
|
|
2740
|
+
const stopPoll = setInterval(() => {
|
|
2741
|
+
void (async () => {
|
|
2742
|
+
if (slot.stop) return;
|
|
2743
|
+
try {
|
|
2744
|
+
const snap = await getDoc5(
|
|
2745
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2746
|
+
);
|
|
2747
|
+
const fresh = snap.data();
|
|
2748
|
+
if (!fresh?.stopRequestedAt || slot.stop) return;
|
|
2749
|
+
slot.stop = { at: fresh.stopRequestedAt, by: fresh.stopRequestedBy?.id ?? "someone" };
|
|
2750
|
+
log2(`Job ${job.id}: stop requested \u2014 ending the session.`);
|
|
2751
|
+
slot.abort.abort();
|
|
2752
|
+
} catch {
|
|
2753
|
+
}
|
|
2754
|
+
})();
|
|
2755
|
+
}, STOP_POLL_MS);
|
|
2756
|
+
stopPoll.unref();
|
|
2427
2757
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2428
2758
|
void heartbeat();
|
|
2429
2759
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
@@ -2450,6 +2780,37 @@ async function startDaemon() {
|
|
|
2450
2780
|
let githubToken;
|
|
2451
2781
|
let extraMcpServers = [];
|
|
2452
2782
|
let statuses = DEFAULT_TASK_STATUSES;
|
|
2783
|
+
let knownSecrets = [];
|
|
2784
|
+
const progress = createProgressWriter({
|
|
2785
|
+
write: (p) => updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
2786
|
+
progress: p
|
|
2787
|
+
}),
|
|
2788
|
+
secrets: () => knownSecrets,
|
|
2789
|
+
// Two consecutive DENIALS mean this job is no longer ours to write — a force-stop took it, or
|
|
2790
|
+
// the machine was revoked. The §15.30 door: the news arrives as `permission-denied`.
|
|
2791
|
+
//
|
|
2792
|
+
// A PROBE RATHER THAN A BARE ABORT, and it is not optional. If a deploy lands the daemon
|
|
2793
|
+
// before the rules, every progress write is denied on a perfectly healthy job, and aborting
|
|
2794
|
+
// on that signal alone would kill live work for the length of the rollout. One read settles
|
|
2795
|
+
// which of the two it is.
|
|
2796
|
+
onDenied: () => {
|
|
2797
|
+
void (async () => {
|
|
2798
|
+
try {
|
|
2799
|
+
const snap = await getDoc5(
|
|
2800
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2801
|
+
);
|
|
2802
|
+
const fresh = snap.data();
|
|
2803
|
+
if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
|
|
2804
|
+
log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
|
|
2805
|
+
slot.abort.abort();
|
|
2806
|
+
}
|
|
2807
|
+
} catch {
|
|
2808
|
+
}
|
|
2809
|
+
})();
|
|
2810
|
+
},
|
|
2811
|
+
log: log2
|
|
2812
|
+
});
|
|
2813
|
+
progress.push({ kind: "start", label: "Starting up" });
|
|
2453
2814
|
try {
|
|
2454
2815
|
const secrets = await loadSecrets(shipId);
|
|
2455
2816
|
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 +2864,15 @@ async function startDaemon() {
|
|
|
2503
2864
|
throw e;
|
|
2504
2865
|
}
|
|
2505
2866
|
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
2867
|
+
knownSecrets = [
|
|
2868
|
+
idToken,
|
|
2869
|
+
secrets?.claudeToken,
|
|
2870
|
+
githubToken,
|
|
2871
|
+
secrets?.githubPat,
|
|
2872
|
+
// §15.31. BOTH the composed header value and the bare credential — see
|
|
2873
|
+
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
2874
|
+
...mcpSecretsToRedact(extraMcpServers)
|
|
2875
|
+
];
|
|
2506
2876
|
const session = await getDriver(engineId).run({
|
|
2507
2877
|
prompt,
|
|
2508
2878
|
agent,
|
|
@@ -2515,17 +2885,13 @@ async function startDaemon() {
|
|
|
2515
2885
|
githubToken,
|
|
2516
2886
|
timeoutMs: JOB_TIMEOUT_MS,
|
|
2517
2887
|
signal: slot.abort.signal,
|
|
2518
|
-
log: log2
|
|
2888
|
+
log: log2,
|
|
2889
|
+
// §15.36. The driver emits Crew's step vocabulary; the writer coalesces, redacts and
|
|
2890
|
+
// persists. A throw here must never reach the driver's stdout loop, which is why `push`
|
|
2891
|
+
// swallows everything.
|
|
2892
|
+
onStep: (step) => progress.push(step)
|
|
2519
2893
|
});
|
|
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
|
-
]);
|
|
2894
|
+
transcript = redactTranscript(session.transcript, knownSecrets);
|
|
2529
2895
|
usage = session.usage;
|
|
2530
2896
|
if (session.limit && getEngine(engineId).usageWindows) {
|
|
2531
2897
|
sessionLimit = session.limit;
|
|
@@ -2545,11 +2911,29 @@ async function startDaemon() {
|
|
|
2545
2911
|
transient = isTransientFirestoreError(e);
|
|
2546
2912
|
}
|
|
2547
2913
|
wake?.release();
|
|
2914
|
+
progress.stop();
|
|
2915
|
+
clearInterval(stopPoll);
|
|
2548
2916
|
try {
|
|
2549
2917
|
if (!serving.has(shipId)) {
|
|
2550
2918
|
log2(
|
|
2551
2919
|
`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
2920
|
);
|
|
2921
|
+
} else if (slot.stop && failure) {
|
|
2922
|
+
const transcriptPath = transcript ? await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript) : "";
|
|
2923
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, {
|
|
2924
|
+
status: "stopped",
|
|
2925
|
+
usage,
|
|
2926
|
+
transcriptPath,
|
|
2927
|
+
mcpServers: extraMcpServers.map((s) => s.key)
|
|
2928
|
+
});
|
|
2929
|
+
const by = slot.stop.by;
|
|
2930
|
+
if (target.kind === "chat") {
|
|
2931
|
+
await markChatStopped(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, by);
|
|
2932
|
+
} else {
|
|
2933
|
+
await markTaskStopped(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }, by);
|
|
2934
|
+
}
|
|
2935
|
+
log2(`Job ${job.id} STOPPED by ${by} (attempt ${job.attempt}).`);
|
|
2936
|
+
notify("Crew job stopped", `${targetLabel} was stopped before it finished.`);
|
|
2553
2937
|
} else if (shuttingDown) {
|
|
2554
2938
|
await releaseJob(
|
|
2555
2939
|
sess(shipId).fb.db,
|
|
@@ -2690,8 +3074,8 @@ async function startDaemon() {
|
|
|
2690
3074
|
{
|
|
2691
3075
|
status: "offline",
|
|
2692
3076
|
lastSeenAt: now,
|
|
2693
|
-
currentJob:
|
|
2694
|
-
currentJobs:
|
|
3077
|
+
currentJob: deleteField2(),
|
|
3078
|
+
currentJobs: deleteField2()
|
|
2695
3079
|
},
|
|
2696
3080
|
{ merge: true }
|
|
2697
3081
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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.",
|