@lumi.ai/runner 0.3.6 → 0.4.0

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 +121 -57
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -532,12 +532,17 @@ function saveConfig(config2) {
532
532
  fs.writeFileSync(configPath(), `${JSON.stringify(config2, null, 2)}
533
533
  `, { mode: 384 });
534
534
  }
535
+ function forgetShip(config2, shipId) {
536
+ const shipKeys = { ...config2.shipKeys ?? {} };
537
+ delete shipKeys[shipId];
538
+ return { ...config2, shipKeys, ships: config2.ships.filter((id) => id !== shipId) };
539
+ }
535
540
  function mcpUrl(config2) {
536
541
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
537
542
  }
538
543
 
539
544
  // src/version.ts
540
- var RUNNER_VERSION = true ? "0.3.6" : "0.0.0-dev";
545
+ var RUNNER_VERSION = true ? "0.4.0" : "0.0.0-dev";
541
546
 
542
547
  // src/auth.ts
543
548
  import { signInWithCustomToken } from "firebase/auth";
@@ -609,13 +614,11 @@ async function probeShipKey(config2, shipId) {
609
614
  }
610
615
  function pruneDeadShips(config2, failures) {
611
616
  const dropped = failures.filter((f) => f.gone).map((f) => f.shipId);
612
- if (dropped.length === 0) return { config: config2, dropped };
613
- const shipKeys = { ...config2.shipKeys ?? {} };
614
- for (const shipId of dropped) delete shipKeys[shipId];
615
- return {
616
- config: { ...config2, shipKeys, ships: config2.ships.filter((id) => !dropped.includes(id)) },
617
- dropped
618
- };
617
+ return { config: dropped.reduce(forgetShip, config2), dropped };
618
+ }
619
+ function isEnrolmentGone(error) {
620
+ const code = error?.code;
621
+ return code === "permission-denied" || code === "firestore/permission-denied";
619
622
  }
