@lumi.ai/runner 0.3.6 → 0.5.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/README.md +22 -4
- package/dist/cli.js +313 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ workspace.
|
|
|
5
5
|
|
|
6
6
|
Crew agents don't run in the cloud. They run **on your machine**, in your checkouts, with your
|
|
7
7
|
tools. This daemon is what makes that happen: it watches the job queues of the Ships you assign it
|
|
8
|
-
to, claims work
|
|
8
|
+
to, claims work oldest-first, and executes each job as a headless Claude session.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npm i -g @lumi.ai/runner
|
|
@@ -98,6 +98,24 @@ reachability, the background service, and whether that service still points at a
|
|
|
98
98
|
Your `PATH` is captured into the unit at install time. launchd and systemd start processes with a
|
|
99
99
|
minimal environment, so without that `claude`, `git` and `gh` would not be found.
|
|
100
100
|
|
|
101
|
+
## How many jobs at once
|
|
102
|
+
|
|
103
|
+
One, until you say otherwise — the same behaviour this daemon has always had.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
lumi-runner config set parallel 3 # this machine, across every Ship
|
|
107
|
+
lumi-runner config set parallel 1 --ship shp_abc123 # cap one Ship's share of it
|
|
108
|
+
lumi-runner config set parallel default --ship shp_abc123 # back to the machine number
|
|
109
|
+
lumi-runner config list # what is set, and what each Ship gets
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The machine number is a ceiling, not a total: three Ships at `2` each on a machine set to `3` will
|
|
113
|
+
never run more than three sessions. A Ship with no number of its own may use the whole machine.
|
|
114
|
+
|
|
115
|
+
Each job is a separate `claude` process with its own empty working directory, so they don't share
|
|
116
|
+
state — but they do share your CPU, your RAM and your Claude usage window. Start at 2 and watch a
|
|
117
|
+
real job before going higher. Restart the daemon (`lumi-runner service restart`) to pick up a change.
|
|
118
|
+
|
|
101
119
|
## While a job runs
|
|
102
120
|
|
|
103
121
|
- **Idle sleep is inhibited** (`caffeinate` / `systemd-inhibit`, best-effort on Windows), so a
|
|
@@ -105,9 +123,9 @@ minimal environment, so without that `claude`, `git` and `gh` would not be found
|
|
|
105
123
|
gets that veto on macOS, and it shouldn't.
|
|
106
124
|
- **A desktop notification** fires on job start, finish and terminal failure, and when the daemon is
|
|
107
125
|
stopped with work in flight. Silence it with `lumi-runner config set notifications off`.
|
|
108
|
-
- **SIGTERM releases the job.** The daemon aborts
|
|
109
|
-
its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
110
|
-
you an attempt.
|
|
126
|
+
- **SIGTERM releases the job.** The daemon aborts every running session, hands each job back to the
|
|
127
|
+
queue with its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
128
|
+
never costs you an attempt.
|
|
111
129
|
|
|
112
130
|
## Credentials
|
|
113
131
|
|
package/dist/cli.js
CHANGED
|
@@ -532,12 +532,27 @@ 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
|
+
const next = {
|
|
539
|
+
...config2,
|
|
540
|
+
shipKeys,
|
|
541
|
+
ships: config2.ships.filter((id) => id !== shipId)
|
|
542
|
+
};
|
|
543
|
+
if (next.shipParallelJobs) {
|
|
544
|
+
const overrides = { ...next.shipParallelJobs };
|
|
545
|
+
delete overrides[shipId];
|
|
546
|
+
next.shipParallelJobs = overrides;
|
|
547
|
+
}
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
535
550
|
function mcpUrl(config2) {
|
|
536
551
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
537
552
|
}
|
|
538
553
|
|
|
539
554
|
// src/version.ts
|
|
540
|
-
var RUNNER_VERSION = true ? "0.
|
|
555
|
+
var RUNNER_VERSION = true ? "0.5.0" : "0.0.0-dev";
|
|
541
556
|
|
|
542
557
|
// src/auth.ts
|
|
543
558
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -609,13 +624,11 @@ async function probeShipKey(config2, shipId) {
|
|
|
609
624
|
}
|
|
610
625
|
function pruneDeadShips(config2, failures) {
|
|
611
626
|
const dropped = failures.filter((f) => f.gone).map((f) => f.shipId);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
dropped
|
|
618
|
-
};
|
|
627
|
+
return { config: dropped.reduce(forgetShip, config2), dropped };
|
|
628
|
+
}
|
|
629
|
+
function isEnrolmentGone(error) {
|
|
630
|
+
const code = error?.code;
|
|
631
|
+
return code === "permission-denied" || code === "firestore/permission-denied";
|
|
619
632
|
}
|
|
620
633
|
async function signInToShip(fb, config2, shipId) {
|
|
621
634
|
const key = config2.shipKeys?.[shipId];
|
|
@@ -1655,6 +1668,35 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
|
|
|
1655
1668
|
);
|
|
1656
1669
|
}
|
|
1657
1670
|
|
|
1671
|
+
// src/jobs/capacity.ts
|
|
1672
|
+
var DEFAULT_PARALLEL_JOBS = 1;
|
|
1673
|
+
var PARALLEL_MAX = 8;
|
|
1674
|
+
var clamp = (n) => Math.min(PARALLEL_MAX, Math.max(1, Math.floor(n)));
|
|
1675
|
+
var readCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1 ? clamp(value) : null;
|
|
1676
|
+
function machineCap(config2) {
|
|
1677
|
+
return readCount(config2.maxParallelJobs) ?? DEFAULT_PARALLEL_JOBS;
|
|
1678
|
+
}
|
|
1679
|
+
function shipCap(config2, shipId) {
|
|
1680
|
+
const machine = machineCap(config2);
|
|
1681
|
+
const own = readCount(config2.shipParallelJobs?.[shipId]);
|
|
1682
|
+
return own === null ? machine : Math.min(own, machine);
|
|
1683
|
+
}
|
|
1684
|
+
function selectDispatch(input) {
|
|
1685
|
+
const free = input.machineCap - input.runningTotal;
|
|
1686
|
+
if (free <= 0) return [];
|
|
1687
|
+
const taken = new Map(input.runningByShip);
|
|
1688
|
+
const picked = [];
|
|
1689
|
+
for (const entry of [...input.pending].sort((a, b) => a.job.createdAt - b.job.createdAt)) {
|
|
1690
|
+
if (picked.length >= free) break;
|
|
1691
|
+
if (!input.eligible(entry)) continue;
|
|
1692
|
+
const running = taken.get(entry.shipId) ?? 0;
|
|
1693
|
+
if (running >= input.shipCap(entry.shipId)) continue;
|
|
1694
|
+
taken.set(entry.shipId, running + 1);
|
|
1695
|
+
picked.push(entry);
|
|
1696
|
+
}
|
|
1697
|
+
return picked;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1658
1700
|
// src/jobs/finish.ts
|
|
1659
1701
|
import {
|
|
1660
1702
|
addDoc,
|
|
@@ -1821,9 +1863,9 @@ async function startDaemon() {
|
|
|
1821
1863
|
if (!s) throw new Error(`No session for Ship ${shipId}`);
|
|
1822
1864
|
return s;
|
|
1823
1865
|
};
|
|
1824
|
-
const
|
|
1866
|
+
const serving = new Set(sessions.map((s) => s.shipId));
|
|
1825
1867
|
console.log(
|
|
1826
|
-
`Runner ${config2.runnerId} serving ${
|
|
1868
|
+
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
1827
1869
|
);
|
|
1828
1870
|
const logLines = [];
|
|
1829
1871
|
const log2 = (line) => {
|
|
@@ -1835,7 +1877,13 @@ async function startDaemon() {
|
|
|
1835
1877
|
};
|
|
1836
1878
|
if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
|
|
1837
1879
|
const startedAt = Date.now();
|
|
1838
|
-
|
|
1880
|
+
const running = /* @__PURE__ */ new Map();
|
|
1881
|
+
const runningByShip = () => {
|
|
1882
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1883
|
+
for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
|
|
1884
|
+
return counts;
|
|
1885
|
+
};
|
|
1886
|
+
const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
|
|
1839
1887
|
const approved = /* @__PURE__ */ new Map();
|
|
1840
1888
|
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
1841
1889
|
const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
|
|
@@ -1846,43 +1894,56 @@ async function startDaemon() {
|
|
|
1846
1894
|
beating = true;
|
|
1847
1895
|
const now = Date.now();
|
|
1848
1896
|
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
|
-
|
|
1897
|
+
for (const shipId of [...serving]) {
|
|
1898
|
+
try {
|
|
1899
|
+
const snap = await getDoc5(shipRunnerRef(shipId));
|
|
1900
|
+
if (!snap.exists()) {
|
|
1901
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
const isApproved = snap.data()?.approved === true;
|
|
1905
|
+
const wasApproved = approved.get(shipId) === true;
|
|
1906
|
+
approved.set(shipId, isApproved);
|
|
1907
|
+
if (!isApproved && !warnedUnapproved.has(shipId)) {
|
|
1908
|
+
warnedUnapproved.add(shipId);
|
|
1909
|
+
log2(
|
|
1910
|
+
`Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
if (isApproved && !wasApproved) {
|
|
1914
|
+
warnedUnapproved.delete(shipId);
|
|
1915
|
+
needsRefill.add(shipId);
|
|
1916
|
+
}
|
|
1917
|
+
const live = liveJobsOn(shipId);
|
|
1918
|
+
await setDoc2(
|
|
1919
|
+
shipRunnerRef(shipId),
|
|
1920
|
+
{
|
|
1921
|
+
hostname: os3.hostname(),
|
|
1922
|
+
version: RUNNER_VERSION,
|
|
1923
|
+
status: "online",
|
|
1924
|
+
lastSeenAt: now,
|
|
1925
|
+
startedAt,
|
|
1926
|
+
currentJob: live[0] ?? null,
|
|
1927
|
+
currentJobs: live,
|
|
1928
|
+
parallelLimit: shipCap(config2, shipId),
|
|
1929
|
+
lastLogLines: [...logLines]
|
|
1930
|
+
},
|
|
1931
|
+
{ merge: true }
|
|
1871
1932
|
);
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1933
|
+
} catch (e) {
|
|
1934
|
+
if (isEnrolmentGone(e)) {
|
|
1935
|
+
forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
|
|
1876
1939
|
}
|
|
1877
1940
|
}
|
|
1878
|
-
} catch (e) {
|
|
1879
|
-
console.error("heartbeat failed:", e instanceof Error ? e.message : e);
|
|
1880
1941
|
} finally {
|
|
1881
1942
|
beating = false;
|
|
1882
1943
|
}
|
|
1883
1944
|
for (const shipId of [...needsRefill]) {
|
|
1884
1945
|
needsRefill.delete(shipId);
|
|
1885
|
-
if (approved.get(shipId) !== true) continue;
|
|
1946
|
+
if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
|
|
1886
1947
|
try {
|
|
1887
1948
|
const snap = await getDocs3(
|
|
1888
1949
|
query3(
|
|
@@ -1907,14 +1968,21 @@ async function startDaemon() {
|
|
|
1907
1968
|
const pending = /* @__PURE__ */ new Map();
|
|
1908
1969
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
1909
1970
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1971
|
+
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
1972
|
+
const listenerError = (shipId, what) => (e) => {
|
|
1973
|
+
if (isEnrolmentGone(e)) {
|
|
1974
|
+
forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
console.error(`${what} listener error (${shipId}):`, e.message);
|
|
1978
|
+
};
|
|
1979
|
+
for (const shipId of serving) {
|
|
1912
1980
|
const q = query3(
|
|
1913
1981
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
1914
1982
|
where3("status", "==", "queued"),
|
|
1915
1983
|
orderBy3("createdAt", "asc")
|
|
1916
1984
|
);
|
|
1917
|
-
|
|
1985
|
+
unsubsByShip.set(shipId, [
|
|
1918
1986
|
onSnapshot2(
|
|
1919
1987
|
q,
|
|
1920
1988
|
(snap) => {
|
|
@@ -1929,10 +1997,9 @@ async function startDaemon() {
|
|
|
1929
1997
|
}
|
|
1930
1998
|
poke();
|
|
1931
1999
|
},
|
|
1932
|
-
(
|
|
1933
|
-
)
|
|
1934
|
-
|
|
1935
|
-
unsubs.push(
|
|
2000
|
+
listenerError(shipId, "jobs")
|
|
2001
|
+
),
|
|
2002
|
+
// Agents, purely so the claim gate can resolve a queued job's engine without a read.
|
|
1936
2003
|
onSnapshot2(
|
|
1937
2004
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
|
|
1938
2005
|
(snap) => {
|
|
@@ -1941,10 +2008,11 @@ async function startDaemon() {
|
|
|
1941
2008
|
}
|
|
1942
2009
|
poke();
|
|
1943
2010
|
},
|
|
1944
|
-
(
|
|
1945
|
-
)
|
|
1946
|
-
|
|
1947
|
-
|
|
2011
|
+
listenerError(shipId, "agents")
|
|
2012
|
+
),
|
|
2013
|
+
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
2014
|
+
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
2015
|
+
// session to rediscover it.
|
|
1948
2016
|
subscribeEngineLimits(
|
|
1949
2017
|
sess(shipId).fb.db,
|
|
1950
2018
|
shipId,
|
|
@@ -1963,22 +2031,58 @@ async function startDaemon() {
|
|
|
1963
2031
|
}
|
|
1964
2032
|
sweepLimits();
|
|
1965
2033
|
},
|
|
1966
|
-
(
|
|
2034
|
+
listenerError(shipId, "engine limits")
|
|
1967
2035
|
)
|
|
2036
|
+
]);
|
|
2037
|
+
}
|
|
2038
|
+
function forgetShipLocally(shipId, why) {
|
|
2039
|
+
if (!serving.delete(shipId)) return;
|
|
2040
|
+
for (const unsub of unsubsByShip.get(shipId) ?? []) unsub();
|
|
2041
|
+
unsubsByShip.delete(shipId);
|
|
2042
|
+
for (const key of [...pending.keys()]) {
|
|
2043
|
+
if (pending.get(key)?.shipId === shipId) pending.delete(key);
|
|
2044
|
+
}
|
|
2045
|
+
for (const key of [...engineLimits.keys()]) {
|
|
2046
|
+
if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
|
|
2047
|
+
}
|
|
2048
|
+
for (const key of [...agentEngines.keys()]) {
|
|
2049
|
+
if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
|
|
2050
|
+
}
|
|
2051
|
+
approved.delete(shipId);
|
|
2052
|
+
warnedUnapproved.delete(shipId);
|
|
2053
|
+
needsRefill.delete(shipId);
|
|
2054
|
+
config2 = forgetShip(config2, shipId);
|
|
2055
|
+
try {
|
|
2056
|
+
saveConfig(config2);
|
|
2057
|
+
} catch (e) {
|
|
2058
|
+
log2(`Could not rewrite the config after leaving Ship ${shipId}: ${e instanceof Error ? e.message : e}`);
|
|
2059
|
+
}
|
|
2060
|
+
log2(
|
|
2061
|
+
`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
2062
|
);
|
|
2063
|
+
const stranded = [...running.values()].filter((r) => r.shipId === shipId);
|
|
2064
|
+
if (stranded.length > 0) {
|
|
2065
|
+
log2(
|
|
2066
|
+
`Stopping the ${stranded.length} job(s) it was running there \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put them back on the queue itself.`
|
|
2067
|
+
);
|
|
2068
|
+
for (const r of stranded) r.abort.abort();
|
|
2069
|
+
}
|
|
2070
|
+
if (serving.size === 0) {
|
|
2071
|
+
log2(
|
|
2072
|
+
"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."
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
1969
2075
|
}
|
|
1970
2076
|
const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
|
|
1971
|
-
let
|
|
2077
|
+
let dispatching = false;
|
|
1972
2078
|
let pokeRequested = false;
|
|
1973
2079
|
let shuttingDown = false;
|
|
1974
|
-
let sessionAbort = null;
|
|
1975
|
-
let inFlight = null;
|
|
1976
2080
|
function poke() {
|
|
1977
|
-
if (
|
|
2081
|
+
if (dispatching || shuttingDown) {
|
|
1978
2082
|
pokeRequested = !shuttingDown;
|
|
1979
2083
|
return;
|
|
1980
2084
|
}
|
|
1981
|
-
|
|
2085
|
+
dispatch();
|
|
1982
2086
|
}
|
|
1983
2087
|
let limitTimer = null;
|
|
1984
2088
|
function armLimitTimer() {
|
|
@@ -2004,30 +2108,51 @@ async function startDaemon() {
|
|
|
2004
2108
|
armLimitTimer();
|
|
2005
2109
|
if (cleared.length > 0) poke();
|
|
2006
2110
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2111
|
+
function dispatch() {
|
|
2112
|
+
dispatching = true;
|
|
2009
2113
|
try {
|
|
2010
2114
|
for (; ; ) {
|
|
2011
2115
|
pokeRequested = false;
|
|
2012
2116
|
if (shuttingDown) break;
|
|
2013
2117
|
const now = Date.now();
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2118
|
+
const picks = selectDispatch({
|
|
2119
|
+
pending: pending.values(),
|
|
2120
|
+
runningByShip: runningByShip(),
|
|
2121
|
+
runningTotal: running.size,
|
|
2122
|
+
machineCap: machineCap(config2),
|
|
2123
|
+
shipCap: (shipId) => shipCap(config2, shipId),
|
|
2124
|
+
// Only Ships that have approved this machine are eligible; claiming elsewhere would
|
|
2125
|
+
// just be denied by the rules, so skip rather than hammer the queue. Engine-generic:
|
|
2126
|
+
// the limit map is keyed by (Ship, engine) and the engine comes from the registry via
|
|
2127
|
+
// the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
|
|
2128
|
+
// `pending`, which is what lets a reset resume them with only a poke.
|
|
2129
|
+
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)
|
|
2130
|
+
});
|
|
2131
|
+
for (const pick of picks) {
|
|
2132
|
+
pending.delete(`${pick.shipId}/${pick.job.id}`);
|
|
2133
|
+
startJob(pick.shipId, pick.job);
|
|
2022
2134
|
}
|
|
2023
|
-
if (!pokeRequested
|
|
2135
|
+
if (!pokeRequested) break;
|
|
2024
2136
|
}
|
|
2025
2137
|
} finally {
|
|
2026
|
-
|
|
2027
|
-
if (pokeRequested) poke();
|
|
2138
|
+
dispatching = false;
|
|
2028
2139
|
}
|
|
2029
2140
|
}
|
|
2141
|
+
function startJob(shipId, job) {
|
|
2142
|
+
const entry = { shipId, abort: new AbortController(), promise: Promise.resolve() };
|
|
2143
|
+
running.set(job.id, entry);
|
|
2144
|
+
entry.promise = processJob(shipId, job, entry).catch((e) => {
|
|
2145
|
+
log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
|
|
2146
|
+
}).finally(() => {
|
|
2147
|
+
running.delete(job.id);
|
|
2148
|
+
if (!shuttingDown) poke();
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2030
2151
|
async function claim(shipId, job) {
|
|
2152
|
+
if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now())) {
|
|
2153
|
+
pending.set(`${shipId}/${job.id}`, { shipId, job });
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
2031
2156
|
const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
|
|
2032
2157
|
try {
|
|
2033
2158
|
let claimed = null;
|
|
@@ -2058,7 +2183,7 @@ async function startDaemon() {
|
|
|
2058
2183
|
async function loadSecrets(shipId) {
|
|
2059
2184
|
return loadRunnerSecrets(sess(shipId).fb.db, shipId);
|
|
2060
2185
|
}
|
|
2061
|
-
async function processJob(shipId, queuedJob) {
|
|
2186
|
+
async function processJob(shipId, queuedJob, slot) {
|
|
2062
2187
|
const job = await claim(shipId, queuedJob);
|
|
2063
2188
|
if (!job) return;
|
|
2064
2189
|
const target = jobTarget(job);
|
|
@@ -2078,12 +2203,13 @@ async function startDaemon() {
|
|
|
2078
2203
|
transcriptPath: "",
|
|
2079
2204
|
error: "Job names neither a task nor a chat, so there is nothing to run."
|
|
2080
2205
|
});
|
|
2081
|
-
currentJob = null;
|
|
2082
2206
|
return;
|
|
2083
2207
|
}
|
|
2084
2208
|
const targetLabel = target.kind === "task" ? `task ${target.taskId}` : `chat ${target.chatId}`;
|
|
2085
|
-
log2(
|
|
2086
|
-
|
|
2209
|
+
log2(
|
|
2210
|
+
`Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt}) \u2014 slot ${running.size}/${machineCap(config2)}`
|
|
2211
|
+
);
|
|
2212
|
+
slot.mirror = {
|
|
2087
2213
|
jobId: job.id,
|
|
2088
2214
|
// Conditional spread: Firestore rejects an explicit undefined in the mirror write.
|
|
2089
2215
|
...target.kind === "task" ? { taskId: target.taskId } : { chatId: target.chatId },
|
|
@@ -2095,7 +2221,6 @@ async function startDaemon() {
|
|
|
2095
2221
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2096
2222
|
void heartbeat();
|
|
2097
2223
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
2098
|
-
sessionAbort = new AbortController();
|
|
2099
2224
|
notify(
|
|
2100
2225
|
"Crew job started",
|
|
2101
2226
|
target.kind === "task" ? `Agent is working on task ${target.taskId}.` : "Agent is replying in a chat."
|
|
@@ -2155,7 +2280,7 @@ async function startDaemon() {
|
|
|
2155
2280
|
}
|
|
2156
2281
|
throw e;
|
|
2157
2282
|
}
|
|
2158
|
-
if (
|
|
2283
|
+
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
2159
2284
|
const session = await getDriver(engineId).run({
|
|
2160
2285
|
prompt,
|
|
2161
2286
|
agent,
|
|
@@ -2166,7 +2291,7 @@ async function startDaemon() {
|
|
|
2166
2291
|
secrets,
|
|
2167
2292
|
githubToken,
|
|
2168
2293
|
timeoutMs: JOB_TIMEOUT_MS,
|
|
2169
|
-
signal:
|
|
2294
|
+
signal: slot.abort.signal,
|
|
2170
2295
|
log: log2
|
|
2171
2296
|
});
|
|
2172
2297
|
transcript = redactTranscript(session.transcript, [
|
|
@@ -2192,9 +2317,12 @@ async function startDaemon() {
|
|
|
2192
2317
|
failure = e instanceof Error ? e.message : String(e);
|
|
2193
2318
|
}
|
|
2194
2319
|
wake?.release();
|
|
2195
|
-
sessionAbort = null;
|
|
2196
2320
|
try {
|
|
2197
|
-
if (
|
|
2321
|
+
if (!serving.has(shipId)) {
|
|
2322
|
+
log2(
|
|
2323
|
+
`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.`
|
|
2324
|
+
);
|
|
2325
|
+
} else if (shuttingDown) {
|
|
2198
2326
|
await releaseJob(
|
|
2199
2327
|
sess(shipId).fb.db,
|
|
2200
2328
|
shipId,
|
|
@@ -2244,11 +2372,11 @@ async function startDaemon() {
|
|
|
2244
2372
|
} catch (e) {
|
|
2245
2373
|
log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
2246
2374
|
}
|
|
2247
|
-
|
|
2248
|
-
await setAgentStatus(shipId, job.agentId, "idle");
|
|
2375
|
+
slot.mirror = void 0;
|
|
2376
|
+
if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
|
|
2249
2377
|
if (!shuttingDown) void heartbeat();
|
|
2250
2378
|
}
|
|
2251
|
-
await Promise.all(
|
|
2379
|
+
await Promise.all([...serving].map((shipId) => loadSecrets(shipId).catch(() => null)));
|
|
2252
2380
|
await heartbeat();
|
|
2253
2381
|
const heartbeatTimer = setInterval(() => {
|
|
2254
2382
|
void heartbeat();
|
|
@@ -2261,26 +2389,37 @@ async function startDaemon() {
|
|
|
2261
2389
|
shuttingDown = true;
|
|
2262
2390
|
clearInterval(heartbeatTimer);
|
|
2263
2391
|
if (limitTimer) clearTimeout(limitTimer);
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2392
|
+
for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
|
|
2393
|
+
const inFlight = [...running.values()];
|
|
2394
|
+
if (inFlight.length > 0) {
|
|
2395
|
+
log2(
|
|
2396
|
+
`${signal} received with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
|
|
2397
|
+
);
|
|
2398
|
+
notify(
|
|
2399
|
+
"Crew runner stopping",
|
|
2400
|
+
`${inFlight.length} job(s) were in progress; they have been returned to the queue.`
|
|
2401
|
+
);
|
|
2402
|
+
for (const r of inFlight) r.abort.abort();
|
|
2269
2403
|
} else {
|
|
2270
2404
|
log2(`${signal} received \u2014 shutting down.`);
|
|
2271
2405
|
}
|
|
2272
|
-
if (inFlight) {
|
|
2406
|
+
if (inFlight.length > 0) {
|
|
2273
2407
|
await Promise.race([
|
|
2274
|
-
inFlight,
|
|
2408
|
+
Promise.all(inFlight.map((r) => r.promise)),
|
|
2275
2409
|
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
|
|
2276
2410
|
]);
|
|
2277
2411
|
}
|
|
2278
2412
|
const now = Date.now();
|
|
2279
2413
|
try {
|
|
2280
|
-
for (const shipId of
|
|
2414
|
+
for (const shipId of serving) {
|
|
2281
2415
|
await setDoc2(
|
|
2282
2416
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
|
|
2283
|
-
{
|
|
2417
|
+
{
|
|
2418
|
+
status: "offline",
|
|
2419
|
+
lastSeenAt: now,
|
|
2420
|
+
currentJob: deleteField(),
|
|
2421
|
+
currentJobs: deleteField()
|
|
2422
|
+
},
|
|
2284
2423
|
{ merge: true }
|
|
2285
2424
|
);
|
|
2286
2425
|
}
|
|
@@ -2290,7 +2429,10 @@ async function startDaemon() {
|
|
|
2290
2429
|
};
|
|
2291
2430
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
2292
2431
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
2293
|
-
|
|
2432
|
+
const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
|
|
2433
|
+
log2(
|
|
2434
|
+
`Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
|
|
2435
|
+
);
|
|
2294
2436
|
await new Promise(() => {
|
|
2295
2437
|
});
|
|
2296
2438
|
}
|
|
@@ -2427,6 +2569,9 @@ var TOGGLES = {
|
|
|
2427
2569
|
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
2428
2570
|
keepAwake: "Keep this machine awake while a job is running"
|
|
2429
2571
|
};
|
|
2572
|
+
var PARALLEL = "parallel";
|
|
2573
|
+
var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
|
|
2574
|
+
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
2430
2575
|
function isToggle(key) {
|
|
2431
2576
|
return Object.hasOwn(TOGGLES, key);
|
|
2432
2577
|
}
|
|
@@ -2435,13 +2580,29 @@ function parseBool(value) {
|
|
|
2435
2580
|
if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
2436
2581
|
throw new CliError(`Expected on/off, got "${value}".`);
|
|
2437
2582
|
}
|
|
2583
|
+
function parseParallel(value) {
|
|
2584
|
+
if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
|
|
2585
|
+
const n = Number(value);
|
|
2586
|
+
if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
|
|
2587
|
+
throw new CliError(
|
|
2588
|
+
`Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
return n;
|
|
2592
|
+
}
|
|
2438
2593
|
async function runConfigList() {
|
|
2439
2594
|
const config2 = requireConfig();
|
|
2440
2595
|
const values = Object.fromEntries(
|
|
2441
2596
|
Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
|
|
2442
2597
|
);
|
|
2598
|
+
const machine = machineCap(config2);
|
|
2599
|
+
const ships = config2.ships.map((shipId) => ({
|
|
2600
|
+
shipId,
|
|
2601
|
+
parallel: shipCap(config2, shipId),
|
|
2602
|
+
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
2603
|
+
}));
|
|
2443
2604
|
if (isJson()) {
|
|
2444
|
-
emitJson({ configDir: configDir(), ...values });
|
|
2605
|
+
emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
|
|
2445
2606
|
return 0;
|
|
2446
2607
|
}
|
|
2447
2608
|
say.line(` ${configDir()}`);
|
|
@@ -2449,13 +2610,27 @@ async function runConfigList() {
|
|
|
2449
2610
|
for (const key of Object.keys(TOGGLES)) {
|
|
2450
2611
|
say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
|
|
2451
2612
|
}
|
|
2613
|
+
say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
|
|
2614
|
+
if (ships.length > 0) {
|
|
2615
|
+
say.line("");
|
|
2616
|
+
for (const ship2 of ships) {
|
|
2617
|
+
const note2 = ship2.explicit ? "" : " (machine default)";
|
|
2618
|
+
say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2452
2621
|
return 0;
|
|
2453
2622
|
}
|
|
2454
|
-
async function runConfigSet(key, value) {
|
|
2623
|
+
async function runConfigSet(key, value, options = {}) {
|
|
2624
|
+
const config2 = requireConfig();
|
|
2625
|
+
if (key === PARALLEL) return setParallel(config2, value, options.ship);
|
|
2455
2626
|
if (!isToggle(key)) {
|
|
2456
|
-
throw new CliError(
|
|
2627
|
+
throw new CliError(
|
|
2628
|
+
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL].join(", ")}.`
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
if (options.ship) {
|
|
2632
|
+
throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
|
|
2457
2633
|
}
|
|
2458
|
-
const config2 = requireConfig();
|
|
2459
2634
|
config2[key] = parseBool(value);
|
|
2460
2635
|
saveConfig(config2);
|
|
2461
2636
|
if (isJson()) {
|
|
@@ -2465,6 +2640,41 @@ async function runConfigSet(key, value) {
|
|
|
2465
2640
|
say.success(`${key} = ${config2[key] ? "on" : "off"}`);
|
|
2466
2641
|
return 0;
|
|
2467
2642
|
}
|
|
2643
|
+
function setParallel(config2, value, ship2) {
|
|
2644
|
+
const parsed = parseParallel(value);
|
|
2645
|
+
if (ship2) {
|
|
2646
|
+
if (!config2.ships.includes(ship2)) {
|
|
2647
|
+
throw new CliError(
|
|
2648
|
+
`This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
const overrides = { ...config2.shipParallelJobs ?? {} };
|
|
2652
|
+
if (parsed === null) delete overrides[ship2];
|
|
2653
|
+
else overrides[ship2] = parsed;
|
|
2654
|
+
config2.shipParallelJobs = overrides;
|
|
2655
|
+
} else if (parsed === null) {
|
|
2656
|
+
delete config2.maxParallelJobs;
|
|
2657
|
+
} else {
|
|
2658
|
+
config2.maxParallelJobs = parsed;
|
|
2659
|
+
}
|
|
2660
|
+
saveConfig(config2);
|
|
2661
|
+
const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
|
|
2662
|
+
if (isJson()) {
|
|
2663
|
+
emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
|
|
2664
|
+
return 0;
|
|
2665
|
+
}
|
|
2666
|
+
const machine = machineCap(config2);
|
|
2667
|
+
say.success(
|
|
2668
|
+
ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
|
|
2669
|
+
);
|
|
2670
|
+
if (ship2 && parsed !== null && parsed > machine) {
|
|
2671
|
+
say.line(
|
|
2672
|
+
` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
2676
|
+
return 0;
|
|
2677
|
+
}
|
|
2468
2678
|
|
|
2469
2679
|
// src/cli/commands/doctor.ts
|
|
2470
2680
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -3621,6 +3831,12 @@ service.command("status").description("Is the background service installed and r
|
|
|
3621
3831
|
program.command("uninstall").description("Remove the background service and print the final npm step").option("--purge", "also delete the config directory (loses this machine's runner id)").action(action(async (options) => runUninstall(options)));
|
|
3622
3832
|
var config = program.command("config").description("Local per-machine preferences");
|
|
3623
3833
|
config.command("list").description("Show current settings").action(action(runConfigList));
|
|
3624
|
-
config.command("set <key> <value>").description(
|
|
3834
|
+
config.command("set <key> <value>").description(
|
|
3835
|
+
'Change a setting (notifications | keepAwake \u2192 on/off, parallel \u2192 1-8 or "default")'
|
|
3836
|
+
).option("--ship <shipId>", "apply to one Ship only (parallel)").action(
|
|
3837
|
+
action(
|
|
3838
|
+
async (key, value, options) => runConfigSet(key, value, options)
|
|
3839
|
+
)
|
|
3840
|
+
);
|
|
3625
3841
|
await program.parseAsync(process.argv);
|
|
3626
3842
|
//# sourceMappingURL=cli.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.",
|