omp-conductor 0.15.0 → 0.15.2

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/README.md CHANGED
@@ -583,12 +583,13 @@ Two words, and one of them takes a flag:
583
583
  | Verb | Claiming | Tick sends | Dispatch daemon | Conductor pane |
584
584
  | --- | --- | --- | --- | --- |
585
585
  | `hold` | paused | disarmed | left running | left running |
586
+ | `hold --keep-ticks` | paused | **left armed** | left running | left running |
586
587
  | `stop` | paused | disarmed | stopped (systemctl-aware) | left running |
587
588
  | `stop --pane` | paused | disarmed | stopped | stopped + recovery pinned off |
588
589
 
589
590
  `resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after your reply appears as a *user* turn in the orchestrator transcript. `stop --pane` targets the configured conductor agent only — it does **not** run `systemctl stop herdr-fleet`. To bounce the daemon without stopping the fleet, use `restart`.
590
591
 
591
- **Removed in 0.15.0**, each exiting `2` with a pointer: `halt` (now `stop`), `pause` (use `hold`), `release-pane` (now part of `resume`), and `graph-setup` (now [`setup graph`](#code-graph-discovery)). Dropping `pause` means the CLI no longer offers "stop claiming but keep ticking" the state still exists internally (`upgrade` passes through it while draining), but as an operator verb the model is now three words instead of five.
592
+ **Removed in 0.15.0**, each exiting `2` with a pointer: `halt` (now `stop`), `pause` (use `hold`), `release-pane` (now part of `resume`), and `graph-setup` (now [`setup graph`](#code-graph-discovery)). Dropping `pause` cost one real capability, "stop claiming but keep ticking", which is now `hold --keep-ticks` rather than a fifth verb. It matters because disarming is the expensive half of a hold: re-arming sends a Telegram challenge and blocks until you answer it in the chat, so stopping claims for ten minutes otherwise costs a manual round trip to get the heartbeat back while the workers a hold deliberately leaves running have nothing shepherding them.
592
593
 
593
594
  `status` prints a layered header (`dispatch` / `ticks` / next tick time / `pane` / `recovery` / `herdr` / `telegram` / `daemon`) so a paused fleet cannot hide an armed orchestrator still spending turns. The Telegram line calls the official `getMe` endpoint to prove the token and API are usable without sending a message, then separately reports whether the inbound bridge is configured.
594
595
 
@@ -2036,7 +2037,7 @@ writes this file for external orchestration. A manual configuration has this for
2036
2037
  | `intervalSeconds` | yes | — | Whole seconds between ticks, minimum `60`. A tick costs a full turn of a frontier model, so a sub-minute period is refused rather than obeyed. |
2037
2038
  | `project` | no | the only configured project | Which conductor project this fleet session ticks for. `setup host` stamps it, one tick config per fleet cwd, and it is what lets a host with several configured projects resolve *this* fleet's brief, reporting policy, digest ledger and release grants. Omitting it is the pre-multi-project spelling: correct on a single-project host, and on a host with two or more it degrades every tick to the default reporting scope with no release grants — `status` and the tick log then name the one fix (`re-run omp-conductor setup host`). A name no configured project has degrades the same way. |
2038
2039
  | `budgetSeconds` | no | `600` | Seconds a turn may run before the tick guard refuses its remaining tool calls (#189), and before a queued operator message preempts them. An integer ≥ 60; anything else degrades to the default. |
2039
- | `armedFile` | no | none — the gate passes | Path to the arm marker. A tick does nothing while the file is missing. Relative paths resolve against the session cwd, so `state/armed` means `<cwd>/state/armed`. `setup host` writes `<state dir>/armed-<project>`, one marker per project, so `arm --project A` cannot arm B. A value it did not generate is left alone as your own choice. |
2040
+ | `armedFile` | no | none — the gate passes | Path to the arm marker. A tick does nothing while the file is missing. **Re-read from disk on every tick**, so a `setup host` restamp onto `armed-<project>` binds on the next heartbeat instead of leaving a live pane watching the path it captured at session start. Relative paths resolve against the session cwd, so `state/armed` means `<cwd>/state/armed`. `setup host` writes `<state dir>/armed-<project>`, one marker per project, so `arm --project A` cannot arm B. A value it did not generate is left alone as your own choice. |
2040
2041
  | `accessFile` | no | none — the gate passes | Path to the Telegram bridge's `access.json`. Every tick re-reads it and requires `enabled: true` with exactly one entry in `allowFrom`. Relative paths resolve against the session cwd. **Configure this on any fleet deploy** — see below. |
2041
2042
  | `message` | no | `Tick <ISO timestamp>: re-read <workspaceRoot>/ORCHESTRATOR.md from disk, then run your standing loop from it.`, then the reporting-policy line, delivery rule, and mechanical availability state | When set, this text replaces the ordinary reporting-policy line and delivery rule, but the runtime-owned availability state is still appended: a custom prompt cannot infer whether the operator may be interrupted. Re-read from disk on **every** tick, so rewording it binds the next heartbeat instead of waiting for a session restart; a re-read that fails — caught mid-edit, removed, or invalid — keeps the value read at session start rather than stopping the heartbeat. `intervalSeconds` is *not* re-read: rescheduling a live timer still needs a restart. The default *orders* the session to re-read its brief, naming the path resolved from the project's `workspaceRoot`, because a standing prompt drifts out of a long-lived session's context while the file on disk does not. |
2042
2043
  | `agentName` | no | the project name, else `fleet` | The herdr agent name the orchestrator's pane is registered under. Under herdr this is the whole of the identity check below. `setup host` writes the project name, so two fleets in one herdr session are distinguishable; when no tick config names one, the fallback matches `AGENT_NAME=${AGENT_NAME:-fleet}` in the recovery plugin's `recover.sh`, so both halves key on one name. Rename the agent and set this to match. |
@@ -2085,6 +2086,12 @@ line — the heartbeat fails closed and says so rather than letting two fleets b
2085
2086
  answer to `fleet`. Set `agentName` explicitly if you would rather keep the old
2086
2087
  name; a value that is not the shared default is never rewritten.
2087
2088
 
2089
+ The marker itself needs no restart: `armedFile` is re-read from disk every tick, so
2090
+ the restamped path binds on the next heartbeat. Before 0.15.2 it was read once at
2091
+ session start, and a restamp under a live pane left that pane watching a path the
2092
+ restamp had just replaced — `status` reported `armed` from the file while the
2093
+ heartbeat skipped silently as "not armed", which writes no stall marker.
2094
+
2088
2095
  Recovery is fail-closed across that window. A restamped `agentName` moves the
2089
2096
  recovery plugin's own state files to per-agent paths that do not exist yet, and
2090
2097
  the live pane is still saved under `fleet`, so the snapshot offers no candidate
@@ -2295,7 +2302,7 @@ omp-conductor upgrade [--to VERSION] [--project NAME]
2295
2302
  omp-conductor status [--project NAME]
2296
2303
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
2297
2304
  omp-conductor board [--project NAME]
2298
- omp-conductor hold [--project NAME]
2305
+ omp-conductor hold [--keep-ticks] [--project NAME]
2299
2306
  omp-conductor stop [--pane] [--project NAME]
2300
2307
  omp-conductor arm [--project NAME]
2301
2308
  omp-conductor disarm [--project NAME]
@@ -2331,7 +2338,7 @@ omp-conductor help
2331
2338
  | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, bounded hold groups, and the GitHub API budget (`graphql` / `core` remaining and reset, in the caps block); API failures are marked `DEGRADED` so queue starvation cannot look idle. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
2332
2339
  | `ledger [--issue N] [--limit N]` | The action audit: every [mediated-verb](#the-mediated-verbs-126) mutation and every next-attempt turn budget. Verb entries include the arguments, decision, named refusal, and resulting SHA. Turn-budget entries remain after an override is replaced or consumed. Reads (`conductor_pr_status`) are absent so polling cannot bury the signal. `--issue` narrows both histories; `--limit` defaults to 50. Recent verb refusals and pending turn overrides also appear in `status`. |
2333
2340
  | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, the last 24 hours of Merged and Settled, and Parked (an issue the tracker has not confirmed closed — still open, or a label read that failed — so nothing dispatches it until a human labels it). Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
2334
- | `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--stop). |
2341
+ | `hold [--keep-ticks] [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this when the intent is "stop the conductor" without killing processes. `--keep-ticks` pauses claiming but leaves the arm marker, so the heartbeat keeps reporting and `resume` alone restores the fleet — no fresh arm challenge. See [Stop the conductor](#stop-the-conductor-hold--stop). |
2335
2342
  | `stop [--pane] [--project NAME]` | Stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `stop --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. To bounce the daemon without stopping the fleet, use `restart`. |
