@lumi.ai/runner 0.5.0 → 0.5.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/dist/cli.js +82 -42
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -552,7 +552,7 @@ function mcpUrl(config2) {
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
// src/version.ts
|
|
555
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
555
|
+
var RUNNER_VERSION = true ? "0.5.2" : "0.0.0-dev";
|
|
556
556
|
|
|
557
557
|
// src/auth.ts
|
|
558
558
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -909,52 +909,80 @@ import {
|
|
|
909
909
|
query,
|
|
910
910
|
where
|
|
911
911
|
} from "firebase/firestore";
|
|
912
|
+
|
|
913
|
+
// src/jobs/retry.ts
|
|
914
|
+
var FIRESTORE_RETRY_DELAYS_MS = [250, 750, 2e3];
|
|
915
|
+
function isTransientFirestoreError(error) {
|
|
916
|
+
const raw = error?.code;
|
|
917
|
+
const code = typeof raw === "string" ? raw.replace(/^firestore\//, "") : "";
|
|
918
|
+
if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
const message = error?.message;
|
|
922
|
+
return typeof message === "string" && message.toLowerCase().includes("client is offline");
|
|
923
|
+
}
|
|
924
|
+
async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
|
|
925
|
+
for (let i = 0; ; i++) {
|
|
926
|
+
try {
|
|
927
|
+
return await read();
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (i >= delays.length || !isTransientFirestoreError(error)) throw error;
|
|
930
|
+
await sleep2(delays[i]);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/jobs/contextPack.ts
|
|
912
936
|
var MAX_ACTIVITY_IN_PROMPT = 40;
|
|
913
937
|
var MAX_PREVIOUS_JOBS_READ = 5;
|
|
914
938
|
async function loadJobContext(db, shipId, job) {
|
|
915
939
|
const shipRef = doc(db, COLLECTIONS.ships, shipId);
|
|
916
940
|
const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
|
|
917
|
-
const [shipSnap, agentSnap, taskSnap, activitySnap, jobsSnap, indexSnap] = await
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
941
|
+
const [shipSnap, agentSnap, taskSnap, activitySnap, jobsSnap, indexSnap] = await withFirestoreRetry(
|
|
942
|
+
() => Promise.all([
|
|
943
|
+
getDoc(shipRef),
|
|
944
|
+
getDoc(doc(shipRef, COLLECTIONS.agents, job.agentId)),
|
|
945
|
+
getDoc(taskRef),
|
|
946
|
+
// DESC + reverse, not ASC + limit: a limit takes the FIRST rows the order produces, so an
|
|
947
|
+
// ascending query with a limit would hand the agent the OLDEST 40 events and hide everything
|
|
948
|
+
// that has happened since — the exact opposite of what continuity needs. The extra row is
|
|
949
|
+
// how truncation is detected without a second count query.
|
|
950
|
+
getDocs(
|
|
951
|
+
query(
|
|
952
|
+
collection(taskRef, COLLECTIONS.activity),
|
|
953
|
+
orderBy("createdAt", "desc"),
|
|
954
|
+
limit(MAX_ACTIVITY_IN_PROMPT + 1)
|
|
955
|
+
)
|
|
956
|
+
),
|
|
957
|
+
// The extra row here is for a different reason: this job itself is usually the newest match
|
|
958
|
+
// and is filtered out below, so without it a full window yields one report short.
|
|
959
|
+
getDocs(
|
|
960
|
+
query(
|
|
961
|
+
collection(shipRef, COLLECTIONS.jobs),
|
|
962
|
+
where("taskId", "==", job.taskId),
|
|
963
|
+
orderBy("createdAt", "desc"),
|
|
964
|
+
limit(MAX_PREVIOUS_JOBS_READ + 1)
|
|
965
|
+
)
|
|
966
|
+
),
|
|
967
|
+
// The knowledge CATALOG — one document, so this is +1 read regardless of how much the Ship
|
|
968
|
+
// knows, and it rides the existing Promise.all so it costs no extra latency either. The
|
|
969
|
+
// agent's memory is free: it is a field on the agent doc already being fetched above.
|
|
970
|
+
//
|
|
971
|
+
// ENRICHMENT, not identity, exactly like the playbook read below: a Ship whose catalog is
|
|
972
|
+
// missing, unreadable or not yet deployed still has a perfectly valid session. Caught rather
|
|
973
|
+
// than thrown, and deliberately NOT placed where a rejection would propagate.
|
|
974
|
+
getDoc(doc(shipRef, COLLECTIONS.indexes, INDEX_DOCS.knowledge)).catch(() => null)
|
|
975
|
+
])
|
|
976
|
+
);
|
|
951
977
|
if (!shipSnap.exists() || !agentSnap.exists() || !taskSnap.exists()) {
|
|
952
978
|
throw new Error("Job context incomplete: ship, agent or task missing.");
|
|
953
979
|
}
|
|
954
980
|
const task = { id: taskSnap.id, ...taskSnap.data() };
|
|
955
981
|
let parentTask = null;
|
|
956
982
|
if (task.parentTaskId) {
|
|
957
|
-
const parentSnap = await
|
|
983
|
+
const parentSnap = await withFirestoreRetry(
|
|
984
|
+
() => getDoc(doc(shipRef, COLLECTIONS.tasks, task.parentTaskId))
|
|
985
|
+
);
|
|
958
986
|
if (parentSnap.exists()) {
|
|
959
987
|
parentTask = { id: parentSnap.id, ...parentSnap.data() };
|
|
960
988
|
}
|
|
@@ -1151,7 +1179,7 @@ async function loadChatContext(db, shipId, job) {
|
|
|
1151
1179
|
const shipRef = doc2(db, COLLECTIONS.ships, shipId);
|
|
1152
1180
|
const chatRef = doc2(shipRef, COLLECTIONS.chats, job.chatId);
|
|
1153
1181
|
const messagesCol = collection2(chatRef, COLLECTIONS.chatMessages);
|
|
1154
|
-
const [shipSnap, agentSnap, chatSnap, messagesSnap, countSnap, membersSnap, agentsSnap, indexSnap] = await Promise.all([
|
|
1182
|
+
const [shipSnap, agentSnap, chatSnap, messagesSnap, countSnap, membersSnap, agentsSnap, indexSnap] = await withFirestoreRetry(() => Promise.all([
|
|
1155
1183
|
getDoc2(shipRef),
|
|
1156
1184
|
getDoc2(doc2(shipRef, COLLECTIONS.agents, job.agentId)),
|
|
1157
1185
|
getDoc2(chatRef),
|
|
@@ -1165,7 +1193,7 @@ async function loadChatContext(db, shipId, job) {
|
|
|
1165
1193
|
// ENRICHMENT, not identity — same posture as the task pack: a Ship whose catalog is
|
|
1166
1194
|
// missing or unreadable still has a valid session.
|
|
1167
1195
|
getDoc2(doc2(shipRef, COLLECTIONS.indexes, INDEX_DOCS.knowledge)).catch(() => null)
|
|
1168
|
-
]);
|
|
1196
|
+
]));
|
|
1169
1197
|
if (!shipSnap.exists() || !agentSnap.exists() || !chatSnap.exists()) {
|
|
1170
1198
|
throw new Error("Chat job context incomplete: ship, agent or chat missing.");
|
|
1171
1199
|
}
|
|
@@ -1575,8 +1603,8 @@ import {
|
|
|
1575
1603
|
var TerminalJobError = class extends Error {
|
|
1576
1604
|
};
|
|
1577
1605
|
async function readIntegration(db, shipId) {
|
|
1578
|
-
const snap = await
|
|
1579
|
-
doc3(db, COLLECTIONS.ships, shipId, COLLECTIONS.integrations, INTEGRATION_DOCS.github)
|
|
1606
|
+
const snap = await withFirestoreRetry(
|
|
1607
|
+
() => getDoc3(doc3(db, COLLECTIONS.ships, shipId, COLLECTIONS.integrations, INTEGRATION_DOCS.github))
|
|
1580
1608
|
);
|
|
1581
1609
|
return snap.exists() ? snap.data() : null;
|
|
1582
1610
|
}
|
|
@@ -1606,8 +1634,8 @@ async function resolveGithubToken(input) {
|
|
|
1606
1634
|
// src/jobs/secrets.ts
|
|
1607
1635
|
import { doc as doc4, getDoc as getDoc4 } from "firebase/firestore";
|
|
1608
1636
|
async function loadRunnerSecrets(db, shipId) {
|
|
1609
|
-
const snap = await
|
|
1610
|
-
doc4(db, COLLECTIONS.ships, shipId, COLLECTIONS.secrets, SECRET_DOCS.runner)
|
|
1637
|
+
const snap = await withFirestoreRetry(
|
|
1638
|
+
() => getDoc4(doc4(db, COLLECTIONS.ships, shipId, COLLECTIONS.secrets, SECRET_DOCS.runner))
|
|
1611
1639
|
);
|
|
1612
1640
|
return snap.exists() ? snap.data() : null;
|
|
1613
1641
|
}
|
|
@@ -2227,6 +2255,7 @@ async function startDaemon() {
|
|
|
2227
2255
|
);
|
|
2228
2256
|
let failure = null;
|
|
2229
2257
|
let terminal = false;
|
|
2258
|
+
let transient = false;
|
|
2230
2259
|
let sessionLimit = null;
|
|
2231
2260
|
let engineId = DEFAULT_ENGINE_ID;
|
|
2232
2261
|
let transcript = "";
|
|
@@ -2315,6 +2344,7 @@ async function startDaemon() {
|
|
|
2315
2344
|
if (!session.ok) failure = session.resultText || "Session failed.";
|
|
2316
2345
|
} catch (e) {
|
|
2317
2346
|
failure = e instanceof Error ? e.message : String(e);
|
|
2347
|
+
transient = isTransientFirestoreError(e);
|
|
2318
2348
|
}
|
|
2319
2349
|
wake?.release();
|
|
2320
2350
|
try {
|
|
@@ -2355,6 +2385,16 @@ async function startDaemon() {
|
|
|
2355
2385
|
"Crew job finished",
|
|
2356
2386
|
target.kind === "task" ? `Task ${target.taskId} is done.` : "Agent replied in a chat."
|
|
2357
2387
|
);
|
|
2388
|
+
} else if (transient) {
|
|
2389
|
+
await releaseJob(
|
|
2390
|
+
sess(shipId).fb.db,
|
|
2391
|
+
shipId,
|
|
2392
|
+
job,
|
|
2393
|
+
`Lost the connection to Firestore \u2014 released without consuming a retry: ${failure.slice(0, 120)}`
|
|
2394
|
+
);
|
|
2395
|
+
log2(
|
|
2396
|
+
`Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}`
|
|
2397
|
+
);
|
|
2358
2398
|
} else if (!terminal && job.attempt < MAX_ATTEMPTS) {
|
|
2359
2399
|
log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
|
|
2360
2400
|
await requeueForRetry(sess(shipId).fb.db, shipId, job, failure);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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.",
|