omp-conductor 0.20.1 → 0.20.3

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.
@@ -52,7 +52,10 @@ export type CodeGraphHealth =
52
52
  active: "active" | "inactive" | "unknown";
53
53
  };
54
54
  refresh: {
55
- result: "success" | "failed" | "unknown";
55
+ /** "running": the oneshot is executing right now (#1090). systemd has
56
+ * already cleared the previous exit timestamp mid-run, so this is a
57
+ * progress report — never a claim about staleness or absence. */
58
+ result: "success" | "failed" | "running" | "unknown";
56
59
  fresh: boolean | null;
57
60
  lastSuccessAt?: string;
58
61
  ageMs?: number;
@@ -208,6 +211,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
208
211
  const serviceResult = fields.get("Result");
209
212
  const exitStatus = fields.get("ExecMainStatus");
210
213
  const completedAt = fields.get("ExecMainExitTimestamp");
214
+ // #1090: an empty ExecMainExitTimestamp says "no completed run yet", which
215
+ // is equally true of a unit that has never run and of one executing right
216
+ // now — systemd clears it when the next run starts. ActiveState comes back
217
+ // from the same single `systemctl show` and separates the two; evidence
218
+ // captured without the property keeps the conservative pre-#1090 reading.
219
+ const activeState = fields.get("ActiveState");
211
220
  if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
212
221
  return unknown("refresh service returned an unrecognized result", true);
213
222
  }
@@ -219,6 +228,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
219
228
  };
220
229
  }
221
230
  if (completedAt === "" || completedAt === "n/a") {
231
+ if (activeState === "activating" || activeState === "active") {
232
+ // There is no completed timestamp to measure staleness against yet, but
233
+ // this is progress, not absence: the reindex is keeping the graph fresh
234
+ // at this moment, so neither the row nor the previous message degrades.
235
+ return { health: { result: "running", fresh: null }, uncertain: false };
236
+ }
222
237
  return unknown("no successful refresh has been recorded", false);
223
238
  }
224
239
  const completedMs = Date.parse(completedAt);
@@ -290,6 +305,7 @@ export async function probeCodeGraph(
290
305
  "--property=Result",
291
306
  "--property=ExecMainStatus",
292
307
  "--property=ExecMainExitTimestamp",
308
+ "--property=ActiveState",
293
309
  ]),
294
310
  ]);
295
311
 
