@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.
- package/dist/cli.js +121 -57
- 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.
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
1827
|
+
const serving = new Set(sessions.map((s) => s.shipId));
|
|
1825
1828
|
console.log(
|
|
1826
|
-
`Runner ${config2.runnerId} serving ${
|
|
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
|
|
1850
|
-
|
|
1851
|
-
shipRunnerRef(shipId)
|
|
1852
|
-
{
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
|
1911
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
(
|
|
1945
|
-
)
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
(
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
+
"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.",
|