mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { fileURLToPath } from "url";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import {
|
|
acquirePidLock,
|
|
PidLockError,
|
|
releasePidLock,
|
|
startPidLockHeartbeat,
|
|
updatePidLock,
|
|
} from "../src/server/pid-lock.js";
|
|
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
|
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
|
import { runSupervisor } from "./supervisor.js";
|
|
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
|
|
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
|
|
|
process.title = "Paseo Supervisor";
|
|
|
|
interface DaemonRunnerConfig {
|
|
devMode: boolean;
|
|
workerArgs: string[];
|
|
}
|
|
|
|
function parseConfig(argv: string[]): DaemonRunnerConfig {
|
|
let devMode = false;
|
|
const workerArgs: string[] = [];
|
|
|
|
for (const arg of argv) {
|
|
if (arg === "--dev") {
|
|
devMode = true;
|
|
continue;
|
|
}
|
|
if (arg === "--reclaim-stale-pid-lock") {
|
|
throw new Error(
|
|
"--reclaim-stale-pid-lock was removed: stop the existing supervisor before starting another.",
|
|
);
|
|
}
|
|
workerArgs.push(arg);
|
|
}
|
|
|
|
return { devMode, workerArgs };
|
|
}
|
|
|
|
function resolveWorkerEntry(): string {
|
|
const candidates = [
|
|
fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)),
|
|
fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)),
|
|
fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)),
|
|
fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return candidates[0];
|
|
}
|
|
|
|
function resolveDevWorkerEntry(): string {
|
|
const candidate = fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url));
|
|
if (!existsSync(candidate)) {
|
|
throw new Error(`Dev worker entry not found: ${candidate}`);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function resolveWorkerExecArgv(workerEntry: string, devMode: boolean): string[] {
|
|
const execArgv = workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
|
if (!devMode) {
|
|
return execArgv;
|
|
}
|
|
const devArgs = [
|
|
"--heapsnapshot-near-heap-limit=3",
|
|
"--max-old-space-size=3072",
|
|
"--report-on-fatalerror",
|
|
"--report-directory=/tmp/paseo-reports",
|
|
];
|
|
const inspectArg = process.env.PASEO_NODE_INSPECT ?? "--inspect";
|
|
if (inspectArg !== "0" && inspectArg !== "false" && inspectArg !== "off") {
|
|
devArgs.push(inspectArg);
|
|
}
|
|
return [...devArgs, ...execArgv];
|
|
}
|
|
|
|
function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): string | null {
|
|
const packageMarker = `${path.sep}node_modules${path.sep}@getpaseo${path.sep}server${path.sep}`;
|
|
const markerIndex = currentScriptPath.lastIndexOf(packageMarker);
|
|
if (markerIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const appRoot = currentScriptPath.slice(0, markerIndex);
|
|
const runnerPath = path.join(appRoot, "dist", "daemon", "node-entrypoint-runner.js");
|
|
return existsSync(runnerPath) ? runnerPath : null;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const config = parseConfig(process.argv.slice(2));
|
|
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
|
const workerExecArgv = resolveWorkerExecArgv(workerEntry, config.devMode);
|
|
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
const packagedNodeEntrypointRunner =
|
|
process.env.ELECTRON_RUN_AS_NODE === "1"
|
|
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
|
|
: null;
|
|
|
|
applySherpaLoaderEnv(workerEnv);
|
|
|
|
const paseoHome = resolvePaseoHome(workerEnv);
|
|
const persistedConfig = loadPersistedConfig(paseoHome);
|
|
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
|
|
|
|
try {
|
|
await acquirePidLock(paseoHome, null, {
|
|
ownerPid: process.pid,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof PidLockError) {
|
|
process.stderr.write(`${error.message}\n`);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
let lockReleased = false;
|
|
let requestSupervisorShutdown: ((reason: string) => void) | null = null;
|
|
const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, {
|
|
ownerPid: process.pid,
|
|
onError: (error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`PID lock heartbeat failed: ${message}\n`);
|
|
if (error instanceof PidLockError) {
|
|
requestSupervisorShutdown?.("pid_lock_ownership_lost");
|
|
}
|
|
},
|
|
});
|
|
const releaseLock = async (): Promise<void> => {
|
|
if (lockReleased) {
|
|
return;
|
|
}
|
|
lockReleased = true;
|
|
stopLockHeartbeat();
|
|
await releasePidLock(paseoHome, {
|
|
ownerPid: process.pid,
|
|
});
|
|
};
|
|
|
|
const supervisor = runSupervisor({
|
|
name: "DaemonRunner",
|
|
startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)",
|
|
resolveWorkerEntry: () => workerEntry,
|
|
workerArgs: config.workerArgs,
|
|
workerEnv,
|
|
workerExecArgv,
|
|
resolveWorkerSpawnSpec: packagedNodeEntrypointRunner
|
|
? (resolvedWorkerEntry) => ({
|
|
command: process.execPath,
|
|
args: [
|
|
packagedNodeEntrypointRunner,
|
|
"node-script",
|
|
resolvedWorkerEntry,
|
|
...config.workerArgs,
|
|
],
|
|
env: {
|
|
...workerEnv,
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
},
|
|
})
|
|
: undefined,
|
|
restartOnCrash: true,
|
|
logFile: supervisorLogFile,
|
|
onWorkerReady: async ({ listen }) => {
|
|
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
|
|
},
|
|
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
|
|
onSupervisorExit: releaseLock,
|
|
});
|
|
requestSupervisorShutdown = supervisor.requestShutdown;
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
process.stderr.write(`${message}\n`);
|
|
process.exit(1);
|
|
});
|