omp-conductor 0.3.23 → 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 +36 -17
- package/package.json +1 -1
- package/src/approval-surface.ts +218 -0
- package/src/daemon.ts +40 -19
- package/src/fleet.ts +7 -55
- package/src/orchestrator-tick.ts +67 -40
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.
|
|
@@ -714,12 +720,15 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
|
|
|
714
720
|
attempt; a merge does not spend failure or continuation budget. A settled row also
|
|
715
721
|
loses `agent:in-progress` from its issue: the row transition and the label are one
|
|
716
722
|
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
|
-
|
|
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.
|
|
723
732
|
|
|
724
733
|
Until this existed, nothing ever revisited a `pushed-green` row: the startup
|
|
725
734
|
reconciler only settles rows that held a process, and `merged` went unwritten. On
|
|
@@ -1259,13 +1268,23 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
|
|
|
1259
1268
|
startup snapshot would keep dispatching for days after the channel went away. A
|
|
1260
1269
|
stale arm marker must not outlive the channel that makes running unattended safe.
|
|
1261
1270
|
|
|
1262
|
-
The check passes only when
|
|
1263
|
-
|
|
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,
|
|
1264
1275
|
unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
|
|
1265
1276
|
absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
|
|
1266
1277
|
the conductor refuses to guess which human is on the hook). Failure modes are
|
|
1267
1278
|
deliberately not distinguished in the decision: each one means a page lands
|
|
1268
|
-
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.
|
|
1269
1288
|
|
|
1270
1289
|
Leaving `accessFile` unset passes the gate, because an ordinary developer session
|
|
1271
1290
|
that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
|
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
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -893,26 +893,33 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
893
893
|
* hand-editing one, and `unblock` refused to clear that particular label — so
|
|
894
894
|
* both issues were permanently unclaimable with no supported way back (#18).
|
|
895
895
|
*
|
|
896
|
-
* Never throws, and
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
* not
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
896
|
+
* Never throws, and reports whether the label is provably gone, because the
|
|
897
|
+
* caller has to decide what to write to the store on the strength of it. A
|
|
898
|
+
* sweep must not lose the rest of its rows to one unreachable tracker, and it
|
|
899
|
+
* must not terminalise a row whose label it failed to drop: the settlement
|
|
900
|
+
* sweep only ever revisits `pushed-*` rows, so a row written terminal is a row
|
|
901
|
+
* nothing asks about again, and swallowing the failure under it would recreate
|
|
902
|
+
* the exact permanent-`agent:in-progress` state of #18 in the one case that
|
|
903
|
+
* still reaches it. Answering false instead leaves the row where the next tick
|
|
904
|
+
* will find it.
|
|
905
|
+
*
|
|
906
|
+
* Removing a label the issue does not carry is success, not failure: the GitHub
|
|
907
|
+
* adapter treats an absent label as a no-op, so false means the tracker could
|
|
908
|
+
* not be reached or refused — a condition that passes.
|
|
904
909
|
*/
|
|
905
910
|
export async function releaseInProgress(
|
|
906
911
|
d: Pick<Deps, "project" | "tracker">,
|
|
907
912
|
issue: number,
|
|
908
913
|
why: string,
|
|
909
|
-
): Promise<
|
|
914
|
+
): Promise<boolean> {
|
|
910
915
|
const label = d.project.stateLabels.inProgress;
|
|
911
916
|
try {
|
|
912
917
|
await d.tracker.removeLabel(issue, label);
|
|
913
918
|
log(`#${issue} released ${label}: ${why}`);
|
|
919
|
+
return true;
|
|
914
920
|
} catch (err) {
|
|
915
|
-
log(`#${issue} could not release ${label} (${errText(err)}) — ${why};
|
|
921
|
+
log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
|
|
922
|
+
return false;
|
|
916
923
|
}
|
|
917
924
|
}
|
|
918
925
|
|
|
@@ -946,6 +953,15 @@ export async function releaseInProgress(
|
|
|
946
953
|
* the same Tracker port the dispatcher claimed the issue with — orphan detection
|
|
947
954
|
* is only trustworthy while every state label on the tracker came from this
|
|
948
955
|
* package.
|
|
956
|
+
*
|
|
957
|
+
* The two writes are ordered label-then-row, and the order is load-bearing. This
|
|
958
|
+
* sweep is the only thing that revisits a `pushed-*` row, so the terminal state
|
|
959
|
+
* is also the row's exit from it: written first, a tracker that then failed on
|
|
960
|
+
* the label would leave `agent:in-progress` with nothing left to retry it — #18
|
|
961
|
+
* exactly, in the last window able to reach it. Writing the label first makes
|
|
962
|
+
* failure cost a repeated `gh` call on the next tick instead, and the row stays
|
|
963
|
+
* in the busy set throughout, so no second worker can be sent at the issue while
|
|
964
|
+
* it waits.
|
|
949
965
|
*/
|
|
950
966
|
export async function settlePushedGreen(
|
|
951
967
|
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
@@ -978,14 +994,19 @@ export async function settlePushedGreen(
|
|
|
978
994
|
|
|
979
995
|
const settlement = settlementFor(pr, run.prUrl);
|
|
980
996
|
if (settlement !== undefined) {
|
|
997
|
+
// Label first, row second, and the order is the whole safety argument.
|
|
998
|
+
// The sweep only ever revisits `pushed-*` rows, so writing the terminal
|
|
999
|
+
// state first would put this row beyond every later tick — and a tracker
|
|
1000
|
+
// that failed on the label in that instant would strand
|
|
1001
|
+
// `agent:in-progress` permanently, which is #18 again in the one window
|
|
1002
|
+
// still able to reach it. Leaving the row `pushed-*` costs a stale active
|
|
1003
|
+
// row until the tracker answers, and the busy set keeps the issue
|
|
1004
|
+
// occupied meanwhile, so nothing can be dispatched onto it in between.
|
|
1005
|
+
if (!(await releaseInProgress(d, run.issue, settlement.reason))) continue;
|
|
981
1006
|
const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
|
|
982
1007
|
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
983
1008
|
store.updateRun(run.id, patch);
|
|
984
1009
|
log(`#${run.issue} settled: ${settlement.reason}`);
|
|
985
|
-
// Store and tracker in the same breath, for the reason above: the row is
|
|
986
|
-
// terminal, so the label's interlock is spent. `releaseInProgress` never
|
|
987
|
-
// throws, so a tracker hiccup costs this label and not the later rows.
|
|
988
|
-
await releaseInProgress(d, run.issue, settlement.reason);
|
|
989
1010
|
continue;
|
|
990
1011
|
}
|
|
991
1012
|
|
|
@@ -1002,13 +1023,13 @@ export async function settlePushedGreen(
|
|
|
1002
1023
|
store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
|
|
1003
1024
|
log(`#${run.issue} checks settled: ${verification.reason}`);
|
|
1004
1025
|
} else if (verification.status === "failed") {
|
|
1026
|
+
// Equally terminal, so the same label-first order for the same reason:
|
|
1027
|
+
// this row is about to leave the sweep's reach. The green branch above
|
|
1028
|
+
// releases nothing — that row is still awaiting a merge, and its live PR
|
|
1029
|
+
// is exactly the work the label must keep guarding.
|
|
1030
|
+
if (!(await releaseInProgress(d, run.issue, verification.reason))) continue;
|
|
1005
1031
|
store.updateRun(run.id, { state: "failed", lastError: verification.reason });
|
|
1006
1032
|
log(`#${run.issue} checks failed: ${verification.reason}`);
|
|
1007
|
-
// Equally terminal: a red check on a pushed row ends the attempt, so the
|
|
1008
|
-
// same release applies. The green branch above deliberately does not —
|
|
1009
|
-
// that row is still awaiting a merge, and its live PR is exactly the work
|
|
1010
|
-
// the label must keep guarding.
|
|
1011
|
-
await releaseInProgress(d, run.issue, verification.reason);
|
|
1012
1033
|
} else {
|
|
1013
1034
|
store.updateRun(run.id, { lastError: verification.reason });
|
|
1014
1035
|
}
|
package/src/fleet.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { createInterface } from "node:readline";
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
31
|
+
import { readApprovalSurface } from "./approval-surface.ts";
|
|
31
32
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
32
33
|
import { formatDispatchSummary, isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
33
34
|
import {
|
|
@@ -1082,62 +1083,11 @@ function readPairedChannel(path: string): Channel {
|
|
|
1082
1083
|
}
|
|
1083
1084
|
|
|
1084
1085
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
* for a turn whose prompt resolves a notify target. A Telegram-originated turn
|
|
1090
|
-
* resolves one from its own `<telegram-message>` wrapper; a locally injected
|
|
1091
|
-
* orchestrator tick has no wrapper, so it resolves one only through
|
|
1092
|
-
* `notifyTarget()` — which needs `notifyMode` set to "away" or "always" *and* a
|
|
1093
|
-
* destination, this session's forum topic under `topicsChat` or the flat
|
|
1094
|
-
* `notifyChat`. With neither, the tool is simply absent from the tick.
|
|
1095
|
-
*
|
|
1096
|
-
* That is exactly what happened on 2026-08-09 06:17Z: the fleet's access.json
|
|
1097
|
-
* had no `notifyMode`, so the locally injected tick could not ask the
|
|
1098
|
-
* Learning-loop yes/no question the package floor requires — while this very
|
|
1099
|
-
* status line reported `telegram ok (@tbcoder_bot; inbound configured)`
|
|
1100
|
-
* throughout (#114). A health row that stays green through a broken contract is
|
|
1101
|
-
* worse than no row, so the approval surface is now part of it.
|
|
1102
|
-
*
|
|
1103
|
-
* The legacy `away: true` boolean counts: `loadAccess()` migrates it to
|
|
1104
|
-
* `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
|
|
1105
|
-
* and must not be reported as broken.
|
|
1086
|
+
* The approval half of the Telegram surface is a different question from
|
|
1087
|
+
* whether inbound works, and it is answered by {@link readApprovalSurface} —
|
|
1088
|
+
* shared with the orchestrator tick so this row and the tick's own warning can
|
|
1089
|
+
* never disagree about whether an amendment can be asked.
|
|
1106
1090
|
*/
|
|
1107
|
-
type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
|
|
1108
|
-
|
|
1109
|
-
function readApprovalSurface(path: string): ApprovalSurface {
|
|
1110
|
-
let parsed: unknown;
|
|
1111
|
-
try {
|
|
1112
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1113
|
-
} catch {
|
|
1114
|
-
return { kind: "missing", reason: `telegram_ask unavailable on local ticks: cannot read ${path}` };
|
|
1115
|
-
}
|
|
1116
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1117
|
-
return { kind: "missing", reason: `telegram_ask unavailable on local ticks: ${path} is not an object` };
|
|
1118
|
-
}
|
|
1119
|
-
const access = parsed as { readonly [key: string]: unknown };
|
|
1120
|
-
const mode = access["notifyMode"];
|
|
1121
|
-
const active = mode === "away" || mode === "always" || access["away"] === true;
|
|
1122
|
-
if (!active) {
|
|
1123
|
-
return {
|
|
1124
|
-
kind: "missing",
|
|
1125
|
-
reason:
|
|
1126
|
-
`telegram_ask unavailable on local ticks: no notifyMode in ${path} — ` +
|
|
1127
|
-
`set it to "always" and give it a destination (notifyChat, or topicsChat for a forum)`,
|
|
1128
|
-
};
|
|
1129
|
-
}
|
|
1130
|
-
const destination = access["notifyChat"] ?? access["topicsChat"];
|
|
1131
|
-
if (typeof destination !== "string" || destination.length === 0) {
|
|
1132
|
-
return {
|
|
1133
|
-
kind: "missing",
|
|
1134
|
-
reason:
|
|
1135
|
-
`telegram_ask unavailable on local ticks: notifyMode is set but ${path} names no destination — ` +
|
|
1136
|
-
"set notifyChat to the paired owner id (or topicsChat for a forum)",
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
return { kind: "ready" };
|
|
1140
|
-
}
|
|
1141
1091
|
|
|
1142
1092
|
function readBotToken(): string | undefined {
|
|
1143
1093
|
const env = process.env["TELEGRAM_BOT_TOKEN"];
|
|
@@ -1205,6 +1155,8 @@ export async function probeTelegramHealth(
|
|
|
1205
1155
|
// Inbound first: a bridge that is down says nothing about the approval
|
|
1206
1156
|
// surface, and stacking two remedies on one row buries the one to act on.
|
|
1207
1157
|
if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
|
|
1158
|
+
// The token is already proven: `getMe` succeeded above. What is left is
|
|
1159
|
+
// whether an answer could come back, which is the access file's business.
|
|
1208
1160
|
const approval = readApprovalSurface(accessPath);
|
|
1209
1161
|
if (approval.kind === "missing") {
|
|
1210
1162
|
return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -46,8 +46,9 @@
|
|
|
46
46
|
|
|
47
47
|
import { spawnSync } from "node:child_process";
|
|
48
48
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
49
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
50
50
|
import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
|
|
51
|
+
import { hasBotToken, readApprovalSurface, TELEGRAM_APPROVAL_TOOL } from "./approval-surface.ts";
|
|
51
52
|
import {
|
|
52
53
|
briefPathForProject,
|
|
53
54
|
policyPathForProject,
|
|
@@ -340,15 +341,9 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
340
341
|
export const TICK_DELIVERY_RULE =
|
|
341
342
|
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
|
|
342
343
|
|
|
343
|
-
/**
|
|
344
|
-
*
|
|
345
|
-
|
|
346
|
-
*
|
|
347
|
-
* Held as a constant because two things must agree on it: the prose that tells
|
|
348
|
-
* the turn to call it, and the preflight that checks whether it is callable at
|
|
349
|
-
* all.
|
|
350
|
-
*/
|
|
351
|
-
export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
|
|
344
|
+
/** Re-exported so the tick's own contract stays readable from one file: the
|
|
345
|
+
* constant itself lives beside the check that decides whether it is callable. */
|
|
346
|
+
export { TELEGRAM_APPROVAL_TOOL };
|
|
352
347
|
|
|
353
348
|
/**
|
|
354
349
|
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
@@ -917,21 +912,44 @@ export function tickDecision(input: {
|
|
|
917
912
|
}
|
|
918
913
|
|
|
919
914
|
/**
|
|
920
|
-
* Whether the Telegram bridge can still reach a person: enabled,
|
|
921
|
-
* one paired owner.
|
|
915
|
+
* Whether the Telegram bridge can still reach a person: a bot token, enabled,
|
|
916
|
+
* with exactly one paired owner.
|
|
922
917
|
*
|
|
923
918
|
* Fail-closed, and every failure mode collapses to the same answer on purpose —
|
|
924
919
|
* missing file, truncated write, hand-edit that dropped `enabled`, a second
|
|
925
|
-
* chat id pasted in,
|
|
926
|
-
* tempt a future reader into treating one of them
|
|
927
|
-
* are: each one means a tier-2 escalation lands
|
|
920
|
+
* chat id pasted in, the pairing revoked, or no token for the bot to send with.
|
|
921
|
+
* Distinguishing them would only tempt a future reader into treating one of them
|
|
922
|
+
* as benign, and none of them are: each one means a tier-2 escalation lands
|
|
923
|
+
* nowhere.
|
|
924
|
+
*
|
|
925
|
+
* The token belongs in this gate rather than further down. Without one nothing
|
|
926
|
+
* outbound works, so a tick would carry a delivery rule ordering a
|
|
927
|
+
* `telegram_send` that cannot happen and an approval fallback naming the same
|
|
928
|
+
* unusable tool — an unattended fleet dispatching with no way to page anybody,
|
|
929
|
+
* which is the one thing the arm handshake exists to prevent. A missing token
|
|
930
|
+
* fails the whole channel; the narrower approval preflight is reserved for a
|
|
931
|
+
* bridge that can send but cannot ask.
|
|
928
932
|
*
|
|
929
|
-
* Re-read on every tick
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
+
* Re-read on every tick, and specifically *not* snapshotted at session start,
|
|
934
|
+
* because of what this gate is actually about. Tier 2 is paged by conductor
|
|
935
|
+
* itself: `escalate.ts` reads the same `.env` and calls `api.telegram.org`
|
|
936
|
+
* directly, never through omp-telegram's bridge. So the question "can an
|
|
937
|
+
* escalation still reach a person" is answered by the file as it stands now, and
|
|
938
|
+
* an operator who fixes a missing token has fixed paging immediately — a startup
|
|
939
|
+
* snapshot would keep a working fleet silent until somebody restarted the
|
|
940
|
+
* session, which is a worse failure than the one it would prevent. Re-reading
|
|
941
|
+
* also catches the reverse: a channel that goes away mid-session stops the
|
|
942
|
+
* heartbeat on the next tick rather than days later.
|
|
943
|
+
*
|
|
944
|
+
* What the file cannot answer is whether *omp-telegram* holds a token. It binds
|
|
945
|
+
* one in `startBot()` at session start and rebinds only on `/telegram token` and
|
|
946
|
+
* `/telegram on`, so a token added out-of-band leaves the bridge's own tools —
|
|
947
|
+
* `telegram_ask`, `telegram_send` — dead until one of those runs. That is real,
|
|
948
|
+
* and it is deliberately not modelled here: this gate protects paging, the
|
|
949
|
+
* README says to reload the bridge, and `omp-conductor status` shows the row.
|
|
933
950
|
*/
|
|
934
951
|
function channelIsUp(path: string): boolean {
|
|
952
|
+
if (!hasBotToken(dirname(path))) return false;
|
|
935
953
|
let parsed: unknown;
|
|
936
954
|
try {
|
|
937
955
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -1115,13 +1133,32 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1115
1133
|
}
|
|
1116
1134
|
}
|
|
1117
1135
|
|
|
1118
|
-
// The floor's approval primitive,
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
|
|
1136
|
+
// The floor's approval primitive, decided from configuration rather than from
|
|
1137
|
+
// the live mounted set — and that is a correction, not a shortcut. The first
|
|
1138
|
+
// attempt at this read `pi.getActiveTools()` here, which is wrong at exactly
|
|
1139
|
+
// this point in the lifecycle: omp-telegram mounts `telegram_ask` in
|
|
1140
|
+
// `before_agent_start` and takes it away again in `agent_end`
|
|
1141
|
+
// (`restorePromptTools`), so it exists only *during* a turn. This runs between
|
|
1142
|
+
// turns, composing the prompt that is about to start one, so the live set
|
|
1143
|
+
// never contains the tool — a correctly configured fleet would have been told
|
|
1144
|
+
// the approval was unavailable on every single tick, and the error line meant
|
|
1145
|
+
// to flag a real fault would have fired 144 times a day saying nothing.
|
|
1146
|
+
//
|
|
1147
|
+
// What decides the mounting is knowable in advance and is shared with the
|
|
1148
|
+
// status row, so the two cannot disagree: a locally injected tick has no
|
|
1149
|
+
// `<telegram-message>` wrapper, so omp-telegram can only resolve a target
|
|
1150
|
+
// through `notifyTarget()` — `notifyMode` plus a destination, both in the
|
|
1151
|
+
// access file. Appended last, after the friction digest: the digest is what
|
|
1152
|
+
// provokes an amendment, so the sentence saying the amendment cannot be
|
|
1153
|
+
// approved here is the one that should read last.
|
|
1154
|
+
//
|
|
1155
|
+
// No access file configured means no fleet channel to judge, so nothing is
|
|
1156
|
+
// claimed: the channel gate above already treats that as "not the fleet". A
|
|
1157
|
+
// missing bot token is likewise not this check's business — it fails the
|
|
1158
|
+
// channel gate outright, so a tick that reaches here can already send, and the
|
|
1159
|
+
// only open question is whether an answer can come back.
|
|
1160
|
+
const approval = config.accessFile === undefined ? undefined : readApprovalSurface(config.accessFile);
|
|
1161
|
+
if (approval?.kind === "missing") {
|
|
1125
1162
|
content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
|
|
1126
1163
|
if (!session.approvalToolMissingLogged) {
|
|
1127
1164
|
session.approvalToolMissingLogged = true;
|
|
@@ -1129,20 +1166,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1129
1166
|
// inbound configured)` throughout the incident, so nothing else told the
|
|
1130
1167
|
// operator the approval contract was unsatisfiable. A line buried at
|
|
1131
1168
|
// info, beside one "tick sent" per interval, would not be found.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
// one only through `notifyTarget()` — `notifyMode` "away" or "always",
|
|
1137
|
-
// plus a destination.
|
|
1138
|
-
pi.logger.error(
|
|
1139
|
-
`[omp-conductor] ${TELEGRAM_APPROVAL_TOOL} is not mounted on this tick surface: omp-telegram mounts it ` +
|
|
1140
|
-
"only for a turn that resolves a notify target, and a locally injected tick resolves one only when " +
|
|
1141
|
-
`notifyMode is "away" or "always" with a destination (notifyChat, or topicsChat for a forum) in ` +
|
|
1142
|
-
`${config.accessFile ?? "the omp-telegram access.json"} — until then ticks instruct the orchestrator ` +
|
|
1143
|
-
"to deliver amendment questions with telegram_send and never to assume an answer",
|
|
1144
|
-
{ tool: TELEGRAM_APPROVAL_TOOL, ...(config.accessFile === undefined ? {} : { accessFile: config.accessFile }) },
|
|
1145
|
-
);
|
|
1169
|
+
pi.logger.error(`[omp-conductor] ${approval.reason}`, {
|
|
1170
|
+
tool: TELEGRAM_APPROVAL_TOOL,
|
|
1171
|
+
accessFile: config.accessFile,
|
|
1172
|
+
});
|
|
1146
1173
|
}
|
|
1147
1174
|
}
|
|
1148
1175
|
|