omp-conductor 0.3.22 → 0.3.24
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 +89 -30
- package/package.json +1 -1
- package/src/approval-surface.ts +218 -0
- package/src/board.ts +292 -55
- package/src/briefs/orchestrator.md +17 -2
- package/src/daemon.ts +122 -15
- package/src/fleet.ts +17 -1
- package/src/orchestrator-tick.ts +130 -15
- package/src/tracker/github.ts +19 -6
- package/src/types.ts +25 -3
- package/src/unblock.ts +55 -17
package/README.md
CHANGED
|
@@ -165,6 +165,22 @@ Also required on the host:
|
|
|
165
165
|
writes `access.json`. The fleet heartbeat also reads that file and refuses to
|
|
166
166
|
tick unless exactly one owner is paired, on the grounds that unattended
|
|
167
167
|
dispatch is only defensible while a tier-2 page can reach a person.
|
|
168
|
+
- **Approving a Learning-loop amendment from a heartbeat tick** needs one more
|
|
169
|
+
setting than pairing: a notify destination. `/telegram notify` writes
|
|
170
|
+
`notifyMode` and `notifyChat` into the same `access.json`. omp-telegram
|
|
171
|
+
mounts its `telegram_ask` tool only for a turn that resolves a notify target,
|
|
172
|
+
and a locally injected tick resolves one only through that setting — so
|
|
173
|
+
without it the orchestrator can page you but cannot put a yes/no question in
|
|
174
|
+
front of you, which is the one thing the Learning loop's approval step
|
|
175
|
+
requires. `omp-conductor status` reports this on the `telegram` row, and a
|
|
176
|
+
tick that cannot ask says so in its own prompt and falls back to
|
|
177
|
+
`telegram_send`.
|
|
178
|
+
|
|
179
|
+
Set `notifyChat` even on a forum fleet. `/telegram topics` routes to a topic
|
|
180
|
+
this session claims at runtime, and that claim is not visible in
|
|
181
|
+
`access.json` — so a file carrying only `topicsChat` is reported as
|
|
182
|
+
unconfigured rather than guessed at, on the grounds that a health row which
|
|
183
|
+
reads green over a broken contract is worse than one that overstates a fault.
|
|
168
184
|
|
|
169
185
|
With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
|
|
170
186
|
that configuration: it is supported, just slower to reach you.
|
|
@@ -462,22 +478,34 @@ Four control planes used to answer "stop" differently. The package verbs:
|
|
|
462
478
|
|
|
463
479
|
`omp-conductor board [--project NAME]` opens the same facts as a live terminal
|
|
464
480
|
kanban instead of a scrolling wall of status text. Its columns are Queue,
|
|
465
|
-
Claimed, Running, Green, Blocked, Failed,
|
|
466
|
-
|
|
467
|
-
is
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
481
|
+
Claimed, Running, Green, Blocked, Failed, Orphaned, Merged, and History.
|
|
482
|
+
|
|
483
|
+
Every column is a claim about an issue's state **now**, and each issue appears
|
|
484
|
+
in exactly one of them. Claimed, Running, and Green come from the store, because
|
|
485
|
+
a live worker or an open pull request is the most current fact there is. Every
|
|
486
|
+
stopped column comes from the labels the tracker carries right now: Queue is the
|
|
487
|
+
queue label plus the latest tick's admission holds, Failed is the failed state
|
|
488
|
+
label, Blocked is the blocked state label, and Orphaned is an issue the
|
|
489
|
+
in-progress label still claims with no worker running it — a protected orphan
|
|
490
|
+
whose worktree is kept because it may hold uncommitted work. So a requeued issue
|
|
491
|
+
is queue work even when its last attempt failed, and a closed issue is not
|
|
492
|
+
active work at all. Merged and History are the two backward-looking columns,
|
|
493
|
+
both bounded to the last 24 hours and neither one an action list: History holds
|
|
494
|
+
recently finished runs that no current label claims, so the board stays
|
|
495
|
+
operational rather than becoming an analytics archive.
|
|
471
496
|
|
|
472
497
|
The board refreshes run, spend, turn, and dispatch values from SQLite every
|
|
473
|
-
second. It refreshes the slower daemon, Herdr, Telegram,
|
|
474
|
-
layer every ten seconds or immediately with `r
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
498
|
+
second. It refreshes the slower daemon, Herdr, Telegram, code-graph, and tracker
|
|
499
|
+
label layer every ten seconds or immediately with `r`; the header reports
|
|
500
|
+
`tracker ok`, or how stale the label read is and why, so an empty Queue is never
|
|
501
|
+
mistaken for an empty queue. Use arrow keys or `h/j/k/l` to select a card,
|
|
502
|
+
`Enter` to inspect and follow its worker transcript, `u` to run the normal
|
|
503
|
+
unblock workflow on a Blocked, Failed, or Orphaned card, `i` / `p` to open the
|
|
504
|
+
issue / pull request, `?` for help, and `q`, `Esc`, or `Ctrl-C` to go back and
|
|
505
|
+
quit. Narrow terminals show a sliding subset of columns around the selection;
|
|
506
|
+
terminals below 50×20 get a single resize instruction instead of a broken
|
|
507
|
+
layout. The board is read-only except for the explicit `u` action: it never
|
|
508
|
+
claims work or changes stages.
|
|
481
509
|
|
|
482
510
|
`halt --pane` is **fail-closed**: it exits `0` only when the conductor agent is
|
|
483
511
|
*proven* gone. It writes the recovery pin first, so a failed stop still cannot be
|
|
@@ -518,7 +546,8 @@ Per tick, for the daemon's project:
|
|
|
518
546
|
the independent head/check verification; green → `pushed-green`, red →
|
|
519
547
|
`failed`, and still pending stays occupied. For every verified
|
|
520
548
|
`pushed-green` run, ask what became of its PR. Merged → `merged`; closed
|
|
521
|
-
without merging → `failed`. Unknown answers leave the row unchanged.
|
|
549
|
+
without merging → `failed`. Unknown answers leave the row unchanged. Every row
|
|
550
|
+
that settles also loses its `agent:in-progress` label. This
|
|
522
551
|
maintenance runs even while dispatch is paused or workers are active, so
|
|
523
552
|
status converges on the five-minute tick cadence. It also runs above admission
|
|
524
553
|
so a row settled here frees its issue in the same tick. See
|
|
@@ -548,8 +577,12 @@ Per tick, for the daemon's project:
|
|
|
548
577
|
survived step 8 — so at most one API call per free slot, never one per queued
|
|
549
578
|
issue — the daemon asks whether an **open** PR already closes it. An open PR
|
|
550
579
|
normally holds the issue. One narrow exception permits a routed continuation:
|
|
551
|
-
the
|
|
552
|
-
|
|
580
|
+
the latest run must be terminal, and the open PR must be that run's retained
|
|
581
|
+
work — either the PR URL it recorded or a PR opened on the branch it retained.
|
|
582
|
+
The branch half matters because a run can be cap-killed before its worker ever
|
|
583
|
+
opens a PR, leaving a retained branch and no recorded URL; a PR pushed to that
|
|
584
|
+
branch afterwards is still the continuation target. Drafts count because their
|
|
585
|
+
branch can hold the only copy of the work.
|
|
553
586
|
The tracker also finds work missing from a new, moved, restored, or cleared
|
|
554
587
|
store. If the check fails, the candidate is **held**, not admitted, and
|
|
555
588
|
retried next tick: the cost of holding is five minutes, the cost of admitting
|
|
@@ -583,13 +616,14 @@ Then, per admitted issue:
|
|
|
583
616
|
| Outcome | Labels | Worktree | Escalation |
|
|
584
617
|
| --- | --- | --- | --- |
|
|
585
618
|
| `pushed-pending` | `agent:in-progress` stays while the daemon rechecks GitHub | removed | none |
|
|
586
|
-
| `pushed-green` | `agent:in-progress` stays
|
|
619
|
+
| `pushed-green` | `agent:in-progress` stays while the PR is open | removed | none |
|
|
587
620
|
| `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
|
|
588
621
|
| `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then retained until the PR or issue is terminal | Tier 1 |
|
|
589
622
|
| unexpected error | swapped to `agent:failed` | same | Tier 1 |
|
|
590
623
|
|
|
591
624
|
`pushed-pending` and `pushed-green` are not the end of the row: later ticks
|
|
592
|
-
verify outstanding checks and settle the PR once it resolves
|
|
625
|
+
verify outstanding checks and settle the PR once it resolves, and a row that
|
|
626
|
+
settles gives up its `agent:in-progress` label. See
|
|
593
627
|
[what settles a green PR](#what-settles-a-green-pr).
|
|
594
628
|
|
|
595
629
|
Label swaps add the new label before removing the old one: the reverse order
|
|
@@ -683,11 +717,18 @@ every pushed PR it is still holding:
|
|
|
683
717
|
| could not be determined | unchanged | A flaky network, a revoked token, a deleted PR. An unknown answer never settles a row; the next tick asks again for free. |
|
|
684
718
|
|
|
685
719
|
Run history is untouched. A PR closed without merging becomes a concrete failed
|
|
686
|
-
attempt; a merge does not spend failure or continuation budget. A
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
the sweep
|
|
720
|
+
attempt; a merge does not spend failure or continuation budget. A settled row also
|
|
721
|
+
loses `agent:in-progress` from its issue: the row transition and the label are one
|
|
722
|
+
fact, and a terminal answer about the PR proves no worker process owns the issue,
|
|
723
|
+
so the duplicate-dispatch guard it exists for is spent. The label comes off
|
|
724
|
+
*before* the row is written terminal, because this sweep is the only thing that
|
|
725
|
+
revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
|
|
726
|
+
row pending and the next tick tries again, rather than stranding the label on an
|
|
727
|
+
issue nothing will look at twice. The issue stays in the busy set while it waits,
|
|
728
|
+
so nothing is dispatched onto it in between. Anything beyond that one release — a
|
|
729
|
+
re-queue, a `blocked` marker — is still the orchestrator's drain-duty judgement.
|
|
730
|
+
One unreachable PR costs its own row and nothing else; the rest of the sweep
|
|
731
|
+
still settles.
|
|
691
732
|
|
|
692
733
|
Until this existed, nothing ever revisited a `pushed-green` row: the startup
|
|
693
734
|
reconciler only settles rows that held a process, and `merged` went unwritten. On
|
|
@@ -696,6 +737,12 @@ merged and whose issues were all closed, through two daemon restarts — and bec
|
|
|
696
737
|
the active set *is* the busy set, those three issues were permanently unclaimable.
|
|
697
738
|
A status page that has stopped being evidence is worse than no status page.
|
|
698
739
|
|
|
740
|
+
The label half of that outlived the row half by two days. On 2026-08-09 a merged
|
|
741
|
+
PR and a closed-unmerged one both settled their rows correctly and both left their
|
|
742
|
+
issues carrying `agent:in-progress`, which eligibility reads as "a worker owns
|
|
743
|
+
this" — with the brief forbidding the orchestrator from editing a state label and
|
|
744
|
+
`unblock` declining to clear that one, neither issue could ever be claimed again.
|
|
745
|
+
|
|
699
746
|
### Continuation runs
|
|
700
747
|
|
|
701
748
|
When a worktree is provisioned onto a branch that already exists in the mirror
|
|
@@ -960,7 +1007,9 @@ label on the issue, and eligibility reads any state label as disqualifying, so a
|
|
|
960
1007
|
answered issue that keeps one is never re-claimed and the answer is inert — nothing
|
|
961
1008
|
fails, the issue just stops existing as far as dispatch is concerned.
|
|
962
1009
|
[`omp-conductor unblock <issue>`](#cli-reference) is the way back: it clears the
|
|
963
|
-
label through the same tracker the dispatcher writes with
|
|
1010
|
+
label through the same tracker the dispatcher writes with, including
|
|
1011
|
+
`agent:in-progress` when the newest recorded run is terminal, since a terminal row
|
|
1012
|
+
is proof the worker process is gone. The brief tells the
|
|
964
1013
|
orchestrator to run that verb rather than edit the label itself, and that is not a
|
|
965
1014
|
formality — orphan detection works by comparing `agent:in-progress` labels against
|
|
966
1015
|
live runs, and it is only trustworthy while every state label on the tracker was
|
|
@@ -1219,13 +1268,23 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
|
|
|
1219
1268
|
startup snapshot would keep dispatching for days after the channel went away. A
|
|
1220
1269
|
stale arm marker must not outlive the channel that makes running unattended safe.
|
|
1221
1270
|
|
|
1222
|
-
The check passes only when
|
|
1223
|
-
|
|
1271
|
+
The check passes only when a bot token is resolvable — `TELEGRAM_BOT_TOKEN` in
|
|
1272
|
+
the environment, or in the `.env` beside `accessFile` — and the file parses to an
|
|
1273
|
+
object with `enabled: true` and exactly one `allowFrom` entry. Everything else
|
|
1274
|
+
stops the heartbeat: no token, so nothing outbound works at all; file missing,
|
|
1224
1275
|
unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
|
|
1225
1276
|
absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
|
|
1226
1277
|
the conductor refuses to guess which human is on the hook). Failure modes are
|
|
1227
1278
|
deliberately not distinguished in the decision: each one means a page lands
|
|
1228
|
-
nowhere.
|
|
1279
|
+
nowhere. `omp-conductor status` is where they are told apart — its `telegram` row
|
|
1280
|
+
names the specific fault.
|
|
1281
|
+
|
|
1282
|
+
One caveat the file cannot express: omp-telegram binds its own copy of the token
|
|
1283
|
+
in `startBot()` at session start, and rebinds it only on `/telegram token` or
|
|
1284
|
+
`/telegram on`. Writing a token into `.env` out-of-band therefore restores
|
|
1285
|
+
tier-2 paging immediately — conductor sends those itself — while the bridge's own
|
|
1286
|
+
tools, `telegram_send` and `telegram_ask`, stay dead until you reload it. After
|
|
1287
|
+
adding a token by hand, run `/telegram on` in the orchestrator session.
|
|
1229
1288
|
|
|
1230
1289
|
Leaving `accessFile` unset passes the gate, because an ordinary developer session
|
|
1231
1290
|
that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
|
|
@@ -1350,7 +1409,7 @@ omp-conductor help
|
|
|
1350
1409
|
| `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
|
|
1351
1410
|
| `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
|
|
1352
1411
|
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. 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. The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
|
|
1353
|
-
| `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status
|
|
1412
|
+
| `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, and the last 24 hours of Merged and History. 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. |
|
|
1354
1413
|
| `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this over `pause` when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--halt). |
|
|
1355
1414
|
| `halt [--pane] [--project NAME]` | `hold`, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `halt --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. |
|
|
1356
1415
|
| `arm [--project NAME]` | Proof-gated: send a Telegram challenge and write the arm marker only after your reply appears as a user turn in the orchestrator transcript. Never auto-armed by `resume` / `hold`. |
|
|
@@ -1358,7 +1417,7 @@ omp-conductor help
|
|
|
1358
1417
|
| `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
|
|
1359
1418
|
| `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
1360
1419
|
| `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
|
|
1361
|
-
| `unblock <issue>` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` is
|
|
1420
|
+
| `unblock <issue>` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. Exits `2` when the issue number is missing or malformed. |
|
|
1362
1421
|
| `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
|
|
1363
1422
|
| `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
|
|
1364
1423
|
| `daemon --once` | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.24",
|
|
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.",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can a *locally injected* turn ask its operator a yes/no question?
|
|
3
|
+
*
|
|
4
|
+
* One predicate, shared by the tick that has to warn about the answer and the
|
|
5
|
+
* status row that has to report it, because two spellings of it would drift and
|
|
6
|
+
* the whole point is that the health line and the tick agree.
|
|
7
|
+
*
|
|
8
|
+
* It is deliberately a question about **configuration**, not about the mounted
|
|
9
|
+
* tool set, and that distinction is the correction to a first attempt that got
|
|
10
|
+
* it wrong. omp-telegram mounts `telegram_ask` in `before_agent_start` and
|
|
11
|
+
* removes it again in `agent_end` (`restorePromptTools`), so the tool exists
|
|
12
|
+
* only *during* a turn. Anything that looks at `getActiveTools()` between turns
|
|
13
|
+
* — which is exactly where a heartbeat composes its prompt — sees the base set
|
|
14
|
+
* and would report the tool missing on every tick, including a correctly
|
|
15
|
+
* configured fleet. A false alarm every interval is worse than no alarm: it
|
|
16
|
+
* trains the operator to ignore the line that is supposed to mean something.
|
|
17
|
+
*
|
|
18
|
+
* What actually decides the mounting is knowable ahead of the turn. omp-telegram
|
|
19
|
+
* resolves a prompt target from the turn's own `<telegram-message>` wrapper, or
|
|
20
|
+
* failing that from `notifyTarget()`, which needs `notifyMode` set to "away" or
|
|
21
|
+
* "always" *and* a destination it can reach. A locally injected tick never has a
|
|
22
|
+
* wrapper, so for ticks the predicate reduces to config keys readable before the
|
|
23
|
+
* turn starts — with one deliberate narrowing, documented at the destination
|
|
24
|
+
* check below: only `notifyChat` counts, because a forum topic depends on live
|
|
25
|
+
* per-session state no file can evidence.
|
|
26
|
+
*
|
|
27
|
+
* That is exactly what failed on 2026-08-09 06:17Z: the fleet's access.json had
|
|
28
|
+
* no `notifyMode`, so the locally injected tick could not ask the Learning-loop
|
|
29
|
+
* yes/no question the package floor requires — while the status line reported
|
|
30
|
+
* `telegram ok (@tbcoder_bot; inbound configured)` throughout (#114). A health
|
|
31
|
+
* row that stays green through a broken contract is worse than no row.
|
|
32
|
+
*
|
|
33
|
+
* The legacy `away: true` boolean counts: `loadAccess()` migrates it to
|
|
34
|
+
* `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
|
|
35
|
+
* and must not be reported as broken.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
|
|
41
|
+
/** The tool the package floor names for the Learning-loop yes/no amendment
|
|
42
|
+
* approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`). Held as
|
|
43
|
+
* a constant because the prose that tells a turn to call it and the check that
|
|
44
|
+
* says whether it can be called must name the same thing. */
|
|
45
|
+
export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
|
|
46
|
+
|
|
47
|
+
export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
|
|
48
|
+
|
|
49
|
+
/** One checked read of a JSON property, so nothing below asserts a shape the
|
|
50
|
+
* parse never proved. Anything that is not a plain object, or a key that is
|
|
51
|
+
* absent, answers undefined — which every caller here already treats as
|
|
52
|
+
* "unconfigured". */
|
|
53
|
+
function field(value: unknown, key: string): unknown {
|
|
54
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
55
|
+
return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether omp-telegram would find a bot token — its rule, not a reasonable one.
|
|
60
|
+
*
|
|
61
|
+
* This gates the heartbeat, so it has to predict another package's answer, and
|
|
62
|
+
* anywhere it is more generous than that package the gate fails *open*: it would
|
|
63
|
+
* report a token, let the tick fire, and leave the fleet dispatching with an
|
|
64
|
+
* outbound channel that never worked. So it mirrors `resolveToken()` in
|
|
65
|
+
* omp-telegram's `access.ts` exactly — process environment first, then the first
|
|
66
|
+
* line of `<state dir>/.env` matching `/^TELEGRAM_BOT_TOKEN=(.*)$/` after a
|
|
67
|
+
* trim, first match winning even when its value is empty.
|
|
68
|
+
*
|
|
69
|
+
* That regex is deliberately strict where a dotenv reader would not be:
|
|
70
|
+
* `export TELEGRAM_BOT_TOKEN=…` and `TELEGRAM_BOT_TOKEN = …` are both files
|
|
71
|
+
* omp-telegram reads no token from, so both must close this gate.
|
|
72
|
+
*
|
|
73
|
+
* `escalate.ts` parses the same file more permissively on purpose, and the two
|
|
74
|
+
* are not a contradiction to be tidied away: that one asks "can *conductor* send
|
|
75
|
+
* a page with this", and it can, because it does the parsing and the request
|
|
76
|
+
* itself. This one asks "will *omp-telegram* have a token", and only omp-telegram
|
|
77
|
+
* decides that.
|
|
78
|
+
*/
|
|
79
|
+
export function hasBotToken(stateDir: string): boolean {
|
|
80
|
+
const fromEnv = process.env["TELEGRAM_BOT_TOKEN"];
|
|
81
|
+
if (fromEnv !== undefined && fromEnv.length > 0) return true;
|
|
82
|
+
let raw: string;
|
|
83
|
+
try {
|
|
84
|
+
raw = readFileSync(join(stateDir, ".env"), "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
for (const line of raw.split("\n")) {
|
|
89
|
+
const match = /^TELEGRAM_BOT_TOKEN=(.*)$/.exec(line.trim());
|
|
90
|
+
if (match === null) continue;
|
|
91
|
+
// First match wins even when empty, exactly as `resolveToken()` returns on
|
|
92
|
+
// its first hit — a file whose first token line is blank has no token, and
|
|
93
|
+
// reading past it would disagree with the package this predicts.
|
|
94
|
+
return (match[1] ?? "").length > 0;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reads the omp-telegram access file and answers whether a locally injected
|
|
100
|
+
* turn would resolve an answerable destination.
|
|
101
|
+
*
|
|
102
|
+
* A bot token is *not* checked here, deliberately: without one nothing outbound
|
|
103
|
+
* works at all, so it is a whole-channel fault rather than an approval-surface
|
|
104
|
+
* one. Both callers settle it first — the tick through its escalation-channel
|
|
105
|
+
* gate, which refuses to fire at all, and `probeTelegramHealth` through a
|
|
106
|
+
* `getMe` that has already succeeded. This function is only ever asked about a
|
|
107
|
+
* bridge that can send. Never throws: an unreadable or malformed file is a
|
|
108
|
+
* missing surface with the path named, not an exception taken out of a
|
|
109
|
+
* heartbeat. */
|
|
110
|
+
export function readApprovalSurface(path: string): ApprovalSurface {
|
|
111
|
+
let access: unknown;
|
|
112
|
+
try {
|
|
113
|
+
access = JSON.parse(readFileSync(path, "utf8"));
|
|
114
|
+
} catch {
|
|
115
|
+
return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` };
|
|
116
|
+
}
|
|
117
|
+
if (access === null || typeof access !== "object" || Array.isArray(access)) {
|
|
118
|
+
return {
|
|
119
|
+
kind: "missing",
|
|
120
|
+
reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const mode = field(access, "notifyMode");
|
|
124
|
+
const active = mode === "away" || mode === "always" || field(access, "away") === true;
|
|
125
|
+
if (!active) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "missing",
|
|
128
|
+
reason:
|
|
129
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` +
|
|
130
|
+
`set it to "always" and set notifyChat to the paired owner id`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// Only `notifyChat` counts, and `topicsChat` deliberately does not, even
|
|
134
|
+
// though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a
|
|
135
|
+
// topic destination only when `ownTopic && access.topicsChat`, and `ownTopic`
|
|
136
|
+
// is per-session runtime state — the thread this process actually claimed —
|
|
137
|
+
// assigned during topic setup and cleared on failure. A file cannot prove it.
|
|
138
|
+
// So a fleet carrying `topicsChat` alone may or may not have the tool mounted,
|
|
139
|
+
// and this check answers the safe way: #114 exists because a health signal
|
|
140
|
+
// said "ok" through a broken contract, and repeating that with a stale
|
|
141
|
+
// `topicsChat` would be the same bug wearing a different key. `notifyChat` is
|
|
142
|
+
// resolvable from the file alone, so it is what "configured" means here.
|
|
143
|
+
const destination = field(access, "notifyChat");
|
|
144
|
+
if (typeof destination !== "string" || destination.length === 0) {
|
|
145
|
+
const topicsChat = field(access, "topicsChat");
|
|
146
|
+
const viaTopic =
|
|
147
|
+
typeof topicsChat === "string" && topicsChat.length > 0
|
|
148
|
+
? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show"
|
|
149
|
+
: "";
|
|
150
|
+
return {
|
|
151
|
+
kind: "missing",
|
|
152
|
+
reason:
|
|
153
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` +
|
|
154
|
+
`${viaTopic} — set notifyChat to the paired owner id`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Naming a destination is not the same as being able to answer in it, and
|
|
159
|
+
// `buildPromptTarget()` applies two more gates before a target exists. Both
|
|
160
|
+
// are decidable from this file, so both belong here — a `ready` that skipped
|
|
161
|
+
// them would say the approval works while a stale or wrong chat id silently
|
|
162
|
+
// reproduces #114.
|
|
163
|
+
//
|
|
164
|
+
// First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else
|
|
165
|
+
// yields no responder and therefore no target.
|
|
166
|
+
const allowFrom = field(access, "allowFrom");
|
|
167
|
+
const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined;
|
|
168
|
+
if (owner === undefined) {
|
|
169
|
+
return {
|
|
170
|
+
kind: "missing",
|
|
171
|
+
reason:
|
|
172
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` +
|
|
173
|
+
"so no one is authorised to answer",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns
|
|
178
|
+
// use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only
|
|
179
|
+
// authorises the owner's *own* chat — a DM chat id equals its user id, so a
|
|
180
|
+
// notifyChat pointing at any other person is unanswerable by the paired owner.
|
|
181
|
+
if (!destination.startsWith("-")) {
|
|
182
|
+
if (destination !== owner) {
|
|
183
|
+
return {
|
|
184
|
+
kind: "missing",
|
|
185
|
+
reason:
|
|
186
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` +
|
|
187
|
+
`is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` +
|
|
188
|
+
"set notifyChat to the paired owner id",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return { kind: "ready" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A group destination needs a policy entry, and that policy must admit the
|
|
195
|
+
// owner: an empty or absent `allowFrom` means everyone in the group, else the
|
|
196
|
+
// owner has to be listed.
|
|
197
|
+
const policy = field(field(access, "groups"), destination);
|
|
198
|
+
if (policy === null || typeof policy !== "object") {
|
|
199
|
+
return {
|
|
200
|
+
kind: "missing",
|
|
201
|
+
reason:
|
|
202
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` +
|
|
203
|
+
"no entry under groups, so no answer from it is authorised",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const groupAllow = field(policy, "allowFrom");
|
|
207
|
+
const admitted =
|
|
208
|
+
!Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner);
|
|
209
|
+
if (!admitted) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "missing",
|
|
212
|
+
reason:
|
|
213
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` +
|
|
214
|
+
`paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { kind: "ready" };
|
|
218
|
+
}
|