2336
2343
  | `arm [--project NAME]` | Proof-gated: send a Telegram challenge and write this project's arm marker only after your reply appears as a user turn in the orchestrator transcript. The challenge names the project, so a host running two fleets is not ambiguous. Never auto-armed by `resume` / `hold`. |
2337
2344
  | `disarm [--project NAME]` | Remove this project's arm marker so its ticks skip; another project's ticks keep running. Also clears a pre-per-project shared `armed` marker while that marker is still what holds this fleet's gate open — otherwise the disarm would not disarm. Processes untouched. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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.",
package/src/cli.ts CHANGED
@@ -105,7 +105,7 @@ usage:
105
105
  omp-conductor board [--project NAME] [--json]
106
106
  omp-conductor status [--project NAME]
107
107
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
108
- omp-conductor hold [--project NAME | --all]
108
+ omp-conductor hold [--keep-ticks] [--project NAME | --all]
109
109
  omp-conductor arm [--project NAME | --all]
110
110
  omp-conductor disarm [--project NAME | --all]
111
111
  omp-conductor tail <issue> [--project NAME]
@@ -638,11 +638,18 @@ try {
638
638
  // `restart`, which is what the removed kill-only `stop` was reached for.
639
639
  case "stop": {
640
640
  const withPane = argv.includes("--pane");
641
- const targets = targetProjects().map((project) => ({
642
- project,
643
- hold: hold(project.name, "halt"),
644
- pin: withPane ? pinPaneHalt(project.name).path : undefined,
645
- }));
641
+ const targets = targetProjects().map((project) => {
642
+ // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
643
+ // `hold` affordance. Assert the invariant instead of printing a path that
644
+ // might not exist.
645
+ const held = hold(project.name, "halt");
646
+ if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
647
+ return {
648
+ project,
649
+ hold: { ...held, disarmed: held.disarmed },
650
+ pin: withPane ? pinPaneHalt(project.name).path : undefined,
651
+ };
652
+ });
646
653
  const stop = await stopDaemon();
647
654
  const stopLine =
648
655
  stop.kind === "not-running"
@@ -820,14 +827,17 @@ try {
820
827
  }
821
828
 
822
829
  case "hold": {
830
+ const keepTicks = argv.includes("--keep-ticks");
823
831
  for (const project of targetProjects()) {
824
- const r = hold(project.name);
832
+ const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
825
833
  process.stdout.write(
826
834
  `held — claiming paused` +
827
835
  `${r.wasPaused ? " (already paused)" : ""}` +
828
- `; ticks disarmed at ${r.disarmed.path}` +
829
- `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
830
- `daemon and pane left running; stop to stop the daemon too\n`,
836
+ (r.disarmed === undefined
837
+ ? "; ticks left armed resume restores the fleet with no new arm challenge"
838
+ : `; ticks disarmed at ${r.disarmed.path}` +
839
+ `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`) +
840
+ `\ndaemon and pane left running; stop to stop the daemon too\n`,
831
841
  );
832
842
  }
833
843
  break;
package/src/fleet.ts CHANGED
@@ -403,11 +403,13 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
403
403
 
404
404
  export interface HoldResult {
405
405
  wasPaused: boolean;
406
- disarmed: { path: string; wasArmed: boolean };
406
+ /** Absent when the caller kept the heartbeat armed. */
407
+ disarmed?: { path: string; wasArmed: boolean };
407
408
  }
408
409
 
409
410
  export interface HaltResult {
410
- hold: HoldResult;
411
+ /** `stop` always disarms, so callers can print the marker path unconditionally. */
412
+ hold: HoldResult & { disarmed: NonNullable<HoldResult["disarmed"]> };
411
413
  stop: StopResult;
412
414
  }
413
415
 
@@ -423,9 +425,26 @@ export interface HaltWithPaneResult extends HaltResult {
423
425
  pane: PaneStopResult;
424
426
  }
425
427
 
426
- export function hold(projectName?: string, source: string = "hold"): HoldResult {
428
+ /**
429
+ * Stop claiming, leave the daemon and pane up.
430
+ *
431
+ * `keepTicks` is the old `pause` shape, and it is not a nicety: disarming is the
432
+ * expensive half of a hold. Re-arming sends a Telegram challenge and blocks on the
433
+ * operator answering it in the chat, so an operator who only wanted to stop new
434
+ * claims for ten minutes pays a manual round trip to get the heartbeat back — and
435
+ * meanwhile nothing shepherds the workers already in flight, which a hold
436
+ * deliberately leaves running. Keeping the marker means `resume` alone restores
437
+ * the fleet. The tick never consults the pause sentinel, so a held fleet with
438
+ * armed ticks keeps reporting while it claims nothing.
439
+ */
440
+ export function hold(
441
+ projectName?: string,
442
+ source: string = "hold",
443
+ opts: { keepTicks?: boolean } = {},
444
+ ): HoldResult {
427
445
  const wasPaused = isPaused(projectName);
428
446
  setPaused(true, { source }, projectName);
447
+ if (opts.keepTicks === true) return { wasPaused };
429
448
  return { wasPaused, disarmed: disarmTicks(projectName) };
430
449
  }
431
450
 
@@ -435,8 +454,12 @@ export function releaseHold(projectName?: string): void {
435
454
 
436
455
  export async function halt(projectName?: string): Promise<HaltResult> {
437
456
  const held = hold(projectName, "halt");
457
+ // `keepTicks` is a `hold` affordance: taking the fleet down always disarms, and
458
+ // callers print the marker path. Assert the invariant rather than defaulting a
459
+ // path we would then print as fact.
460
+ if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
438
461
  const stop = await stopDaemon();
439
- return { hold: held, stop };
462
+ return { hold: { ...held, disarmed: held.disarmed }, stop };
440
463
  }
441
464
 
442
465
  /**
@@ -1021,9 +1044,29 @@ export interface FleetLayers {
1021
1044
  daemon: { running: boolean; pid?: number; port?: number };
1022
1045
  }
1023
1046
 
1047
+ /**
1048
+ * The project a bare read means, when the config leaves no doubt.
1049
+ *
1050
+ * The lifecycle verbs resolve their target before writing, so `hold` on a
1051
+ * single-project host writes `paused-<name>`. A status read that passed
1052
+ * `undefined` straight through saw only the legacy global sentinel and reported
1053
+ * `dispatch running` for a fleet the operator had just held — control and status
1054
+ * disagreeing about the same fleet, which is the one thing this surface exists to
1055
+ * settle. A host with several projects and no name keeps the legacy-only read:
1056
+ * guessing which project a bare `status` meant is what {@link findProject}
1057
+ * refuses to do.
1058
+ */
1059
+ function bareReadProject(): string | undefined {
1060
+ try {
1061
+ return findProject(loadConfig()).name;
1062
+ } catch {
1063
+ return undefined;
1064
+ }
1065
+ }
1066
+
1024
1067
  export function fleetLayers(projectName?: string): FleetLayers {
1025
1068
  const rec = livingDaemon();
1026
- const paused = isPaused(projectName);
1069
+ const paused = isPaused(projectName ?? bareReadProject());
1027
1070
  const dispatch: DispatchLayer = rec === undefined ? "stopped" : paused ? "paused" : "running";
1028
1071
 
1029
1072
  const tick = resolveTickConfig(projectName);
@@ -1462,6 +1462,35 @@ function currentMessage(cwd: string, startup: TickConfig): string | undefined {
1462
1462
  return reread.kind === "ok" ? reread.config.message : startup.message;
1463
1463
  }
1464
1464
 
1465
+ /**
1466
+ * The tick config fields honoured live, as they are on disk *now*, same contract as
1467
+ * {@link currentMessage}: a successful re-read owns the answer, a failed one
1468
+ * keeps the startup value.
1469
+ *
1470
+ * `setup host` restamps `armedFile` and `project` under a live pane — that is
1471
+ * how a fleet moves onto its per-project arm marker (#317). Reading the path
1472
+ * captured at session start instead meant the restamp silently killed the
1473
+ * heartbeat: `status` reads the file and reports `armed`, while the tick kept
1474
+ * checking the path the restamp had just replaced, skipped as "not armed" —
1475
+ * a reason that deliberately writes no stall marker — and the fleet stopped
1476
+ * ticking until somebody noticed and restarted the pane.
1477
+ *
1478
+ * `intervalSeconds` stays startup-only: rescheduling a live managed timer is a
1479
+ * different change and still needs a restart.
1480
+ */
1481
+ function currentConfig(
1482
+ cwd: string,
1483
+ startup: TickConfig,
1484
+ ): { armedFile?: string; accessFile?: string; project?: string } {
1485
+ const reread = readTickConfig(cwd);
1486
+ const live = reread.kind === "ok" ? reread.config : startup;
1487
+ return {
1488
+ ...(live.armedFile === undefined ? {} : { armedFile: live.armedFile }),
1489
+ ...(live.accessFile === undefined ? {} : { accessFile: live.accessFile }),
1490
+ ...(live.project === undefined ? {} : { project: live.project }),
1491
+ };
1492
+ }
1493
+
1465
1494
  /**
1466
1495
  * Marker writes are best-effort by construction. This runs inside the loop
1467
1496
  * whose whole job is to keep prompting a session; a read-only filesystem, a
@@ -1518,7 +1547,15 @@ export function requestImmediateTick(cwd: string, reason: string): boolean {
1518
1547
  }
1519
1548
 
1520
1549
  /** First non-empty token after the optional ISO stamp on a poke line. */
1521
- export function readTickRequestReason(cwd: string): string | undefined {
1550
+ /**
1551
+ * The poke's first line verbatim, which is its identity.
1552
+ *
1553
+ * Both writers stamp an ISO timestamp (`${iso} ${reason}`), so two distinct
1554
+ * requests never share a line and a request that is still sitting there
1555
+ * unconsumed always does. That is what lets the mid-interval poll act once per
1556
+ * request instead of once per poll.
1557
+ */
1558
+ function readTickRequestLine(cwd: string): string | undefined {
1522
1559
  const path = join(cwd, TICK_REQUESTED_FILE);
1523
1560
  if (!existsSync(path)) return undefined;
1524
1561
  let raw: string;
@@ -1528,7 +1565,12 @@ export function readTickRequestReason(cwd: string): string | undefined {
1528
1565
  return undefined;
1529
1566
  }
1530
1567
  const line = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
1531
- if (line.length === 0) return undefined;
1568
+ return line.length === 0 ? undefined : line;
1569
+ }
1570
+
1571
+ export function readTickRequestReason(cwd: string): string | undefined {
1572
+ const line = readTickRequestLine(cwd);
1573
+ if (line === undefined) return undefined;
1532
1574
  const parts = line.split(/\s+/);
1533
1575
  if (parts.length === 0) return undefined;
1534
1576
  // recover.sh writes `${iso} recover`; a bare reason is also accepted.
@@ -1824,7 +1866,8 @@ interface TickSession {
1824
1866
  * otherwise emit a notification every interval, forever.
1825
1867
  */
1826
1868
  function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
1827
- const arm = config.armedFile === undefined ? undefined : resolveArmState(config.armedFile, config.project);
1869
+ const live = currentConfig(ctx.cwd, config);
1870
+ const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
1828
1871
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
1829
1872
  session.legacyArmLogged = true;
1830
1873
  pi.logger.error(
@@ -1833,7 +1876,7 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1833
1876
  }
1834
1877
  const decision = tickDecision({
1835
1878
  armed: arm === undefined || arm.armed,
1836
- channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
1879
+ channelOk: live.accessFile === undefined || channelIsUp(live.accessFile),
1837
1880
  hasPending: ctx.hasPendingMessages(),
1838
1881
  });
1839
1882
 
@@ -1859,9 +1902,9 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1859
1902
 
1860
1903
  // Floor refresh is independent of which prompt we send: a custom message still
1861
1904
  // expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
1862
- refreshComposedBriefBestEffort(config.project);
1905
+ refreshComposedBriefBestEffort(live.project);
1863
1906
 
1864
- const scope = resolveTickScope(config.project);
1907
+ const scope = resolveTickScope(live.project);
1865
1908
  // The transport contract, read once from the same file at the same moment so
1866
1909
  // the approval line, the delivery rule and the narration line cannot disagree
1867
1910
  // (#169, #179). No access file means no fleet channel to judge; a session
@@ -1869,10 +1912,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1869
1912
  // fail toward "not delivered" — the durable outbox instruction — rather than
1870
1913
  // asserting a delivery the bridge cannot make.
1871
1914
  const approval =
1872
- config.accessFile === undefined
1915
+ live.accessFile === undefined
1873
1916
  ? undefined
1874
1917
  : session.bridgeTokenAtStart
1875
- ? readApprovalSurface(config.accessFile)
1918
+ ? readApprovalSurface(live.accessFile)
1876
1919
  : ({
1877
1920
  kind: "missing",
1878
1921
  reason:
@@ -1881,9 +1924,9 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1881
1924
  "now — run `/telegram on` in this session, or restart it, to rebind the bridge",
1882
1925
  } as const);
1883
1926
  const profile =
1884
- config.accessFile === undefined || !session.bridgeTokenAtStart
1927
+ live.accessFile === undefined || !session.bridgeTokenAtStart
1885
1928
  ? undefined
1886
- : readDaemonProfile(config.accessFile);
1929
+ : readDaemonProfile(live.accessFile);
1887
1930
 
1888
1931
  // A configured message owns the ordinary reporting and delivery clauses.
1889
1932
  // Mechanical evidence is different: release-policy drift and repeated
@@ -2181,8 +2224,28 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
2181
2224
  // interval cannot arm a second timer faster than the primary.
2182
2225
  const pollMs = Math.min(TICK_REQUEST_POLL_MS, config.intervalSeconds * 1000);
2183
2226
  if (pollMs < config.intervalSeconds * 1000) {
2227
+ // One attempt per request, not one per poll. `clearTickRequest` only fires
2228
+ // on a successful send, so a request the gates refuse — disarmed fleet,
2229
+ // bridge down — stays on disk by design. Acting on it every poll turned that
2230
+ // into a 10s hot loop that re-entered `tick()` forever and kept rewriting the
2231
+ // runtime status. Remembering the line we already tried leaves the fixed
2232
+ // heartbeat as the fallback for that request, and still wakes promptly for
2233
+ // the next distinct one.
2234
+ let attempted: string | undefined;
2184
2235
  ctx.setInterval(() => {
2185
- if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
2236
+ const line = readTickRequestLine(ctx.cwd);
2237
+ if (line === undefined) {
2238
+ // Cleared: a send landed, or an operator removed it. Re-arm for the next.
2239
+ attempted = undefined;
2240
+ return;
2241
+ }
2242
+ // A live turn owns the session. Coalesce by leaving the request on disk and
2243
+ // trying again next poll — without re-entering `tick()`, which would drive
2244
+ // the pending-skip counter at poll cadence and forge a stall diagnosis in
2245
+ // seconds when STALL_TICKS is calibrated for heartbeat intervals.
2246
+ if (ctx.hasPendingMessages()) return;
2247
+ if (line === attempted) return;
2248
+ attempted = line;
2186
2249
  const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2187
2250
  pi.logger.info(
2188
2251
  `[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
package/src/setup-host.ts CHANGED
@@ -72,6 +72,21 @@ function systemdQuote(value: string): string {
72
72
  return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
73
73
  }
74
74
 
75
+ /**
76
+ * A bare path for the settings that take exactly one, never a quoted word list.
77
+ *
78
+ * `Environment=` and `ExecStart=` unquote their values, so {@link systemdQuote}
79
+ * is right there. `WorkingDirectory=` does not: systemd reads the whole value as
80
+ * the path, so a quoted one fails `systemd-analyze verify` with "path is not
81
+ * absolute" and the unit refuses to start — a landmine that only fires on the
82
+ * next restart, because the running daemon keeps the unit it started with.
83
+ * Spaces need no quoting here; `%` still does, or it reads as a specifier.
84
+ */
85
+ function systemdPath(value: string): string {
86
+ if (/\r|\n/.test(value)) throw new Error("systemd values cannot contain newlines");
87
+ return value.replaceAll("%", "%%");
88
+ }
89
+
75
90
  function shellQuote(value: string): string {
76
91
  return `'${value.replaceAll("'", "'\\''")}'`;
77
92
  }
@@ -268,7 +283,7 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
268
283
  `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
269
284
  `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
270
285
  `Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
271
- `WorkingDirectory=${systemdQuote(stateDir())}`,
286
+ `WorkingDirectory=${systemdPath(stateDir())}`,
272
287
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
273
288
  "Restart=on-failure",
274
289
  "SuccessExitStatus=0 143",