omp-conductor 0.7.1 → 0.9.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/src/decisions.ts CHANGED
@@ -17,7 +17,7 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
17
17
  /**
18
18
  * A precondition whose truth this package can check on its own.
19
19
  *
20
- * Exactly three kinds, deliberately. Each one is a question the tracker or npm
20
+ * Exactly six kinds, deliberately. Each one is a question the tracker or npm
21
21
  * already answers, so the row moves from "parked" to "act on this" without a
22
22
  * human re-reading it. Anything richer — a label appearing, a workflow going
23
23
  * green — is a follow-on issue rather than a grammar nobody validated.
@@ -25,11 +25,22 @@ import type { DecisionRecord, Store, Tracker } from "./types.ts";
25
25
  export type DecisionCondition =
26
26
  | { kind: "pr-merged"; url: string }
27
27
  | { kind: "issue-closed"; issue: number }
28
- | { kind: "npm-version"; spec: string };
28
+ | { kind: "npm-version"; spec: string }
29
+ | { kind: "pr-checks-green"; url: string }
30
+ | { kind: "pr-mergeable"; url: string }
31
+ | { kind: "rate-limit-reset" };
29
32
 
30
33
  /** `pkg@version`, including a scoped package (`@scope/pkg@1.2.3`). */
31
34
  const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
32
35
 
36
+ /**
37
+ * Check states that count as a green verdict for `pr-checks-green`, copied from
38
+ * the daemon's failure classifier (`failure-class.ts` `SUCCESS_CHECK_STATES`):
39
+ * both spellings are terminally successful, and anything else — a failure, a
40
+ * still-running/pending check, a cancelled or skipped runner — is not.
41
+ */
42
+ const GREEN_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
43
+
33
44
  /**
34
45
  * Parse a raw condition, or `undefined` when it is not one of the three forms.
35
46
  *
@@ -46,8 +57,8 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
46
57
  const rest = text.slice(at + 1).trim();
47
58
  if (rest.length === 0) return undefined;
48
59
 
49
- if (kind === "pr-merged") {
50
- return rest.startsWith("https://") ? { kind: "pr-merged", url: rest } : undefined;
60
+ if (kind === "pr-merged" || kind === "pr-checks-green" || kind === "pr-mergeable") {
61
+ return rest.startsWith("https://") ? { kind, url: rest } : undefined;
51
62
  }
52
63
  if (kind === "issue-closed") {
53
64
  if (!/^\d+$/.test(rest)) return undefined;
@@ -57,14 +68,22 @@ export function parseCondition(raw: string): DecisionCondition | undefined {
57
68
  if (kind === "npm-version") {
58
69
  return NPM_SPEC.test(rest) ? { kind: "npm-version", spec: rest } : undefined;
59
70
  }
71
+ if (kind === "rate-limit-reset") {
72
+ // Spelled `rate-limit-reset:github`: the grammar requires a non-empty rest,
73
+ // and `github` names the one GraphQL provider the fleet is on.
74
+ return rest === "github" ? { kind: "rate-limit-reset" } : undefined;
75
+ }
60
76
  return undefined;
61
77
  }
62
78
 
63
- /** The three accepted forms, for a refusal that can be acted on in one turn. */
79
+ /** The six accepted forms, for a refusal that can be acted on in one turn. */
64
80
  export const CONDITION_FORMS = [
65
81
  "pr-merged:https://github.com/owner/repo/pull/123",
66
82
  "issue-closed:123",
67
83
  "npm-version:omp-conductor@0.4.3",
84
+ "pr-checks-green:https://github.com/owner/repo/pull/123",
85
+ "pr-mergeable:https://github.com/owner/repo/pull/123",
86
+ "rate-limit-reset:github",
68
87
  ] as const;
69
88
 
70
89
  /** Probe for `npm-version`, injectable so tests never reach the network. */
@@ -90,6 +109,33 @@ export const probeNpmVersion: NpmProbe = async (spec) => {
90
109
  }
91
110
  };
92
111
 
