omp-conductor 0.15.12 → 0.15.13

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/REFERENCE.md CHANGED
@@ -2633,10 +2633,15 @@ Known and deliberate in this version:
2633
2633
  restart policy stays a visible detector rather than a silent spin. Prefer
2634
2634
  `omp-conductor stop` / `systemctl stop` over raw `kill`: a raw kill is not an
2635
2635
  intentional stop, and the daemon comes straight back.
2636
- - **A failed orchestrator degrades quietly.** The daemon logs a warning and keeps
2637
- running, but tier-1 escalations then land in issue comments — which is exactly the
2638
- "nobody reads it until morning" path the orchestrator exists to avoid. The warning
2639
- is in `daemon.log`; nothing pages you about it.
2636
+ - **A failed orchestrator doesn't stay quiet.** Start failure and an
2637
+ unexpected session death each open one durable orchestrator-down incident
2638
+ that pages tier 2 once ("down since <t>, tier-1 escalations diverting to
2639
+ issue comments") and is shown as a degrade row by `omp-conductor status` —
2640
+ mode, since-when, and how many tier-1 escalations were diverted while it was
2641
+ down. The incident survives a daemon restart while still down (it is
2642
+ re-derived, not forgotten), and one closing notice lands when the orchestrator
2643
+ recovers. What remains a separate watchdog is the `.conductor-stalled` wedge
2644
+ path, for a session that stays alive but stops draining its queue.
2640
2645
  - **Workers are not terminal panes, so you cannot watch them there.** Each
2641
2646
  worker is an omp session the daemon starts as a child process. The resident
2642
2647
  daemon tracks workers in a background pool so the five-minute loop keeps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -7,70 +7,97 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
+ import { buildStopProvenance } from "../stop-provenance.ts";
10
11
  import { setPaused } from "../daemon.ts";
11
- import { restartDaemon, type RestartResult } from "../lifecycle.ts";
12
+ import { restartDaemon, type RestartResult, type StopDelivery } from "../lifecycle.ts";
13
+ import { openStore, dbPath } from "../store.ts";
12
14
  import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
13
15
 
14
16
  export async function restartCommand(ctx: CommandContext): Promise<void> {
15
- if (ctx.argv.includes("--now")) {
16
- // Inherit the running daemon's port and project: a restart that quietly
17
- // moved to the default port would leave every existing health check
18
- // pointing at nothing. When the unit owns the live pid, restartDaemon
19
- // goes through systemctl so the replacement stays supervised; a failed
20
- // installed unit is reset and started through systemd the same way and
21
- // is never replaced by an unmanaged daemon. Success is only reported
22
- // after the manager is proven to own the reported pid.
23
- const { previous, record, via } = await restartDaemon({
24
- port: ctx.portFlag(),
25
- project: ctx.projectFlag,
17
+ const store = openStore(dbPath());
18
+ try {
19
+ // Provenance (#378): one record naming the request, every configured
20
+ // project with its live-run count, and via the lifecycle chokepoint — the
21
+ // delivery method used. The shared daemon serves all projects, so a restart
22
+ // is never silent even when the operator narrows nothing.
23
+ const provenance = buildStopProvenance({
24
+ controlPath: "cli restart",
25
+ reason: "operator restart",
26
+ scope: "global",
26
27
  });
27
- if (previous !== undefined) {
28
- process.stdout.write(
29
- `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
30
- );
31
- }
32
- process.stdout.write(
33
- `started — pid ${record.pid}, /healthz on :${record.port}` +
34
- `${record.project === undefined ? "" : `, project ${record.project}`}` +
35
- `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
36
- );
37
- return;
38
- }
28
+ const record = (signal: StopDelivery) => store.recordDaemonStop(signal.provenance);
39
29
 
40
- // Default: drain the fleet before restarting — pause new claims, wait for
41
- // live workers to reach 0/N (bounded by --timeout), restart, then restore
42
- // the prior dispatch state. A timed-out drain restarts nothing and leaves
43
- // dispatch paused, so it is safe to run while the daemon is wedged.
44
- const timeoutRaw = ctx.flag("timeout");
45
- const timeoutSeconds =
46
- timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
47
- const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
48
- let result!: RestartResult;
49
- const deps: UpgradeDeps = {
50
- ...DEFAULT_DEPS,
51
- setPaused: (v, project) =>
52
- setPaused(v, { source: "restart", reason: "restart, draining" }, project),
53
- restartDaemon: async () => {
54
- result = await restartDaemon({
30
+ if (ctx.argv.includes("--now")) {
31
+ // Inherit the running daemon's port and project: a restart that quietly
32
+ // moved to the default port would leave every existing health check
33
+ // pointing at nothing. When the unit owns the live pid, restartDaemon
34
+ // goes through systemctl so the replacement stays supervised; a failed
35
+ // installed unit is reset and started through systemd the same way and
36
+ // is never replaced by an unmanaged daemon. Success is only reported
37
+ // after the manager is proven to own the reported pid.
38
+ const { previous, record: newRecord, via } = await restartDaemon({
55
39
  port: ctx.portFlag(),
56
40
  project: ctx.projectFlag,
41
+ provenance,
42
+ record,
57
43
  });
58
- },
59
- };
60
- try {
61
- await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
62
- } catch (err) {
63
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
64
- process.exit(1);
65
- }
66
- if (result.previous !== undefined) {
44
+ if (previous !== undefined) {
45
+ process.stdout.write(
46
+ `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
47
+ );
48
+ }
49
+ process.stdout.write(
50
+ `started — pid ${newRecord.pid}, /healthz on :${newRecord.port}` +
51
+ `${newRecord.project === undefined ? "" : `, project ${newRecord.project}`}` +
52
+ `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${newRecord.logFile}\n`,
53
+ );
54
+ return;
55
+ }
56
+
57
+ // Default: drain the fleet before restarting — pause new claims, wait for
58
+ // live workers to reach 0/N (bounded by --timeout), restart, then restore
59
+ // the prior dispatch state. A timed-out drain restarts nothing and leaves
60
+ // dispatch paused, so it is safe to run while the daemon is wedged.
61
+ const timeoutRaw = ctx.flag("timeout");
62
+ const timeoutSeconds =
63
+ timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
64
+ const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
65
+ let result!: RestartResult;
66
+ const deps: UpgradeDeps = {
67
+ ...DEFAULT_DEPS,
68
+ setPaused: (v, project) =>
69
+ setPaused(v, { source: "restart", reason: "restart, draining" }, project),
70
+ restartDaemon: async () => {
71
+ result = await restartDaemon({
72
+ port: ctx.portFlag(),
73
+ project: ctx.projectFlag,
74
+ provenance,
75
+ record,
76
+ });
77
+ },
78
+ };
79
+ // Record the request at ENTRY, before the drain: a caller that disconnects
80
+ // or hits --timeout while workers drain never reaches the lifecycle
81
+ // chokepoint, and the durable provenance must survive that — it is exactly
82
+ // the stop-with-no-witness incident #378 exists for (#378).
83
+ store.recordDaemonStop(provenance);
84
+ try {
85
+ await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
86
+ } catch (err) {
87
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
88
+ process.exit(1);
89
+ }
90
+ if (result.previous !== undefined) {
91
+ process.stdout.write(
92
+ `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
93
+ );
94
+ }
67
95
  process.stdout.write(
68
- `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
96
+ `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
97
+ `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
98
+ `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
69
99
  );
100
+ } finally {
101
+ store.close();
70
102
  }
71
- process.stdout.write(
72
- `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
73
- `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
74
- `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
75
- );
76
103
  }
@@ -7,8 +7,10 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
+ import { buildStopProvenance } from "../stop-provenance.ts";
10
11
  import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
11
12
  import { stopDaemon } from "../lifecycle.ts";
13
+ import { openStore, dbPath } from "../store.ts";
12
14
 
13
15
  export async function stopCommand(ctx: CommandContext): Promise<void> {
14
16
  const withPane = ctx.argv.includes("--pane");
@@ -24,28 +26,49 @@ const targets = ctx.targetProjects().map((project) => {
24
26
  pin: withPane ? pinPaneHalt(project.name).path : undefined,
25
27
  };
26
28
  });
27
- const stop = await stopDaemon();
28
- const stopLine =
29
- stop.kind === "not-running"
30
- ? "daemon was not running"
31
- : `daemon stopped pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
32
- for (const target of targets) {
33
- if (target.pin !== undefined) {
34
- const pane = await stopConductorPane(target.project.name);
35
- process.stdout.write(
36
- `stopped claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
37
- `${stopLine}\n` +
38
- `pane recovery pinned at ${target.pin}\n` +
39
- `pane stop: ${pane.stopped} — ${pane.detail}\n` +
40
- ` (conductor agent "${pane.agentName}" only herdr-fleet.service was NOT stopped;\n` +
41
- ` resume clears the pin when you want recovery again)\n`,
42
- );
43
- } else {
44
- process.stdout.write(
45
- `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
46
- `${stopLine}\n` +
47
- `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
48
- );
29
+ const store = openStore(dbPath());
30
+ try {
31
+ // Provenance (#378): the shared daemon serves every configured project, so
32
+ // the record names the request's own project (or the global scope) AND every
33
+ // sibling with its live-run count no silent cross-project stop.
34
+ const provenance = buildStopProvenance({
35
+ controlPath: "cli stop",
36
+ reason: "operator stop",
37
+ scope: targets.length > 1 ? "global" : "project",
38
+ project: targets.length === 1 ? targets[0]!.project.name : undefined,
39
+ });
40
+ const stop = await stopDaemon({
41
+ provenance,
42
+ // The durable row is written by the lifecycle at the exact chokepoint —
43
+ // immediately before systemctl/SIGTERM, with the delivery method that is
44
+ // about to be used — which is what makes "provenance precedes signalling"
45
+ // provable rather than claimed.
46
+ record: (signal) => store.recordDaemonStop(signal.provenance),
47
+ });
48
+ const stopLine =
49
+ stop.kind === "not-running"
50
+ ? "daemon was not running"
51
+ : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
52
+ for (const target of targets) {
53
+ if (target.pin !== undefined) {
54
+ const pane = await stopConductorPane(target.project.name);
55
+ process.stdout.write(
56
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
57
+ `${stopLine}\n` +
58
+ `pane recovery pinned at ${target.pin}\n` +
59
+ `pane stop: ${pane.stopped} — ${pane.detail}\n` +
60
+ ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
61
+ ` resume clears the pin when you want recovery again)\n`,
62
+ );
63
+ } else {
64
+ process.stdout.write(
65
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
66
+ `${stopLine}\n` +
67
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
68
+ );
69
+ }
49
70
  }
71
+ } finally {
72
+ store.close();
50
73
  }
51
74
  }