@gajae-gateway/cli 0.1.8 → 0.2.1

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.
@@ -1,8 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { access, mkdir, readFile, readlink, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import {
6
+ acquireLease,
7
+ homeLayout,
8
+ type Lease,
9
+ LeaseHeldError,
10
+ type LeaseName,
11
+ leaseHeld,
12
+ readLeaseOwner,
13
+ } from "@gajae-gateway/home";
14
+
15
+ import {
16
+ BROKER_UNIT,
6
17
  currentPlatform,
7
18
  launchAgentPlistPath,
8
19
  restartOrder,
@@ -31,7 +42,7 @@ export type RestartState = "queued" | "running" | "ok" | "stale" | "failed";
31
42
 
32
43
  export interface RestartStep {
33
44
  readonly label: string;
34
- /** Absent on systemd, where the gateway unit's restart carries its dependents. */
45
+ /** The final service-manager command; update broker receipts separately record agent sync. */
35
46
  command?: readonly string[];
36
47
  exitStatus?: number;
37
48
  result: RestartStepResult;
@@ -39,34 +50,37 @@ export interface RestartStep {
39
50
  processStartedAt?: string;
40
51
  binary?: string;
41
52
  binaryModifiedAt?: string;
53
+ agentSync?: { readonly command: readonly string[]; readonly exitStatus: number; readonly error?: string };
42
54
  detail?: string;
43
55
  }
44
56
 
45
57
  export interface RestartReceipt {
46
58
  readonly id: string;
59
+ readonly trigger: "update" | "manual";
47
60
  state: RestartState;
48
61
  readonly platform: ServicePlatform;
49
62
  readonly requestedAt: string;
50
63
  supervisorPid?: number;
51
64
  finishedAt?: string;
65
+ /** Why the sequence never ran, e.g. the supervisor could not be started. */
66
+ detail?: string;
52
67
  readonly steps: RestartStep[];
53
68
  }
54
69
 
55
- /** A running service process as the service manager and `ps` report it. */
70
+ /** The main PID reported by the service manager. */
56
71
  export interface ServiceProcess {
57
72
  readonly pid: number;
58
- /** Epoch ms; `ps` reports whole seconds. */
59
- readonly startedAt: number;
60
- readonly binary: string;
61
73
  }
62
74
 
63
75
  export type CommandRunner = (command: readonly string[]) => number | PromiseLike<number>;
64
76
  export type ServiceProbe = (label: string) => Promise<ServiceProcess | undefined>;
65
77
  export type IsServiceInstalled = (label: string) => Promise<boolean>;
66
78
 
79
+ /** The trigger is explicit so only update receipts synchronize provisioned agent state. */
67
80
  export interface RunRestartOptions {
68
81
  readonly home: string;
69
82
  readonly id: string;
83
+ readonly trigger?: "update" | "manual";
70
84
  readonly platform?: ServicePlatform;
71
85
  readonly uid?: number;
72
86
  readonly runner?: CommandRunner;
@@ -84,7 +98,7 @@ export interface RunRestartOptions {
84
98
  readonly env?: NodeJS.ProcessEnv;
85
99
  /** LaunchAgents directory on darwin. Defaults to ~/Library/LaunchAgents. */
86
100
  readonly launchAgentsDir?: string;
87
- /** systemd unit directory on linux. Defaults to $XDG_CONFIG_HOME/systemd/user. */
101
+ readonly binDir?: string;
88
102
  readonly unitDir?: string;
89
103
  }
90
104
 
@@ -115,16 +129,29 @@ async function writeReceipt(home: string, receipt: RestartReceipt): Promise<void
115
129
  await rename(temporary, path);
116
130
  }
117
131
 
118
- function newReceipt(id: string, platform: ServicePlatform, uid: number | undefined, now: number): RestartReceipt {
132
+ function newReceipt(
133
+ id: string,
134
+ platform: ServicePlatform,
135
+ uid: number | undefined,
136
+ now: number,
137
+ trigger: "update" | "manual",
138
+ ): RestartReceipt {
119
139
  const commands = restartStackCommands(platform, uid);
120
140
  return {
121
141
  id,
142
+ trigger,
122
143
  state: "queued",
123
144
  platform,
124
145
  requestedAt: new Date(now).toISOString(),
125
146
  steps: restartOrder().map((spec, index) => ({
126
147
  label: spec.label,
127
- ...(platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {}),
148
+ ...(platform === "darwin"
149
+ ? { command: commands[index] }
150
+ : spec.id === "broker"
151
+ ? { command: commands[0] }
152
+ : spec.id === "gateway"
153
+ ? { command: commands[1] }
154
+ : {}),
128
155
  result: "pending",
129
156
  })),
130
157
  };
@@ -146,32 +173,19 @@ async function capture(command: readonly string[]): Promise<string | undefined>
146
173
  return (await child.exited) === 0 ? output : undefined;
147
174
  }
148
175
 
149
- /** Reads the live pid from the service manager, then its start time and executable from `ps`. */
150
- export function defaultServiceProbe(platform: ServicePlatform): ServiceProbe {
176
+ /** Reads the current main PID directly from launchd or systemd. */
177
+ export function defaultServiceProbe(platform: ServicePlatform, uid = process.getuid?.() ?? 0): ServiceProbe {
151
178
  return async (label) => {
152
179
  const spec = serviceSpecs().find((candidate) => candidate.label === label);
153
180
  if (spec === undefined) throw new Error(`unknown service label: ${label}`);
154
- let pid: number | undefined;
155
- if (platform === "darwin") {
156
- const listed = await capture(["launchctl", "list", label]);
157
- const match = listed?.match(/"PID"\s*=\s*(\d+);/);
158
- pid = match ? Number(match[1]) : undefined;
159
- } else {
160
- const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
161
- pid = shown === undefined ? undefined : Number(shown.trim());
162
- }
163
- if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return undefined;
164
- const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
165
- const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
166
- if (lstart === undefined || args === undefined) return undefined;
167
- const startedAt = Date.parse(lstart.trim());
168
- if (!Number.isFinite(startedAt)) return undefined;
169
- // The service definitions exec `<bin-dir>/<binary> <fixed args>`; stripping
170
- // the known arguments keeps a bin dir containing spaces intact.
171
- const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
172
- const line = args.trim();
173
- const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
174
- return { pid, startedAt, binary };
181
+ const shown =
182
+ platform === "darwin"
183
+ ? await capture(["launchctl", "print", `gui/${uid}/${label}`])
184
+ : await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
185
+ const rawPid = platform === "darwin" ? shown?.match(/^\s*pid = (\d+);?\s*$/m)?.[1] : shown?.trim();
186
+ const pid = rawPid === undefined ? undefined : Number(rawPid);
187
+ if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
188
+ return { pid };
175
189
  };
176
190
  }
177
191
 
@@ -215,27 +229,59 @@ function defaultIsServiceInstalled(options: {
215
229
  };
216
230
  }
217
231
 
218
- /**
219
- * `ps -o lstart` is whole seconds, and on Linux it is derived from the boot
220
- * time plus clock ticks, so it can read up to one second EARLY. Measured on
221
- * gaebal-gajae 2026-09-30: systemd logged `Started` at 10:55:50.170 while
222
- * `ps` reported 10:55:49, and the freshly restarted gateway was judged stale,
223
- * aborting the rest of the stack. The bound is floored and allowed that second.
224
- */
225
- function psStartBound(ms: number): number {
226
- return Math.floor(ms / 1_000) * 1_000 - PS_START_SKEW_MS;
232
+ const SERVICE_LEASES: Partial<Record<string, LeaseName>> = {
233
+ broker: "broker",
234
+ gateway: "gateway",
235
+ "adapter-discord": "adapter-discord",
236
+ "adapter-slack": "adapter-slack",
237
+ "adapter-telegram": "adapter-telegram",
238
+ };
239
+
240
+ function serviceStopCommand(platform: ServicePlatform, uid: number | undefined): readonly string[] {
241
+ if (platform === "linux") return ["systemctl", "--user", "stop", BROKER_UNIT];
242
+ const target = uid ?? process.getuid?.() ?? 0;
243
+ return ["launchctl", "bootout", `gui/${target}/dev.gajaeway.broker`];
227
244
  }
228
- const PS_START_SKEW_MS = 1_000;
229
245
 
246
+ function serviceBootstrapCommand(
247
+ options: RunRestartOptions,
248
+ platform: ServicePlatform,
249
+ label: string,
250
+ ): readonly string[] {
251
+ if (platform === "linux") return ["systemctl", "--user", "start", BROKER_UNIT];
252
+ const target = options.uid ?? process.getuid?.() ?? 0;
253
+ const userHome = options.env?.HOME || homedir();
254
+ const agentsDir = options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents");
255
+ const spec = serviceSpecs().find((candidate) => candidate.label === label);
256
+ if (spec === undefined) throw new Error(`unknown service label: ${label}`);
257
+ return ["launchctl", "bootstrap", `gui/${target}`, launchAgentPlistPath(spec, agentsDir)];
258
+ }
230
259
  /**
231
260
  * Runs the ordered restart, verifying each service before the next command,
232
261
  * and persists the receipt after every transition. The first service that is
233
262
  * stale or missing aborts the remainder.
234
263
  */
235
264
  export async function runRestartStack(options: RunRestartOptions): Promise<RestartReceipt> {
265
+ let lease: Lease;
266
+ try {
267
+ lease = await acquireLease(options.home, "update");
268
+ console.info(`lease_acquired name=update pid=${process.pid}`);
269
+ } catch (error) {
270
+ if (error instanceof LeaseHeldError) console.error(error.message);
271
+ else console.error(`lease_failed name=update detail=${error instanceof Error ? error.message : String(error)}`);
272
+ throw error;
273
+ }
274
+ try {
275
+ return await runRestartStackWithLease(options);
276
+ } finally {
277
+ await lease.release();
278
+ }
279
+ }
280
+
281
+ async function runRestartStackWithLease(options: RunRestartOptions): Promise<RestartReceipt> {
236
282
  const platform = options.platform ?? currentPlatform();
237
283
  const runner = options.runner ?? defaultCommandRunner;
238
- const probe = options.probe ?? defaultServiceProbe(platform);
284
+ const probe = options.probe ?? defaultServiceProbe(platform, options.uid);
239
285
  const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
240
286
  const isInstalled =
241
287
  options.isServiceInstalled ??
@@ -249,12 +295,21 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
249
295
  const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
250
296
  const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
251
297
  const pollMs = options.pollMs ?? POLL_MS;
298
+ const binDir = options.binDir ?? process.env.GAJAEWAY_BIN_DIR ?? dirname(process.execPath);
299
+
300
+ const originalPids = new Map<string, number | undefined>();
301
+ for (const spec of restartOrder()) {
302
+ if (SERVICE_LEASES[spec.id] === undefined && (await isInstalled(spec.label)))
303
+ originalPids.set(spec.label, (await probe(spec.label))?.pid);
304
+ }
252
305
 
253
306
  const existing = await readRestartReceipt(options.home);
307
+ if (existing?.id === options.id && existing.state !== "queued")
308
+ throw new Error(`restart-stack ${options.id}: receipt is ${existing.state}; refusing to start worker`);
254
309
  const receipt =
255
- existing?.id === options.id && existing.state === "queued"
310
+ existing?.id === options.id
256
311
  ? existing
257
- : newReceipt(options.id, platform, options.uid, now());
312
+ : newReceipt(options.id, platform, options.uid, now(), options.trigger ?? "manual");
258
313
  receipt.state = "running";
259
314
  receipt.supervisorPid = process.pid;
260
315
  await writeReceipt(options.home, receipt);
@@ -266,7 +321,15 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
266
321
  continue;
267
322
  }
268
323
  try {
269
- // Check if the service is installed before trying to restart it
324
+ if (
325
+ step.label === "dev.gajaeway.broker" &&
326
+ !(await Bun.file(homeLayout(options.home).brokerProvisionManifest).exists())
327
+ ) {
328
+ step.result = "skipped";
329
+ step.detail = "broker agent is not provisioned";
330
+ await writeReceipt(options.home, receipt);
331
+ continue;
332
+ }
270
333
  const installed = await isInstalled(step.label);
271
334
  if (!installed) {
272
335
  step.result = "skipped";
@@ -275,16 +338,76 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
275
338
  continue;
276
339
  }
277
340
 
278
- if (step.command !== undefined) {
279
- issuedAt = now();
280
- const status = await runner(step.command);
281
- step.exitStatus = status;
282
- if (status !== 0) {
341
+ if (step.label === "dev.gajaeway.broker" && receipt.trigger === "update") {
342
+ const stopCommand = serviceStopCommand(platform, options.uid);
343
+ const stopStatus = await runner(stopCommand);
344
+ if (stopStatus !== 0 && !(platform === "darwin" && (await probe(step.label)) === undefined))
345
+ throw new Error(`${stopCommand.join(" ")} exited with status ${stopStatus}`);
346
+
347
+ const provisionCommand = [
348
+ join(binDir, "gajaeway-gateway"),
349
+ "broker-agent",
350
+ "provision",
351
+ "--mode",
352
+ "update",
353
+ "--lease-wait-ms",
354
+ "35000",
355
+ ];
356
+ const provisionStatus = await runner(provisionCommand);
357
+ step.agentSync = {
358
+ command: provisionCommand,
359
+ exitStatus: provisionStatus,
360
+ ...(provisionStatus === 1 || provisionStatus === 3
361
+ ? { error: `broker-agent provision exited with status ${provisionStatus}` }
362
+ : provisionStatus === 4
363
+ ? { error: "broker_agent_unrecoverable" }
364
+ : {}),
365
+ };
366
+ if (provisionStatus === 4) {
367
+ delete step.command;
368
+ step.exitStatus = 4;
283
369
  step.result = "failed";
284
- step.detail = `${step.command.join(" ")} exited with status ${status}`;
370
+ step.detail = "broker left stopped: broker_agent_unrecoverable; run gajaeway setup --broker-agent";
371
+ } else if (provisionStatus === 0 || provisionStatus === 1 || provisionStatus === 3) {
372
+ const startCommand = serviceBootstrapCommand(options, platform, step.label);
373
+ step.command = startCommand;
374
+ issuedAt = now();
375
+ const startStatus = await runner(startCommand);
376
+ step.exitStatus = startStatus;
377
+ if (startStatus !== 0) {
378
+ step.result = "failed";
379
+ step.detail = `${startCommand.join(" ")} exited with status ${startStatus}`;
380
+ } else await verifyStep(step, issuedAt);
381
+ } else {
382
+ delete step.command;
383
+ step.exitStatus = provisionStatus;
384
+ step.result = "failed";
385
+ step.detail = `broker-agent provision exited with unexpected status ${provisionStatus}`;
386
+ }
387
+ } else {
388
+ if (step.command !== undefined) {
389
+ issuedAt = now();
390
+ let command = step.command;
391
+ let status = await runner(command);
392
+ if (
393
+ status !== 0 &&
394
+ receipt.trigger === "update" &&
395
+ platform === "darwin" &&
396
+ (await probe(step.label)) === undefined
397
+ ) {
398
+ command = serviceBootstrapCommand(options, platform, step.label);
399
+ step.command = command;
400
+ issuedAt = now();
401
+ status = await runner(command);
402
+ }
403
+ step.exitStatus = status;
404
+ if (status !== 0) {
405
+ step.result = "failed";
406
+ step.detail = `${command.join(" ")} exited with status ${status}`;
407
+ }
285
408
  }
409
+ if (step.result === "pending") await verifyStep(step, issuedAt);
286
410
  }
287
- if (step.result === "pending") await verifyStep(step, issuedAt);
288
411
  } catch (error) {
289
412
  step.result = "failed";
290
413
  step.detail = error instanceof Error ? error.message : String(error);
@@ -300,32 +423,58 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
300
423
  return receipt;
301
424
 
302
425
  async function verifyStep(step: RestartStep, since: number): Promise<void> {
426
+ const spec = serviceSpecs().find((candidate) => candidate.label === step.label);
427
+ if (spec === undefined) throw new Error(`unknown service label: ${step.label}`);
428
+ const leaseName = SERVICE_LEASES[spec.id];
429
+ const binary = join(binDir, spec.binary);
430
+ const binaryModifiedAt = leaseName === undefined ? undefined : await modifiedAt(binary);
303
431
  const deadline = now() + timeoutMs;
304
432
  for (;;) {
305
433
  const running = await probe(step.label);
306
- let binaryModifiedAt = 0;
307
- if (running !== undefined) {
308
- binaryModifiedAt = await modifiedAt(running.binary);
309
- step.pid = running.pid;
310
- step.processStartedAt = new Date(running.startedAt).toISOString();
311
- step.binary = running.binary;
312
- step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
313
- if (running.startedAt >= psStartBound(Math.max(binaryModifiedAt, since))) {
434
+ const owner = leaseName === undefined ? undefined : await readLeaseOwner(options.home, leaseName);
435
+ if (running !== undefined) step.pid = running.pid;
436
+
437
+ if (leaseName !== undefined) {
438
+ step.binary = binary;
439
+ step.binaryModifiedAt = new Date(binaryModifiedAt as number).toISOString();
440
+ if (owner !== undefined) step.processStartedAt = owner.startedAt;
441
+ const startedAt = owner === undefined ? Number.NaN : Date.parse(owner.startedAt);
442
+ if (
443
+ running !== undefined &&
444
+ owner !== undefined &&
445
+ running.pid === owner.pid &&
446
+ Number.isFinite(startedAt) &&
447
+ startedAt >= Math.max(since, binaryModifiedAt as number)
448
+ ) {
314
449
  step.result = "ok";
315
450
  delete step.detail;
316
451
  return;
317
452
  }
453
+ } else if (running !== undefined && running.pid !== originalPids.get(step.label)) {
454
+ step.result = "ok";
455
+ delete step.detail;
456
+ return;
318
457
  }
458
+
319
459
  if (now() >= deadline) {
320
460
  if (running === undefined) {
321
461
  step.result = "failed";
322
462
  step.detail = "no running process after restart";
463
+ } else if (leaseName === undefined) {
464
+ step.result = "stale";
465
+ step.detail = "MainPID did not change after restart";
466
+ } else if (owner === undefined) {
467
+ step.result = "stale";
468
+ step.detail = "no lease owner after restart";
469
+ } else if (running.pid !== owner.pid) {
470
+ step.result = "stale";
471
+ step.detail = "lease owner PID does not match service MainPID";
472
+ } else if (Date.parse(owner.startedAt) < (binaryModifiedAt as number)) {
473
+ step.result = "stale";
474
+ step.detail = "process started before the deployed binary was modified";
323
475
  } else {
324
476
  step.result = "stale";
325
- step.detail =
326
- running.startedAt < binaryModifiedAt
327
- ? "process started before the deployed binary was modified"
328
- : "process was not restarted by this sequence";
477
+ step.detail = "lease owner predates this restart sequence";
329
478
  }
330
479
  return;
331
480
  }
@@ -336,53 +485,110 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
336
485
 
337
486
  export interface LaunchRestartOptions {
338
487
  readonly home: string;
488
+ readonly trigger?: "update" | "manual";
339
489
  readonly platform?: ServicePlatform;
340
490
  readonly uid?: number;
491
+ /** Installed service binary directory used for lease verification. */
492
+ readonly binDir?: string;
341
493
  /** Argv that re-enters the CLI; `--run <id>` is appended. */
342
494
  readonly worker?: readonly string[];
343
495
  readonly env?: NodeJS.ProcessEnv;
496
+ /** Starts systemd-run; injectable for launcher-result handling. */
497
+ readonly systemdRun?: (command: readonly string[]) => Bun.Subprocess;
498
+ /** Maximum time to wait for systemd-run to acknowledge the worker exec. */
499
+ readonly systemdRunTimeoutMs?: number;
344
500
  }
345
501
 
502
+ const SYSTEMD_RUN_TIMEOUT_MS = 30_000;
503
+
346
504
  /** The argv that runs this CLI again, whether compiled or run from source. */
347
505
  export function cliCommand(): string[] {
348
506
  return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
349
507
  }
350
508
 
351
- function alive(pid: number | undefined): boolean {
352
- if (pid === undefined) return false;
353
- try {
354
- process.kill(pid, 0);
355
- return true;
356
- } catch {
357
- return false;
358
- }
359
- }
360
-
361
509
  /**
362
- * Queues a restart and hands it to a detached supervisor, returning as soon as
363
- * the supervisor is spawned so the caller may be killed by the restart itself.
510
+ * Queues a restart and hands it to a detached supervisor, returning once the
511
+ * platform launcher acknowledges it so the caller may be killed by the restart.
364
512
  */
365
513
  export async function launchRestartStack(
366
514
  options: LaunchRestartOptions,
367
515
  ): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
368
516
  const platform = options.platform ?? currentPlatform();
369
- const existing = await readRestartReceipt(options.home);
370
- if (existing?.state === "running" && alive(existing.supervisorPid))
371
- throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
372
- const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
517
+ if (await leaseHeld(options.home, "update"))
518
+ throw new LeaseHeldError("update", await readLeaseOwner(options.home, "update"));
519
+ const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now(), options.trigger ?? "manual");
373
520
  await writeReceipt(options.home, receipt);
374
521
  const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
375
- const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
522
+ const argv = supervisorArgv(platform, worker, options.home, receipt.id, options.binDir);
523
+ const spawnOptions = {
376
524
  detached: true,
377
- stdin: "ignore",
378
- stdout: "ignore",
379
- stderr: "ignore",
380
- env: { ...(options.env ?? process.env), GAJAEWAY_HOME: options.home },
381
- });
525
+ stdin: "ignore" as const,
526
+ stdout: "ignore" as const,
527
+ stderr: "ignore" as const,
528
+ env: {
529
+ ...(options.env ?? process.env),
530
+ GAJAEWAY_HOME: options.home,
531
+ ...(options.binDir === undefined ? {} : { GAJAEWAY_BIN_DIR: options.binDir }),
532
+ },
533
+ };
534
+ let child: Bun.Subprocess;
535
+ try {
536
+ child =
537
+ platform === "linux"
538
+ ? (options.systemdRun ?? ((command) => Bun.spawn([...command], spawnOptions)))(argv)
539
+ : Bun.spawn(argv, spawnOptions);
540
+ } catch (error) {
541
+ const detail =
542
+ platform === "linux"
543
+ ? `supervisor systemd-run could not be started (${error instanceof Error ? error.message : String(error)}); without a service manager here, restart through the host's own supervisor`
544
+ : `supervisor ${argv[0]} could not be started (${error instanceof Error ? error.message : String(error)})`;
545
+ await failLaunch(options.home, receipt, detail);
546
+ throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
547
+ }
548
+ if (platform === "linux") {
549
+ const outcome = await waitForLauncher(child, options.systemdRunTimeoutMs ?? SYSTEMD_RUN_TIMEOUT_MS);
550
+ if (outcome.kind === "timed_out") {
551
+ const detail = `supervisor systemd-run timed out after ${options.systemdRunTimeoutMs ?? SYSTEMD_RUN_TIMEOUT_MS}ms; handoff outcome is indeterminate`;
552
+ await failLaunch(options.home, receipt, detail);
553
+ throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
554
+ }
555
+ if (outcome.exitStatus !== 0) {
556
+ const detail = `supervisor systemd-run exited with status ${outcome.exitStatus}`;
557
+ await failLaunch(options.home, receipt, detail);
558
+ throw new Error(`restart-stack ${receipt.id}: failed: ${detail}`);
559
+ }
560
+ }
382
561
  child.unref();
383
562
  return { receipt, supervisorPid: child.pid };
384
563
  }
385
564
 
565
+ async function failLaunch(home: string, receipt: RestartReceipt, detail: string): Promise<void> {
566
+ receipt.state = "failed";
567
+ receipt.finishedAt = new Date().toISOString();
568
+ receipt.detail = detail;
569
+ for (const step of receipt.steps) step.result = "skipped";
570
+ await writeReceipt(home, receipt);
571
+ }
572
+
573
+ async function waitForLauncher(
574
+ child: Bun.Subprocess,
575
+ timeoutMs: number,
576
+ ): Promise<{ readonly kind: "exited"; readonly exitStatus: number } | { readonly kind: "timed_out" }> {
577
+ let timer: ReturnType<typeof setTimeout> | undefined;
578
+ const outcome = await Promise.race([
579
+ child.exited.then((exitStatus) => ({ kind: "exited" as const, exitStatus })),
580
+ new Promise<{ readonly kind: "timed_out" }>((resolve) => {
581
+ timer = setTimeout(() => resolve({ kind: "timed_out" }), timeoutMs);
582
+ }),
583
+ ]);
584
+ if (timer !== undefined) clearTimeout(timer);
585
+ if (outcome.kind === "timed_out") {
586
+ child.kill("SIGKILL");
587
+ void child.exited.catch(() => undefined);
588
+ }
589
+ return outcome;
590
+ }
591
+
386
592
  /**
387
593
  * A new session escapes the caller's process group, which is enough under
388
594
  * launchd. Under systemd the caller usually lives in a service cgroup that the
@@ -394,6 +600,7 @@ export function supervisorArgv(
394
600
  worker: readonly string[],
395
601
  home: string,
396
602
  id: string,
603
+ binDir?: string,
397
604
  ): string[] {
398
605
  if (platform === "darwin") return [...worker];
399
606
  return [
@@ -401,8 +608,10 @@ export function supervisorArgv(
401
608
  "--user",
402
609
  "--collect",
403
610
  "--quiet",
611
+ "--service-type=exec",
404
612
  `--unit=gajaeway-restart-stack-${id}`,
405
613
  `--setenv=GAJAEWAY_HOME=${home}`,
614
+ ...(binDir === undefined ? [] : [`--setenv=GAJAEWAY_BIN_DIR=${binDir}`]),
406
615
  "--",
407
616
  ...worker,
408
617
  ];
@@ -412,22 +621,30 @@ export function supervisorArgv(
412
621
  * The receipt's state, except that an unfinished sequence whose supervisor is
413
622
  * gone is reported as interrupted rather than left looking in progress.
414
623
  */
415
- export function effectiveRestartState(receipt: RestartReceipt): RestartState | "interrupted" {
416
- if (receipt.state === "running" && !alive(receipt.supervisorPid)) return "interrupted";
624
+ export async function effectiveRestartState(
625
+ receipt: RestartReceipt,
626
+ home: string,
627
+ ): Promise<RestartState | "interrupted"> {
628
+ if (receipt.state === "running" && !(await leaseHeld(home, "update"))) return "interrupted";
417
629
  return receipt.state;
418
630
  }
419
631
 
420
- export function renderRestartReceipt(receipt: RestartReceipt): string[] {
632
+ export function renderRestartReceipt(
633
+ receipt: RestartReceipt,
634
+ state: RestartState | "interrupted" = receipt.state,
635
+ ): string[] {
421
636
  const lines = [
422
- `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
637
+ `restart-stack ${receipt.id}: ${state}`,
423
638
  `requestedAt: ${receipt.requestedAt}`,
424
639
  `finishedAt: ${receipt.finishedAt ?? "-"}`,
640
+ ...(receipt.detail === undefined ? [] : [`detail: ${receipt.detail}`]),
425
641
  ];
426
642
  for (const step of receipt.steps) {
427
643
  const facts = [
428
644
  step.pid === undefined ? undefined : `pid=${step.pid}`,
429
645
  step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
430
646
  step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
647
+ step.agentSync?.error === undefined ? undefined : `agentSync.error=${step.agentSync.error}`,
431
648
  step.detail,
432
649
  ].filter((fact) => fact !== undefined);
433
650
  lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);