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 +11 -4
- package/package.json +1 -1
- package/src/cli.ts +20 -10
- package/src/fleet.ts +48 -5
- package/src/orchestrator-tick.ts +74 -11
- package/src/setup-host.ts +16 -1
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`
|
|
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.
|
|
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
|
-
|
|
643
|
-
hold
|
|
644
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
1905
|
+
refreshComposedBriefBestEffort(live.project);
|
|
1863
1906
|
|
|
1864
|
-
const scope = resolveTickScope(
|
|
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
|
-
|
|
1915
|
+
live.accessFile === undefined
|
|
1873
1916
|
? undefined
|
|
1874
1917
|
: session.bridgeTokenAtStart
|
|
1875
|
-
? readApprovalSurface(
|
|
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
|
-
|
|
1927
|
+
live.accessFile === undefined || !session.bridgeTokenAtStart
|
|
1885
1928
|
? undefined
|
|
1886
|
-
: readDaemonProfile(
|
|
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
|
-
|
|
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=${
|
|
286
|
+
`WorkingDirectory=${systemdPath(stateDir())}`,
|
|
272
287
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
273
288
|
"Restart=on-failure",
|
|
274
289
|
"SuccessExitStatus=0 143",
|