omp-conductor 0.15.9 → 0.15.11

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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/escalate.ts CHANGED
@@ -20,12 +20,12 @@
20
20
  * those strings end up in daemon logs and, on the fallback path, in a public
21
21
  * issue comment.
22
22
  */
23
- import { createHash } from "node:crypto";
24
23
  import { readFileSync } from "node:fs";
25
24
  import { homedir } from "node:os";
26
25
  import { join } from "node:path";
27
26
 
28
27
  import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
28
+ import { heldNoticeId } from "./notices.ts";
29
29
  import type { OrchestratorHandle } from "./orchestrator.ts";
30
30
  import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
31
31
 
@@ -240,7 +240,7 @@ export function createEscalator(
240
240
  }
241
241
  if (disposition !== "interrupt") {
242
242
  store.addHeldNotice({
243
- id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
243
+ id: heldNoticeId(key),
244
244
  project: p.name,
245
245
  category,
246
246
  summary: e.summary,
@@ -350,6 +350,15 @@ export function readTelegramToken(): string | undefined {
350
350
  return undefined;
351
351
  }
352
352
 
353
+ /** One live omp-telegram topic claim, as much of it as conductor reads. */
354
+ export interface ClaimedTopic {
355
+ threadId: number;
356
+ /** The bridge's own title for the claim. */
357
+ name: string;
358
+ /** The herdr space the claiming pane sits in, when the bridge captured one. */
359
+ workspaceLabel?: string;
360
+ }
361
+
353
362
  /**
354
363
  * The forum topic a project's Telegram actually lives in *now*.
355
364
  *
@@ -362,12 +371,19 @@ export function readTelegramToken(): string | undefined {
362
371
  *
363
372
  * The operator's pin still wins whenever it is live. Only a pin that is
364
373
  * *provably* absent from the current claims is replaced, and only by the claim
365
- * whose name is this project's — so a deliberately separate alerts topic is
366
- * never hijacked by the pane's own thread. Unreadable bridge state changes
374
+ * this project can be identified with — so a deliberately separate alerts topic
375
+ * is never hijacked by the pane's own thread. Unreadable bridge state changes
367
376
  * nothing, and #318's stale-topic retry remains the last line of defence.
368
377
  *
369
- * The substitution is logged, naming the project and the claim's name. Never an
370
- * id: a log line is a place these leak from.
378
+ * Identity is read from the herdr space first, and only then from the claim's
379
+ * title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
380
+ * halves of that fall down on a real multi-project host: the agent-name lookup
381
+ * is best-effort, and a fleet whose panes live under one state directory gives
382
+ * every pane the same basename. `workspaceLabel` is the herdr space, which is
383
+ * one-to-one with the project, so it is the identity that survives a re-claim.
384
+ *
385
+ * The substitution is logged, naming the project and which identity answered.
386
+ * Never an id: a log line is a place these leak from.
371
387
  */
372
388
  export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
373
389
  const pinned = project.escalation.telegramTopicId;
@@ -375,24 +391,42 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
375
391
  const claims = claimedTelegramTopics();
376
392
  if (claims.length === 0) return pinned;
377
393
  if (claims.some((claim) => claim.threadId === pinned)) return pinned;
378
- const named = claims.find((claim) => claim.name === project.name);
379
- if (named === undefined) return pinned;
394
+ const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === project.name);
395
+ const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
396
+ if (match === undefined) return pinned;
380
397
  warn(
381
398
  `escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
382
- `following omp-telegram's current "${named.name}" claim instead`,
399
+ `following omp-telegram's current "${project.name}" ` +
400
+ `${bySpace === undefined ? "claim" : "herdr space claim"} instead`,
383
401
  );
384
- return named.threadId;
402
+ return match.threadId;
403
+ }
404
+
405
+ /**
406
+ * The one claim answering to `predicate`, or nothing when none or several do.
407
+ *
408
+ * Ambiguity is not a coin toss: two claims wearing the same identity means the
409
+ * bridge cannot say which pane belongs to this project, and paging the wrong
410
+ * project's topic is worse than the flat-chat degrade #318 already handles.
411
+ */
412
+ function soleClaim(
413
+ claims: readonly ClaimedTopic[],
414
+ predicate: (claim: ClaimedTopic) => boolean,
415
+ ): ClaimedTopic | undefined {
416
+ const hits = claims.filter(predicate);
417
+ return hits.length === 1 ? hits[0] : undefined;
385
418
  }
386
419
 
387
420
  /**
388
- * omp-telegram's live topic claims: `{ threads: { "<threadId>": { name } } }`.
389
- * Borrowed exactly as the token is, and tolerant of every shape it might not
390
- * be — a bridge that has never run a forum pane has no file at all.
421
+ * omp-telegram's live topic claims, from
422
+ * `{ threads: { "<threadId>": { name, workspaceLabel } } }`. Borrowed exactly as
423
+ * the token is, and tolerant of every shape it might not be — a bridge that has
424
+ * never run a forum pane has no file at all.
391
425
  *
392
426
  * `stateDir` is passed by the setup wizard, which has already probed for the
393
427
  * bridge; send-time callers let it resolve the same way the token does.
394
428
  */
395
- export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: number; name: string }> {
429
+ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
396
430
  const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
397
431
  const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
398
432
  let raw: unknown;
@@ -404,16 +438,23 @@ export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: numb
404
438
  if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
405
439
  const threads = raw.threads;
406
440
  if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
407
- const out: Array<{ threadId: number; name: string }> = [];
441
+ const out: ClaimedTopic[] = [];
408
442
  for (const [id, entry] of Object.entries(threads)) {
409
443
  const threadId = Number(id);
410
444
  if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
411
445
  let name = id;
412
- if (typeof entry === "object" && entry !== null && "name" in entry) {
413
- const candidate = entry.name;
414
- if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
446
+ let workspaceLabel: string | undefined;
447
+ if (typeof entry === "object" && entry !== null) {
448
+ if ("name" in entry) {
449
+ const candidate = entry.name;
450
+ if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
451
+ }
452
+ if ("workspaceLabel" in entry) {
453
+ const candidate = entry.workspaceLabel;
454
+ if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
455
+ }
415
456
  }
416
- out.push({ threadId, name });
457
+ out.push(workspaceLabel === undefined ? { threadId, name } : { threadId, name, workspaceLabel });
417
458
  }
418
459
  return out;
419
460
  }
@@ -15,7 +15,7 @@
15
15
  * green test per class" possible without a daemon, a tracker or a network.
16
16
  */
17
17
 
18
- import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
18
+ import type { Caps, FailureClass, RecoveryAction, RunRecord } from "./types.ts";
19
19
 
20
20
  /** Facts the caller fetched, each only for the rows that need it. */
21
21
  export interface ClassifyFacts {
@@ -222,7 +222,11 @@ export function dispatchInfra(
222
222
  // front of the thrown message, so a bare /^git / would never match a real row.
223
223
  const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
224
224
 
225
- export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
225
+ export function classifyRun(
226
+ run: RunRecord,
227
+ facts: ClassifyFacts,
228
+ caps?: Pick<Caps, "workerWallClockMs">,
229
+ ): Classification {
226
230
  const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
227
231
  const providerError =
228
232
  facts.sessionError ?? (run.lastError === undefined ? undefined : { message: run.lastError });
@@ -337,7 +341,29 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
337
341
  evidence: `turns ${run.turns}/${run.maxTurns}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
338
342
  };
339
343
  }
340
- // Below its own ceiling, so nothing this worker did ended it: a daemon
344
+ // A run killed at the wall-clock cap is a cap kill too, even though its
345
+ // turns ceiling never tripped — the harness would have kept burning a
346
+ // worker slot until the ticks ran out (#490). The same split as the turns
347
+ // path: artifacts mean the next attempt resumes from real work; nothing to
348
+ // show means a human re-scopes, naming the time and spend consumed.
349
+ const wallClockCap = caps?.workerWallClockMs;
350
+ const wallClockElapsed =
351
+ run.endedAt === undefined ? undefined : run.endedAt - run.startedAt;
352
+ if (wallClockCap !== undefined && wallClockElapsed !== undefined && wallClockElapsed >= wallClockCap) {
353
+ const clock = `${Math.round(wallClockElapsed / 60_000)}m of ${Math.round(wallClockCap / 60_000)}m`;
354
+ return hasArtifacts
355
+ ? {
356
+ cls: "wall-clock-cap-progress",
357
+ recovery: "continue",
358
+ evidence: `wall clock ${clock} with work to continue from (${run.prUrl ?? run.salvageSha ?? run.headSha})`,
359
+ }
360
+ : {
361
+ cls: "wall-clock-cap-spinning",
362
+ recovery: "escalate",
363
+ evidence: `wall clock ${clock}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
364
+ };
365
+ }
366
+ // Below its own ceilings, so nothing this worker did ended it: a daemon
341
367
  // restart, a drain, an operator. Costs no budget and needs no human.
342
368
  return {
343
369
  cls: "admin-kill",
package/src/fleet.ts CHANGED
@@ -29,6 +29,7 @@ import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { formatZonedMinute } from "./availability.ts";
31
31
  import { findProject, loadConfig, stateDir } from "./config.ts";
32
+ import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
32
33
  import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
33
34
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
34
35
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
@@ -250,6 +251,18 @@ function armGate(projectName?: string): { path: string; named?: string } {
250
251
  return named === undefined ? { path } : { path, named };
251
252
  }
252
253
 
254
+ /**
255
+ * This project's current tick-arm verdict — the marker the heartbeat reads,
256
+ * returned with the path that carries it so a verb can report what actually
257
+ * remains. Read-only: the same decision `disarm` takes, so control and status
258
+ * about one fleet cannot disagree (a verb telling the operator to re-arm a
259
+ * fleet that is already armed is the defect #375 removes).
260
+ */
261
+ export function armState(projectName?: string): { path: string; armed: boolean } {
262
+ const { path, named } = armGate(projectName);
263
+ return { path, armed: resolveArmState(path, named).armed };
264
+ }
265
+
253
266
  /**
254
267
  * Clears the arm gate for this project — and `wasArmed` is the gate the
255
268
  * heartbeat reads, not merely one file's presence.
@@ -362,23 +375,34 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
362
375
  }
363
376
 
364
377
  const send = deps.sendChallenge ?? sendTelegramMessage;
378
+ const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
365
379
  // Read before the send, not after: a transcript untouched since this instant
366
380
  // cannot contain the reply, and that is what the waiter filters on.
367
381
  const sentAt = (deps.now ?? Date.now)();
382
+ // The orchestrator must be able to classify the reply as an *active* arming
383
+ // proof for as long as this handshake is live, so the authenticated record
384
+ // (hash + expiry, never the code) is written before the challenge goes out
385
+ // and cleared the moment this end settles (#415).
386
+ recordArmChallenge(named, code, sentAt + timeoutMs);
368
387
  try {
369
388
  await send(token, channel.owner, text, topicId);
370
389
  } catch (err) {
390
+ // The challenge never went out, so it must not linger as a classifiable
391
+ // proof either.
392
+ clearArmChallenge(named);
371
393
  throw new Error(
372
394
  `arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
373
395
  );
374
396
  }
375
397
 
376
- const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
377
398
  const injected = deps.waitForUserTurn;
378
399
  const scan: SessionScan = injected
379
400
  ? { seen: await injected(dir, code, sentAt, timeoutMs), scanned: [], ignored: [] }
380
401
  : await waitForChallengeInSessions(dir, code, sentAt, timeoutMs, deps);
381
402
  if (!scan.seen) {
403
+ // The proof missed its window: a lookalike reply must not stay classifiable
404
+ // after arming gave up, so the record is cleared before the failure lands.
405
+ clearArmChallenge(named);
382
406
  const listing = [
383
407
  `session dir: ${dir}`,
384
408
  ...scan.scanned.map((f) => ` watched: ${f}`),
@@ -402,6 +426,10 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
402
426
  // marker that re-arms the fleet, and turn into a meaningless legacy warning
403
427
  // the moment a second project is configured.
404
428
  if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
429
+ // The transcript proof landed and this project is armed: the record has done
430
+ // its job. A stale record would also keep a later unsolicited lookalike alive
431
+ // longer than the fresh challenge it was cut for.
432
+ clearArmChallenge(named);
405
433
  return { path, alreadyArmed, owner: channel.owner, challenge: code };
406
434
  }
407
435
 
@@ -1393,6 +1421,7 @@ export function formatFleetStatus(
1393
1421
  decisions: string | undefined = undefined,
1394
1422
  failureClasses: string | undefined = undefined,
1395
1423
  workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
1424
+ intake: string | undefined = undefined,
1396
1425
  ): string {
1397
1426
  const tickLine =
1398
1427
  layers.ticksDetail === undefined
@@ -1473,6 +1502,7 @@ export function formatFleetStatus(
1473
1502
  ...(brief === undefined ? [] : [brief]),
1474
1503
  ...(decisions === undefined ? [] : [decisions]),
1475
1504
  ...(failureClasses === undefined ? [] : [failureClasses]),
1505
+ ...(intake === undefined ? [] : [intake]),
1476
1506
  ...(graphBlock === undefined ? [] : [graphBlock]),
1477
1507
  daemonBlock,
1478
1508
  "",
@@ -1656,6 +1686,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
1656
1686
  decisionStatusLine(project.name),
1657
1687
  failureClassBlock(project.name),
1658
1688
  workerPhases,
1689
+ intakeStatusLine(project.name),
1659
1690
  );
1660
1691
  }
1661
1692
 
@@ -1734,6 +1765,32 @@ function decisionStatusLine(projectName: string): string | undefined {
1734
1765
  }
1735
1766
  }
1736
1767
 
1768
+ /**
1769
+ * One line naming how many raw ideas are still pending (#299), or nothing when
1770
+ * there are none.
1771
+ *
1772
+ * Deliberately absent at zero, unlike the decision line: an empty intake is the
1773
+ * common state of a healthy fleet, and a row that prints "0 pending" every
1774
+ * status would be permanent noise. Only a nonzero backlog is news, exactly like
1775
+ * {@link failureClassBlock}. Omitted on any throw, like the other optional rows.
1776
+ * Exported so the conditional is testable against a real store.
1777
+ */
1778
+ export function intakeStatusLine(projectName: string): string | undefined {
1779
+ const path = dbPath();
1780
+ if (!existsSync(path)) return undefined;
1781
+ let store: Store | undefined;
1782
+ try {
1783
+ store = openStore(path);
1784
+ const pending = store.pendingIntake(projectName);
1785
+ if (pending.length === 0) return undefined;
1786
+ return `intake ${pending.length} pending idea(s) — omp-conductor intake list`;
1787
+ } catch {
1788
+ return undefined;
1789
+ } finally {
1790
+ store?.close();
1791
+ }
1792
+ }
1793
+
1737
1794
  // ---------------------------------------------------------------------------
1738
1795
  // arm proof helpers
1739
1796
  // ---------------------------------------------------------------------------
@@ -264,7 +264,7 @@ export async function probeCodeGraph(
264
264
  const repos: CodeGraphRepoHealth[] = configured.map((repo) => {
265
265
  const clone: CheckState = deps.exists(repo.graphProject) ? "present" : "missing";
266
266
  const index: CheckState = roots === undefined ? "unknown" : roots.has(repo.graphProject) ? "present" : "missing";
267
- if (clone === "missing") reasons.push(`${repo.name}: configured clone is missing`);
267
+ if (clone === "missing") reasons.push(`${repo.name}: no code-graph clone at ${repo.graphProject}; run \`omp-conductor setup graph\``);
268
268
  if (index === "missing") reasons.push(`${repo.name}: no indexed project exactly matches ${repo.graphProject}`);
269
269
  return { name: repo.name, path: repo.graphProject, clone, index };
270
270
  });
@@ -90,4 +90,4 @@ export async function projectLabels(
90
90
  if (!progressed) break;
91
91
  }
92
92
  return { applied, deferred };
93
- }
93
+ }
package/src/lifecycle.ts CHANGED
@@ -66,6 +66,18 @@ const LOG_TAIL_BYTES = 4_096;
66
66
  export function runtimeDir(): string {
67
67
  const override = process.env["OMP_CONDUCTOR_RUNTIME_DIR"];
68
68
  if (override !== undefined && override.length > 0) return override;
69
+ if (process.env["NODE_ENV"] === "test") {
70
+ // A test process must never read the host-global runtime dir: doing so
71
+ // names the real installed daemon's pid, and a test `stop` that inherits
72
+ // that record would signal — and stop — production's own unit (#399).
73
+ // Fail closed: a lifecycle test that omitted OMP_CONDUCTOR_RUNTIME_DIR
74
+ // (the runtime half of the test lifecycle seam) gets a loud error rather
75
+ // than a silent read of the operator's pidfile.
76
+ throw new Error(
77
+ "OMP_CONDUCTOR_RUNTIME_DIR must be set under NODE_ENV=test — " +
78
+ "refusing to read the host runtime directory (~/.omp/run/daemons/omp-conductor) from a test process",
79
+ );
80
+ }
69
81
  return join(homedir(), ".omp", "run", "daemons", "omp-conductor");
70
82
  }
71
83
 
@@ -178,6 +190,152 @@ export function livingDaemon(): DaemonRecord | undefined {
178
190
  return undefined;
179
191
  }
180
192
 
193
+ /**
194
+ * The `daemon --once` lease file, one per process. Created `O_EXCL` so two
195
+ * simultaneous `--once` runs cannot both win ownership, and it names the
196
+ * holder's pid so a stale file (holder died) is removable rather than a
197
+ * permanent lock.
198
+ *
199
+ * The pidfile (`daemon.json`) is what makes this process *visible* as the owner
200
+ * for the duration of the tick (`status`, `worker stop`, a concurrent `daemon`
201
+ * start, orphan reconciliation all probe it); the lease file is what makes
202
+ * ownership *exclusive* — it is the thing that lets two simultaneous `--once`
203
+ * processes serialise, which the pidfile alone cannot (each would just
204
+ * overwrite it). They are deliberately two files: the lease is never the
205
+ * daemon record, so a short-lived drill can never impersonate or clear the
206
+ * managed daemon's record by virtue of holding a lock.
207
+ */
208
+ function onceLeasePath(): string {
209
+ return join(runtimeDir(), "once.lock");
210
+ }
211
+
212
+ interface OnceLeaseHolder {
213
+ pid: number;
214
+ startedAt: number;
215
+ }
216
+
217
+ /** Parses the lease file; garbage is the same answer as absence — "not held". */
218
+ function readOnceLeaseHolder(): OnceLeaseHolder | undefined {
219
+ let parsed: unknown;
220
+ try {
221
+ parsed = JSON.parse(readFileSync(onceLeasePath(), "utf8"));
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ if (parsed === null || typeof parsed !== "object") return undefined;
226
+ const pid = Reflect.get(parsed, "pid");
227
+ const startedAt = Reflect.get(parsed, "startedAt");
228
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1) return undefined;
229
+ if (typeof startedAt !== "number" || !Number.isFinite(startedAt)) return undefined;
230
+ return { pid, startedAt };
231
+ }
232
+
233
+ /** Bounded attempts to clear a stale lease and re-acquire it. */
234
+ const ONCE_LEASE_RETRIES = 3;
235
+
236
+ /** A once tick has no log file of its own; the record's field is informational. */
237
+ const ONCE_RECORD_LOG = "<once tick — no daemon log>";
238
+
239
+ export interface OnceLease {
240
+ /** The owned record that makes this process the visible daemon for the tick. */
241
+ record: DaemonRecord;
242
+ /**
243
+ * Drops exclusivity — but only while it is still ours. A manager that
244
+ * claimed the record mid-tick must be left alone: clearing it would clear
245
+ * somebody else's daemon.
246
+ */
247
+ release(): void;
248
+ }
249
+
250
+ export type OnceLeaseAcquire = { ok: true; lease: OnceLease } | { ok: false; pid: number };
251
+
252
+ /**
253
+ * Acquires the exclusive daemon/state ownership a `--once` tick must hold for
254
+ * its whole lifecycle (#431). A drill is still a dispatcher — it settles rows,
255
+ * projects labels, admits and launches workers — so it takes the same
256
+ * ownership the long-running dispatcher holds via the pidfile, and refuses
257
+ * (having done nothing) while a live daemon owns the record or another live
258
+ * `--once` holds the lease.
259
+ *
260
+ * Returns `{ ok: false, pid }` — having performed no mutation — when another
261
+ * live process owns the project/state. A stale lease and a stale record whose
262
+ * holder died are cleared and retried. On success the process both holds the
263
+ * exclusive lease file and has published itself as the daemon owner, so every
264
+ * ownership probe (`livingDaemon`, a `daemon` start, a second `--once`) sees
265
+ * this process for the whole tick.
266
+ */
267
+ export function acquireOnceLease(
268
+ o: { port?: number; project?: string; logFile?: string } = {},
269
+ ): OnceLeaseAcquire {
270
+ // A live dispatcher already owns the shared record. Never run beside it.
271
+ const daemon = livingDaemon();
272
+ if (daemon !== undefined && daemon.pid !== process.pid) {
273
+ return { ok: false, pid: daemon.pid };
274
+ }
275
+
276
+ const dir = ensureRuntimeDir();
277
+ const path = onceLeasePath();
278
+
279
+ for (let attempt = 0; attempt < ONCE_LEASE_RETRIES; attempt++) {
280
+ let created = false;
281
+ try {
282
+ const fd = openSync(path, "wx", 0o600);
283
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`);
284
+ closeSync(fd);
285
+ created = true;
286
+ } catch (err) {
287
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
288
+ const holder = readOnceLeaseHolder();
289
+ if (holder !== undefined && isAlive(holder.pid)) {
290
+ // A live holder — another `--once` (or a process still finishing one).
291
+ return { ok: false, pid: holder.pid };
292
+ }
293
+ // Stale or unreadable holder: clear it and try once more.
294
+ rmSync(path, { force: true });
295
+ continue;
296
+ }
297
+ if (!created) continue;
298
+
299
+ const record: DaemonRecord = {
300
+ pid: process.pid,
301
+ port: o.port ?? DEFAULT_PORT,
302
+ startedAt: Date.now(),
303
+ logFile: o.logFile ?? ONCE_RECORD_LOG,
304
+ ...(o.project === undefined ? {} : { project: o.project }),
305
+ };
306
+ try {
307
+ writeRecord(record);
308
+ } catch (err) {
309
+ rmSync(path, { force: true });
310
+ throw err;
311
+ }
312
+
313
+ // Confirm we still own the shared record: a manager may have claimed it in
314
+ // the instant between our lease create and our record write.
315
+ const current = readRecord();
316
+ if (current !== undefined && current.pid === process.pid) {
317
+ return {
318
+ ok: true,
319
+ lease: {
320
+ record,
321
+ release: () => {
322
+ const owned = readRecord();
323
+ if (owned !== undefined && owned.pid === process.pid) clearRecord();
324
+ const holder = readOnceLeaseHolder();
325
+ if (holder !== undefined && holder.pid === process.pid) {
326
+ rmSync(path, { force: true });
327
+ }
328
+ },
329
+ },
330
+ };
331
+ }
332
+ rmSync(path, { force: true });
333
+ return { ok: false, pid: current?.pid ?? process.pid };
334
+ }
335
+
336
+ throw new Error("could not acquire the daemon lease — a stale holder kept reappearing");
337
+ }
338
+
181
339
  /**
182
340
  * Probes the daemon's own health endpoint. Never throws: a refused connection,
183
341
  * a DNS-less host, a hung socket and a 500 are all just "not healthy", and the
@@ -892,8 +1050,17 @@ async function terminate(pid: number, timeoutMs: number): Promise<boolean> {
892
1050
  return !isAlive(pid);
893
1051
  }
894
1052
 
895
- /** Sends a signal, treating "already gone" as success. Returns false if the send failed. */
896
- function signal(pid: number, sig: NodeJS.Signals): boolean {
1053
+ /** Whether a raw signal should be delivered to a pid. */
1054
+ export type SignalFn = (pid: number, sig: NodeJS.Signals) => boolean;
1055
+
1056
+ /**
1057
+ * The default signal sender. Production reaches the real `kill`. Under
1058
+ * NODE_ENV=test it dispatches to the fail-closed refusal instead: a test
1059
+ * process must never signal a pid it did not explicitly authorise, and the
1060
+ * default authorises nothing (#399).
1061
+ */
1062
+ function defaultSignal(pid: number, sig: NodeJS.Signals): boolean {
1063
+ if (process.env["NODE_ENV"] === "test") return testSignal(pid, sig);
897
1064
  try {
898
1065
  process.kill(pid, sig);
899
1066
  return true;
@@ -902,6 +1069,35 @@ function signal(pid: number, sig: NodeJS.Signals): boolean {
902
1069
  }
903
1070
  }
904
1071
 
1072
+ /**
1073
+ * Child-process CLI tests cannot inject {@link setSignalForTest}. Under
1074
+ * NODE_ENV=test they therefore get a refusal: a test process may only signal
1075
+ * a pid its own code explicitly authorised, and a child has authorised none.
1076
+ * Without this, a child `stop` that reads the operator's real daemon.json
1077
+ * (an *inherited* runtime record) would SIGTERM the installed daemon — the
1078
+ * #399 incident.
1079
+ */
1080
+ function testSignal(_pid: number, _sig: NodeJS.Signals): boolean {
1081
+ return false;
1082
+ }
1083
+
1084
+ let signalFn: SignalFn = defaultSignal;
1085
+
1086
+ /**
1087
+ * Test-only: replace the raw signal sender. Pass `undefined` to restore.
1088
+ * A test that intentionally spawned a helper may authorise signals to exactly
1089
+ * that helper pid; every other pid is refused. Ownership is never inferred
1090
+ * from writing a record — a pidfile fixture authorises nothing (#399).
1091
+ */
1092
+ export function setSignalForTest(fn: SignalFn | undefined): void {
1093
+ signalFn = fn ?? defaultSignal;
1094
+ }
1095
+
1096
+ /** Sends a signal, treating "already gone" as success. Returns false if the send failed. */
1097
+ function signal(pid: number, sig: NodeJS.Signals): boolean {
1098
+ return signalFn(pid, sig);
1099
+ }
1100
+
905
1101
  /**
906
1102
  * The tail of the daemon log, formatted for an error message. Reads the last
907
1103
  * few KB by offset rather than the whole file, because the log is append-only
package/src/notices.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** The deterministic primary id of a held notice derived from its escalation
4
+ * key. Sha256 of the key — 64 hex characters, a shape the digest handoff
5
+ * accepts verbatim — keeps the re-derived notice from a later poll idempotent
6
+ * against `ON CONFLICT(id) DO NOTHING` while remaining a real ledger row id. */
7
+ export function heldNoticeId(key: string): string {
8
+ return createHash("sha256").update(`held-notice\0${key}`).digest("hex");
9
+ }
package/src/omp.ts CHANGED
@@ -739,6 +739,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
739
739
  tool: message.tool,
740
740
  reason: message.reason,
741
741
  args: message.args,
742
+ ...(message.matched === undefined ? {} : { matched: message.matched }),
743
+ ...(message.invocation === undefined ? {} : { invocation: message.invocation }),
742
744
  });
743
745
  break;
744
746
  }