112
+ /** Probe for `rate-limit-reset`, injectable so tests never reach the network. */
113
+ export type RateLimitProbe = () => Promise<boolean>;
114
+
115
+ /**
116
+ * `gh api rate_limit` — any remaining GraphQL quota means the limit has reset
117
+ * (or never bound). Bounded at 10 s like {@link probeNpmVersion}, for the same
118
+ * reason: this runs inside a tick, and a hung `gh` must cost one unevaluated
119
+ * decision, not the tick.
120
+ */
121
+ export const probeRateLimitReset: RateLimitProbe = async () => {
122
+ const proc = Bun.spawn(
123
+ ["gh", "api", "rate_limit", "--jq", ".resources.graphql.remaining"],
124
+ { stdout: "pipe", stderr: "ignore" },
125
+ );
126
+ const timer = setTimeout(() => {
127
+ proc.kill();
128
+ }, 10_000);
129
+ try {
130
+ const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
131
+ return code === 0 && Number.parseInt(text.trim(), 10) > 0;
132
+ } catch {
133
+ return false;
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ };
138
+
93
139
  /**
94
140
  * Check every open decision that carries a condition and has not met it yet.
95
141
  *
@@ -105,7 +151,7 @@ export async function evaluateDecisionConditions(
105
151
  store: Store,
106
152
  project: string,
107
153
  tracker: Tracker,
108
- probeNpm: NpmProbe,
154
+ probes: { npm: NpmProbe; rateLimit: RateLimitProbe },
109
155
  now: () => number,
110
156
  ): Promise<DecisionRecord[]> {
111
157
  const met: DecisionRecord[] = [];
@@ -119,8 +165,22 @@ export async function evaluateDecisionConditions(
119
165
  satisfied = (await tracker.prState(condition.url)) === "merged";
120
166
  } else if (condition.kind === "issue-closed") {
121
167
  satisfied = (await tracker.issueState(condition.issue)) === "closed";
168
+ } else if (condition.kind === "npm-version") {
169
+ satisfied = await probes.npm(condition.spec);
170
+ } else if (condition.kind === "pr-checks-green") {
171
+ // The same conclusion values the daemon's failure classifier treats as
172
+ // a green verdict (`success` / `neutral`, lowercased): a non-empty list
173
+ // in which every check is terminally successful and none is failing or
174
+ // pending (#189).
175
+ const checks = await tracker.checkConclusions(condition.url);
176
+ satisfied =
177
+ checks.length > 0 && checks.every((c) => GREEN_CHECK_STATES[c.state.trim().toLowerCase()] === true);
178
+ } else if (condition.kind === "pr-mergeable") {
179
+ // `clean` is the tracker's "this PR can merge" literal; `unknown` and a
180
+ // conflict are both unsatisfied (#189).
181
+ satisfied = (await tracker.mergeable(condition.url)) === "clean";
122
182
  } else {
123
- satisfied = await probeNpm(condition.spec);
183
+ satisfied = await probes.rateLimit();
124
184
  }
125
185
  } catch {
126
186
  continue;
package/src/diff-flags.ts CHANGED
@@ -435,6 +435,14 @@ function evidence(text: string): string {
435
435
  export interface SettlementAudit {
436
436
  /** The worker's final report, verbatim. */
437
437
  report: string;
438
+ /** Earlier attempts' reports for the same issue, oldest first. Their
439
+ * `changed:` disclosures are pooled as coverage so a file disclosed in a
440
+ * prior attempt is never flagged as undisclosed in the final one, while the
441
+ * reverse (unmatched-claim) direction reads only the current report — a file
442
+ * claimed in attempt 1 and reverted by attempt 3 must not come back as a new
443
+ * finding (#199). Absent means this is the only attempt, or older rows never
444
+ * stored a report. */
445
+ priorReports?: readonly string[];
438
446
  /** The dispatching issue's title and body — the attribution source. */
439
447
  issueText: string;
440
448
  diff: PrDiff;
@@ -457,8 +465,13 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
457
465
  function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
458
466
  const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
459
467
  const claims = claimedPaths(audit.report);
468
+ const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
469
+ // Coverage pools the current report with every prior attempt's disclosures:
470
+ // a file the final report no longer names was disclosed while the work was
471
+ // still in flight, so it must not be flagged undisclosed (#199).
472
+ const coverage = [...new Set([...claims, ...priorClaims])];
460
473
 
461
- if (claims.length === 0) {
474
+ if (claims.length === 0 && priorClaims.length === 0) {
462
475
  // The degenerate case of the same check: with no usable `changed:` line
463
476
  // every file is undisclosed, and saying so once beats saying it per file.
464
477
  // Reporting it at all is what stops the check being defeated by writing
@@ -476,8 +489,8 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
476
489
  }
477
490
 
478
491
  for (const file of touched) {
479
- if (claims.some((claim) => covers(claim, file.path))) continue;
480
- if (file.previousPath !== undefined && claims.some((claim) => covers(claim, file.previousPath ?? ""))) {
492
+ if (coverage.some((claim) => covers(claim, file.path))) continue;
493
+ if (file.previousPath !== undefined && coverage.some((claim) => covers(claim, file.previousPath ?? ""))) {
481
494
  continue;
482
495
  }
483
496
  flags.push({
@@ -491,6 +504,10 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
491
504
  // file it edited and then reverted, or one it renamed away from. Worth
492
505
  // surfacing because a `changed:` line that describes a different PR is the
493
506
  // signature of a report written from memory rather than from `git diff`.
507
+ //
508
+ // It iterates only the CURRENT report's claims (#199). A file claimed in
509
+ // attempt 1 and reverted by attempt 3 was true then and false now; pooling
510
+ // priors into this loop would turn every honest revert into a fresh finding.
494
511
  for (const claim of claims) {
495
512
  if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
496
513
  continue;
@@ -661,18 +678,30 @@ const HEADING = "settlement audit";
661
678
  * that could not be read in full — then the absence of flags is not evidence of
662
679
  * anything, and saying so is the honest report.
663
680
  */
681
+ /** Appended to the heading when a multi-attempt audit pooled prior disclosures,
682
+ * so a reader is told the coverage is wider than this one report (#199). */
683
+ function pooledHeading(attempts: number | undefined): string {
684
+ return attempts !== undefined && attempts > 1
685
+ ? ` — disclosures pooled across ${attempts} attempt(s)`
686
+ : "";
687
+ }
688
+
664
689
  export function formatSettlementFlags(
665
690
  flags: readonly SettlementFlag[],
666
- diff: { truncated: boolean } = { truncated: false },
691
+ diff: { truncated: boolean; attempts?: number } = { truncated: false },
667
692
  ): string[] {
668
693
  if (flags.length === 0) {
669
694
  return diff.truncated
670
- ? [`${HEADING}: no flags, but the PR diff was too large to read in full — this is not a clean bill`]
695
+ ? [
696
+ `${HEADING}: no flags, but the PR diff was too large to read in full` +
697
+ `${pooledHeading(diff.attempts)} — this is not a clean bill`,
698
+ ]
671
699
  : [];
672
700
  }
673
701
  const lines = [
674
702
  `${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
675
- (diff.truncated ? ", and the PR diff was too large to read in full" : ""),
703
+ (diff.truncated ? ", and the PR diff was too large to read in full" : "") +
704
+ pooledHeading(diff.attempts),
676
705
  ];
677
706
  for (const flag of flags.slice(0, RENDERED_FLAGS)) {
678
707
  lines.push(
package/src/fleet.ts CHANGED
@@ -42,6 +42,8 @@ import {
42
42
  formatReleaseGrants,
43
43
  formatSalvagedRuns,
44
44
  isPaused,
45
+ pauseProvenance,
46
+ pausedAt,
45
47
  setPaused,
46
48
  statusSnapshot,
47
49
  type StatusSnapshot,
@@ -55,6 +57,7 @@ import {
55
57
  SYSTEMD_UNIT,
56
58
  } from "./lifecycle.ts";
57
59
  import { formatRss, rssBytesFromHealthz } from "./host.ts";
60
+ import { fetchRateLimit } from "./tracker/github.ts";
58
61
  import {
59
62
  readTickConfig,
60
63
  readTickRuntimeStatus,
@@ -252,9 +255,9 @@ export interface HaltWithPaneResult extends HaltResult {
252
255
  pane: PaneStopResult;
253
256
  }
254
257
 
255
- export function hold(projectName?: string): HoldResult {
258
+ export function hold(projectName?: string, source: string = "hold"): HoldResult {
256
259
  const wasPaused = isPaused();
257
- setPaused(true);
260
+ setPaused(true, { source });
258
261
  return { wasPaused, disarmed: disarmTicks(projectName) };
259
262
  }
260
263
 
@@ -263,7 +266,7 @@ export function releaseHold(): void {
263
266
  }
264
267
 
265
268
  export async function halt(projectName?: string): Promise<HaltResult> {
266
- const held = hold(projectName);
269
+ const held = hold(projectName, "halt");
267
270
  const stop = await stopDaemon();
268
271
  return { hold: held, stop };
269
272
  }
@@ -1019,8 +1022,27 @@ export function formatFleetStatus(
1019
1022
 
1020
1023
  const graphBlock = formatCodeGraphHealth(codeGraph, now);
1021
1024
 
1025
+ // Pause provenance (`pause --reason`, an integrity/spend-cap/upgrade pause,
1026
+ // a halt) answers "who stopped the fleet" without opening a file (#185). An
1027
+ // unparseable sentinel — paused but with no datable line 1 — is itself news:
1028
+ // it means a run admitted before an *unknown* pause cannot prove innocence
1029
+ // (#174), so every mutation is refused.
1030
+ const dispatchLine =
1031
+ layers.dispatch === "paused"
1032
+ ? (() => {
1033
+ const prov = pauseProvenance();
1034
+ if (prov !== undefined) {
1035
+ const reason = prov.reason === undefined ? "" : ` — "${prov.reason}"`;
1036
+ return `dispatch paused (source: ${prov.source}${reason})`;
1037
+ }
1038
+ return isPaused() && pausedAt() === undefined
1039
+ ? "dispatch paused (unparseable sentinel — all mutations refused)"
1040
+ : "dispatch paused";
1041
+ })()
1042
+ : `dispatch ${layers.dispatch}`;
1043
+
1022
1044
  return [
1023
- `dispatch ${layers.dispatch}`,
1045
+ dispatchLine,
1024
1046
  tickLine,
1025
1047
  ...(nextTickLine === undefined ? [] : [nextTickLine]),
1026
1048
  paneLine,
@@ -1053,6 +1075,39 @@ function formatProjectBody(s: StatusSnapshot): string {
1053
1075
  // independent controls and an operator has to see which one stopped the
1054
1076
  // fleet (#110).
1055
1077
  ` plan usage ${planUsageLine(s.planUsage)}`,
1078
+ // The tracker's API budget, when the renderer could read it. Absent on a
1079
+ // broken `gh`: one missing row, never a broken report (#188). Next to it,
1080
+ // the refusals the tracker actually observed, so a budget that looks
1081
+ // healthy next to every call being refused is still visible (#198).
1082
+ ...(s.github === undefined
1083
+ ? []
1084
+ : [
1085
+ ` github graphql ${s.github.graphql.remaining}/${s.github.graphql.limit}, core ${s.github.core.remaining}/${s.github.core.limit} (graphql resets ${Math.max(0, Math.round((s.github.graphql.reset * 1000 - Date.now()) / 60_000))}m)` +
1086
+ (s.ghRefusals === undefined || s.ghRefusals.count === 0
1087
+ ? ""
1088
+ : ` — ${s.ghRefusals.count} refusal(s) in last 5m (last ${new Date(s.ghRefusals.latestAt ?? Date.now()).toISOString().slice(11, 19)}Z)`),
1089
+ ]),
1090
+ // The daemon's own observed github traffic today (#198): a separate row
1091
+ // from the polled budget above, so one missing `status` read never hides
1092
+ // the other. `onCall` counts every spawn including ones that end 304, so
1093
+ // `daemon` is spawns and `daemon-304` is the unbilled subset — billed ≈
1094
+ // difference, and the polled budget row stays the authority (#203).
1095
+ ` github calls daemon ${s.ghCallsToday?.find((c) => c.source === "daemon")?.calls ?? 0} today` +
1096
+ ((s.ghCallsToday?.find((c) => c.source === "daemon-304")?.calls ?? 0) === 0
1097
+ ? ""
1098
+ : ` (${s.ghCallsToday!.find((c) => c.source === "daemon-304")!.calls} free 304s)`),
1099
+ // Label-projection ops the tracker has not applied yet (#201): GitHub is
1100
+ // behind what the store decided, and the operator can see the lag instead
1101
+ // of discovering it as a stale label or a missing one.
1102
+ ...(s.labelOps === undefined
1103
+ ? []
1104
+ : [
1105
+ ` labels projection ${s.labelOps.pending} pending (oldest ${
1106
+ s.labelOps.oldestAgeMs >= 60_000
1107
+ ? `${Math.round(s.labelOps.oldestAgeMs / 60_000)}m`
1108
+ : `${Math.max(1, Math.round(s.labelOps.oldestAgeMs / 1000))}s`
1109
+ })`,
1110
+ ]),
1056
1111
  ` new worker turns ${s.caps.workerMaxTurns}`,
1057
1112
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
1058
1113
  ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
@@ -1097,18 +1152,22 @@ export async function renderStatus(projectName?: string): Promise<string> {
1097
1152
  const layers = fleetLayers(projectName);
1098
1153
  const project = findProject(loadConfig(), projectName);
1099
1154
  const rec = livingDaemon();
1100
- const [health, telegram, planUsage] = await Promise.all([
1155
+ const [health, telegram, planUsage, github] = await Promise.all([
1101
1156
  rec === undefined ? undefined : healthCheck(rec.port),
1102
1157
  probeTelegramHealth(projectName),
1103
1158
  // Read here rather than in `statusSnapshot`, which is synchronous and used
1104
1159
  // by callers that must not shell out. An unmetered project never spawns
1105
1160
  // the provider at all.
1106
1161
  readPlanUsage(s.caps.planUsage, sharedUsageSource()),
1162
+ // Same reasoning as `planUsage`: the snapshot is synchronous, this read is
1163
+ // a shell-out, and undefined on any failure — one missing row, never a
1164
+ // broken report (#188).
1165
+ fetchRateLimit(),
1107
1166
  ]);
1108
1167
  const cached = codeGraphFromHealthz(health?.body, project.name);
1109
1168
  const codeGraph = cached ?? (await probeCodeGraph(project));
1110
1169
  return formatFleetStatus(
1111
- { ...s, planUsage },
1170
+ { ...s, planUsage, github },
1112
1171
  layers,
1113
1172
  health,
1114
1173
  telegram,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The label projection outbox (#201).
3
+ *
4
+ * The dispatcher used to write GitHub labels directly, which made the label —
5
+ * not the store — the crash guard against double dispatch, and let one refused
6
+ * label write strand an issue (`#184`, `#198`). Dispatch state is now
7
+ * store-authoritative; every decided label change is an outbox row that this
8
+ * module projects onto the tracker with retry.
9
+ *
10
+ * Both the daemon tick and the `unblock` CLI path drain through the same
11
+ * `projectLabels` so a 403 no longer takes either down: the op defers with
12
+ * backoff and the daemon retries it. Per-issue ops apply in id order, which is
13
+ * what makes a swap atomic — `add` lands before the paired `remove` exactly as
14
+ * enqueued.
15
+ */
16
+
17
+ import { GhRateLimitError, isRateLimitRefusal } from "./tracker/github.ts";
18
+ import type { ProjectConfig, Store, Tracker } from "./types.ts";
19
+
20
+ /** Retry backoff for one failed label op: 30s doubling, capped at 15m (#201). */
21
+ export function labelOpBackoffMs(attempts: number): number {
22
+ return Math.min(30_000 * 2 ** attempts, 15 * 60_000);
23
+ }
24
+
25
+ function errText(e: unknown): string {
26
+ return e instanceof Error ? e.message : String(e);
27
+ }
28
+
29
+ /**
30
+ * Drain as many due label ops as the tracker will take, in id order.
31
+ *
32
+ * A failed op defers with backoff and holds the rest of its issue for the next
33
+ * pass: a later op for the same issue must not land while an earlier one could
34
+ * not (that is the atomicity `#184` needed — add-queue-label must not beat
35
+ * remove-blocked when they were enqueued in that order). Other issues continue.
36
+ *
37
+ * Returns what happened so `unblock` can say "queued, the daemon retries"
38
+ * instead of pretending a deferred op was applied.
39
+ */
40
+ export async function projectLabels(
41
+ store: Store,
42
+ tracker: Tracker,
43
+ project: ProjectConfig,
44
+ ): Promise<{ applied: number; deferred: number }> {
45
+ let applied = 0;
46
+ let deferred = 0;
47
+ // `pendingLabelOps` returns at most the oldest owed row per issue (it
48
+ // excludes any row that still has an earlier pending sibling), so a swap's
49
+ // second op can never land before its first — per-issue atomicity enforced
50
+ // across calls and processes, not just one pass. Re-querying lets a healthy
51
+ // chain drain fully in this one call (unblock clears every state label in
52
+ // one go), while a failing op defers its issue and parks the rest until it
53
+ // settles.
54
+ for (;;) {
55
+ const due = store.pendingLabelOps(project.name, Date.now());
56
+ if (due.length === 0) break;
57
+ let progressed = false;
58
+ for (const op of due) {
59
+ try {
60
+ if (op.op === "add") await tracker.addLabel(op.issue, op.label);
61
+ else await tracker.removeLabel(op.issue, op.label);
62
+ store.settleLabelOp(op.id);
63
+ applied += 1;
64
+ progressed = true;
65
+ } catch (err) {
66
+ // A rate-limit refusal defers WITHOUT counting an attempt: a shared
67
+ // outage (all of GitHub refusing) is not this op's fault, so it must
68
+ // not burn the op's backoff escalation (#208). The circuit breaker's
69
+ // fast-fail (`GhRateLimitError`) is the most common shape — its stderr
70
+ // says "circuit breaker open", which `isRateLimitRefusal`'s body regex
71
+ // does not match — so classify it by type first and honour its
72
+ // `retryAtMs`. Every other failure counts and doubles the next wait.
73
+ if (err instanceof GhRateLimitError) {
74
+ store.deferLabelOp(op.id, errText(err), err.retryAtMs, false);
75
+ } else {
76
+ const rateLimited = isRateLimitRefusal(err);
77
+ store.deferLabelOp(
78
+ op.id,
79
+ errText(err),
80
+ Date.now() + labelOpBackoffMs(op.attempts + 1),
81
+ !rateLimited,
82
+ );
83
+ }
84
+ deferred += 1;
85
+ }
86
+ }
87
+ // Every remaining op in this pass was deferred (parked behind its backoff),
88
+ // so re-querying would only re-select the same deferred rows; nothing more
89
+ // is applicable until a backoff elapses.
90
+ if (!progressed) break;
91
+ }
92
+ return { applied, deferred };
93
+ }
package/src/lifecycle.ts CHANGED
@@ -292,6 +292,13 @@ export type StopResult =
292
292
  | { kind: "stopped"; pid: number; via: "systemctl" | "signal" }
293
293
  | { kind: "not-running" };
294
294
 
295
+ /** What {@link restartDaemon} returns: the pre-restart record, the new one, and the path taken. */
296
+ export interface RestartResult {
297
+ previous: DaemonRecord | undefined;
298
+ record: DaemonRecord;
299
+ via: "systemctl" | "cli";
300
+ }
301
+
295
302
  /**
296
303
  * Stops the daemon.
297
304
  *
@@ -365,7 +372,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
365
372
  */
366
373
  export async function restartDaemon(
367
374
  o: { port?: number; project?: string; timeoutMs?: number } = {},
368
- ): Promise<{ previous: DaemonRecord | undefined; record: DaemonRecord; via: "systemctl" | "cli" }> {
375
+ ): Promise<RestartResult> {
369
376
  const previous = livingDaemon();
370
377
  const ownership = probeUnit();
371
378
  if (ownership.kind === "unknown") {