@lumi.ai/runner 0.6.0 → 0.6.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 +70 -3
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -764,7 +764,7 @@ function mcpUrl(config2) {
764
764
  }
765
765
 
766
766
  // src/version.ts
767
- var RUNNER_VERSION = true ? "0.6.0" : "0.0.0-dev";
767
+ var RUNNER_VERSION = true ? "0.6.2" : "0.0.0-dev";
768
768
 
769
769
  // src/auth.ts
770
770
  import { signInWithCustomToken } from "firebase/auth";
@@ -2328,6 +2328,48 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
2328
2328
  );
2329
2329
  }
2330
2330
 
2331
+ // src/jobs/claimBackoff.ts
2332
+ var BACKOFF_BASE_MS = 3e3;
2333
+ var BACKOFF_MAX_MS = 6e4;
2334
+ var BACKOFF_RETENTION_MS = 10 * 6e4;
2335
+ function backoffFor(releases) {
2336
+ return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, releases - 1));
2337
+ }
2338
+ function noteTransientRelease(backoff, jobId, now) {
2339
+ const releases = (backoff.get(jobId)?.releases ?? 0) + 1;
2340
+ const entry = { releases, eligibleAt: now + backoffFor(releases), poked: false };
2341
+ backoff.set(jobId, entry);
2342
+ return entry;
2343
+ }
2344
+ function isBackedOff(backoff, jobId, now) {
2345
+ const entry = backoff.get(jobId);
2346
+ return !!entry && entry.eligibleAt > now;
2347
+ }
2348
+ function clearBackoff(backoff, jobId) {
2349
+ backoff.delete(jobId);
2350
+ }
2351
+ function pruneExpired2(backoff, now) {
2352
+ const cleared = [];
2353
+ for (const [key, entry] of backoff) {
2354
+ if (entry.eligibleAt > now) continue;
2355
+ if (!entry.poked) {
2356
+ cleared.push(key);
2357
+ entry.poked = true;
2358
+ }
2359
+ if (now - entry.eligibleAt >= BACKOFF_RETENTION_MS) backoff.delete(key);
2360
+ }
2361
+ return cleared;
2362
+ }
2363
+ function nextEligibleAt(backoff, now) {
2364
+ let soonest = null;
2365
+ for (const entry of backoff.values()) {
2366
+ if (entry.eligibleAt > now && (soonest === null || entry.eligibleAt < soonest)) {
2367
+ soonest = entry.eligibleAt;
2368
+ }
2369
+ }
2370
+ return soonest;
2371
+ }
2372
+
2331
2373
  // src/jobs/capacity.ts
2332
2374
  var DEFAULT_PARALLEL_JOBS = 1;
2333
2375
  var PARALLEL_MAX = 8;
@@ -3445,6 +3487,7 @@ async function startDaemon() {
3445
3487
  }
3446
3488
  const pending = /* @__PURE__ */ new Map();
3447
3489
  const engineLimits = /* @__PURE__ */ new Map();
3490
+ const claimBackoff = /* @__PURE__ */ new Map();
3448
3491
  const agentEngines = /* @__PURE__ */ new Map();
3449
3492
  const unsubsByShip = /* @__PURE__ */ new Map();
3450
3493
  const listenerError = (shipId, what) => (e) => {
@@ -3627,6 +3670,23 @@ async function startDaemon() {
3627
3670
  limitTimer = setTimeout(sweepLimits, Math.max(0, at - Date.now()) + 1e3);
3628
3671
  limitTimer.unref();
3629
3672
  }
3673
+ let backoffTimer = null;
3674
+ function armBackoffTimer() {
3675
+ if (backoffTimer) {
3676
+ clearTimeout(backoffTimer);
3677
+ backoffTimer = null;
3678
+ }
3679
+ const at = nextEligibleAt(claimBackoff, Date.now());
3680
+ if (at === null) return;
3681
+ backoffTimer = setTimeout(sweepBackoff, Math.max(0, at - Date.now()) + 1e3);
3682
+ backoffTimer.unref();
3683
+ }
3684
+ function sweepBackoff() {
3685
+ backoffTimer = null;
3686
+ const cleared = pruneExpired2(claimBackoff, Date.now());
3687
+ armBackoffTimer();
3688
+ if (cleared.length > 0) poke();
3689
+ }
3630
3690
  function sweepLimits() {
3631
3691
  limitTimer = null;
3632
3692
  const cleared = pruneExpired(engineLimits, Date.now());
@@ -3658,7 +3718,11 @@ async function startDaemon() {
3658
3718
  // the limit map is keyed by (Ship, engine) and the engine comes from the registry via
3659
3719
  // the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
3660
3720
  // `pending`, which is what lets a reset resume them with only a poke.
3661
- eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)
3721
+ eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
3722
+ // skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
3723
+ // with a poke. Without this the `transient` release below re-claims what it just put
3724
+ // down, every three seconds, forever (jobs/claimBackoff.ts).
3725
+ !isBackedOff(claimBackoff, p.job.id, now)
3662
3726
  });
3663
3727
  for (const pick of picks) {
3664
3728
  pending.delete(`${pick.shipId}/${pick.job.id}`);
@@ -4014,14 +4078,16 @@ async function startDaemon() {
4014
4078
  target.kind === "task" ? `Task ${target.taskId} is done.` : delivery === "agent-replied" ? "Agent replied in a chat." : "Agent finished a chat run without replying \u2014 its answer was posted for it."
4015
4079
  );
4016
4080
  } else if (transient) {
4081
+ const held = noteTransientRelease(claimBackoff, job.id, Date.now());
4017
4082
  await releaseJob(
4018
4083
  sess(shipId).fb.db,
4019
4084
  shipId,
4020
4085
  job,
4021
4086
  `Lost the connection to Firestore \u2014 released without consuming a retry: ${failure.slice(0, 120)}`
4022
4087
  );
4088
+ armBackoffTimer();
4023
4089
  log2(
4024
- `Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}`
4090
+ `Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}. Not re-claiming it here for ${Math.round((held.eligibleAt - Date.now()) / 1e3)}s (drop ${held.releases} on this machine); another daemon may take it meanwhile.`
4025
4091
  );
4026
4092
  } else if (!terminal && job.attempt < MAX_ATTEMPTS) {
4027
4093
  log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
@@ -4043,6 +4109,7 @@ async function startDaemon() {
4043
4109
  log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
4044
4110
  notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
4045
4111
  }
4112
+ if (!transient) clearBackoff(claimBackoff, job.id);
4046
4113
  } catch (e) {
4047
4114
  log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
4048
4115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.6.0",
3
+ "version": "0.6.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.",