omp-conductor 0.15.3 → 0.15.5

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/src/lifecycle.ts CHANGED
@@ -364,9 +364,19 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
364
364
  * Same ownership rule as {@link stopDaemon}: when the unit owns the live pid,
365
365
  * `systemctl restart` is the *only* path — a failed manager call is terminal,
366
366
  * never a fallthrough to raw signals. An *unanswered* ownership query is also
367
- * terminal: "dbus blipped" is not "no unit". Falling back to stop+start is
368
- * reserved for a confirmed hand-started daemon (no systemd, inactive unit, or
369
- * a unit whose MainPID is someone else).
367
+ * terminal: "dbus blipped" is not "no unit". A *failed* installed unit is the
368
+ * third non-fallthrough: it must be restored through the manager
369
+ * (`reset-failed` + `start`), because a detached CLI daemon beside a failed
370
+ * unit is exactly the "restarted, but the fleet is still down" lie this
371
+ * module exists to prevent (#376). Falling back to stop+start is reserved for
372
+ * a confirmed hand-started daemon (no systemd, inactive unit, or a unit whose
373
+ * MainPID is someone else).
374
+ *
375
+ * Success is reported only after the service manager is re-probed and proven
376
+ * to own the daemon: the unit's MainPID must equal the daemon record pid AND
377
+ * `/healthz` must answer. The manager call returning is not that proof — for
378
+ * `Type=simple` the unit is active as soon as the process is forked, before
379
+ * the daemon has bound its port.
370
380
  *
371
381
  * Returns the record of the process that is now answering `/healthz`.
372
382
  */
@@ -379,6 +389,16 @@ export async function restartDaemon(
379
389
  throw new Error(ownershipUnknown("restart", ownership.reason));
380
390
  }
381
391
 
392
+ if (ownership.kind === "failed") {
393
+ // An installed unit in failed state: reset the failure record and let
394
+ // systemd start the service. Never fall through to stopDaemon() +
395
+ // startDaemon() — that leaves the unit failed while handing the operator
396
+ // a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
397
+ // Any manager refusal is terminal; so is unproven ownership afterwards.
398
+ const record = await restoreFailedUnit(o.timeoutMs);
399
+ return { previous, record, via: "systemctl" };
400
+ }
401
+
382
402
  const unitOwns =
383
403
  ownership.kind === "active" && (previous === undefined || previous.pid === ownership.pid);
384
404
 
@@ -393,26 +413,95 @@ export async function restartDaemon(
393
413
  // systemctl restart returns once the new MainPID is up; the pidfile is
394
414
  // written by the daemon itself on boot, so wait for that rather than
395
415
  // inventing a record from the unit alone.
396
- const deadline = Date.now() + READY_TIMEOUT_MS;
397
- for (;;) {
398
- const rec = livingDaemon();
399
- if (rec !== undefined) {
400
- const health = await healthCheck(rec.port);
401
- if (health.ok) return { previous, record: rec, via: "systemctl" };
402
- }
403
- if (Date.now() >= deadline) break;
404
- await sleep(READY_POLL_MS);
405
- }
406
- throw new Error(
407
- `systemctl restart ${SYSTEMD_UNIT} returned, but the daemon never answered /healthz`,
408
- );
416
+ const record = await waitForOwnedDaemon("restart", o.timeoutMs);
417
+ return { previous, record, via: "systemctl" };
409
418
  }
410
419
 
420
+ // Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
421
+ // is somebody else. The detached CLI daemon is the only path left.
411
422
  await stopDaemon({ timeoutMs: o.timeoutMs });
412
423
  const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
413
424
  return { previous, record, via: "cli" };
414
425
  }
415
426
 
