@lumi.ai/runner 0.6.0 → 0.6.3
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 +24 -4
- package/dist/cli.js +185 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,7 +84,9 @@ Run this first when something isn't working. Everything it checks used to be dis
|
|
|
84
84
|
running job*, surfacing as a failed task on your board minutes later: a missing `claude` binary,
|
|
85
85
|
unsaved Ship credentials, a machine no captain approved. It checks Node ≥ 20, your stored session,
|
|
86
86
|
per-Ship approval and credentials, the engine binary, `git`/`gh` when an agent uses GitHub, server
|
|
87
|
-
reachability, the background service,
|
|
87
|
+
reachability, the background service, whether that service still points at a CLI that exists, and
|
|
88
|
+
whether the **service's own `PATH`** can still reach the binaries your jobs spawn — which is a
|
|
89
|
+
different question from whether your shell can (see below).
|
|
88
90
|
|
|
89
91
|
### Background service
|
|
90
92
|
|
|
@@ -99,6 +101,21 @@ reachability, the background service, and whether that service still points at a
|
|
|
99
101
|
Your `PATH` is captured into the unit at install time. launchd and systemd start processes with a
|
|
100
102
|
minimal environment, so without that `claude`, `git` and `gh` would not be found.
|
|
101
103
|
|
|
104
|
+
**It is captured once, and a restart does not refresh it.** `service restart` re-runs the unit
|
|
105
|
+
exactly as written, so a daemon keeps the `PATH` of whatever shell first installed it. Install a
|
|
106
|
+
tool somewhere new afterwards — `~/.local/bin`, a Homebrew prefix, an nvm switch — and the daemon
|
|
107
|
+
cannot see it, while every check you can run by hand (`which claude`, `lumi-runner doctor`) is
|
|
108
|
+
answered by your *current* shell and looks fine. The symptom is a job that fails minutes later
|
|
109
|
+
with `spawn claude ENOENT`.
|
|
110
|
+
|
|
111
|
+
Two things close that gap: `doctor` reports it as **Service PATH**, and `lumi-runner setup` now
|
|
112
|
+
reinstalls the service (rather than merely restarting it) when your environment has drifted from
|
|
113
|
+
the installed unit. To fix it directly, from a shell where the tool works:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
lumi-runner service install # rewrites the unit with your current PATH
|
|
117
|
+
```
|
|
118
|
+
|
|
102
119
|
## How many jobs at once
|
|
103
120
|
|
|
104
121
|
One, until you say otherwise — the same behaviour this daemon has always had.
|
|
@@ -187,8 +204,11 @@ always as a warning — being out of date never fails the preflight.
|
|
|
187
204
|
- **Idle sleep is inhibited** (`caffeinate` / `systemd-inhibit`, best-effort on Windows), so a
|
|
188
205
|
laptop doesn't suspend mid-session. It cannot veto you choosing Shut Down — no background process
|
|
189
206
|
gets that veto on macOS, and it shouldn't.
|
|
190
|
-
- **
|
|
191
|
-
stopped with work in flight
|
|
207
|
+
- **Desktop notifications are off by default.** They fire on job start, finish, terminal failure and
|
|
208
|
+
on a daemon stopped with work in flight — which on a busy machine is an interruption every few
|
|
209
|
+
minutes carrying nothing the task board and the Daemons live log do not already show. Turn them on
|
|
210
|
+
with `lumi-runner config set notifications on`. (Sleep inhibition is a separate setting and stays
|
|
211
|
+
on: "don't close the lid" is `keepAwake`, not a notification.)
|
|
192
212
|
- **SIGTERM releases the job.** The daemon aborts every running session, hands each job back to the
|
|
193
213
|
queue with its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
194
214
|
never costs you an attempt.
|
|
@@ -254,7 +274,7 @@ claiming from one queue.
|
|
|
254
274
|
|---|---|
|
|
255
275
|
| `LUMI_RUNNER_HOME` | Config + log directory (default `~/.lumi-runner`) |
|
|
256
276
|
| `CREW_CLAUDE_BIN` | Path to the Claude binary (default: `claude` from `PATH`) |
|
|
257
|
-
| `CREW_NO_NOTIFY` |
|
|
277
|
+
| `CREW_NO_NOTIFY` | Force desktop notifications off, whatever the config says (they are off by default) |
|
|
258
278
|
| `CREW_NO_POWER` | Disable sleep inhibition |
|
|
259
279
|
| `LUMI_RUNNER_REGISTRY` | npm registry to check for updates (default `https://registry.npmjs.org`) |
|
|
260
280
|
| `LUMI_RUNNER_CHANNEL` | Force the update channel: `latest` or `dev` |
|
package/dist/cli.js
CHANGED
|
@@ -251,9 +251,9 @@ function str(input, key) {
|
|
|
251
251
|
const value = input[key];
|
|
252
252
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
253
253
|
}
|
|
254
|
-
function basename(
|
|
255
|
-
const parts =
|
|
256
|
-
return parts[parts.length - 1] ??
|
|
254
|
+
function basename(path9) {
|
|
255
|
+
const parts = path9.split(/[\\/]/).filter(Boolean);
|
|
256
|
+
return parts[parts.length - 1] ?? path9;
|
|
257
257
|
}
|
|
258
258
|
function hostOf(url) {
|
|
259
259
|
try {
|
|
@@ -341,8 +341,8 @@ function builtinDetail(tool, input) {
|
|
|
341
341
|
case "Write":
|
|
342
342
|
case "Edit":
|
|
343
343
|
case "MultiEdit": {
|
|
344
|
-
const
|
|
345
|
-
return
|
|
344
|
+
const path9 = str(input, "file_path");
|
|
345
|
+
return path9 ? basename(path9) : void 0;
|
|
346
346
|
}
|
|
347
347
|
case "Glob":
|
|
348
348
|
case "Grep":
|
|
@@ -759,12 +759,20 @@ function forgetShip(config2, shipId) {
|
|
|
759
759
|
function allowsLocalMcp(config2, shipId) {
|
|
760
760
|
return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
|
|
761
761
|
}
|
|
762
|
+
var TOGGLE_DEFAULTS = {
|
|
763
|
+
notifications: false,
|
|
764
|
+
keepAwake: true,
|
|
765
|
+
autoUpdate: true
|
|
766
|
+
};
|
|
767
|
+
function notificationsEnabled(config2) {
|
|
768
|
+
return config2?.notifications ?? TOGGLE_DEFAULTS.notifications;
|
|
769
|
+
}
|
|
762
770
|
function mcpUrl(config2) {
|
|
763
771
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
764
772
|
}
|
|
765
773
|
|
|
766
774
|
// src/version.ts
|
|
767
|
-
var RUNNER_VERSION = true ? "0.6.
|
|
775
|
+
var RUNNER_VERSION = true ? "0.6.3" : "0.0.0-dev";
|
|
768
776
|
|
|
769
777
|
// src/auth.ts
|
|
770
778
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1030,7 +1038,7 @@ var WINDOWS_SCRIPT = [
|
|
|
1030
1038
|
].join(" ");
|
|
1031
1039
|
function enabled() {
|
|
1032
1040
|
if (process.env.CREW_NO_NOTIFY === "1") return false;
|
|
1033
|
-
return loadConfig()
|
|
1041
|
+
return notificationsEnabled(loadConfig());
|
|
1034
1042
|
}
|
|
1035
1043
|
function notify(title, body) {
|
|
1036
1044
|
if (!enabled()) return;
|
|
@@ -1890,7 +1898,7 @@ async function claudeHealthCheck() {
|
|
|
1890
1898
|
const done = (health) => {
|
|
1891
1899
|
if (settled) return;
|
|
1892
1900
|
settled = true;
|
|
1893
|
-
resolve(health);
|
|
1901
|
+
resolve({ ...health, binary: bin });
|
|
1894
1902
|
};
|
|
1895
1903
|
let stdout = "";
|
|
1896
1904
|
const child = spawn3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -2328,6 +2336,48 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
|
|
|
2328
2336
|
);
|
|
2329
2337
|
}
|
|
2330
2338
|
|
|
2339
|
+
// src/jobs/claimBackoff.ts
|
|
2340
|
+
var BACKOFF_BASE_MS = 3e3;
|
|
2341
|
+
var BACKOFF_MAX_MS = 6e4;
|
|
2342
|
+
var BACKOFF_RETENTION_MS = 10 * 6e4;
|
|
2343
|
+
function backoffFor(releases) {
|
|
2344
|
+
return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, releases - 1));
|
|
2345
|
+
}
|
|
2346
|
+
function noteTransientRelease(backoff, jobId, now) {
|
|
2347
|
+
const releases = (backoff.get(jobId)?.releases ?? 0) + 1;
|
|
2348
|
+
const entry = { releases, eligibleAt: now + backoffFor(releases), poked: false };
|
|
2349
|
+
backoff.set(jobId, entry);
|
|
2350
|
+
return entry;
|
|
2351
|
+
}
|
|
2352
|
+
function isBackedOff(backoff, jobId, now) {
|
|
2353
|
+
const entry = backoff.get(jobId);
|
|
2354
|
+
return !!entry && entry.eligibleAt > now;
|
|
2355
|
+
}
|
|
2356
|
+
function clearBackoff(backoff, jobId) {
|
|
2357
|
+
backoff.delete(jobId);
|
|
2358
|
+
}
|
|
2359
|
+
function pruneExpired2(backoff, now) {
|
|
2360
|
+
const cleared = [];
|
|
2361
|
+
for (const [key, entry] of backoff) {
|
|
2362
|
+
if (entry.eligibleAt > now) continue;
|
|
2363
|
+
if (!entry.poked) {
|
|
2364
|
+
cleared.push(key);
|
|
2365
|
+
entry.poked = true;
|
|
2366
|
+
}
|
|
2367
|
+
if (now - entry.eligibleAt >= BACKOFF_RETENTION_MS) backoff.delete(key);
|
|
2368
|
+
}
|
|
2369
|
+
return cleared;
|
|
2370
|
+
}
|
|
2371
|
+
function nextEligibleAt(backoff, now) {
|
|
2372
|
+
let soonest = null;
|
|
2373
|
+
for (const entry of backoff.values()) {
|
|
2374
|
+
if (entry.eligibleAt > now && (soonest === null || entry.eligibleAt < soonest)) {
|
|
2375
|
+
soonest = entry.eligibleAt;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
return soonest;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2331
2381
|
// src/jobs/capacity.ts
|
|
2332
2382
|
var DEFAULT_PARALLEL_JOBS = 1;
|
|
2333
2383
|
var PARALLEL_MAX = 8;
|
|
@@ -2386,11 +2436,11 @@ function redactTranscript(transcript, knownSecrets) {
|
|
|
2386
2436
|
return out;
|
|
2387
2437
|
}
|
|
2388
2438
|
async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
2389
|
-
const
|
|
2390
|
-
await uploadBytes(storageRef(storage,
|
|
2439
|
+
const path9 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
|
|
2440
|
+
await uploadBytes(storageRef(storage, path9), new TextEncoder().encode(redacted), {
|
|
2391
2441
|
contentType: "application/x-ndjson"
|
|
2392
2442
|
});
|
|
2393
|
-
return
|
|
2443
|
+
return path9;
|
|
2394
2444
|
}
|
|
2395
2445
|
function utcDay(millis) {
|
|
2396
2446
|
return new Date(millis).toISOString().slice(0, 10);
|
|
@@ -2729,6 +2779,10 @@ function serviceEnv() {
|
|
|
2729
2779
|
}
|
|
2730
2780
|
return env;
|
|
2731
2781
|
}
|
|
2782
|
+
function serviceEnvDrift(current, installed) {
|
|
2783
|
+
if (!installed) return [];
|
|
2784
|
+
return Object.keys(current).filter((name) => current[name] !== installed[name]).sort();
|
|
2785
|
+
}
|
|
2732
2786
|
function removeLegacyService() {
|
|
2733
2787
|
const removed = [];
|
|
2734
2788
|
if (process.platform === "darwin") {
|
|
@@ -2827,6 +2881,24 @@ function parsePlistCliPath(plist) {
|
|
|
2827
2881
|
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
2828
2882
|
return args.length >= 2 ? args[1] : void 0;
|
|
2829
2883
|
}
|
|
2884
|
+
function parsePlistEnv(plist) {
|
|
2885
|
+
const block = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(plist);
|
|
2886
|
+
if (!block) return void 0;
|
|
2887
|
+
const env = {};
|
|
2888
|
+
for (const m of block[1].matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
2889
|
+
env[unescapeXml(m[1])] = unescapeXml(m[2]);
|
|
2890
|
+
}
|
|
2891
|
+
return env;
|
|
2892
|
+
}
|
|
2893
|
+
function parseSystemdEnv(unit) {
|
|
2894
|
+
const env = {};
|
|
2895
|
+
let found = false;
|
|
2896
|
+
for (const m of unit.matchAll(/^Environment="([^="]+)=([^"]*)"\s*$/gm)) {
|
|
2897
|
+
env[m[1]] = m[2];
|
|
2898
|
+
found = true;
|
|
2899
|
+
}
|
|
2900
|
+
return found ? env : void 0;
|
|
2901
|
+
}
|
|
2830
2902
|
function parseSystemdCliPath(unit) {
|
|
2831
2903
|
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
2832
2904
|
if (!line) return void 0;
|
|
@@ -2835,16 +2907,17 @@ function parseSystemdCliPath(unit) {
|
|
|
2835
2907
|
const parts = command.slice(0, -" start".length).split(" ");
|
|
2836
2908
|
return parts.length === 2 ? parts[1] : void 0;
|
|
2837
2909
|
}
|
|
2838
|
-
function execFacts(unitPath, parse) {
|
|
2910
|
+
function execFacts(unitPath, parse, parseEnv) {
|
|
2839
2911
|
let text;
|
|
2840
2912
|
try {
|
|
2841
2913
|
text = fs4.readFileSync(unitPath, "utf8");
|
|
2842
2914
|
} catch {
|
|
2843
2915
|
return {};
|
|
2844
2916
|
}
|
|
2917
|
+
const unitEnv = parseEnv(text);
|
|
2845
2918
|
const execPath = parse(text);
|
|
2846
|
-
if (!execPath || !path4.isAbsolute(execPath)) return {};
|
|
2847
|
-
return { execPath, execMissing: !fs4.existsSync(execPath) };
|
|
2919
|
+
if (!execPath || !path4.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
|
|
2920
|
+
return { execPath, execMissing: !fs4.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
|
|
2848
2921
|
}
|
|
2849
2922
|
function systemdUnit() {
|
|
2850
2923
|
const env = serviceEnv();
|
|
@@ -2871,7 +2944,7 @@ function serviceStatus() {
|
|
|
2871
2944
|
if (process.platform === "darwin") {
|
|
2872
2945
|
const unitPath = launchAgentPath();
|
|
2873
2946
|
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
2874
|
-
const exec = execFacts(unitPath, parsePlistCliPath);
|
|
2947
|
+
const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
|
|
2875
2948
|
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2876
2949
|
if (!printed.ok) {
|
|
2877
2950
|
return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
|
|
@@ -2892,7 +2965,7 @@ function serviceStatus() {
|
|
|
2892
2965
|
state: active.out === "active" ? "running" : "installed",
|
|
2893
2966
|
detail: `systemd user unit is ${active.out || "unknown"}.`,
|
|
2894
2967
|
unitPath,
|
|
2895
|
-
...execFacts(unitPath, parseSystemdCliPath)
|
|
2968
|
+
...execFacts(unitPath, parseSystemdCliPath, parseSystemdEnv)
|
|
2896
2969
|
};
|
|
2897
2970
|
}
|
|
2898
2971
|
if (process.platform === "win32") {
|
|
@@ -3445,6 +3518,7 @@ async function startDaemon() {
|
|
|
3445
3518
|
}
|
|
3446
3519
|
const pending = /* @__PURE__ */ new Map();
|
|
3447
3520
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
3521
|
+
const claimBackoff = /* @__PURE__ */ new Map();
|
|
3448
3522
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
3449
3523
|
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
3450
3524
|
const listenerError = (shipId, what) => (e) => {
|
|
@@ -3627,6 +3701,23 @@ async function startDaemon() {
|
|
|
3627
3701
|
limitTimer = setTimeout(sweepLimits, Math.max(0, at - Date.now()) + 1e3);
|
|
3628
3702
|
limitTimer.unref();
|
|
3629
3703
|
}
|
|
3704
|
+
let backoffTimer = null;
|
|
3705
|
+
function armBackoffTimer() {
|
|
3706
|
+
if (backoffTimer) {
|
|
3707
|
+
clearTimeout(backoffTimer);
|
|
3708
|
+
backoffTimer = null;
|
|
3709
|
+
}
|
|
3710
|
+
const at = nextEligibleAt(claimBackoff, Date.now());
|
|
3711
|
+
if (at === null) return;
|
|
3712
|
+
backoffTimer = setTimeout(sweepBackoff, Math.max(0, at - Date.now()) + 1e3);
|
|
3713
|
+
backoffTimer.unref();
|
|
3714
|
+
}
|
|
3715
|
+
function sweepBackoff() {
|
|
3716
|
+
backoffTimer = null;
|
|
3717
|
+
const cleared = pruneExpired2(claimBackoff, Date.now());
|
|
3718
|
+
armBackoffTimer();
|
|
3719
|
+
if (cleared.length > 0) poke();
|
|
3720
|
+
}
|
|
3630
3721
|
function sweepLimits() {
|
|
3631
3722
|
limitTimer = null;
|
|
3632
3723
|
const cleared = pruneExpired(engineLimits, Date.now());
|
|
@@ -3658,7 +3749,11 @@ async function startDaemon() {
|
|
|
3658
3749
|
// the limit map is keyed by (Ship, engine) and the engine comes from the registry via
|
|
3659
3750
|
// the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
|
|
3660
3751
|
// `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)
|
|
3752
|
+
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
|
|
3753
|
+
// skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
|
|
3754
|
+
// with a poke. Without this the `transient` release below re-claims what it just put
|
|
3755
|
+
// down, every three seconds, forever (jobs/claimBackoff.ts).
|
|
3756
|
+
!isBackedOff(claimBackoff, p.job.id, now)
|
|
3662
3757
|
});
|
|
3663
3758
|
for (const pick of picks) {
|
|
3664
3759
|
pending.delete(`${pick.shipId}/${pick.job.id}`);
|
|
@@ -4014,14 +4109,16 @@ async function startDaemon() {
|
|
|
4014
4109
|
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
4110
|
);
|
|
4016
4111
|
} else if (transient) {
|
|
4112
|
+
const held = noteTransientRelease(claimBackoff, job.id, Date.now());
|
|
4017
4113
|
await releaseJob(
|
|
4018
4114
|
sess(shipId).fb.db,
|
|
4019
4115
|
shipId,
|
|
4020
4116
|
job,
|
|
4021
4117
|
`Lost the connection to Firestore \u2014 released without consuming a retry: ${failure.slice(0, 120)}`
|
|
4022
4118
|
);
|
|
4119
|
+
armBackoffTimer();
|
|
4023
4120
|
log2(
|
|
4024
|
-
`Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}
|
|
4121
|
+
`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
4122
|
);
|
|
4026
4123
|
} else if (!terminal && job.attempt < MAX_ATTEMPTS) {
|
|
4027
4124
|
log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
|
|
@@ -4043,6 +4140,7 @@ async function startDaemon() {
|
|
|
4043
4140
|
log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
|
|
4044
4141
|
notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
|
|
4045
4142
|
}
|
|
4143
|
+
if (!transient) clearBackoff(claimBackoff, job.id);
|
|
4046
4144
|
} catch (e) {
|
|
4047
4145
|
log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
4048
4146
|
}
|
|
@@ -4370,7 +4468,7 @@ function glyph(level) {
|
|
|
4370
4468
|
|
|
4371
4469
|
// src/cli/commands/config.ts
|
|
4372
4470
|
var TOGGLES = {
|
|
4373
|
-
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
4471
|
+
notifications: "Desktop notifications when a job starts, finishes or fails (off by default)",
|
|
4374
4472
|
keepAwake: "Keep this machine awake while a job is running",
|
|
4375
4473
|
autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
|
|
4376
4474
|
};
|
|
@@ -4420,7 +4518,7 @@ function parseParallel(value) {
|
|
|
4420
4518
|
async function runConfigList() {
|
|
4421
4519
|
const config2 = requireConfig();
|
|
4422
4520
|
const values = Object.fromEntries(
|
|
4423
|
-
Object.keys(TOGGLES).map((key) => [key, config2[key]
|
|
4521
|
+
Object.keys(TOGGLES).map((key) => [key, config2[key] ?? TOGGLE_DEFAULTS[key]])
|
|
4424
4522
|
);
|
|
4425
4523
|
const machine = machineCap(config2);
|
|
4426
4524
|
const ships = config2.ships.map((shipId) => ({
|
|
@@ -4579,6 +4677,8 @@ function setParallel(config2, value, ship2) {
|
|
|
4579
4677
|
|
|
4580
4678
|
// src/cli/commands/doctor.ts
|
|
4581
4679
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4680
|
+
import fs7 from "node:fs";
|
|
4681
|
+
import path8 from "node:path";
|
|
4582
4682
|
import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
|
|
4583
4683
|
|
|
4584
4684
|
// src/cli/session.ts
|
|
@@ -4650,8 +4750,36 @@ function serviceCheckFrom(status) {
|
|
|
4650
4750
|
}
|
|
4651
4751
|
return warn("service", "Background service", `Unrecognised service state: ${status.state}.`);
|
|
4652
4752
|
}
|
|
4653
|
-
function
|
|
4654
|
-
|
|
4753
|
+
function serviceBinaryCheckFrom(input) {
|
|
4754
|
+
const { status, binaries, resolves } = input;
|
|
4755
|
+
if (status.state === "not-installed" || status.state === "unsupported") return null;
|
|
4756
|
+
const pathValue = status.unitEnv?.PATH;
|
|
4757
|
+
if (pathValue === void 0) return null;
|
|
4758
|
+
const relative = [...new Set(binaries)].filter((b) => !path8.isAbsolute(b));
|
|
4759
|
+
if (relative.length === 0) return null;
|
|
4760
|
+
const missing = relative.filter((b) => !resolves(b, pathValue));
|
|
4761
|
+
const id = "service:path";
|
|
4762
|
+
const label = "Service PATH";
|
|
4763
|
+
if (missing.length === 0) {
|
|
4764
|
+
return ok(id, label, `The daemon can reach ${relative.join(", ")}.`);
|
|
4765
|
+
}
|
|
4766
|
+
return warn(
|
|
4767
|
+
id,
|
|
4768
|
+
label,
|
|
4769
|
+
`The daemon's PATH is missing ${missing.join(", ")} \u2014 it was captured when the service was installed, and jobs will fail with \`spawn ${missing[0]} ENOENT\` even though this terminal finds it.`,
|
|
4770
|
+
"Run `lumi-runner service install` from a shell where these work, to rewrite the unit with the current PATH."
|
|
4771
|
+
);
|
|
4772
|
+
}
|
|
4773
|
+
function resolvesOnPath(binary, pathValue) {
|
|
4774
|
+
for (const dir of pathValue.split(path8.delimiter)) {
|
|
4775
|
+
if (!dir) continue;
|
|
4776
|
+
try {
|
|
4777
|
+
fs7.accessSync(path8.join(dir, binary), fs7.constants.X_OK);
|
|
4778
|
+
return true;
|
|
4779
|
+
} catch {
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
return false;
|
|
4655
4783
|
}
|
|
4656
4784
|
function versionCheckFrom(input) {
|
|
4657
4785
|
const { current, latest, channel, autoUpdate } = input;
|
|
@@ -4819,14 +4947,17 @@ async function runDoctor() {
|
|
|
4819
4947
|
for (const id of shipResults.engines) engines.add(id);
|
|
4820
4948
|
}
|
|
4821
4949
|
const needsGithub = shipResults.needsGithub;
|
|
4950
|
+
const jobBinaries = [];
|
|
4822
4951
|
for (const engineId of engines) {
|
|
4823
4952
|
const health = await getDriver(engineId).healthCheck();
|
|
4953
|
+
if (health.binary) jobBinaries.push(health.binary);
|
|
4824
4954
|
checks.push(
|
|
4825
4955
|
health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, health.detail) : fail(`engine:${engineId}`, `Engine "${engineId}"`, health.detail, health.fix)
|
|
4826
4956
|
);
|
|
4827
4957
|
}
|
|
4828
4958
|
if (needsGithub) {
|
|
4829
4959
|
for (const binary of ["git", "gh"]) {
|
|
4960
|
+
jobBinaries.push(binary);
|
|
4830
4961
|
checks.push(
|
|
4831
4962
|
onPath(binary) ? ok(`bin:${binary}`, `\`${binary}\``, "On PATH.") : fail(
|
|
4832
4963
|
`bin:${binary}`,
|
|
@@ -4838,7 +4969,14 @@ async function runDoctor() {
|
|
|
4838
4969
|
}
|
|
4839
4970
|
}
|
|
4840
4971
|
checks.push(await checkMcp(mcpUrl(config2)));
|
|
4841
|
-
|
|
4972
|
+
const service2 = serviceStatus();
|
|
4973
|
+
checks.push(serviceCheckFrom(service2));
|
|
4974
|
+
const servicePath = serviceBinaryCheckFrom({
|
|
4975
|
+
status: service2,
|
|
4976
|
+
binaries: jobBinaries,
|
|
4977
|
+
resolves: resolvesOnPath
|
|
4978
|
+
});
|
|
4979
|
+
if (servicePath) checks.push(servicePath);
|
|
4842
4980
|
checks.push(await checkVersion(config2));
|
|
4843
4981
|
progress.stop("Checks complete.");
|
|
4844
4982
|
return report2(checks);
|
|
@@ -5094,6 +5232,17 @@ async function runServiceUninstall() {
|
|
|
5094
5232
|
say.success("Service removed. The daemon will not start on its own any more.");
|
|
5095
5233
|
return 0;
|
|
5096
5234
|
}
|
|
5235
|
+
async function runServiceRepair(reason) {
|
|
5236
|
+
const result = installService();
|
|
5237
|
+
restartService();
|
|
5238
|
+
if (isJson()) {
|
|
5239
|
+
emitJson({ repaired: true, reason, unitPath: result.unitPath, notes: result.notes, ...serviceStatus() });
|
|
5240
|
+
return 0;
|
|
5241
|
+
}
|
|
5242
|
+
say.success(`Service reinstalled (${reason}): ${result.unitPath}`);
|
|
5243
|
+
for (const note2 of result.notes) say.warn(note2);
|
|
5244
|
+
return 0;
|
|
5245
|
+
}
|
|
5097
5246
|
async function runServiceRestart() {
|
|
5098
5247
|
restartService();
|
|
5099
5248
|
if (isJson()) {
|
|
@@ -5118,20 +5267,20 @@ async function runServiceStatus() {
|
|
|
5118
5267
|
}
|
|
5119
5268
|
|
|
5120
5269
|
// src/cli/commands/uninstall.ts
|
|
5121
|
-
import
|
|
5270
|
+
import fs8 from "node:fs";
|
|
5122
5271
|
async function runUninstall(options) {
|
|
5123
5272
|
const before = serviceStatus();
|
|
5124
5273
|
const dir = configDir();
|
|
5125
5274
|
const hadService = before.state !== "not-installed" && before.state !== "unsupported";
|
|
5126
5275
|
if (hadService) uninstallService();
|
|
5127
5276
|
let purged = false;
|
|
5128
|
-
if (options.purge &&
|
|
5277
|
+
if (options.purge && fs8.existsSync(dir)) {
|
|
5129
5278
|
const confirmed = await promptConfirm({
|
|
5130
5279
|
message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
|
|
5131
5280
|
initialValue: false
|
|
5132
5281
|
});
|
|
5133
5282
|
if (confirmed) {
|
|
5134
|
-
|
|
5283
|
+
fs8.rmSync(dir, { recursive: true, force: true });
|
|
5135
5284
|
purged = true;
|
|
5136
5285
|
}
|
|
5137
5286
|
}
|
|
@@ -5268,7 +5417,8 @@ async function runSetup(options) {
|
|
|
5268
5417
|
}
|
|
5269
5418
|
say.step("Checking this machine\u2026");
|
|
5270
5419
|
const doctorExit = await runDoctor();
|
|
5271
|
-
const
|
|
5420
|
+
const status = serviceStatus();
|
|
5421
|
+
const service2 = status.state;
|
|
5272
5422
|
if (service2 === "not-installed") {
|
|
5273
5423
|
const install = await promptConfirm({
|
|
5274
5424
|
message: "Start the daemon automatically whenever this machine is on?",
|
|
@@ -5278,8 +5428,14 @@ async function runSetup(options) {
|
|
|
5278
5428
|
if (install) await runServiceInstall();
|
|
5279
5429
|
else say.info("Skipped. Run `lumi-runner start` manually, or `lumi-runner service install` later.");
|
|
5280
5430
|
} else if (service2 === "running" || service2 === "installed") {
|
|
5281
|
-
|
|
5282
|
-
|
|
5431
|
+
const drift = serviceEnvDrift(serviceEnv(), status.unitEnv);
|
|
5432
|
+
if (drift.length > 0) {
|
|
5433
|
+
say.step(`Reinstalling the service \u2014 ${drift.join(", ")} changed since it was installed\u2026`);
|
|
5434
|
+
await runServiceRepair(`${drift.join(", ")} changed`);
|
|
5435
|
+
} else {
|
|
5436
|
+
say.step("Restarting the daemon so it picks up this configuration\u2026");
|
|
5437
|
+
await runServiceRestart();
|
|
5438
|
+
}
|
|
5283
5439
|
}
|
|
5284
5440
|
say.outro(
|
|
5285
5441
|
doctorExit === 0 ? "Ready. This machine will claim jobs for its Ships." : "Setup finished, but some checks failed \u2014 fix those and re-run `lumi-runner doctor`."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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.",
|