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/src/daemon.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
Caps,
|
|
29
29
|
DispatchSummary,
|
|
30
30
|
Escalation,
|
|
31
|
+
OpenCloser,
|
|
31
32
|
PrState,
|
|
32
33
|
ProjectConfig,
|
|
33
34
|
ReadyIssue,
|
|
@@ -879,6 +880,49 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
879
880
|
return undefined;
|
|
880
881
|
}
|
|
881
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Drops the in-progress label from an issue whose run is provably over.
|
|
885
|
+
*
|
|
886
|
+
* Settlement used to write only half of what it knew. On 2026-08-09 that cost
|
|
887
|
+
* the reference fleet two issues in one night: veltro#331 settled to `failed`
|
|
888
|
+
* at 23:53Z once the orchestrator closed veltro#332 unmerged, and veltro#344
|
|
889
|
+
* settled to `merged` at 05:54Z once chad#452 squash-merged. Both rows left the
|
|
890
|
+
* active set correctly; an authoritative `gh issue view` on each afterwards
|
|
891
|
+
* still showed `agent:in-progress`. `routing.isEligible` rejects any issue
|
|
892
|
+
* carrying a state label, the composed brief forbids the orchestrator from
|
|
893
|
+
* hand-editing one, and `unblock` refused to clear that particular label — so
|
|
894
|
+
* both issues were permanently unclaimable with no supported way back (#18).
|
|
895
|
+
*
|
|
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.
|
|
909
|
+
*/
|
|
910
|
+
export async function releaseInProgress(
|
|
911
|
+
d: Pick<Deps, "project" | "tracker">,
|
|
912
|
+
issue: number,
|
|
913
|
+
why: string,
|
|
914
|
+
): Promise<boolean> {
|
|
915
|
+
const label = d.project.stateLabels.inProgress;
|
|
916
|
+
try {
|
|
917
|
+
await d.tracker.removeLabel(issue, label);
|
|
918
|
+
log(`#${issue} released ${label}: ${why}`);
|
|
919
|
+
return true;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; retrying next tick`);
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
882
926
|
/**
|
|
883
927
|
* Asks the tracker about every `pushed-green` PR and settles the ones that
|
|
884
928
|
* resolved.
|
|
@@ -889,11 +933,35 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
889
933
|
* and not the mapping: that a row without a PR costs no API call, and that one
|
|
890
934
|
* unreachable PR does not stop the others from settling.
|
|
891
935
|
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
* issue's labels should say next is the orchestrator's drain duty
|
|
896
|
-
*
|
|
936
|
+
* The label is released too, which reverses what this function first promised.
|
|
937
|
+
* It used to leave tracker labels alone exactly as {@link reconcileOrphanedRuns}
|
|
938
|
+
* does, reasoning that a merge closes the issue anyway and that deciding what an
|
|
939
|
+
* issue's labels should say next is the orchestrator's drain duty. There turned
|
|
940
|
+
* out to be no such path: on 2026-08-09 two settled rows left their issues
|
|
941
|
+
* carrying `agent:in-progress` forever, with the brief forbidding the
|
|
942
|
+
* orchestrator from touching it and `unblock` declining to (see
|
|
943
|
+
* {@link releaseInProgress}). The row transition and the label are one fact, and
|
|
944
|
+
* writing half of it is the whole of that bug.
|
|
945
|
+
*
|
|
946
|
+
* Releasing it is safe here specifically because of what these rows are. A
|
|
947
|
+
* `pushed-green` or `pushed-pending` row has no process behind it — its worker
|
|
948
|
+
* exited and its worktree is gone — so a terminal answer about its PR proves no
|
|
949
|
+
* worker owns the issue, and the duplicate-dispatch interlock the label exists
|
|
950
|
+
* for is spent. {@link reconcileOrphanedRuns} still leaves labels alone for the
|
|
951
|
+
* opposite reason: an orphaned `running` row is work nobody has read yet. And
|
|
952
|
+
* the brief's rule stays absolute, because this is a daemon-owned write through
|
|
953
|
+
* the same Tracker port the dispatcher claimed the issue with — orphan detection
|
|
954
|
+
* is only trustworthy while every state label on the tracker came from this
|
|
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.
|
|
897
965
|
*/
|
|
898
966
|
export async function settlePushedGreen(
|
|
899
967
|
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
@@ -926,6 +994,15 @@ export async function settlePushedGreen(
|
|
|
926
994
|
|
|
927
995
|
const settlement = settlementFor(pr, run.prUrl);
|
|
928
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;
|
|
929
1006
|
const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
|
|
930
1007
|
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
931
1008
|
store.updateRun(run.id, patch);
|
|
@@ -946,6 +1023,11 @@ export async function settlePushedGreen(
|
|
|
946
1023
|
store.updateRun(run.id, { state: "pushed-green", lastError: undefined });
|
|
947
1024
|
log(`#${run.issue} checks settled: ${verification.reason}`);
|
|
948
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;
|
|
949
1031
|
store.updateRun(run.id, { state: "failed", lastError: verification.reason });
|
|
950
1032
|
log(`#${run.issue} checks failed: ${verification.reason}`);
|
|
951
1033
|
} else {
|
|
@@ -1224,7 +1306,7 @@ export async function admitCandidates(
|
|
|
1224
1306
|
// tracker is the only party that remembers, so it is asked. The cost is
|
|
1225
1307
|
// bounded by free slots, not by queue depth: the call sits behind the two
|
|
1226
1308
|
// cheap local filters and candidates beyond capacity skip it.
|
|
1227
|
-
let closer:
|
|
1309
|
+
let closer: OpenCloser | undefined;
|
|
1228
1310
|
try {
|
|
1229
1311
|
closer = await tracker.openCloserFor(issue);
|
|
1230
1312
|
} catch (err) {
|
|
@@ -1241,18 +1323,43 @@ export async function admitCandidates(
|
|
|
1241
1323
|
}
|
|
1242
1324
|
if (closer !== undefined) {
|
|
1243
1325
|
const latest = store.latestRun(project.name, issue);
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1326
|
+
// Terminality is the first half of the test and is not negotiable: while a
|
|
1327
|
+
// run is live its worker is still pushing to that branch, and a second
|
|
1328
|
+
// worker sent at the same PR is exactly the duplicate-work failure this
|
|
1329
|
+
// guard exists to kill. Only a run that has stopped can be continued.
|
|
1330
|
+
const retained =
|
|
1331
|
+
latest?.state === "blocked" ||
|
|
1332
|
+
latest?.state === "failed" ||
|
|
1333
|
+
latest?.state === "killed" ||
|
|
1334
|
+
latest?.state === "orphaned"
|
|
1335
|
+
? latest
|
|
1336
|
+
: undefined;
|
|
1337
|
+
// The second half asks "is this open PR our retained work", and accepts
|
|
1338
|
+
// two identities for it, because the branch is the durable artefact of a
|
|
1339
|
+
// retained run and the PR is not. A cap kill can end a run before any PR
|
|
1340
|
+
// exists: veltro#324 attempt 1 was killed at the turns cap on
|
|
1341
|
+
// 2026-08-09T00:47Z before its worker opened one, so the row kept `branch`
|
|
1342
|
+
// and `prUrl` stayed NULL. chad#438 was opened from that exact branch
|
|
1343
|
+
// afterwards, and URL equality — the only test 0.3.20 had — can never match
|
|
1344
|
+
// a URL the terminal run never recorded, so every tick held #324 as
|
|
1345
|
+
// `open-pr` until an operator closed recoverable work to free the branch
|
|
1346
|
+
// (#50). An ordinary issue whose open PR is unrelated still fails both
|
|
1347
|
+
// identities and stays ineligible, and an empty `headRefName` (a reply that
|
|
1348
|
+
// did not carry the field) is never a match: unknown is not identity.
|
|
1349
|
+
let resume: string | undefined;
|
|
1350
|
+
if (retained !== undefined) {
|
|
1351
|
+
if (retained.prUrl === closer.url) {
|
|
1352
|
+
resume = `from ${retained.state} run (matched recorded PR URL)`;
|
|
1353
|
+
} else if (closer.headRefName !== "" && retained.branch === closer.headRefName) {
|
|
1354
|
+
resume = `from ${retained.state} run (matched retained branch ${closer.headRefName})`;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (resume === undefined) {
|
|
1251
1358
|
hold(issue, "open-pr");
|
|
1252
|
-
log(`#${issue} skipped: open PR ${closer} already closes it`);
|
|
1359
|
+
log(`#${issue} skipped: open PR ${closer.url} already closes it`);
|
|
1253
1360
|
continue;
|
|
1254
1361
|
}
|
|
1255
|
-
log(`#${issue} continuing retained PR ${closer}
|
|
1362
|
+
log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
|
|
1256
1363
|
}
|
|
1257
1364
|
|
|
1258
1365
|
admitted.push({ r, attempt: priorRuns + 1 });
|
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 {
|
|
@@ -1081,6 +1082,13 @@ function readPairedChannel(path: string): Channel {
|
|
|
1081
1082
|
return { kind: "up", owner: String(owner) };
|
|
1082
1083
|
}
|
|
1083
1084
|
|
|
1085
|
+
/**
|
|
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.
|
|
1090
|
+
*/
|
|
1091
|
+
|
|
1084
1092
|
function readBotToken(): string | undefined {
|
|
1085
1093
|
const env = process.env["TELEGRAM_BOT_TOKEN"];
|
|
1086
1094
|
if (env !== undefined && env.length > 0) return env;
|
|
@@ -1144,8 +1152,16 @@ export async function probeTelegramHealth(
|
|
|
1144
1152
|
? (result["result"] as Record<string, unknown>)
|
|
1145
1153
|
: undefined;
|
|
1146
1154
|
const username = typeof user?.["username"] === "string" ? `@${user["username"]}` : "authenticated";
|
|
1155
|
+
// Inbound first: a bridge that is down says nothing about the approval
|
|
1156
|
+
// surface, and stacking two remedies on one row buries the one to act on.
|
|
1147
1157
|
if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
|
|
1148
|
-
|
|
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.
|
|
1160
|
+
const approval = readApprovalSurface(accessPath);
|
|
1161
|
+
if (approval.kind === "missing") {
|
|
1162
|
+
return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
|
|
1163
|
+
}
|
|
1164
|
+
return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
|
|
1149
1165
|
}
|
|
1150
1166
|
|
|
1151
1167
|
export function sessionDirForCwd(cwd: string): string {
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
* waiting for a session restart — and one delivery rule
|
|
30
30
|
* ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
|
|
31
31
|
* written as end-of-turn text on such a turn reaches nobody. An operator's own
|
|
32
|
-
* `message` replaces both, and is re-read per tick for the same reason.
|
|
32
|
+
* `message` replaces both, and is re-read per tick for the same reason. One
|
|
33
|
+
* clause is not the operator's to replace: a tick composed on a surface that
|
|
34
|
+
* has no `telegram_ask` says so ({@link TICK_APPROVAL_UNAVAILABLE_RULE}),
|
|
35
|
+
* custom prompt included, because the floor's amendment approval names a tool
|
|
36
|
+
* that surface cannot call.
|
|
33
37
|
*
|
|
34
38
|
* The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
|
|
35
39
|
* it inside `omp-conductor` costs an ordinary session nothing. That file is a
|
|
@@ -42,8 +46,9 @@
|
|
|
42
46
|
|
|
43
47
|
import { spawnSync } from "node:child_process";
|
|
44
48
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
49
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
46
50
|
import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
|
|
51
|
+
import { hasBotToken, readApprovalSurface, TELEGRAM_APPROVAL_TOOL } from "./approval-surface.ts";
|
|
47
52
|
import {
|
|
48
53
|
briefPathForProject,
|
|
49
54
|
policyPathForProject,
|
|
@@ -336,6 +341,40 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
336
341
|
export const TICK_DELIVERY_RULE =
|
|
337
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.";
|
|
338
343
|
|
|
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 };
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
350
|
+
* on a surface where {@link TELEGRAM_APPROVAL_TOOL} is not mounted.
|
|
351
|
+
*
|
|
352
|
+
* The floor mandates a call the ordinary tick cannot make. On 2026-08-09 06:17Z
|
|
353
|
+
* a locally injected veltrosecurity tick reached the amendment step and found
|
|
354
|
+
* no `telegram_ask` at all (`read xd://telegram_ask` answered `No such tool`);
|
|
355
|
+
* the very next turn, which began as an inbound Telegram message at 06:33Z,
|
|
356
|
+
* found the same tool mounted. Availability tracks turn origin, and an
|
|
357
|
+
* amendment proposal normally arises on the locally injected half — issue #114.
|
|
358
|
+
*
|
|
359
|
+
* Fails closed in the only sense that helps a fleet: the duties still run, but
|
|
360
|
+
* the turn is told the approval primitive is missing *before* it can reach the
|
|
361
|
+
* step that needs one. The fallback named here is the one the floor already
|
|
362
|
+
* documents for a `telegram_ask` that never delivered — re-deliver with
|
|
363
|
+
* `telegram_send` — so a session has one answer to "the ask did not happen",
|
|
364
|
+
* not two. The last clause is the actual hazard #114 exposed: a turn that knows
|
|
365
|
+
* it must ask, and cannot, is one inference away from recording an approval
|
|
366
|
+
* nobody gave.
|
|
367
|
+
*
|
|
368
|
+
* Unlike {@link TICK_DELIVERY_RULE} this is appended to a configured `message`
|
|
369
|
+
* too. An operator's prompt owns the reporting contract and is theirs to get
|
|
370
|
+
* wrong; it cannot consent, on the orchestrator's behalf, to a tool being
|
|
371
|
+
* absent from the surface the turn actually runs on.
|
|
372
|
+
*/
|
|
373
|
+
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
374
|
+
`The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
|
|
375
|
+
`If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
|
|
376
|
+
`never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
377
|
+
|
|
339
378
|
function frictionLabel(kind: FrictionSignal["kind"]): string {
|
|
340
379
|
if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
|
|
341
380
|
if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
|
|
@@ -873,21 +912,44 @@ export function tickDecision(input: {
|
|
|
873
912
|
}
|
|
874
913
|
|
|
875
914
|
/**
|
|
876
|
-
* Whether the Telegram bridge can still reach a person: enabled,
|
|
877
|
-
* one paired owner.
|
|
915
|
+
* Whether the Telegram bridge can still reach a person: a bot token, enabled,
|
|
916
|
+
* with exactly one paired owner.
|
|
878
917
|
*
|
|
879
918
|
* Fail-closed, and every failure mode collapses to the same answer on purpose —
|
|
880
919
|
* missing file, truncated write, hand-edit that dropped `enabled`, a second
|
|
881
|
-
* chat id pasted in,
|
|
882
|
-
* tempt a future reader into treating one of them
|
|
883
|
-
* 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.
|
|
884
932
|
*
|
|
885
|
-
* Re-read on every tick
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
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.
|
|
889
950
|
*/
|
|
890
951
|
function channelIsUp(path: string): boolean {
|
|
952
|
+
if (!hasBotToken(dirname(path))) return false;
|
|
891
953
|
let parsed: unknown;
|
|
892
954
|
try {
|
|
893
955
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -983,6 +1045,15 @@ interface TickSession {
|
|
|
983
1045
|
* file every interval, for as long as the session lives.
|
|
984
1046
|
*/
|
|
985
1047
|
scopeFallbackLogged: boolean;
|
|
1048
|
+
/**
|
|
1049
|
+
* Whether the missing {@link TELEGRAM_APPROVAL_TOOL} has been logged. Latched
|
|
1050
|
+
* for {@link TickSession.scopeFallbackLogged}'s reason and then some: on the
|
|
1051
|
+
* surface #114 reports, the tool is absent on *every* locally injected tick,
|
|
1052
|
+
* so an unguarded line would be one error per interval — 144 a day at the
|
|
1053
|
+
* ten-minute heartbeat this fleet runs — which is how a real fault becomes
|
|
1054
|
+
* background noise.
|
|
1055
|
+
*/
|
|
1056
|
+
approvalToolMissingLogged: boolean;
|
|
986
1057
|
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
987
1058
|
pendingSkips: number;
|
|
988
1059
|
}
|
|
@@ -1062,6 +1133,46 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1062
1133
|
}
|
|
1063
1134
|
}
|
|
1064
1135
|
|
|
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") {
|
|
1162
|
+
content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
|
|
1163
|
+
if (!session.approvalToolMissingLogged) {
|
|
1164
|
+
session.approvalToolMissingLogged = true;
|
|
1165
|
+
// Error level, and once: `status` reported `telegram ok (@tbcoder_bot;
|
|
1166
|
+
// inbound configured)` throughout the incident, so nothing else told the
|
|
1167
|
+
// operator the approval contract was unsatisfiable. A line buried at
|
|
1168
|
+
// info, beside one "tick sent" per interval, would not be found.
|
|
1169
|
+
pi.logger.error(`[omp-conductor] ${approval.reason}`, {
|
|
1170
|
+
tool: TELEGRAM_APPROVAL_TOOL,
|
|
1171
|
+
accessFile: config.accessFile,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1065
1176
|
try {
|
|
1066
1177
|
pi.sendMessage(
|
|
1067
1178
|
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
@@ -1136,10 +1247,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1136
1247
|
// is the line that tells an operator which session is driving the fleet.
|
|
1137
1248
|
let decided = false;
|
|
1138
1249
|
// Held per registration for the same reason: the "using the default reporting
|
|
1139
|
-
// scope, because ..." line
|
|
1140
|
-
// counter is about this session's own
|
|
1141
|
-
// process starts with
|
|
1142
|
-
const session: TickSession = {
|
|
1250
|
+
// scope, because ..." line and the missing-approval-tool line are each logged
|
|
1251
|
+
// once for this heartbeat, and the stall counter is about this session's own
|
|
1252
|
+
// queue. A second session in the same process starts with all three at zero.
|
|
1253
|
+
const session: TickSession = {
|
|
1254
|
+
scopeFallbackLogged: false,
|
|
1255
|
+
approvalToolMissingLogged: false,
|
|
1256
|
+
pendingSkips: 0,
|
|
1257
|
+
};
|
|
1143
1258
|
let releaseGateArmed = false;
|
|
1144
1259
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1145
1260
|
// which pane owns it. The gate therefore starts closed and only honours an
|
package/src/tracker/github.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type {
|
|
17
17
|
IssueState,
|
|
18
|
+
OpenCloser,
|
|
18
19
|
PrState,
|
|
19
20
|
PrVerification,
|
|
20
21
|
ProjectConfig,
|
|
@@ -54,7 +55,7 @@ const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
|
|
|
54
55
|
repository(owner:$owner,name:$repo){
|
|
55
56
|
issue(number:$n){
|
|
56
57
|
closedByPullRequestsReferences(first:10){
|
|
57
|
-
nodes{ number state isDraft url repository{ nameWithOwner } }
|
|
58
|
+
nodes{ number state isDraft url headRefName repository{ nameWithOwner } }
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -73,7 +74,9 @@ interface ClosersResponse {
|
|
|
73
74
|
repository?: {
|
|
74
75
|
issue?: {
|
|
75
76
|
closedByPullRequestsReferences?: {
|
|
76
|
-
nodes?:
|
|
77
|
+
nodes?:
|
|
78
|
+
| ({ state: string; isDraft: boolean; url: string; headRefName?: string } | null)[]
|
|
79
|
+
| null;
|
|
77
80
|
} | null;
|
|
78
81
|
} | null;
|
|
79
82
|
} | null;
|
|
@@ -166,7 +169,7 @@ function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
/**
|
|
169
|
-
* The
|
|
172
|
+
* The first OPEN closer in a `gh api graphql` reply, if any.
|
|
170
173
|
*
|
|
171
174
|
* Split from the call so the state filter — the only real logic in this file —
|
|
172
175
|
* is pinned against recorded payloads instead of a live repo.
|
|
@@ -176,12 +179,22 @@ function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
|
176
179
|
* and the branch behind a draft still holds the only copy of the work. Sending
|
|
177
180
|
* a second worker at it duplicates that work exactly as much as a ready PR
|
|
178
181
|
* would, so OPEN is the whole test.
|
|
182
|
+
*
|
|
183
|
+
* `headRefName` is selected because admission needs to recognise a PR opened on
|
|
184
|
+
* a run's retained branch *after* that run ended — veltro#324 on 2026-08-09,
|
|
185
|
+
* killed at the turns cap before its worker opened a PR, so the row kept the
|
|
186
|
+
* branch and `prUrl` stayed NULL (#50). A node without it is not a crash and
|
|
187
|
+
* not a hold: an empty string simply never equals a stored branch, so such a
|
|
188
|
+
* reply degrades to the URL-equality identity that shipped before.
|
|
179
189
|
*/
|
|
180
|
-
export function firstOpenCloser(raw: string):
|
|
190
|
+
export function firstOpenCloser(raw: string): OpenCloser | undefined {
|
|
181
191
|
const nodes =
|
|
182
192
|
(JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences
|
|
183
193
|
?.nodes ?? [];
|
|
184
|
-
|
|
194
|
+
const open = nodes.find((n) => n !== null && n.state === "OPEN");
|
|
195
|
+
return open === undefined || open === null
|
|
196
|
+
? undefined
|
|
197
|
+
: { url: open.url, headRefName: open.headRefName ?? "" };
|
|
185
198
|
}
|
|
186
199
|
|
|
187
200
|
/**
|
|
@@ -433,7 +446,7 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
433
446
|
);
|
|
434
447
|
},
|
|
435
448
|
|
|
436
|
-
async openCloserFor(issue: number): Promise<
|
|
449
|
+
async openCloserFor(issue: number): Promise<OpenCloser | undefined> {
|
|
437
450
|
// GraphQL wants the halves of `owner/repo` separately. Config validates
|
|
438
451
|
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
439
452
|
// errors and the caller holds the candidate rather than guessing.
|
package/src/types.ts
CHANGED
|
@@ -247,6 +247,24 @@ export interface PrVerification {
|
|
|
247
247
|
/** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
|
|
248
248
|
export type IssueState = "open" | "closed";
|
|
249
249
|
|
|
250
|
+
/**
|
|
251
|
+
* An OPEN pull request that already closes an issue, as admission sees it.
|
|
252
|
+
*
|
|
253
|
+
* The head branch travels with the URL because admission has two different
|
|
254
|
+
* questions to answer about the same PR, and only one of them the URL can
|
|
255
|
+
* answer. "Is there finished work here" is a URL question. "Is this *our*
|
|
256
|
+
* retained work, resumed" is a branch question, because a run can retain a
|
|
257
|
+
* branch and never produce a PR: on 2026-08-09T00:47Z veltro#324 attempt 1 was
|
|
258
|
+
* killed at the turns cap before its worker opened one, and the PR that later
|
|
259
|
+
* appeared on that exact branch (chad#438) could never be matched by URL
|
|
260
|
+
* equality against a `prUrl` the terminal run never recorded (#50).
|
|
261
|
+
*/
|
|
262
|
+
export interface OpenCloser {
|
|
263
|
+
url: string;
|
|
264
|
+
/** Head branch of the open PR, for retained-continuation identity. */
|
|
265
|
+
headRefName: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
250
268
|
/**
|
|
251
269
|
* Deliberately narrow so a Gitea or local-file tracker can drop in later.
|
|
252
270
|
* Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
|
|
@@ -271,8 +289,8 @@ export interface Tracker {
|
|
|
271
289
|
*/
|
|
272
290
|
parentOf(issue: number): Promise<number | undefined>;
|
|
273
291
|
/**
|
|
274
|
-
* The
|
|
275
|
-
*
|
|
292
|
+
* The OPEN pull request that already closes `issue`, or undefined when none
|
|
293
|
+
* does.
|
|
276
294
|
*
|
|
277
295
|
* Admission has to ask the tracker because the store cannot answer. The busy
|
|
278
296
|
* set is built from run rows, so it only knows work *this* database recorded:
|
|
@@ -280,8 +298,12 @@ export interface Tracker {
|
|
|
280
298
|
* restore onto a new host, or simply a database younger than the PRs all
|
|
281
299
|
* present pushed-and-open work as an untouched queue item. The tracker is the
|
|
282
300
|
* only party that remembers across all of those.
|
|
301
|
+
*
|
|
302
|
+
* Returns {@link OpenCloser} rather than a bare URL because the branch is the
|
|
303
|
+
* half admission needs to recognise a continuation the store never saw a PR
|
|
304
|
+
* for; see that type for the veltro#324 case that forced the widening.
|
|
283
305
|
*/
|
|
284
|
-
openCloserFor(issue: number): Promise<
|
|
306
|
+
openCloserFor(issue: number): Promise<OpenCloser | undefined>;
|
|
285
307
|
/**
|
|
286
308
|
* Whether an issue is still open, or undefined when tracker/network state is
|
|
287
309
|
* ambiguous. Cleanup must never interpret undefined as permission to delete.
|