@tpsdev-ai/flair 0.49.0 → 0.50.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.
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { escapeXml } from "../lib/xml-escape.js";
22
- import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
22
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
23
23
  // Re-exported so this module's public surface is unchanged by the extraction
24
24
  // into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
25
25
  // sync enable` — needs the identical launchctl/systemctl interpretation, and
@@ -180,8 +180,8 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
180
180
  * known pattern — the caller already prints the raw stderr, so the operator
181
181
  * still has something to go on.
182
182
  */
183
- export function describeLoadFailure(plat, loadResult) {
184
- return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
183
+ export function describeLoadFailure(plat, loadResult, session) {
184
+ return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable", session);
185
185
  }
186
186
  /**
187
187
  * Formats the `flair rem nightly enable` report from an `EnableResult`.
@@ -199,6 +199,15 @@ export function describeLoadFailure(plat, loadResult) {
199
199
  * happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
200
200
  * therefore withholds the headline too, instead of being treated as success.
201
201
  */
202
+ function appendFlairBinWarning(lines, r) {
203
+ if (r.flairBinCanonical !== false || !r.flairBin)
204
+ return;
205
+ const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair rem nightly enable");
206
+ if (warning.length === 0)
207
+ return;
208
+ lines.push("");
209
+ lines.push(...warning);
210
+ }
202
211
  export function formatEnableReport(r, input) {
203
212
  const { hour, minute, agentId, flairUrl } = input;
204
213
  const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
@@ -216,11 +225,15 @@ export function formatEnableReport(r, input) {
216
225
  ];
217
226
  if (lr.stderr)
218
227
  lines.push(` stderr: ${lr.stderr.trim()}`);
219
- const remedy = describeLoadFailure(r.platform, lr);
228
+ const lingerEnabled = input.lingerEnabled !== undefined
229
+ ? input.lingerEnabled
230
+ : (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
231
+ const remedy = describeLoadFailure(r.platform, lr, { lingerEnabled, env: input.env });
220
232
  lines.push("");
221
233
  lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
222
234
  lines.push("");
223
235
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
236
+ appendFlairBinWarning(lines, r);
224
237
  return { lines, ok: false };
225
238
  }
226
239
  if (!r.firstRunVerified) {
@@ -272,6 +285,7 @@ export function formatEnableReport(r, input) {
272
285
  }
273
286
  lines.push("");
274
287
  lines.push(` Check anytime with: flair rem nightly status`);
288
+ appendFlairBinWarning(lines, r);
275
289
  return { lines, ok: false };
276
290
  }
277
291
  const lines = [
@@ -288,6 +302,7 @@ export function formatEnableReport(r, input) {
288
302
  lines.push(` First run: completed through the service manager, exit 0`);
289
303
  lines.push("");
290
304
  lines.push(`Disable with \`flair rem nightly disable\`.`);
305
+ appendFlairBinWarning(lines, r);
291
306
  return { lines, ok: true };
292
307
  }
293
308
  /**
@@ -329,7 +344,8 @@ export function formatStatusReport(s) {
329
344
  */
330
345
  export function enableScheduler(opts) {
331
346
  const plat = detectPlatform(opts.platformOverride);
332
- const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
347
+ const resolvedFlair = resolveFlairBin(opts.flairBin);
348
+ const flairBin = resolvedFlair.path;
333
349
  const nodeBin = resolveNodeBin(opts.nodeBin);
334
350
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
335
351
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
@@ -383,6 +399,7 @@ export function enableScheduler(opts) {
383
399
  return {
384
400
  platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
385
401
  firstRunVerified: firstRun?.verified === true, firstRun,
402
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
386
403
  };
387
404
  }
388
405
  // Linux: systemd user units.
@@ -408,6 +425,7 @@ export function enableScheduler(opts) {
408
425
  return {
409
426
  platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
410
427
  firstRunVerified: firstRun?.verified === true, firstRun,
428
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
411
429
  };
412
430
  }
413
431
  /**
@@ -502,16 +502,18 @@ export class BootstrapMemories extends Resource {
502
502
  skillAssignments.push(record);
503
503
  continue;
504
504
  }
505
- // flair#1182 — the raw soul container (key→value), independent of the
506
- // token-budgeted/priority-truncated `sections.soul` lines built below.
507
- soulMap[record.key] = record.value;
508
505
  const line = `**${record.key}:** ${record.value}`;
509
506
  const tokens = estimateTokens(line);
510
507
  const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
511
- soulEntries.push({ key: record.key, line, tokens, priority });
508
+ soulEntries.push({ key: record.key, value: record.value, line, tokens, priority });
512
509
  }
513
510
  // Sort by priority (lower = more important)
514
511
  soulEntries.sort((a, b) => a.priority - b.priority);
512
+ // flair#1371 — the structured `soul` map follows the admission decision.
513
+ // Filling it during the scan (flair#1182) shipped every key even when
514
+ // this loop dropped the entry, so sections.soul / soulTokens / the
515
+ // context pointer described N while `soul` delivered N+1 (delivered
516
+ // but not counted or charged — the #1206 mirror).
515
517
  for (const entry of soulEntries) {
516
518
  if (soulTokens + entry.tokens > soulMaxTokens) {
517
519
  // Skip large entries that exceed budget — truncate or skip
@@ -522,6 +524,7 @@ export class BootstrapMemories extends Resource {
522
524
  if (maxChars > 100) {
523
525
  const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
524
526
  sections.soul.push(truncated);
527
+ soulMap[entry.key] = entry.value;
525
528
  const cost = estimateTokens(truncated);
526
529
  soulTokens += cost;
527
530
  tokenBudget -= cost; // #1199 — soul draws from the shared budget
@@ -529,6 +532,7 @@ export class BootstrapMemories extends Resource {
529
532
  continue;
530
533
  }
531
534
  sections.soul.push(entry.line);
535
+ soulMap[entry.key] = entry.value;
532
536
  soulTokens += entry.tokens;
533
537
  tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
534
538
  }
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "harper";
1
+ import { Resource, databases, server, logger } from "harper";
2
2
  import { promises as fsp, existsSync, readFileSync } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join, dirname } from "node:path";
@@ -8,6 +8,9 @@ import { resolveBuildInfo } from "./build-info.js";
8
8
  import { getMigrationStatusSnapshot } from "./migrations/status.js";
9
9
  import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
10
10
  import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
11
+ import { hybridEnabled } from "./bm25.js";
12
+ import { bm25IndexEnabled, bm25IndexStatus } from "./bm25-index-service.js";
13
+ import { buildPublicHealthBody, resolveSearchReadiness } from "./search-readiness.js";
11
14
  const db = databases;
12
15
  const redactHome = (p) => {
13
16
  const home = homedir();
@@ -52,7 +55,7 @@ function resolveVersion() {
52
55
  return process.env.npm_package_version ?? "dev";
53
56
  }
54
57
  /**
55
- * Health endpoint — truly public, returns only { ok: true }.
58
+ * Health endpoint — truly public. Identity-free; no auth, no role check.
56
59
  *
57
60
  * `allowRead() { return true }` opens Harper's role gate for anonymous GETs,
58
61
  * which is what makes /Health work for callers outside `authorizeLocal`'s
@@ -66,8 +69,15 @@ function resolveVersion() {
66
69
  *
67
70
  * Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
68
71
  * declare the Resource anonymously-accessible at the Harper layer; let the
69
- * handler itself enforce whatever it needs (in this case, nothing — /Health
70
- * is intentionally and always public).
72
+ * handler itself enforce whatever it needs.
73
+ *
74
+ * flair#1326: `ok: true` used to mean only "this resource answered." That
75
+ * is a green light that lies when search routes are not mounted yet, or
76
+ * when the hybrid BM25 index is still cold (first search after restart
77
+ * scans the corpus; the lag grows with store size). `searchReady` is
78
+ * always present. When search cannot be served at all, this endpoint
79
+ * returns HTTP 503 and `ok: false`. When the process is live but recall
80
+ * is still cold, it stays 200 and names the lag on `searchReadyReason`.
71
81
  *
72
82
  * Rich stats (memory counts, agent names, etc.) are behind /HealthDetail
73
83
  * which requires authentication. This prevents information leakage on
@@ -96,13 +106,39 @@ export class Health extends Resource {
96
106
  // a 40-hex sha when the build ran in a git work tree, an honest null
97
107
  // otherwise (tarball builds) — never omitted, never fabricated (Sherlock).
98
108
  const build = resolveBuildInfo();
99
- return {
100
- ok: true,
109
+ const readiness = currentSearchReadiness();
110
+ const body = buildPublicHealthBody(readiness, {
101
111
  version: build?.version ?? resolveVersion(),
102
112
  buildCommit: build?.commit ?? null,
103
- };
113
+ });
114
+ if (readiness.status !== 200) {
115
+ return new Response(JSON.stringify(body), {
116
+ status: readiness.status,
117
+ headers: { "content-type": "application/json" },
118
+ });
119
+ }
120
+ return body;
104
121
  }
105
122
  }
123
+ /** Same sources /Health and /HealthDetail consult so they cannot disagree. */
124
+ let _warnedMissingRegistry = false;
125
+ function currentSearchReadiness() {
126
+ // Shipped Harper launch: this Resource is already registered, so
127
+ // server.resources is populated. The null skip is a stated fail-open
128
+ // (Sherlock on #1406) for the injectable/test path — not an accident.
129
+ const resources = server.resources ?? null;
130
+ if (!resources && !_warnedMissingRegistry) {
131
+ _warnedMissingRegistry = true;
132
+ logger.warn?.("Health: server.resources is absent — skipping the search-route mount check (table-only fail-open). Shipped Harper launch always exposes the registry.");
133
+ }
134
+ return resolveSearchReadiness({
135
+ resources,
136
+ memoryTable: db.flair?.Memory,
137
+ bm25: bm25IndexStatus(),
138
+ hybridEnabled: hybridEnabled(),
139
+ bm25IndexEnabled: bm25IndexEnabled(),
140
+ });
141
+ }
106
142
  /**
107
143
  * Authenticated health detail — returns memory/agent/soul stats + process info.
108
144
  * Requires Ed25519 agent auth or admin basic auth.
@@ -127,6 +163,15 @@ export class HealthDetail extends Resource {
127
163
  const stats = { ok: true };
128
164
  const nowMs = Date.now();
129
165
  const warnings = [];
166
+ // flair#1326: same search-ready signal as public /Health. HealthDetail
167
+ // stays HTTP 200 (it is a stats dump, not a traffic gate); the field
168
+ // and a warning name the lag so `flair status` / operators can see it.
169
+ const readiness = currentSearchReadiness();
170
+ stats.searchReady = readiness.searchReady;
171
+ if (readiness.searchReadyReason) {
172
+ stats.searchReadyReason = readiness.searchReadyReason;
173
+ warnings.push({ level: "warn", message: readiness.searchReadyReason });
174
+ }
130
175
  const ctx = this.getContext?.();
131
176
  // #614 fix: resolve identity via the shared three-way verdict
132
177
  // (internal/agent/anonymous — agent-auth.ts) instead of reading
@@ -811,6 +811,7 @@ export const TOOLS = {
811
811
  { count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
812
812
  { count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
813
813
  { count: "sections.events", containers: ["events"] }, // #1206
814
+ { count: "sections.soul", containers: ["soul"] }, // #1371
814
815
  ],
815
816
  // present + typed even when empty — never a bare {} / missing key (#1182).
816
817
  selfDescribingEmpty: [
@@ -0,0 +1,100 @@
1
+ /**
2
+ * search-readiness.ts — the honest /Health search-ready signal (flair#1326).
3
+ *
4
+ * Harper's process can answer /health (and Flair's /Health resource can
5
+ * answer {ok:true}) while search is still unusable:
6
+ *
7
+ * 1. Boot window — jsResources register incrementally. /Health can be up
8
+ * while /Memory and /SemanticSearch still 404 from Harper's catch-all
9
+ * (documented in packages/adk-flair-js/test/helpers/boot-harper.mjs).
10
+ * 2. Cold BM25 index — hybrid retrieval's persistent index is lazy-built
11
+ * on the first search, not at component start (bm25-index-service.ts).
12
+ * That first-query corpus scan grows with store size. /Health answering
13
+ * is not "recall is warm."
14
+ *
15
+ * This module is Harper-free so the decision is unit-testable against the
16
+ * shipped function. Callers inject the registry / table / index status they
17
+ * already have.
18
+ *
19
+ * Two layers, on purpose:
20
+ * - Routes/table not mounted → not healthy (ok:false, HTTP 503). A
21
+ * traffic-gating probe that only looks at status must not get a green
22
+ * light for a node whose search routes are not serving.
23
+ * - Routes up but index still cold → process is live (ok:true, HTTP 200)
24
+ * and searchReady:false names the lag. We do NOT 503 on a cold index:
25
+ * the index builds on the first search, and a health check must not
26
+ * trigger that scan (bm25-index-service.ts: "Eager building would add a
27
+ * full corpus scan to every boot including … health checks"). 503-until-
28
+ * warm would deadlock — health waits for the index, the index waits for
29
+ * a search that never comes.
30
+ */
31
+ function routeMounted(resources, name) {
32
+ const entry = resources.get?.(name) ?? resources.getMatch?.(name);
33
+ return Boolean(entry?.Resource);
34
+ }
35
+ /**
36
+ * Decide whether search is actually usable, and whether /Health should claim
37
+ * the process is healthy.
38
+ *
39
+ * `resources` is optional. Stated fail-open (Sherlock on #1406): when the
40
+ * registry is missing we skip the route-mount check rather than 503 forever.
41
+ * A table handle can exist while `/Memory` and `/SemanticSearch` still 404,
42
+ * so this is weaker than the primary defense. In the shipped launch path
43
+ * `/Health` is served by this Resource after Harper has registered us, so
44
+ * `server.resources` is populated; the skip is the injectable/test path
45
+ * (and a theoretical export without a registry). Health.ts logs once if
46
+ * the live call site actually takes it.
47
+ */
48
+ export function resolveSearchReadiness(opts) {
49
+ if (opts.resources) {
50
+ const memoryMounted = routeMounted(opts.resources, "Memory");
51
+ const searchMounted = routeMounted(opts.resources, "SemanticSearch");
52
+ if (!memoryMounted || !searchMounted) {
53
+ const missing = [
54
+ !memoryMounted ? "Memory" : null,
55
+ !searchMounted ? "SemanticSearch" : null,
56
+ ].filter(Boolean).join(", ");
57
+ return notServing(`search routes not mounted (${missing})`);
58
+ }
59
+ }
60
+ if (!opts.memoryTable || typeof opts.memoryTable.search !== "function") {
61
+ return notServing("memory table not queryable");
62
+ }
63
+ // Hybrid + the persistent index are default-on. A cold or in-flight BM25
64
+ // index means the first search will pay a full corpus scan — the #1326
65
+ // lag. Name it; do not fail liveness.
66
+ //
67
+ // `disabled` (feed/build failure) and `FLAIR_BM25_INDEX=false` both fall
68
+ // back to the per-query scan. The kill switch never calls ensureReady, so
69
+ // status stays `empty` for the life of the process — that is serving, not
70
+ // cold. Treating it as lag would make searchReady false forever and refuse
71
+ // a node that is already answering recall.
72
+ const indexInPath = opts.hybridEnabled !== false && opts.bm25IndexEnabled !== false;
73
+ if (indexInPath && opts.bm25) {
74
+ if (opts.bm25.state === "building") {
75
+ return namesLag("bm25 index building — first search is still scanning the corpus");
76
+ }
77
+ if (opts.bm25.state === "empty") {
78
+ return namesLag("bm25 index not built (cold boot; first search scans the corpus)");
79
+ }
80
+ }
81
+ return { searchReady: true, ok: true, status: 200 };
82
+ }
83
+ function notServing(searchReadyReason) {
84
+ return { searchReady: false, ok: false, status: 503, searchReadyReason };
85
+ }
86
+ function namesLag(searchReadyReason) {
87
+ return { searchReady: false, ok: true, status: 200, searchReadyReason };
88
+ }
89
+ /** Public /Health JSON body. `searchReady` is always present (never omitted). */
90
+ export function buildPublicHealthBody(readiness, identity) {
91
+ const body = {
92
+ ok: readiness.ok,
93
+ version: identity.version,
94
+ buildCommit: identity.buildCommit,
95
+ searchReady: readiness.searchReady,
96
+ };
97
+ if (readiness.searchReadyReason)
98
+ body.searchReadyReason = readiness.searchReadyReason;
99
+ return body;
100
+ }
@@ -60,6 +60,7 @@ import { compositeScore } from "./scoring.js";
60
60
  import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
61
61
  import { isAllowedBm25Candidate } from "./bm25-filter.js";
62
62
  import { indexedBm25Ids } from "./bm25-index-service.js";
63
+ import { byRecencyThenId } from "./sort-comparators.js";
63
64
  // Convert HNSW cosine distance (1 - similarity) to similarity score.
64
65
  function distanceToSimilarity(distance) {
65
66
  return 1 - distance;
@@ -484,7 +485,14 @@ export async function retrieveCandidates(params) {
484
485
  // retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
485
486
  // semantic hits), while `_score` carries the honest absolute evidence for
486
487
  // each result — the two can disagree, and that is correct.
487
- filteredResults.sort((a, b) => b._rank - a._rank);
488
+ //
489
+ // Ties at this sort are cross-leg RRF identities (flair#1412): no
490
+ // within-leg tie-break can prevent `1/(k+3)+1/(k+5) === 1/(k+5)+1/(k+3)`.
491
+ // Break them by createdAt DESC (null → oldest, never NaN) then id ASC.
492
+ // Determinism is the id ASC total order. createdAt is best-effort recency
493
+ // within an exact `_rank` tie — federated writer clocks can skew, and
494
+ // that cannot reintroduce nondeterminism (see byRecencyThenId).
495
+ filteredResults.sort((a, b) => (b._rank - a._rank) || byRecencyThenId(a, b));
488
496
  for (const r of filteredResults)
489
497
  delete r._rank;
490
498
  return filteredResults;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deterministic sort comparators (flair#1412).
3
+ *
4
+ * Retrieval and selection sorts used to key on one number (score, rank,
5
+ * priority) and then inherit `Array.prototype.sort`'s input order on ties.
6
+ * That input order is Harper scan order, which is free to change across
7
+ * restarts. The family below makes the tie-break the default thing to
8
+ * reach for: each helper has its own primary key, and they share the
9
+ * `compareKey` tail — not one comparator forced onto every site.
10
+ *
11
+ * Do not collapse this into a single `byScoreThenId`. cosine.ts keys on
12
+ * corpus `.index`; MemoryBootstrap (flair#1409, not this PR) keys on the
13
+ * soul `key`. The shared part is the tail.
14
+ *
15
+ * Locale-independent: `compareKey` is code-unit / numeric `<`/`>`, never
16
+ * `localeCompare`. ISO-8601 UTC strings compare chronologically that way.
17
+ */
18
+ /** Total-order tail. Works for ids, corpus indexes, soul keys. */
19
+ export function compareKey(a, b) {
20
+ return a < b ? -1 : a > b ? 1 : 0;
21
+ }
22
+ /** Score / rank descending, then `id` ascending. */
23
+ export function byNumberDescThenId(getNumber) {
24
+ return (a, b) => (getNumber(b) - getNumber(a)) || compareKey(a.id, b.id);
25
+ }
26
+ /**
27
+ * `createdAt` DESC (null → oldest, never NaN) then `id` ASC.
28
+ *
29
+ * Determinism comes from `id` ASC — ids are unique, so this is a total
30
+ * order no matter what `createdAt` does. Do not date-arithmetic a
31
+ * possibly-null field: `new Date(null).getTime()` is 0, but subtracting
32
+ * NaN (unparseable / missing-after-coercion) is undefined behaviour for
33
+ * `Array.prototype.sort`. Empty-string fallback keeps the comparison in
34
+ * string space; an empty value is less than any ISO-8601 UTC timestamp,
35
+ * so it sorts last under DESC.
36
+ *
37
+ * Clock-skew caveat: `createdAt` is writer-stamped. Across federated
38
+ * writers this is best-effort recency within an exact `_rank` tie, not a
39
+ * correctness claim. Skew can misorder rows the ranker already called
40
+ * equivalent; it cannot reintroduce nondeterminism, because `id` ASC
41
+ * still resolves every remaining tie.
42
+ */
43
+ export function byRecencyThenId(a, b) {
44
+ return compareKey(b.createdAt ?? "", a.createdAt ?? "") || compareKey(a.id, b.id);
45
+ }
@@ -20,9 +20,9 @@
20
20
  * in `verifyFirstRun()`: success may not be claimed until the thing the
21
21
  * operator asked for has been observed to happen once.
22
22
  */
23
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
24
- import { resolve, dirname, isAbsolute } from "node:path";
25
- import { platform } from "node:os";
23
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
24
+ import { resolve, dirname, isAbsolute, basename } from "node:path";
25
+ import { platform, userInfo } from "node:os";
26
26
  import { spawnSync } from "node:child_process";
27
27
  /**
28
28
  * 30s ceiling on launchctl/systemctl invocations so a hung service manager
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
90
90
  return null; // spawn itself failed — inconclusive
91
91
  return false; // covers the no-bus case: empty stdout, nonzero/failed exit
92
92
  }
93
+ /** True when this session already has the env `systemctl --user` needs. */
94
+ export function sessionHasUserBusEnv(env = process.env) {
95
+ return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
96
+ }
97
+ /**
98
+ * Reads whether lingering is already enabled for the current user.
99
+ * `loginctl show-user … Linger=yes` is the official answer; the stamp file
100
+ * `loginctl enable-linger` creates is the fallback when loginctl is missing
101
+ * or inconclusive. A failed probe is `null`, never linger-off — inventing
102
+ * linger-off would repeat the linger remedy after it already ran (#1107).
103
+ */
104
+ export function probeUserLingerEnabled(opts = {}) {
105
+ const run = opts.run ?? spawnReport;
106
+ const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
107
+ let user = "";
108
+ try {
109
+ user = userInfo().username;
110
+ }
111
+ catch {
112
+ user = process.env.USER || process.env.LOGNAME || "";
113
+ }
114
+ if (!user)
115
+ return null;
116
+ const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
117
+ const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
118
+ if (m)
119
+ return m[1] === "yes";
120
+ if (lingerStampExists(user))
121
+ return true;
122
+ return null;
123
+ }
93
124
  /**
94
- * Human remedy text for a failed scheduler-load attempt (flair#850). Covers
95
- * the one root cause traced so far: a missing systemd user session bus,
96
- * which blocks `systemctl --user` entirely in ssh-without-lingering,
97
- * container, and CI contexts. Returns null when the failure doesn't match a
98
- * known patternthe caller already prints the raw stderr, so the operator
99
- * still has something to go on.
125
+ * Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
126
+ * Covers the traced "no systemd user session bus" failure, which blocks
127
+ * `systemctl --user` entirely in ssh-without-lingering, container, and CI
128
+ * contexts. Two cases that used to share one remedy:
129
+ * (a) lingering genuinely off print `loginctl enable-linger`
130
+ * (b) linger already on, this session has no user-bus env — print the
131
+ * `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
132
+ * Repeating (a) after the operator has applied it is the #1107 lie.
133
+ * Returns null when the failure doesn't match a known pattern — the caller
134
+ * already prints the raw stderr, so the operator still has something to go on.
100
135
  *
101
136
  * `enableCommand` is the caller's own enable invocation, named in the remedy
102
137
  * so the operator is told to re-run the command they actually ran.
103
138
  */
104
- export function describeLoadFailure(plat, loadResult, enableCommand) {
139
+ export function describeLoadFailure(plat, loadResult, enableCommand, session) {
105
140
  const stderr = loadResult.stderr || "";
106
141
  if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
142
+ if (session?.lingerEnabled === true) {
143
+ const env = session.env ?? process.env;
144
+ if (!sessionHasUserBusEnv(env)) {
145
+ return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
146
+ "do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
147
+ "Export:\n" +
148
+ " export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
149
+ " export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
150
+ ` then re-run \`${enableCommand}\`.`);
151
+ }
152
+ return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
153
+ "this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
154
+ "do not re-run `loginctl enable-linger` or re-export those variables. " +
155
+ "Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
156
+ `then re-run \`${enableCommand}\`.`);
157
+ }
107
158
  return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
108
159
  "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
109
160
  `— then re-run \`${enableCommand}\`.`);
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
172
223
  "enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
173
224
  "at run time. Install node (or put it on PATH for this shell) and re-run enable.");
174
225
  }
226
+ /**
227
+ * Resolves the path enable will bake as FLAIR_BIN, and whether that path is
228
+ * the stable public `flair` entry (flair#1279).
229
+ *
230
+ * Resolution order:
231
+ * 1. `explicit` — caller/test override. Relatives are resolved against cwd.
232
+ * 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
233
+ * 3. The public `flair` on PATH, only when (1) and (2) are empty.
234
+ * Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
235
+ * target under #1231's `exec <node> <script>` form (`node flair` looks in
236
+ * cwd, not PATH).
237
+ */
238
+ export function resolveFlairBin(explicit, hooks) {
239
+ const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
240
+ const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
241
+ let path;
242
+ if (typeof captured === "string" && captured.length > 0) {
243
+ path = isAbsolute(captured) ? captured : resolve(captured);
244
+ }
245
+ else if (publicBin) {
246
+ path = publicBin;
247
+ }
248
+ else {
249
+ throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
250
+ "found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
251
+ "whose exec target is unknown. Re-run enable via the `flair` command.");
252
+ }
253
+ return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
254
+ }
255
+ /** True when `baked` is the public `flair` entry, not a working-tree capture. */
256
+ export function isCanonicalFlairBin(baked, publicBin) {
257
+ if (basename(baked) === "flair")
258
+ return true;
259
+ if (publicBin && pathsReferToSameFile(baked, publicBin))
260
+ return true;
261
+ return false;
262
+ }
263
+ /**
264
+ * The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
265
+ * baked path is the public entry — callers should not print a warning then.
266
+ */
267
+ export function formatFlairBinWarning(baked, publicBin, enableCommand) {
268
+ if (isCanonicalFlairBin(baked, publicBin))
269
+ return [];
270
+ const lines = [
271
+ `⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
272
+ ` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
273
+ ];
274
+ if (publicBin) {
275
+ lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
276
+ }
277
+ else {
278
+ lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
279
+ }
280
+ return lines;
281
+ }
282
+ function lookupPublicFlairBin() {
283
+ const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
284
+ const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
285
+ if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
286
+ return found;
287
+ return null;
288
+ }
289
+ function pathsReferToSameFile(a, b) {
290
+ try {
291
+ return realpathSync(a) === realpathSync(b);
292
+ }
293
+ catch {
294
+ return resolve(a) === resolve(b);
295
+ }
296
+ }
175
297
  // ─── first-run verification (flair#1231) ────────────────────────────────────
176
298
  // A load/bootstrap command exiting 0 proves the service manager accepted the
177
299
  // job — not that the job can run. The only vantage that exercises the real