427
+ /**
428
+ * Restores a failed installed unit through the manager: `reset-failed` then
429
+ * `start`, and proves the result the same way any managed restart is proven
430
+ * (see {@link waitForOwnedDaemon}). Refusal or unproven ownership is terminal
431
+ * — never a fallthrough to the detached CLI daemon.
432
+ */
433
+ async function restoreFailedUnit(timeoutMs?: number): Promise<DaemonRecord> {
434
+ const reset = systemctl(["reset-failed", SYSTEMD_UNIT]);
435
+ if (!reset.ok) {
436
+ throw new Error(systemctlFailure("reset-failed", reset));
437
+ }
438
+ const started = systemctl(["start", SYSTEMD_UNIT]);
439
+ if (!started.ok) {
440
+ throw new Error(systemctlFailure("start", started));
441
+ }
442
+ return await waitForOwnedDaemon("start", timeoutMs);
443
+ }
444
+
445
+ /**
446
+ * Waits for the daemon a manager call was just made to boot, and refuses to
447
+ * report success until the service manager is proven to own it: the unit's
448
+ * MainPID must equal the daemon record pid AND `/healthz` must answer. A unit
449
+ * that went back to `failed` is a confirmed negative and fails fast — waiting
450
+ * cannot un-fail it. Any other unproven state fails at the deadline with a
451
+ * diagnostic that names the mismatch, never a bare "not ready".
452
+ */
453
+ async function waitForOwnedDaemon(verb: "restart" | "start", timeoutMs?: number): Promise<DaemonRecord> {
454
+ const via = `systemctl ${verb} ${SYSTEMD_UNIT} returned`;
455
+ const inspect = `systemctl status ${SYSTEMD_UNIT}`;
456
+ const deadline = Date.now() + (timeoutMs ?? READY_TIMEOUT_MS);
457
+ for (;;) {
458
+ const rec = livingDaemon();
459
+ const ownership = probeUnit();
460
+ if (rec !== undefined && ownership.kind === "active" && ownership.pid === rec.pid) {
461
+ const health = await healthCheck(rec.port);
462
+ if (health.ok) return rec;
463
+ } else if (ownership.kind === "failed") {
464
+ // A confirmed negative: the start did not take and the unit is failed
465
+ // again. Waiting longer cannot un-fail it.
466
+ throw new Error(
467
+ `${via}, but the unit went back to failed — the daemon is NOT running; check \`${inspect}\``,
468
+ );
469
+ }
470
+ if (Date.now() >= deadline) break;
471
+ await sleep(READY_POLL_MS);
472
+ }
473
+
474
+ // Failed to prove ownership. Name what is wrong rather than a bare timeout,
475
+ // so the operator knows whether to fix the unit or hunt the foreign pid.
476
+ const rec = livingDaemon();
477
+ const ownership = probeUnit();
478
+ if (rec === undefined) {
479
+ throw new Error(
480
+ `${via}, but no live daemon record appeared — the daemon did not come back; check \`${inspect}\``,
481
+ );
482
+ }
483
+ if (ownership.kind === "active") {
484
+ if (ownership.pid === rec.pid) {
485
+ throw new Error(
486
+ `${via}, but the daemon never answered /healthz on :${rec.port} — ` +
487
+ `the service manager owns pid ${rec.pid}, but it is not serving; ` +
488
+ `check \`${inspect}\` and ${rec.logFile}`,
489
+ );
490
+ }
491
+ throw new Error(
492
+ `${via}, but the unit's MainPID (${ownership.pid}) does not match the daemon record pid (${rec.pid}) — ` +
493
+ `the reported pid is NOT owned by the service manager; check \`${inspect}\``,
494
+ );
495
+ }
496
+ if (ownership.kind === "unknown") {
497
+ throw new Error(
498
+ `${via}, but ownership can no longer be determined (${ownership.reason}) — ` +
499
+ `cannot confirm the daemon is managed; check \`${inspect}\``,
500
+ );
501
+ }
502
+ throw new Error(`${via}, but the unit is not active; check \`${inspect}\``);
503
+ }
504
+
416
505
  /**
417
506
  * What we know about {@link SYSTEMD_UNIT}.
418
507
  *
@@ -420,14 +509,22 @@ export async function restartDaemon(
420
509
  * states (`activating`, `deactivating`, `reloading`, `reactivating`) — the
421
510
  * pid is still systemd-owned, so a raw SIGTERM would bounce under
422
511
  * `Restart=on-failure`.
423
- * - `inactive` — confirmed not running (MainPID absent/0), unit absent, *or*
424
- * no `systemctl` binary on this host. Safe to treat as "not supervised here".
512
+ * - `failed` — the unit is installed and its last activation failed
513
+ * (ActiveState=failed, no MainPID). systemd still owns this unit's
514
+ * lifecycle, so restoring the daemon must go through the manager
515
+ * (`reset-failed` + `start`); classifying a failed unit as "not supervised
516
+ * here" is how `restart` launched an unmanaged daemon next to a unit that
517
+ * stayed failed (#376).
518
+ * - `inactive` — confirmed not running (MainPID absent/0, ActiveState not
519
+ * `failed`), unit absent, *or* no `systemctl` binary on this host. Safe to
520
+ * treat as "not supervised here".
425
521
  * - `unknown` — the manager exists (or we cannot tell it does not) but the
426
522
  * query failed: dbus blip, permission, timeout. Must not be collapsed into
427
523
  * `inactive` — that is how a unit-owned daemon gets a raw SIGTERM.
428
524
  */
