omp-conductor 0.3.23 → 0.3.25
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 +74 -32
- package/package.json +1 -1
- package/src/approval-surface.ts +253 -0
- package/src/board.ts +6 -1
- package/src/briefs/orchestrator.md +8 -0
- package/src/cli.ts +9 -2
- package/src/daemon.ts +258 -70
- package/src/fleet.ts +16 -56
- package/src/orchestrator-tick.ts +117 -40
- package/src/store.ts +51 -2
- package/src/types.ts +16 -0
- package/src/unblock.ts +53 -3
- package/src/worktree.ts +26 -12
package/README.md
CHANGED
|
@@ -167,14 +167,20 @@ Also required on the host:
|
|
|
167
167
|
dispatch is only defensible while a tier-2 page can reach a person.
|
|
168
168
|
- **Approving a Learning-loop amendment from a heartbeat tick** needs one more
|
|
169
169
|
setting than pairing: a notify destination. `/telegram notify` writes
|
|
170
|
-
`notifyMode` and `notifyChat` into the same `access.json
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
178
184
|
|
|
179
185
|
With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
|
|
180
186
|
that configuration: it is supported, just slower to reach you.
|
|
@@ -611,7 +617,7 @@ Then, per admitted issue:
|
|
|
611
617
|
| --- | --- | --- | --- |
|
|
612
618
|
| `pushed-pending` | `agent:in-progress` stays while the daemon rechecks GitHub | removed | none |
|
|
613
619
|
| `pushed-green` | `agent:in-progress` stays while the PR is open | removed | none |
|
|
614
|
-
| `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
|
|
620
|
+
| `blocked` | swapped to `agent:blocked` | dirty tree committed to the branch, then removed | Tier 1 |
|
|
615
621
|
| `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then retained until the PR or issue is terminal | Tier 1 |
|
|
616
622
|
| unexpected error | swapped to `agent:failed` | same | Tier 1 |
|
|
617
623
|
|
|
@@ -624,18 +630,38 @@ Then, per admitted issue:
|
|
|
624
630
|
leaves a window in which the issue carries no state label at all, which is
|
|
625
631
|
exactly the shape eligibility reads as fresh work.
|
|
626
632
|
|
|
627
|
-
**
|
|
628
|
-
wall-clock kill and a
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
633
|
+
**Every continuable end salvages the tree first.** A turns-cap kill, a
|
|
634
|
+
wall-clock kill, a crash and a graceful block all leave a tree the next
|
|
635
|
+
attempt removes `--force` — only the run's *branch* is preserved across
|
|
636
|
+
attempts. So before the escalation is written, a dirty tree is committed to
|
|
637
|
+
the run's own branch as `wip(#<issue>): attempt <n> <ending> — auto-salvaged`
|
|
638
|
+
(everything, including files git has never seen) and pushed, and the
|
|
639
|
+
escalation says where it went: `WIP committed to <branch> @ <sha>`. A push
|
|
640
|
+
that is refused leaves the commit in this host's mirror and says so.
|
|
641
|
+
|
|
642
|
+
Blocking was excluded from this until #118, on the argument that a worker
|
|
643
|
+
which stops on purpose has turns left to commit for itself. It cost a
|
|
644
|
+
34-file refactor: the worker blocked to ask whether a failing test was
|
|
645
|
+
obsolete — which is precisely a worker declining to commit a half-migrated
|
|
646
|
+
tree — and the daemon removed the tree seconds later, leaving the run branch
|
|
647
|
+
and `origin/main` on the same commit. A `pushed-green` or `pushed-pending`
|
|
648
|
+
run is now the only end that does not salvage: its deliverable is already on
|
|
649
|
+
a remote branch, and appending a WIP commit would turn the PR the daemon
|
|
650
|
+
just verified red.
|
|
651
|
+
|
|
652
|
+
**A salvage that fails keeps the tree and stops the issue.** If git refuses
|
|
653
|
+
the commit, the worktree is the only copy in existence, so it is retained
|
|
654
|
+
whatever the run's outcome was, the row records the failure, and the issue
|
|
655
|
+
is held out of dispatch with the `unsalvaged-wip` reason — because claiming
|
|
656
|
+
it is what would finally destroy the tree. `status` shows it under `wip` as
|
|
657
|
+
`UNSALVAGED`, and `omp-conductor unblock <n>` refuses. Recover the tree by
|
|
658
|
+
hand, then `unblock <n> --force` records that you accepted it and releases
|
|
659
|
+
the hold.
|
|
660
|
+
|
|
661
|
+
**A preserved tip is named to the next worker.** The sha is written to the
|
|
662
|
+
run row, shown by `status` and the board, and the continuation brief tells
|
|
663
|
+
the resuming worker the exact commit it is building on and that it is the
|
|
664
|
+
only copy.
|
|
639
665
|
|
|
640
666
|
Later ticks reap retained failure trees in bounded batches after the tracker
|
|
641
667
|
proves their PR merged/closed or their issue closed, provided no live run or
|
|
@@ -714,12 +740,15 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
|
|
|
714
740
|
attempt; a merge does not spend failure or continuation budget. A settled row also
|
|
715
741
|
loses `agent:in-progress` from its issue: the row transition and the label are one
|
|
716
742
|
fact, and a terminal answer about the PR proves no worker process owns the issue,
|
|
717
|
-
so the duplicate-dispatch guard it exists for is spent.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
743
|
+
so the duplicate-dispatch guard it exists for is spent. The label comes off
|
|
744
|
+
*before* the row is written terminal, because this sweep is the only thing that
|
|
745
|
+
revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
|
|
746
|
+
row pending and the next tick tries again, rather than stranding the label on an
|
|
747
|
+
issue nothing will look at twice. The issue stays in the busy set while it waits,
|
|
748
|
+
so nothing is dispatched onto it in between. Anything beyond that one release — a
|
|
749
|
+
re-queue, a `blocked` marker — is still the orchestrator's drain-duty judgement.
|
|
750
|
+
One unreachable PR costs its own row and nothing else; the rest of the sweep
|
|
751
|
+
still settles.
|
|
723
752
|
|
|
724
753
|
Until this existed, nothing ever revisited a `pushed-green` row: the startup
|
|
725
754
|
reconciler only settles rows that held a process, and `merged` went unwritten. On
|
|
@@ -1259,13 +1288,26 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
|
|
|
1259
1288
|
startup snapshot would keep dispatching for days after the channel went away. A
|
|
1260
1289
|
stale arm marker must not outlive the channel that makes running unattended safe.
|
|
1261
1290
|
|
|
1262
|
-
The check passes only when
|
|
1263
|
-
|
|
1291
|
+
The check passes only when a bot token is resolvable — `TELEGRAM_BOT_TOKEN` in
|
|
1292
|
+
the environment, or in the `.env` beside `accessFile` — and the file parses to an
|
|
1293
|
+
object with `enabled: true` and exactly one `allowFrom` entry. Everything else
|
|
1294
|
+
stops the heartbeat: no token, so nothing outbound works at all; file missing,
|
|
1264
1295
|
unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
|
|
1265
1296
|
absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
|
|
1266
1297
|
the conductor refuses to guess which human is on the hook). Failure modes are
|
|
1267
1298
|
deliberately not distinguished in the decision: each one means a page lands
|
|
1268
|
-
nowhere.
|
|
1299
|
+
nowhere. `omp-conductor status` is where they are told apart — its `telegram` row
|
|
1300
|
+
names the specific fault.
|
|
1301
|
+
|
|
1302
|
+
One caveat the file cannot express: omp-telegram binds its own copy of the token
|
|
1303
|
+
in `startBot()` at session start, and only when the bridge is switched on. It
|
|
1304
|
+
rebinds only on `/telegram token` or `/telegram on`. So writing a token into
|
|
1305
|
+
`.env` out-of-band — or flipping `enabled` to true by hand — restores tier-2
|
|
1306
|
+
paging immediately, because conductor sends those itself, while the bridge's own
|
|
1307
|
+
tools, `telegram_send` and `telegram_ask`, stay dead until you reload it. After
|
|
1308
|
+
either edit, run `/telegram on` in the orchestrator session. Until you do, ticks
|
|
1309
|
+
carry an explicit note that an amendment cannot be approved on this surface, and
|
|
1310
|
+
the conductor never assumes an answer it did not receive.
|
|
1269
1311
|
|
|
1270
1312
|
Leaving `accessFile` unset passes the gate, because an ordinary developer session
|
|
1271
1313
|
that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
|
|
@@ -1373,7 +1415,7 @@ omp-conductor disarm [--project NAME]
|
|
|
1373
1415
|
omp-conductor release-pane [--project NAME]
|
|
1374
1416
|
omp-conductor tail <issue> [--project NAME]
|
|
1375
1417
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
1376
|
-
omp-conductor unblock <issue> [--project NAME]
|
|
1418
|
+
omp-conductor unblock <issue> [--force] [--project NAME]
|
|
1377
1419
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
1378
1420
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
1379
1421
|
omp-conductor pause
|
|
@@ -1398,7 +1440,7 @@ omp-conductor help
|
|
|
1398
1440
|
| `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
|
|
1399
1441
|
| `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>`. |
|
|
1400
1442
|
| `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. |
|
|
1401
|
-
| `unblock <issue
|
|
1443
|
+
| `unblock <issue> [--force]` | 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. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1402
1444
|
| `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. |
|
|
1403
1445
|
| `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. |
|
|
1404
1446
|
| `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.25",
|
|
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,253 @@
|
|
|
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 { dirname, 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
|
+
/**
|
|
100
|
+
* Whether omp-telegram would have *bound* a token, which is what its own tools
|
|
101
|
+
* depend on and is strictly narrower than the token existing.
|
|
102
|
+
*
|
|
103
|
+
* `startBot()` is the only thing that assigns `token`, and `session_start` calls
|
|
104
|
+
* it only when the bridge is switched on:
|
|
105
|
+
*
|
|
106
|
+
* ```js
|
|
107
|
+
* if (pi.getFlag("telegram") === true || process.env.OMP_TELEGRAM === "1" || access.enabled) await startBot(ctx);
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* So a token sitting beside a bridge that was disabled at start was never
|
|
111
|
+
* picked up, and flipping `enabled` to true in the file afterwards does not pick
|
|
112
|
+
* it up either — only `/telegram on` or a restart does. Both halves therefore
|
|
113
|
+
* have to be true *at the same moment*, which is why this is sampled once rather
|
|
114
|
+
* than re-read.
|
|
115
|
+
*
|
|
116
|
+
* `pi.getFlag("telegram")` is omp's own launch flag and is not visible to this
|
|
117
|
+
* extension, so a session started with it but with `enabled: false` on disk
|
|
118
|
+
* reads as unbound here. That is the conservative direction: the cost is a
|
|
119
|
+
* fallback instruction the orchestrator can carry out, against an amendment
|
|
120
|
+
* recorded as approved that nobody answered.
|
|
121
|
+
*/
|
|
122
|
+
export function bridgeTokenBound(accessPath: string): boolean {
|
|
123
|
+
if (!hasBotToken(dirname(accessPath))) return false;
|
|
124
|
+
if (process.env["OMP_TELEGRAM"] === "1") return true;
|
|
125
|
+
let access: unknown;
|
|
126
|
+
try {
|
|
127
|
+
access = JSON.parse(readFileSync(accessPath, "utf8"));
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return field(access, "enabled") === true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reads the omp-telegram access file and answers whether a locally injected
|
|
135
|
+
* turn would resolve an answerable destination.
|
|
136
|
+
*
|
|
137
|
+
* A bot token is *not* checked here, deliberately: without one nothing outbound
|
|
138
|
+
* works at all, so it is a whole-channel fault rather than an approval-surface
|
|
139
|
+
* one. Both callers settle it first — the tick through its escalation-channel
|
|
140
|
+
* gate, which refuses to fire at all, and `probeTelegramHealth` through a
|
|
141
|
+
* `getMe` that has already succeeded. This function is only ever asked about a
|
|
142
|
+
* bridge that can send. Never throws: an unreadable or malformed file is a
|
|
143
|
+
* missing surface with the path named, not an exception taken out of a
|
|
144
|
+
* heartbeat. */
|
|
145
|
+
export function readApprovalSurface(path: string): ApprovalSurface {
|
|
146
|
+
let access: unknown;
|
|
147
|
+
try {
|
|
148
|
+
access = JSON.parse(readFileSync(path, "utf8"));
|
|
149
|
+
} catch {
|
|
150
|
+
return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` };
|
|
151
|
+
}
|
|
152
|
+
if (access === null || typeof access !== "object" || Array.isArray(access)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "missing",
|
|
155
|
+
reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const mode = field(access, "notifyMode");
|
|
159
|
+
const active = mode === "away" || mode === "always" || field(access, "away") === true;
|
|
160
|
+
if (!active) {
|
|
161
|
+
return {
|
|
162
|
+
kind: "missing",
|
|
163
|
+
reason:
|
|
164
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` +
|
|
165
|
+
`set it to "always" and set notifyChat to the paired owner id`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Only `notifyChat` counts, and `topicsChat` deliberately does not, even
|
|
169
|
+
// though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a
|
|
170
|
+
// topic destination only when `ownTopic && access.topicsChat`, and `ownTopic`
|
|
171
|
+
// is per-session runtime state — the thread this process actually claimed —
|
|
172
|
+
// assigned during topic setup and cleared on failure. A file cannot prove it.
|
|
173
|
+
// So a fleet carrying `topicsChat` alone may or may not have the tool mounted,
|
|
174
|
+
// and this check answers the safe way: #114 exists because a health signal
|
|
175
|
+
// said "ok" through a broken contract, and repeating that with a stale
|
|
176
|
+
// `topicsChat` would be the same bug wearing a different key. `notifyChat` is
|
|
177
|
+
// resolvable from the file alone, so it is what "configured" means here.
|
|
178
|
+
const destination = field(access, "notifyChat");
|
|
179
|
+
if (typeof destination !== "string" || destination.length === 0) {
|
|
180
|
+
const topicsChat = field(access, "topicsChat");
|
|
181
|
+
const viaTopic =
|
|
182
|
+
typeof topicsChat === "string" && topicsChat.length > 0
|
|
183
|
+
? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show"
|
|
184
|
+
: "";
|
|
185
|
+
return {
|
|
186
|
+
kind: "missing",
|
|
187
|
+
reason:
|
|
188
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` +
|
|
189
|
+
`${viaTopic} — set notifyChat to the paired owner id`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Naming a destination is not the same as being able to answer in it, and
|
|
194
|
+
// `buildPromptTarget()` applies two more gates before a target exists. Both
|
|
195
|
+
// are decidable from this file, so both belong here — a `ready` that skipped
|
|
196
|
+
// them would say the approval works while a stale or wrong chat id silently
|
|
197
|
+
// reproduces #114.
|
|
198
|
+
//
|
|
199
|
+
// First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else
|
|
200
|
+
// yields no responder and therefore no target.
|
|
201
|
+
const allowFrom = field(access, "allowFrom");
|
|
202
|
+
const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined;
|
|
203
|
+
if (owner === undefined) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "missing",
|
|
206
|
+
reason:
|
|
207
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` +
|
|
208
|
+
"so no one is authorised to answer",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns
|
|
213
|
+
// use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only
|
|
214
|
+
// authorises the owner's *own* chat — a DM chat id equals its user id, so a
|
|
215
|
+
// notifyChat pointing at any other person is unanswerable by the paired owner.
|
|
216
|
+
if (!destination.startsWith("-")) {
|
|
217
|
+
if (destination !== owner) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "missing",
|
|
220
|
+
reason:
|
|
221
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` +
|
|
222
|
+
`is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` +
|
|
223
|
+
"set notifyChat to the paired owner id",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { kind: "ready" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A group destination needs a policy entry, and that policy must admit the
|
|
230
|
+
// owner: an empty or absent `allowFrom` means everyone in the group, else the
|
|
231
|
+
// owner has to be listed.
|
|
232
|
+
const policy = field(field(access, "groups"), destination);
|
|
233
|
+
if (policy === null || typeof policy !== "object") {
|
|
234
|
+
return {
|
|
235
|
+
kind: "missing",
|
|
236
|
+
reason:
|
|
237
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` +
|
|
238
|
+
"no entry under groups, so no answer from it is authorised",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const groupAllow = field(policy, "allowFrom");
|
|
242
|
+
const admitted =
|
|
243
|
+
!Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner);
|
|
244
|
+
if (!admitted) {
|
|
245
|
+
return {
|
|
246
|
+
kind: "missing",
|
|
247
|
+
reason:
|
|
248
|
+
`${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` +
|
|
249
|
+
`paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { kind: "ready" };
|
|
253
|
+
}
|
package/src/board.ts
CHANGED
|
@@ -418,7 +418,12 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
|
418
418
|
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
419
419
|
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
420
420
|
];
|
|
421
|
-
|
|
421
|
+
// Ordered by what needs a human first. An unsalvaged tree outranks even a
|
|
422
|
+
// last error: the error describes a run that is over, the tree is work that
|
|
423
|
+
// is still at risk and an issue that will not dispatch (#118).
|
|
424
|
+
if (run.salvageError !== undefined && run.salvageAckAt === undefined) lines.push("UNSALVAGED WIP");
|
|
425
|
+
else if (run.state === "pushed-pending") lines.push("checks pending");
|
|
426
|
+
else if (run.salvageSha !== undefined) lines.push(`wip @ ${run.salvageSha.slice(0, 7)}`);
|
|
422
427
|
else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
|
|
423
428
|
else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
|
|
424
429
|
else lines.push(run.branch);
|
|
@@ -125,6 +125,14 @@ which in its output: a run that is genuinely still live, where declining is the
|
|
|
125
125
|
correct answer, and no run row at all to prove the worker is gone. In that second
|
|
126
126
|
case, escalate the stuck issue rather than editing its labels.
|
|
127
127
|
|
|
128
|
+
`unblock` refuses outright, clearing nothing, when the newest attempt's work
|
|
129
|
+
could not be committed and its worktree is the only copy of it — re-claiming the
|
|
130
|
+
issue force-removes that tree. Its output names the directory and offers
|
|
131
|
+
`--force`. **`--force` is never yours to pass.** It records that a human
|
|
132
|
+
inspected the tree and accepted the loss, which is a judgement about work you
|
|
133
|
+
cannot see, and the daemon has already paged the operator about it. Report the
|
|
134
|
+
refusal and move on to the next issue.
|
|
135
|
+
|
|
128
136
|
Never leave an orphan holding a slot "to be safe": a label nobody is working under
|
|
129
137
|
is not safety, it is a deadlocked fleet that looks busy.
|
|
130
138
|
|
package/src/cli.ts
CHANGED
|
@@ -94,7 +94,7 @@ usage:
|
|
|
94
94
|
omp-conductor release-pane [--project NAME]
|
|
95
95
|
omp-conductor tail <issue> [--project NAME]
|
|
96
96
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
97
|
-
omp-conductor unblock <issue> [--project NAME]
|
|
97
|
+
omp-conductor unblock <issue> [--force] [--project NAME]
|
|
98
98
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
99
99
|
omp-conductor pause
|
|
100
100
|
omp-conductor resume
|
|
@@ -150,6 +150,9 @@ usage:
|
|
|
150
150
|
and why the brief's "never hand-edit a state label" rule can stay
|
|
151
151
|
absolute. Run history is kept; answered blocks consume the separate
|
|
152
152
|
operational-continuation budget, not failed implementation attempts.
|
|
153
|
+
Refuses when the newest attempt's work could not be committed and
|
|
154
|
+
its worktree is the only copy: re-claiming removes that tree. Use
|
|
155
|
+
--force once you have recovered it or accepted the loss.
|
|
153
156
|
friction record a bounded observation the daemon cannot classify itself:
|
|
154
157
|
an escalation that belonged in a digest, or a tick report that was
|
|
155
158
|
noise/surprising. Repeated observations feed the existing Learning
|
|
@@ -636,8 +639,12 @@ try {
|
|
|
636
639
|
const project = findProject(cfg, flag(argv, "project"));
|
|
637
640
|
const store = openStore(dbPath());
|
|
638
641
|
try {
|
|
639
|
-
const outcome = await unblockIssue(project, makeTracker(project), store, issue
|
|
642
|
+
const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
|
|
643
|
+
force: argv.includes("--force"),
|
|
644
|
+
});
|
|
640
645
|
process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
|
|
646
|
+
// A refusal must not read as success to a script or a board keypress.
|
|
647
|
+
if (outcome.refused !== undefined) process.exitCode = 3;
|
|
641
648
|
} finally {
|
|
642
649
|
store.close();
|
|
643
650
|
}
|