620
623
  async function signInToShip(fb, config2, shipId) {
621
624
  const key = config2.shipKeys?.[shipId];
@@ -1821,9 +1824,9 @@ async function startDaemon() {
1821
1824
  if (!s) throw new Error(`No session for Ship ${shipId}`);
1822
1825
  return s;
1823
1826
  };
1824
- const liveShips = sessions.map((s) => s.shipId);
1827
+ const serving = new Set(sessions.map((s) => s.shipId));
1825
1828
  console.log(
1826
- `Runner ${config2.runnerId} serving ${liveShips.length} Ship(s): ${liveShips.join(", ")}`
1829
+ `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
1827
1830
  );
1828
1831
  const logLines = [];
1829
1832
  const log2 = (line) => {
@@ -1836,6 +1839,7 @@ async function startDaemon() {
1836
1839
  if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
1837
1840
  const startedAt = Date.now();
1838
1841
  let currentJob = null;
1842
+ let currentJobShip = null;
1839
1843
  const approved = /* @__PURE__ */ new Map();
1840
1844
  const warnedUnapproved = /* @__PURE__ */ new Set();
1841
1845
  const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
@@ -1846,43 +1850,53 @@ async function startDaemon() {
1846
1850
  beating = true;
1847
1851
  const now = Date.now();
1848
1852
  try {
1849
- for (const shipId of liveShips) {
1850
- await setDoc2(
1851
- shipRunnerRef(shipId),
1852
- {
1853
- hostname: os3.hostname(),
1854
- version: RUNNER_VERSION,
1855
- status: "online",
1856
- lastSeenAt: now,
1857
- startedAt,
1858
- currentJob: currentJob ?? null,
1859
- lastLogLines: [...logLines]
1860
- },
1861
- { merge: true }
1862
- );
1863
- const snap = await getDoc5(shipRunnerRef(shipId));
1864
- const isApproved = snap.data()?.approved === true;
1865
- const wasApproved = approved.get(shipId) === true;
1866
- approved.set(shipId, isApproved);
1867
- if (!isApproved && !warnedUnapproved.has(shipId)) {
1868
- warnedUnapproved.add(shipId);
1869
- log2(
1870
- `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1853
+ for (const shipId of [...serving]) {
1854
+ try {
1855
+ const snap = await getDoc5(shipRunnerRef(shipId));
1856
+ if (!snap.exists()) {
1857
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1858
+ continue;
1859
+ }
1860
+ const isApproved = snap.data()?.approved === true;
1861
+ const wasApproved = approved.get(shipId) === true;
1862
+ approved.set(shipId, isApproved);
1863
+ if (!isApproved && !warnedUnapproved.has(shipId)) {
1864
+ warnedUnapproved.add(shipId);
1865
+ log2(
1866
+ `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1867
+ );
1868
+ }
1869
+ if (isApproved && !wasApproved) {
1870
+ warnedUnapproved.delete(shipId);
1871
+ needsRefill.add(shipId);
1872
+ }
1873
+ await setDoc2(
1874
+ shipRunnerRef(shipId),
1875
+ {
1876
+ hostname: os3.hostname(),
1877
+ version: RUNNER_VERSION,
1878
+ status: "online",
1879
+ lastSeenAt: now,
1880
+ startedAt,
1881
+ currentJob: currentJob ?? null,
1882
+ lastLogLines: [...logLines]
1883
+ },
1884
+ { merge: true }
1871
1885
  );
1872
- }
1873
- if (isApproved && !wasApproved) {
1874
- warnedUnapproved.delete(shipId);
1875
- needsRefill.add(shipId);
1886
+ } catch (e) {
1887
+ if (isEnrolmentGone(e)) {
1888
+ forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
1889
+ continue;
1890
+ }
1891
+ console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
1876
1892
  }
1877
1893
  }
1878
- } catch (e) {
1879
- console.error("heartbeat failed:", e instanceof Error ? e.message : e);
1880
1894
  } finally {
1881
1895
  beating = false;
1882
1896
  }
1883
1897
  for (const shipId of [...needsRefill]) {
1884
1898
  needsRefill.delete(shipId);
1885
- if (approved.get(shipId) !== true) continue;
1899
+ if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
1886
1900
  try {
1887
1901
  const snap = await getDocs3(
1888
1902
  query3(
@@ -1907,14 +1921,21 @@ async function startDaemon() {
1907
1921
  const pending = /* @__PURE__ */ new Map();
1908
1922
  const engineLimits = /* @__PURE__ */ new Map();
1909
1923
  const agentEngines = /* @__PURE__ */ new Map();
1910
- const unsubs = [];
1911
- for (const shipId of liveShips) {
1924
+ const unsubsByShip = /* @__PURE__ */ new Map();
1925
+ const listenerError = (shipId, what) => (e) => {
1926
+ if (isEnrolmentGone(e)) {
1927
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1928
+ return;
1929
+ }
1930
+ console.error(`${what} listener error (${shipId}):`, e.message);
1931
+ };
1932
+ for (const shipId of serving) {
1912
1933
  const q = query3(
1913
1934
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1914
1935
  where3("status", "==", "queued"),
1915
1936
  orderBy3("createdAt", "asc")
1916
1937
  );
1917
- unsubs.push(
1938
+ unsubsByShip.set(shipId, [
1918
1939
  onSnapshot2(
1919
1940
  q,
1920
1941
  (snap) => {
@@ -1929,10 +1950,9 @@ async function startDaemon() {
1929
1950
  }
1930
1951
  poke();
1931
1952
  },
1932
- (e) => console.error(`jobs listener error (${shipId}):`, e.message)
1933
- )
1934
- );
1935
- unsubs.push(
1953
+ listenerError(shipId, "jobs")
1954
+ ),
1955
+ // Agents, purely so the claim gate can resolve a queued job's engine without a read.
1936
1956
  onSnapshot2(
1937
1957
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1938
1958
  (snap) => {
@@ -1941,10 +1961,11 @@ async function startDaemon() {
1941
1961
  }
1942
1962
  poke();
1943
1963
  },
1944
- (e) => console.error(`agents listener error (${shipId}):`, e.message)
1945
- )
1946
- );
1947
- unsubs.push(
1964
+ listenerError(shipId, "agents")
1965
+ ),
1966
+ // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
1967
+ // and a second daemon on this Ship — inherits the pause instead of running a doomed
1968
+ // session to rediscover it.
1948
1969
  subscribeEngineLimits(
1949
1970
  sess(shipId).fb.db,
1950
1971
  shipId,
@@ -1963,9 +1984,46 @@ async function startDaemon() {
1963
1984
  }
1964
1985
  sweepLimits();
1965
1986
  },
1966
- (e) => console.error(`engine limits listener error (${shipId}):`, e.message)
1987
+ listenerError(shipId, "engine limits")
1967
1988
  )
1989
+ ]);
1990
+ }
1991
+ function forgetShipLocally(shipId, why) {
1992
+ if (!serving.delete(shipId)) return;
1993
+ for (const unsub of unsubsByShip.get(shipId) ?? []) unsub();
1994
+ unsubsByShip.delete(shipId);
1995
+ for (const key of [...pending.keys()]) {
1996
+ if (pending.get(key)?.shipId === shipId) pending.delete(key);
1997
+ }
1998
+ for (const key of [...engineLimits.keys()]) {
1999
+ if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
2000
+ }
2001
+ for (const key of [...agentEngines.keys()]) {
2002
+ if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
2003
+ }
2004
+ approved.delete(shipId);
2005
+ warnedUnapproved.delete(shipId);
2006
+ needsRefill.delete(shipId);
2007
+ config2 = forgetShip(config2, shipId);
2008
+ try {
2009
+ saveConfig(config2);
2010
+ } catch (e) {
2011
+ log2(`Could not rewrite the config after leaving Ship ${shipId}: ${e instanceof Error ? e.message : e}`);
2012
+ }
2013
+ log2(
2014
+ `Ship ${shipId}: ${why}. Deleted its runner key, its assignment and its queued work from this machine. To serve it again, run \`lumi-runner login\` here and have a captain approve this machine in the browser.`
1968
2015
  );
2016
+ if (currentJobShip === shipId && sessionAbort) {
2017
+ log2(
2018
+ `Stopping the job it was running \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put that job back on the queue itself.`
2019
+ );
2020
+ sessionAbort.abort();
2021
+ }
2022
+ if (serving.size === 0) {
2023
+ log2(
2024
+ "This machine now serves no Ships. Staying up and idle \u2014 run `lumi-runner login` to connect it again, or `lumi-runner uninstall` to retire it."
2025
+ );
2026
+ }
1969
2027
  }
1970
2028
  const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
1971
2029
  let working = false;
@@ -2092,6 +2150,7 @@ async function startDaemon() {
2092
2150
  ...job.workflowName ? { workflowName: job.workflowName } : {},
2093
2151
  startedAt: Date.now()
2094
2152
  };
2153
+ currentJobShip = shipId;
2095
2154
  await setAgentStatus(shipId, job.agentId, "working");
2096
2155
  void heartbeat();
2097
2156
  const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
@@ -2194,7 +2253,11 @@ async function startDaemon() {
2194
2253
  wake?.release();
2195
2254
  sessionAbort = null;
2196
2255
  try {
2197
- if (shuttingDown) {
2256
+ if (!serving.has(shipId)) {
2257
+ log2(
2258
+ `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.`
2259
+ );
2260
+ } else if (shuttingDown) {
2198
2261
  await releaseJob(
2199
2262
  sess(shipId).fb.db,
2200
2263
  shipId,
@@ -2245,10 +2308,11 @@ async function startDaemon() {
2245
2308
  log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2246
2309
  }
2247
2310
  currentJob = null;
2248
- await setAgentStatus(shipId, job.agentId, "idle");
2311
+ currentJobShip = null;
2312
+ if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
2249
2313
  if (!shuttingDown) void heartbeat();
2250
2314
  }
2251
- await Promise.all(liveShips.map((shipId) => loadSecrets(shipId).catch(() => null)));
2315
+ await Promise.all([...serving].map((shipId) => loadSecrets(shipId).catch(() => null)));
2252
2316
  await heartbeat();
2253
2317
  const heartbeatTimer = setInterval(() => {
2254
2318
  void heartbeat();
@@ -2261,7 +2325,7 @@ async function startDaemon() {
2261
2325
  shuttingDown = true;
2262
2326
  clearInterval(heartbeatTimer);
2263
2327
  if (limitTimer) clearTimeout(limitTimer);
2264
- unsubs.forEach((u) => u());
2328
+ for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
2265
2329
  if (sessionAbort) {
2266
2330
  log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
2267
2331
  notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
@@ -2277,7 +2341,7 @@ async function startDaemon() {
2277
2341
  }
2278
2342
  const now = Date.now();
2279
2343
  try {
2280
- for (const shipId of liveShips) {
2344
+ for (const shipId of serving) {
2281
2345
  await setDoc2(
2282
2346
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2283
2347
  { status: "offline", lastSeenAt: now, currentJob: deleteField() },
@@ -2290,7 +2354,7 @@ async function startDaemon() {
2290
2354
  };
2291
2355
  process.on("SIGINT", () => void shutdown("SIGINT"));
2292
2356
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2293
- log2(`Runner online \u2014 watching ${liveShips.length} Ship(s), MCP: ${mcpUrl(config2)}`);
2357
+ log2(`Runner online \u2014 watching ${serving.size} Ship(s), MCP: ${mcpUrl(config2)}`);
2294
2358
  await new Promise(() => {
2295
2359
  });
2296
2360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
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.",