429
525
  export type UnitOwnership =
430
526
  | { kind: "active"; pid: number }
527
+ | { kind: "failed" }
431
528
  | { kind: "inactive" }
432
529
  | { kind: "unknown"; reason: string };
433
530
 
@@ -454,26 +551,39 @@ export function probeUnit(unit = SYSTEMD_UNIT): UnitOwnership {
454
551
  // live pid (> 1) means systemd still owns that process — including during
455
552
  // `activating` / `deactivating` / `reloading`. Filtering on ActiveState here
456
553
  // used to label those transitional states "inactive" and hand the pid to a
457
- // raw SIGTERM, which is exactly the Restart=on-failure bounce.
554
+ // raw SIGTERM, which is exactly the Restart=on-failure bounce. The one
555
+ // place ActiveState decides is the `failed` classification below: with no
556
+ // MainPID there is no pid to hand to anything, but the unit has not gone
557
+ // away either.
458
558
  const lines = ran.stdout
459
559
  .split("\n")
460
560
  .map((l) => l.trim())
461
561
  .filter((l) => l.length > 0);
462
562
  let pid: number | undefined;
563
+ let activeState: string | undefined;
463
564
  for (const line of lines) {
464
565
  if (/^\d+$/.test(line)) {
465
566
  const n = Number(line);
466
567
  if (Number.isInteger(n) && n > 1) pid = n;
568
+ } else {
569
+ activeState = line;
467
570
  }
468
571
  }
469
- if (pid === undefined) return { kind: "inactive" };
470
- return { kind: "active", pid };
572
+ if (pid !== undefined) return { kind: "active", pid };
573
+ // A failed unit is NOT the confirmed negative `inactive` claims to be: the
574
+ // unit is installed and the manager still owns its lifecycle, so restoring
575
+ // the daemon has to go through systemd (`reset-failed` + `start`). Reading
576
+ // `failed` as "not supervised here" is precisely how #376 ended with a
577
+ // healthy-looking but unmanaged pid next to a unit that stayed failed.
578
+ if (activeState === "failed") return { kind: "failed" };
579
+ return { kind: "inactive" };
471
580
  }
472
581
 
473
582
  /**
474
583
  * The MainPID of an *active* {@link SYSTEMD_UNIT}, or `undefined` when the
475
- * unit is confirmed inactive/absent or when ownership could not be determined.
476
- * Prefer {@link probeUnit} when the caller must distinguish those two.
584
+ * unit is confirmed inactive, failed, or absent, or when ownership could not
585
+ * be determined. Prefer {@link probeUnit} when the caller must distinguish
586
+ * those two.
477
587
  */
478
588
  export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
479
589
  const ownership = probeUnit(unit);
@@ -490,8 +600,9 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
490
600
  * - `stop` — unit is active and owns `pid` (or there is no pidfile and the
491
601
  * unit is the only candidate). Caller MUST go through systemctl; a failed
492
602
  * manager call is terminal.
493
- * - `not-ours` — confirmed inactive/absent unit, no systemd binary, or a unit
494
- * whose MainPID is someone else. Caller may SIGTERM its own pidfile process.
603
+ * - `not-ours` — confirmed inactive/failed/absent unit, no systemd binary, or
604
+ * a unit whose MainPID is someone else. Caller may SIGTERM its own pidfile
605
+ * process (a failed unit owns nothing, so signalling its record is safe).
495
606
  * - `unknown` — ownership query failed. Caller MUST NOT signal.
496
607
  */
497
608
  type SystemdStopDecision =
@@ -502,7 +613,10 @@ type SystemdStopDecision =
502
613
  function decideSystemdStop(pid: number | undefined): SystemdStopDecision {
503
614
  const ownership = probeUnit();
504
615
  if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
505
- if (ownership.kind === "inactive") return { kind: "not-ours" };
616
+ // A failed unit owns no process (MainPID 0 systemd has already reaped or
617
+ // lost it), so a live record pid is a confirmed "not ours", exactly like an
618
+ // inactive unit. Restoring the unit is restart's job, not stop's.
619
+ if (ownership.kind === "inactive" || ownership.kind === "failed") return { kind: "not-ours" };
506
620
  if (pid !== undefined && ownership.pid !== pid) return { kind: "not-ours" };
507
621
  return { kind: "stop", mainPid: ownership.pid };
508
622
  }
