@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.
Files changed (2) hide show
  1. package/dist/cli.js +82 -42
  2. 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.0" : "0.0.0-dev";
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 Promise.all([
918
- getDoc(shipRef),
919
- getDoc(doc(shipRef, COLLECTIONS.agents, job.agentId)),
920
- getDoc(taskRef),
921
- // DESC + reverse, not ASC + limit: a limit takes the FIRST rows the order produces, so an
922
- // ascending query with a limit would hand the agent the OLDEST 40 events and hide everything
923
- // that has happened since the exact opposite of what continuity needs. The extra row is
924
- // how truncation is detected without a second count query.
925
- getDocs(
926
- query(
927
- collection(taskRef, COLLECTIONS.activity),
928
- orderBy("createdAt", "desc"),
929
- limit(MAX_ACTIVITY_IN_PROMPT + 1)
930
- )
931
- ),
932
- // The extra row here is for a different reason: this job itself is usually the newest match
933
- // and is filtered out below, so without it a full window yields one report short.
934
- getDocs(
935
- query(
936
- collection(shipRef, COLLECTIONS.jobs),
937
- where("taskId", "==", job.taskId),
938
- orderBy("createdAt", "desc"),
939
- limit(MAX_PREVIOUS_JOBS_READ + 1)
940
- )
941
- ),
942
- // The knowledge CATALOG — one document, so this is +1 read regardless of how much the Ship
943
- // knows, and it rides the existing Promise.all so it costs no extra latency either. The
944
- // agent's memory is free: it is a field on the agent doc already being fetched above.
945
- //
946
- // ENRICHMENT, not identity, exactly like the playbook read below: a Ship whose catalog is
947
- // missing, unreadable or not yet deployed still has a perfectly valid session. Caught rather
948
- // than thrown, and deliberately NOT placed where a rejection would propagate.
949
- getDoc(doc(shipRef, COLLECTIONS.indexes, INDEX_DOCS.knowledge)).catch(() => null)
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 getDoc(doc(shipRef, COLLECTIONS.tasks, task.parentTaskId));
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 getDoc3(
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 getDoc4(
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.0",
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.",