package/src/groom.ts CHANGED
@@ -114,6 +114,17 @@ export interface ReadyGateRejection {
114
114
  * {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
115
115
  * not re-groomed — the spec is rejected, not ungroomed.
116
116
  *
117
+ * That durability is bounded by the verdict itself (#1060). A rejection
118
+ * beside a verdict the contract still accepts is not re-groomable, because
119
+ * the fault lies in the issue — missing criteria, a missing lane — and only
120
+ * an edit to the issue can fix it. A rejection beside a verdict the contract
121
+ * refuses — veltro#731's prose lane entry, persisted before parse-time lane
122
+ * validation — reads as not-durable through {@link toSpecDurableVerdict},
123
+ * and the candidate becomes re-groomable: no issue edit can ever satisfy a
124
+ * gate whose refusal is baked into the verdict, and complying with the
125
+ * refusal's remedy would write the unusable entry into the issue itself, so
126
+ * a fresh pass is the only repair.
127
+ *
117
128
  * Fails closed on anything it cannot read: an empty `missing` array is not a
118
129
  * rejection (a gate that found nothing missing passed), and a non-string entry
119
130
  * would render as `undefined` in a digest line an operator is asked to act on.
package/src/knowledge.ts CHANGED
@@ -19,6 +19,10 @@
19
19
  * accumulated knowledge is worse than one fewer fact, because a worker cannot
20
20
  * tell a clipped warning from a complete one.
21
21
  *
22
+ * What the cap never drops is structure: an operator distillation's headings,
23
+ * paragraphs and blank separators survive every append verbatim and in place —
24
+ * the facts rotate, the shape of the knowledge stays.
25
+ *
22
26
  * Writes are best-effort. This is an optimisation, and a run must never fail
23
27
  * because the overlay could not be written: the callers are settlement paths.
24
28
  */
@@ -74,31 +78,69 @@ function encodeRepoKey(repo: string): string {
74
78
  }
75
79
 
76
80
  /**
77
- * The overlay's entry lines, oldest first, with blank lines and any hand-added
78
- * prose dropped.
81
+ * One line of the overlay document. An `entry` is a fleet-learned fact the
82
+ * `- `-prefixed line the writer produces and attributes; anything else, a
83
+ * heading, a paragraph or a blank separator, is `structure`: hand-added
84
+ * scaffolding from an operator distillation. The distinction is the format:
85
+ * entries are appendable, deduplicated and evictable, structure is none of
86
+ * those things, and it survives every write verbatim and in place.
87
+ */
88
+ type OverlayBlock = { kind: "entry" | "structure"; line: string };
89
+
90
+ /**
91
+ * The overlay document parsed into blocks, in file order. Lossless: joining
92
+ * the lines back with "\n" reproduces the input, which is what makes keeping
93
+ * the structure through a write a property of the parse rather than a hope.
79
94
  *
80
95
  * One parse, shared by the writer and the brief renderer on purpose: they must
81
96
  * agree on what counts as an entry, or the cap one enforces is not the cap the
82
97
  * other renders and a "16 KB" section arrives at 20.
83
98
  */
84
- function entriesOf(text: string): string[] {
85
- return text
86
- .split("\n")
87
- .map((line) => line.trimEnd())
88
- .filter((line) => line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== "");
99
+ function blocksOf(text: string): OverlayBlock[] {
100
+ if (text === "") return [];
101
+ const lines = text.split("\n");
102
+ // The final newline is the file's terminator, not a blank last line: pop it
103
+ // here so appended entries join the document itself, and let renderDoc put
104
+ // exactly one back.
105
+ if (lines.at(-1) === "") lines.pop();
106
+ return lines.map((raw) => {
107
+ const line = raw.trimEnd();
108
+ return line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== ""
109
+ ? { kind: "entry", line }
110
+ : { kind: "structure", line: raw };
111
+ });
112
+ }
113
+
114
+ /**
115
+ * The document text for a block list: a blank run at the EOF seam is
116
+ * whitespace, not content, so it is trimmed and exactly one newline terminates
117
+ * the document — the shape every writer of this format has always produced.
118
+ * Interior blanks pass through untouched.
119
+ */
120
+ function renderDoc(blocks: readonly OverlayBlock[]): string {
121
+ const kept = [...blocks];
122
+ while (kept.at(-1)?.kind === "structure" && kept.at(-1)?.line.trim() === "") kept.pop();
123
+ return `${kept.map((b) => b.line).join("\n")}\n`;
89
124
  }
90
125
 
91
126
  /**
92
- * The newest entries that fit under {@link KNOWLEDGE_MAX_BYTES}, oldest dropped
93
- * first and only ever whole.
127
+ * The newest content that fits under {@link KNOWLEDGE_MAX_BYTES}, oldest
128
+ * entries dropped first and only ever whole.
129
+ *
130
+ * Only entries evict. A distilled file keeps its headings and paragraphs even
131
+ * when the facts beneath them have been rotated out, and when only non-entry
132
+ * text remains the document simply stays over the ceiling: operator prose is
133
+ * not ours to delete, and distilling it back under the cap is the remedy the
134
+ * format itself names.
94
135
  *
95
136
  * Byte lengths, not character counts: the cap is a context-budget promise, and
96
137
  * one accented identifier or box-drawing character is several bytes.
97
138
  */
98
- function withinCap(entries: readonly string[]): string[] {
99
- const kept = [...entries];
100
- while (kept.length > 0 && Buffer.byteLength(`${kept.join("\n")}\n`, "utf8") > KNOWLEDGE_MAX_BYTES) {
101
- kept.shift();
139
+ function withinCap(blocks: readonly OverlayBlock[]): OverlayBlock[] {
140
+ const kept = [...blocks];
141
+ const bytes = (): number => Buffer.byteLength(renderDoc(kept), "utf8");
142
+ while (kept.some((b) => b.kind === "entry") && bytes() > KNOWLEDGE_MAX_BYTES) {
143
+ kept.splice(kept.findIndex((b) => b.kind === "entry"), 1);
102
144
  }
103
145
  return kept;
104
146
  }
@@ -139,6 +181,10 @@ export function readKnowledge(repo: string): string | undefined {
139
181
  * `proofCommands` for every issue in a repo — cannot inflate the file until the
140
182
  * cap evicts something real.
141
183
  *
184
+ * The rewrite preserves every non-entry line the file carries — the headings,
185
+ * paragraphs and blank separators of an operator distillation — exactly where
186
+ * they were: entries rotate under the cap, structure does not.
187
+ *
142
188
  * Best-effort by contract: the callers are settlement paths, and a failed write
143
189
  * here must never turn a finished run into a failed one.
144
190
  */
@@ -157,25 +203,33 @@ export function appendKnowledge(
157
203
  if (rendered.length === 0) return;
158
204
  const path = knowledgePath(repo);
159
205
  try {
160
- const existing = entriesOf(readKnowledge(repo) ?? "");
161
- // Dynamic membership over the file's own lines, grown as this batch is
206
+ const existing = blocksOf(readKnowledge(repo) ?? "");
207
+ // Dynamic membership over the file's own entries, grown as this batch is
162
208
  // folded in, so one call cannot append the same fact twice either.
163
- const seen = new Set(existing);
164
- const added: string[] = [];
209
+ const seen = new Set(existing.filter((b) => b.kind === "entry").map((b) => b.line));
210
+ const added: OverlayBlock[] = [];
165
211
  for (const line of rendered) {
166
212
  if (seen.has(line)) continue;
167
213
  seen.add(line);
168
- added.push(line);
214
+ added.push({ kind: "entry", line });
169
215
  }
170
216
  if (added.length === 0) return;
171
- const kept = withinCap([...existing, ...added]);
217
+ // A batch appended under a distilled paragraph or heading takes one blank
218
+ // separator, so it never glues onto the prose above it; after an entry or
219
+ // a blank none is needed.
220
+ const tail = existing.at(-1);
221
+ const gap: OverlayBlock[] =
222
+ tail !== undefined && tail.kind === "structure" && tail.line.trim() !== ""
223
+ ? [{ kind: "structure", line: "" }]
224
+ : [];
225
+ const kept = withinCap([...existing, ...gap, ...added]);
172
226
  mkdirSync(dirname(path), { recursive: true });
173
227
  // Atomic like every other durable conductor state file: a brief renderer
174
228
  // reading while a settlement writes sees the old file or the new one, never
175
229
  // a half-written entry.
176
230
  const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
177
231
  try {
178
- writeFileSync(tmp, `${kept.join("\n")}\n`, "utf8");
232
+ writeFileSync(tmp, renderDoc(kept), "utf8");
179
233
  renameSync(tmp, path);
180
234
  } catch (err) {
181
235
  rmSync(tmp, { force: true });
@@ -209,7 +263,7 @@ export function appendKnowledge(
209
263
  export function knowledgeSection(repo: string): string {
210
264
  const text = readKnowledge(repo);
211
265
  if (text === undefined) return "";
212
- const entries = withinCap(entriesOf(text));
266
+ const entries = withinCap(blocksOf(text)).filter((b) => b.kind === "entry").map((b) => b.line);
213
267
  if (entries.length === 0) return "";
214
268
  return [
215
269
  KNOWLEDGE_HEADING,
package/src/lifecycle.ts CHANGED
@@ -194,6 +194,103 @@ export function livingDaemon(): DaemonRecord | undefined {
194
194
  return undefined;
195
195
  }
196
196
 
197
+ /**
198
+ * `DaemonRecord.logFile` for a daemon nobody spawned — which is how systemd
199
+ * runs it (`ExecStart … omp-conductor daemon`) and how a hand-run foreground
200
+ * `daemon` works too. The field is required and `status` prints it, so it has
201
+ * to say something true: a foreground daemon opened no log of its own —
202
+ * whoever started it owns its stdout, be that the journal, a terminal, or a
203
+ * pane.
204
+ */
205
+ const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
206
+
207
+ /** What this process would publish as its own boot record, given its flags. */
208
+ function foregroundRecord(o: { port?: number; project?: string }): DaemonRecord {
209
+ return {
210
+ pid: process.pid,
211
+ port: o.port ?? DEFAULT_PORT,
212
+ startedAt: Date.now(),
213
+ logFile: FOREGROUND_LOG,
214
+ ...(o.project === undefined ? {} : { project: o.project }),
215
+ };
216
+ }
217
+
218
+ export type ForegroundBoot =
219
+ | { kind: "claimed"; record: DaemonRecord }
220
+ | { kind: "foreign-live"; pid: number };
221
+
222
+ /**
223
+ * The boot half of pidfile ownership (#1114): a daemon that starts without a
224
+ * managed spawn — systemd's `ExecStart`, a reboot, a bare hand-run — publishes
225
+ * its own record here, so the single-owner guard (`startDaemon`'s live-record
226
+ * refusal), `wake`, `status` and orphan reconciliation are armed no matter who
227
+ * started the process.
228
+ *
229
+ * A foreign live record is refused, never stolen; a stale one is cleared by
230
+ * {@link livingDaemon} on the way past; and a record already naming this pid
231
+ * was written by whoever spawned us and knows the real log file — replacing
232
+ * it with a guess would be a downgrade.
233
+ */
234
+ export function publishForegroundRecord(
235
+ o: { port?: number; project?: string } = {},
236
+ ): ForegroundBoot {
237
+ const running = livingDaemon();
238
+ if (running !== undefined && running.pid !== process.pid) {
239
+ return { kind: "foreign-live", pid: running.pid };
240
+ }
241
+ if (running !== undefined) return { kind: "claimed", record: running };
242
+ const record = foregroundRecord(o);
243
+ writeRecord(record);
244
+ return { kind: "claimed", record };
245
+ }
246
+
247
+ /**
248
+ * The running half of pidfile ownership (#1114): the record can vanish under
249
+ * a perfectly healthy daemon — an external sweep of the shared runtime
250
+ * directory, a tmp cleaner — and nothing else re-arms the single-owner guard
251
+ * until the next restart. The daemon itself repairs that: absent or stale
252
+ * means it publishes again; a live foreign record is left alone (a `--once`
253
+ * drill may legitimately own visibility mid-tick).
254
+ */
255
+ export function republishSelfRecord(o: { port?: number; project?: string } = {}): void {
256
+ const current = livingDaemon();
257
+ // Ours is already published, or a live foreign process owns visibility:
258
+ // either way there is nothing to repair.
259
+ if (current !== undefined) return;
260
+ writeRecord(foregroundRecord(o));
261
+ }
262
+
263
+ /** How often a running daemon re-asserts its record ({@link republishSelfRecord}). */
264
+ export const RECORD_REPAIR_INTERVAL_MS = 60_000;
265
+
266
+ /** Stops the periodic repair started by {@link startRecordRepair}. */
267
+ export interface RecordRepair {
268
+ stop(): void;
269
+ }
270
+
271
+ /**
272
+ * Re-assert the record on an interval while the daemon runs. A failed repair
273
+ * never propagates: the pidfile is visibility, not correctness, and a failed
274
+ * attempt retries on the next tick rather than killing the dispatcher over it.
275
+ */
276
+ export function startRecordRepair(
277
+ o: { port?: number; project?: string } = {},
278
+ intervalMs = RECORD_REPAIR_INTERVAL_MS,
279
+ ): RecordRepair {
280
+ const timer = setInterval(() => {
281
+ try {
282
+ republishSelfRecord(o);
283
+ } catch (err) {
284
+ process.stderr.write(
285
+ `omp-conductor: could not re-assert the daemon pidfile: ${(err as Error).message}\n`,
286
+ );
287
+ }
288
+ }, intervalMs);
289
+ // Bookkeeping must never hold the event loop open at shutdown.
290
+ timer.unref?.();
291
+ return { stop: () => clearInterval(timer) };
292
+ }
293
+
197
294
  /**
198
295
  * The `daemon --once` lease file, one per process. Created `O_EXCL` so two
199
296
  * simultaneous `--once` runs cannot both win ownership, and it names the
@@ -1186,6 +1283,10 @@ export type SystemctlResult = {
1186
1283
  stderr: string;
1187
1284
  /** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
1188
1285
  missing?: boolean;
1286
+ /** The client bound expired (spawnSync ETIMEDOUT). A state-changing verb
1287
+ * that reports this is re-judged from the unit's observed state before it
1288
+ * becomes a failure (#1112). */
1289
+ timedOut?: boolean;
1189
1290
  };
1190
1291
 
1191
1292
  export type SystemctlFn = (args: string[]) => SystemctlResult;
@@ -1251,14 +1352,169 @@ function testSystemctl(args: string[]): SystemctlResult {
1251
1352
  }
1252
1353
  }
1253
1354
 
1254
- function defaultSystemctl(args: string[]): SystemctlResult {
1255
- if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
1355
+ /** Verbs whose completion is a unit job: systemd holds the client until the
1356
+ * stop/start finishes, which legitimately takes as long as the unit's own
1357
+ * graceful-shutdown budget. Everything else answers from manager state and
1358
+ * keeps the short probe bound (#1112). */
1359
+ const STATE_CHANGING_VERBS: Record<string, true> = {
1360
+ start: true,
1361
+ stop: true,
1362
+ restart: true,
1363
+ "try-restart": true,
1364
+ reload: true,
1365
+ "reload-or-restart": true,
1366
+ };
1367
+
1368
+ /**
1369
+ * Bound for probe verbs (`show`, `is-active`, `is-enabled`, …): a hung dbus
1370
+ * becomes `unknown` ownership instead of a hang. Callers must NOT treat that
1371
+ * timeout as "no unit". State-changing verbs get their own budget below — a
1372
+ * 15s bound on `restart` is what declared a healthy restart failed and rolled
1373
+ * a good release back (#1112).
1374
+ */
1375
+ const SYSTEMCTL_PROBE_TIMEOUT_MS = 15_000;
1376
+
1377
+ /**
1378
+ * systemd's built-in `TimeoutStopSec`: how long any unit without an explicit
1379
+ * override may legitimately spend stopping before systemd itself escalates.
1380
+ * A client bound for a state-changing verb never sits below this floor —
1381
+ * smaller is structurally able to lose against a healthy, graceful stop.
1382
+ */
1383
+ const SYSTEMD_DEFAULT_TIMEOUT_STOP_MS = 90_000;
1384
+
1385
+ /** Slack over the unit's own stop budget: the start half of a restart plus
1386
+ * scheduling delay, before the client's stopwatch means anything at all. */
1387
+ const STATE_VERB_SLACK_MS = 30_000;
1388
+
1389
+ type RawSystemctl = (args: string[], timeoutMs: number) => SystemctlResult;
1390
+
1391
+ /** One unit-state reading, in the shape the post-timeout verdict needs. */
1392
+ interface UnitStateSample {
1393
+ activeState?: string;
1394
+ /** `ExecMainStartTimestampMonotonic` — changes on every fresh start of the
1395
+ * unit, which is what makes "the old instance" distinguishable. */
1396
+ startStampMonotonic?: string;
1397
+ /** `TimeoutStopUSec` — integer microseconds, or `infinity`. */
1398
+ timeoutStopUSec?: string;
1399
+ }
1400
+
1401
+ const STATE_SAMPLE_PROPERTIES = [
1402
+ "--property=ActiveState",
1403
+ "--property=ExecMainStartTimestampMonotonic",
1404
+ "--property=TimeoutStopUSec",
1405
+ ] as const;
1406
+
1407
+ function sampleUnitState(unit: string, raw: RawSystemctl): UnitStateSample | undefined {
1408
+ const ran = raw(
1409
+ ["show", unit, "--value", ...STATE_SAMPLE_PROPERTIES],
1410
+ SYSTEMCTL_PROBE_TIMEOUT_MS,
1411
+ );
1412
+ if (!ran.ok) return undefined;
1413
+ const values = ran.stdout.split("\n");
1414
+ const pick = (index: number): string | undefined => {
1415
+ const value = values[index]?.trim();
1416
+ return value === undefined || value === "" ? undefined : value;
1417
+ };
1418
+ return {
1419
+ activeState: pick(0),
1420
+ startStampMonotonic: pick(1),
1421
+ timeoutStopUSec: pick(2),
1422
+ };
1423
+ }
1424
+
1425
+ /**
1426
+ * The client bound for one state-changing verb: at least the unit's own
1427
+ * `TimeoutStopUSec` — read live, because any fixed constant re-creates this
1428
+ * bug on whatever host drains slower than it — and never below systemd's own
1429
+ * default stop budget. An unreadable budget gets the documented fallback.
1430
+ */
1431
+ function stateVerbTimeoutMs(sample: UnitStateSample | undefined): number {
1432
+ let stopMs = SYSTEMD_DEFAULT_TIMEOUT_STOP_MS;
1433
+ const usec =
1434
+ sample?.timeoutStopUSec === undefined ? Number.NaN : Number.parseInt(sample.timeoutStopUSec, 10);
1435
+ if (Number.isFinite(usec) && usec > 0) {
1436
+ stopMs = Math.max(Math.ceil(usec / 1000), stopMs);
1437
+ }
1438
+ return stopMs + STATE_VERB_SLACK_MS;
1439
+ }
1440
+
1441
+ /**
1442
+ * The stopwatch lost; the unit's own state decides. A state-changing verb that
1443
+ * outlived the client bound succeeded exactly when the manager shows the
1444
+ * verb's goal reached: not-active for `stop`, active with a FRESH start
1445
+ * timestamp for the start/restart family — an unchanged timestamp means the
1446
+ * old instance is still the running one. Anything else stays a failure that
1447
+ * carries both facts.
1448
+ */
1449
+ function stateVerbTimeoutVerdict(
1450
+ verb: string,
1451
+ unit: string,
1452
+ before: UnitStateSample | undefined,
1453
+ after: UnitStateSample | undefined,
1454
+ timedOut: SystemctlResult,
1455
+ timeoutMs: number,
1456
+ ): SystemctlResult {
1457
+ const base =
1458
+ `systemctl ${verb} ${unit} exceeded its ${Math.round(timeoutMs / 1000)}s client bound ` +
1459
+ `(${timedOut.stderr.trim() || "client timeout"})`;
1460
+ if (after?.activeState === undefined) {
1461
+ return {
1462
+ ...timedOut,
1463
+ stderr:
1464
+ `${base}, and the unit's state could not be re-read afterwards — ` +
1465
+ `the outcome is unproven, not failed`,
1466
+ };
1467
+ }
1468
+ if (verb === "stop") {
1469
+ if (after.activeState !== "active" && after.activeState !== "deactivating") {
1470
+ // Goal state reached: the stop completed, whatever the stopwatch said.
1471
+ return { ok: true, stdout: "", stderr: "" };
1472
+ }
1473
+ return {
1474
+ ...timedOut,
1475
+ stderr: `${base}; the unit is still ${after.activeState} — the stop did not complete`,
1476
+ };
1477
+ }
1478
+ const freshStart =
1479
+ before !== undefined &&
1480
+ after.startStampMonotonic !== undefined &&
1481
+ after.startStampMonotonic !== before.startStampMonotonic;
1482
+ if (after.activeState === "active" && freshStart) {
1483
+ // The unit restarted; the client simply gave up first.
1484
+ return { ok: true, stdout: "", stderr: "" };
1485
+ }
1486
+ const why =
1487
+ after.activeState !== "active"
1488
+ ? `the unit is ${after.activeState}`
1489
+ : before === undefined
1490
+ ? "the unit is active but no pre-call sample exists to prove the start is fresh"
1491
+ : "its start timestamp is unchanged, so the running instance predates the call";
1492
+ return { ...timedOut, stderr: `${base}; ${why} — the ${verb} did not complete` };
1493
+ }
1494
+
1495
+ /**
1496
+ * Run one systemctl invocation with #1112 semantics: probes keep the short
1497
+ * hung-dbus bound, state-changing verbs wait out the unit's own stop budget,
1498
+ * and a state-changing verb that outlives even that is judged by the unit's
1499
+ * observed state rather than the client's stopwatch. Exported for tests,
1500
+ * which inject their own {@link RawSystemctl}.
1501
+ */
1502
+ export function runSystemctlInvocation(args: string[], raw: RawSystemctl): SystemctlResult {
1503
+ const verb = args[0] ?? "";
1504
+ if (STATE_CHANGING_VERBS[verb] !== true) return raw(args, SYSTEMCTL_PROBE_TIMEOUT_MS);
1505
+ const unit = args.length > 1 && !args[1]!.startsWith("-") ? args[1]! : SYSTEMD_UNIT;
1506
+ const before = sampleUnitState(unit, raw);
1507
+ const timeoutMs = stateVerbTimeoutMs(before);
1508
+ const ran = raw(args, timeoutMs);
1509
+ if (ran.ok || !ran.timedOut) return ran;
1510
+ return stateVerbTimeoutVerdict(verb, unit, before, sampleUnitState(unit, raw), ran, timeoutMs);
1511
+ }
1512
+
1513
+ function spawnSystemctl(args: string[], timeoutMs: number): SystemctlResult {
1256
1514
  try {
1257
1515
  const res = spawnSync("systemctl", args, {
1258
1516
  encoding: "utf8",
1259
- // Bound the wait: a hung dbus becomes `unknown` ownership, not a hang.
1260
- // Callers must NOT treat that timeout as "no unit".
1261
- timeout: 15_000,
1517
+ timeout: timeoutMs,
1262
1518
  env: process.env,
1263
1519
  });
1264
1520
  if (res.error) {
@@ -1268,6 +1524,7 @@ function defaultSystemctl(args: string[]): SystemctlResult {
1268
1524
  stdout: "",
1269
1525
  stderr: err.message,
1270
1526
  ...(err.code === "ENOENT" ? { missing: true } : {}),
1527
+ ...(err.code === "ETIMEDOUT" ? { timedOut: true } : {}),
1271
1528
  };
1272
1529
  }
1273
1530
  return {
@@ -1280,6 +1537,11 @@ function defaultSystemctl(args: string[]): SystemctlResult {
1280
1537
  }
1281
1538
  }
1282
1539
 
1540
+ function defaultSystemctl(args: string[]): SystemctlResult {
1541
+ if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
1542
+ return runSystemctlInvocation(args, spawnSystemctl);
1543
+ }
1544
+
1283
1545
  let systemctl: SystemctlFn = defaultSystemctl;
1284
1546
 
1285
1547
  /** Shared manager runner. Production reaches the hardcoded binary; test
@@ -3642,14 +3642,20 @@ export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Pr
3642
3642
  throw new Error("no Telegram bot token readable — the arm notice cannot send");
3643
3643
  }
3644
3644
  let topicId: number | undefined;
3645
+ let project: { name: string; workspaceRoot?: string } | undefined;
3645
3646
  if (surface.project !== undefined) {
3646
3647
  try {
3647
- topicId = resolveProjectTopicId(findProject(loadConfig(), surface.project));
3648
+ const p = findProject(loadConfig(), surface.project);
3649
+ topicId = resolveProjectTopicId(p);
3650
+ project = { name: p.name, workspaceRoot: p.workspaceRoot };
3648
3651
  } catch {
3649
3652
  /* no project config — flat chat, exactly like armTicks */
3650
3653
  }
3651
3654
  }
3652
- await sendTelegram(token, channel.owner, text, { topicId });
3655
+ await sendTelegram(token, channel.owner, text, {
3656
+ topicId,
3657
+ ...(project === undefined ? {} : { project }),
3658
+ });
3653
3659
  }
3654
3660
 
3655
3661
  /**