@@ -533,11 +647,17 @@ async function runSystemdStop(pid: number, timeoutMs?: number): Promise<void> {
533
647
  }
534
648
  }
535
649
 
536
- function systemctlFailure(verb: "stop" | "restart", ran: SystemctlResult): string {
650
+ function systemctlFailure(
651
+ verb: "stop" | "restart" | "reset-failed" | "start",
652
+ ran: SystemctlResult,
653
+ ): string {
537
654
  const detail = (ran.stderr.trim() || ran.stdout.trim() || "no output").split("\n")[0] ?? "no output";
655
+ const guard =
656
+ verb === "stop" || verb === "restart"
657
+ ? `refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce)`
658
+ : `the unit was NOT restored, so the daemon would be unmanaged`;
538
659
  return (
539
- `systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ` +
540
- `refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce); ` +
660
+ `systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ${guard}; ` +
541
661
  `fix the unit or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
542
662
  );
543
663
  }
package/src/omp.ts CHANGED
@@ -398,6 +398,15 @@ const DRAIN_GRACE_MS = 2_000;
398
398
  /** How long a disposed child gets to exit before it is signalled. */
399
399
  const DISPOSE_GRACE_MS = 5_000;
400
400
 
401
+ /**
402
+ * Thrown by {@link createSession} when the pre-spawn admission gate closes: a
403
+ * daemon stop landed while the session socket was binding, so the child was
404
+ * never spawned. The caller maps this to a stopped run, not a failed one —
405
+ * a shutdown must not charge an attempt against a worker it refused to start
406
+ * (#374).
407
+ */
408
+ export class SessionAdmissionClosedError extends Error {}
409
+
401
410
  export interface CreateSessionOptions {
402
411
  cwd: string;
403
412
  sessionDir?: string;
@@ -430,6 +439,16 @@ export interface CreateSessionOptions {
430
439
  * its verb channel to this pid, and a channel accepts nothing until it is bound.
431
440
  */
432
441
  onSpawn?: (pid: number) => void;
442
+ /**
443
+ * Pre-spawn admission gate (#374). Consulted once, immediately after the
444
+ * socket bind await and immediately before `Bun.spawn` — the last window a
445
+ * daemon stop can land in before a child exists. A gate that returns false
446
+ * closes the listener, removes the socket, and throws
447
+ * {@link SessionAdmissionClosedError} instead of spawning; the run then
448
+ * settles as stopped rather than adding a worker the shutdown would have to
449
+ * wait for. Absent, the spawn always proceeds.
450
+ */
451
+ maySpawn?: () => boolean;
433
452
  /** Child stderr, line by line. Defaults to the process's own stderr. */
434
453
  onChildLog?: (line: string) => void;
435
454
  startupTimeoutMs?: number;
@@ -505,6 +524,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
505
524
  resolve();
506
525
  });
507
526
  });
527
+ // Pre-spawn admission recheck (#374): SIGTERM/SIGINT can land during the
528
+ // listen await above — after the daemon's own pre-launch checks and before
529
+ // this child exists. A closed gate means no child: close the listener,
530
+ // remove the socket (and, when this side created the directory, the
531
+ // directory itself — the same cleanup the failure paths below run), and
532
+ // signal the caller to settle the run as stopped instead of spawning a
533
+ // worker the shutdown would then have to wait for.
534
+ if (opts.maySpawn?.() === false) {
535
+ server.close();
536
+ rmSync(socketPath, { force: true });
537
+ if (owned) rmSync(socketDir, { recursive: true, force: true });
538
+ throw new SessionAdmissionClosedError(
539
+ "daemon shutdown began while the session socket was binding",
540
+ );
541
+ }
508
542
  // The socket is the run's own channel, and the daemon's own uid is the only
509
543
  // one that speaks on it.
510
544
  chmodSync(socketPath, 0o600);
package/src/types.ts CHANGED
@@ -1154,6 +1154,7 @@ export type AdmissionHoldReason =
1154
1154
  | "unsalvaged-wip"
1155
1155
  | "daily-spend-cap"
1156
1156
  | "plan-usage-cap"
1157
+ | "shutting-down"
1157
1158
  | "unroutable:no-repo-label"
1158
1159
  | "unroutable:multiple-repo-labels"
1159
1160
  | "unroutable:unknown-repo";