omp-conductor 0.3.21 → 0.3.23
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 +67 -27
- package/package.json +1 -1
- package/src/board.ts +292 -55
- package/src/briefs/orchestrator.md +24 -2
- package/src/daemon.ts +101 -15
- package/src/fleet.ts +65 -1
- package/src/orchestrator-tick.ts +93 -5
- 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,42 @@ 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 that is the point of it being a function rather than a
|
|
897
|
+
* bare `removeLabel`. Callers are sweeps: the store transition this accompanies
|
|
898
|
+
* has already been written, so a tracker that fails here must cost one label,
|
|
899
|
+
* not the remaining rows. There is no retry — the row is terminal, so no later
|
|
900
|
+
* tick revisits it — which makes the log line the entire record of the miss and
|
|
901
|
+
* why it names both the label and the reason. The operator's recovery for that
|
|
902
|
+
* rare case is `omp-conductor unblock`, which clears the label itself once the
|
|
903
|
+
* newest run is terminal.
|
|
904
|
+
*/
|
|
905
|
+
export async function releaseInProgress(
|
|
906
|
+
d: Pick<Deps, "project" | "tracker">,
|
|
907
|
+
issue: number,
|
|
908
|
+
why: string,
|
|
909
|
+
): Promise<void> {
|
|
910
|
+
const label = d.project.stateLabels.inProgress;
|
|
911
|
+
try {
|
|
912
|
+
await d.tracker.removeLabel(issue, label);
|
|
913
|
+
log(`#${issue} released ${label}: ${why}`);
|
|
914
|
+
} catch (err) {
|
|
915
|
+
log(`#${issue} could not release ${label} (${errText(err)}) — ${why}; clear it with \`unblock\``);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
882
919
|
/**
|
|
883
920
|
* Asks the tracker about every `pushed-green` PR and settles the ones that
|
|
884
921
|
* resolved.
|
|
@@ -889,11 +926,26 @@ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlemen
|
|
|
889
926
|
* and not the mapping: that a row without a PR costs no API call, and that one
|
|
890
927
|
* unreachable PR does not stop the others from settling.
|
|
891
928
|
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
* issue's labels should say next is the orchestrator's drain duty
|
|
896
|
-
*
|
|
929
|
+
* The label is released too, which reverses what this function first promised.
|
|
930
|
+
* It used to leave tracker labels alone exactly as {@link reconcileOrphanedRuns}
|
|
931
|
+
* does, reasoning that a merge closes the issue anyway and that deciding what an
|
|
932
|
+
* issue's labels should say next is the orchestrator's drain duty. There turned
|
|
933
|
+
* out to be no such path: on 2026-08-09 two settled rows left their issues
|
|
934
|
+
* carrying `agent:in-progress` forever, with the brief forbidding the
|
|
935
|
+
* orchestrator from touching it and `unblock` declining to (see
|
|
936
|
+
* {@link releaseInProgress}). The row transition and the label are one fact, and
|
|
937
|
+
* writing half of it is the whole of that bug.
|
|
938
|
+
*
|
|
939
|
+
* Releasing it is safe here specifically because of what these rows are. A
|
|
940
|
+
* `pushed-green` or `pushed-pending` row has no process behind it — its worker
|
|
941
|
+
* exited and its worktree is gone — so a terminal answer about its PR proves no
|
|
942
|
+
* worker owns the issue, and the duplicate-dispatch interlock the label exists
|
|
943
|
+
* for is spent. {@link reconcileOrphanedRuns} still leaves labels alone for the
|
|
944
|
+
* opposite reason: an orphaned `running` row is work nobody has read yet. And
|
|
945
|
+
* the brief's rule stays absolute, because this is a daemon-owned write through
|
|
946
|
+
* the same Tracker port the dispatcher claimed the issue with — orphan detection
|
|
947
|
+
* is only trustworthy while every state label on the tracker came from this
|
|
948
|
+
* package.
|
|
897
949
|
*/
|
|
898
950
|
export async function settlePushedGreen(
|
|
899
951
|
d: Pick<Deps, "project" | "tracker" | "store">,
|
|
@@ -930,6 +982,10 @@ export async function settlePushedGreen(
|
|
|
930
982
|
if (settlement.state === "failed") patch.lastError = settlement.reason;
|
|
931
983
|
store.updateRun(run.id, patch);
|
|
932
984
|
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);
|
|
933
989
|
continue;
|
|
934
990
|
}
|
|
935
991
|
|
|
@@ -948,6 +1004,11 @@ export async function settlePushedGreen(
|
|
|
948
1004
|
} else if (verification.status === "failed") {
|
|
949
1005
|
store.updateRun(run.id, { state: "failed", lastError: verification.reason });
|
|
950
1006
|
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);
|
|
951
1012
|
} else {
|
|
952
1013
|
store.updateRun(run.id, { lastError: verification.reason });
|
|
953
1014
|
}
|
|
@@ -1224,7 +1285,7 @@ export async function admitCandidates(
|
|
|
1224
1285
|
// tracker is the only party that remembers, so it is asked. The cost is
|
|
1225
1286
|
// bounded by free slots, not by queue depth: the call sits behind the two
|
|
1226
1287
|
// cheap local filters and candidates beyond capacity skip it.
|
|
1227
|
-
let closer:
|
|
1288
|
+
let closer: OpenCloser | undefined;
|
|
1228
1289
|
try {
|
|
1229
1290
|
closer = await tracker.openCloserFor(issue);
|
|
1230
1291
|
} catch (err) {
|
|
@@ -1241,18 +1302,43 @@ export async function admitCandidates(
|
|
|
1241
1302
|
}
|
|
1242
1303
|
if (closer !== undefined) {
|
|
1243
1304
|
const latest = store.latestRun(project.name, issue);
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1305
|
+
// Terminality is the first half of the test and is not negotiable: while a
|
|
1306
|
+
// run is live its worker is still pushing to that branch, and a second
|
|
1307
|
+
// worker sent at the same PR is exactly the duplicate-work failure this
|
|
1308
|
+
// guard exists to kill. Only a run that has stopped can be continued.
|
|
1309
|
+
const retained =
|
|
1310
|
+
latest?.state === "blocked" ||
|
|
1311
|
+
latest?.state === "failed" ||
|
|
1312
|
+
latest?.state === "killed" ||
|
|
1313
|
+
latest?.state === "orphaned"
|
|
1314
|
+
? latest
|
|
1315
|
+
: undefined;
|
|
1316
|
+
// The second half asks "is this open PR our retained work", and accepts
|
|
1317
|
+
// two identities for it, because the branch is the durable artefact of a
|
|
1318
|
+
// retained run and the PR is not. A cap kill can end a run before any PR
|
|
1319
|
+
// exists: veltro#324 attempt 1 was killed at the turns cap on
|
|
1320
|
+
// 2026-08-09T00:47Z before its worker opened one, so the row kept `branch`
|
|
1321
|
+
// and `prUrl` stayed NULL. chad#438 was opened from that exact branch
|
|
1322
|
+
// afterwards, and URL equality — the only test 0.3.20 had — can never match
|
|
1323
|
+
// a URL the terminal run never recorded, so every tick held #324 as
|
|
1324
|
+
// `open-pr` until an operator closed recoverable work to free the branch
|
|
1325
|
+
// (#50). An ordinary issue whose open PR is unrelated still fails both
|
|
1326
|
+
// identities and stays ineligible, and an empty `headRefName` (a reply that
|
|
1327
|
+
// did not carry the field) is never a match: unknown is not identity.
|
|
1328
|
+
let resume: string | undefined;
|
|
1329
|
+
if (retained !== undefined) {
|
|
1330
|
+
if (retained.prUrl === closer.url) {
|
|
1331
|
+
resume = `from ${retained.state} run (matched recorded PR URL)`;
|
|
1332
|
+
} else if (closer.headRefName !== "" && retained.branch === closer.headRefName) {
|
|
1333
|
+
resume = `from ${retained.state} run (matched retained branch ${closer.headRefName})`;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (resume === undefined) {
|
|
1251
1337
|
hold(issue, "open-pr");
|
|
1252
|
-
log(`#${issue} skipped: open PR ${closer} already closes it`);
|
|
1338
|
+
log(`#${issue} skipped: open PR ${closer.url} already closes it`);
|
|
1253
1339
|
continue;
|
|
1254
1340
|
}
|
|
1255
|
-
log(`#${issue} continuing retained PR ${closer}
|
|
1341
|
+
log(`#${issue} continuing retained PR ${closer.url} ${resume}`);
|
|
1256
1342
|
}
|
|
1257
1343
|
|
|
1258
1344
|
admitted.push({ r, attempt: priorRuns + 1 });
|
package/src/fleet.ts
CHANGED
|
@@ -1081,6 +1081,64 @@ function readPairedChannel(path: string): Channel {
|
|
|
1081
1081
|
return { kind: "up", owner: String(owner) };
|
|
1082
1082
|
}
|
|
1083
1083
|
|
|
1084
|
+
/**
|
|
1085
|
+
* Whether the *approval* half of the Telegram surface works, which is a
|
|
1086
|
+
* different question from whether inbound works and was never asked before.
|
|
1087
|
+
*
|
|
1088
|
+
* `telegram_ask` is mounted by omp-telegram's `before_agent_start` handler only
|
|
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.
|
|
1106
|
+
*/
|
|
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
|
+
|
|
1084
1142
|
function readBotToken(): string | undefined {
|
|
1085
1143
|
const env = process.env["TELEGRAM_BOT_TOKEN"];
|
|
1086
1144
|
if (env !== undefined && env.length > 0) return env;
|
|
@@ -1144,8 +1202,14 @@ export async function probeTelegramHealth(
|
|
|
1144
1202
|
? (result["result"] as Record<string, unknown>)
|
|
1145
1203
|
: undefined;
|
|
1146
1204
|
const username = typeof user?.["username"] === "string" ? `@${user["username"]}` : "authenticated";
|
|
1205
|
+
// Inbound first: a bridge that is down says nothing about the approval
|
|
1206
|
+
// surface, and stacking two remedies on one row buries the one to act on.
|
|
1147
1207
|
if (channel.kind === "down") return { kind: "degraded", detail: `${username}; inbound ${channel.reason}` };
|
|
1148
|
-
|
|
1208
|
+
const approval = readApprovalSurface(accessPath);
|
|
1209
|
+
if (approval.kind === "missing") {
|
|
1210
|
+
return { kind: "degraded", detail: `${username}; inbound configured; ${approval.reason}` };
|
|
1211
|
+
}
|
|
1212
|
+
return { kind: "ok", detail: `${username}; inbound configured; telegram_ask available` };
|
|
1149
1213
|
}
|
|
1150
1214
|
|
|
1151
1215
|
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
|
|
@@ -336,6 +340,46 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
336
340
|
export const TICK_DELIVERY_RULE =
|
|
337
341
|
"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
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The tool the package floor names for the Learning-loop yes/no amendment
|
|
345
|
+
* approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`).
|
|
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";
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
355
|
+
* on a surface where {@link TELEGRAM_APPROVAL_TOOL} is not mounted.
|
|
356
|
+
*
|
|
357
|
+
* The floor mandates a call the ordinary tick cannot make. On 2026-08-09 06:17Z
|
|
358
|
+
* a locally injected veltrosecurity tick reached the amendment step and found
|
|
359
|
+
* no `telegram_ask` at all (`read xd://telegram_ask` answered `No such tool`);
|
|
360
|
+
* the very next turn, which began as an inbound Telegram message at 06:33Z,
|
|
361
|
+
* found the same tool mounted. Availability tracks turn origin, and an
|
|
362
|
+
* amendment proposal normally arises on the locally injected half — issue #114.
|
|
363
|
+
*
|
|
364
|
+
* Fails closed in the only sense that helps a fleet: the duties still run, but
|
|
365
|
+
* the turn is told the approval primitive is missing *before* it can reach the
|
|
366
|
+
* step that needs one. The fallback named here is the one the floor already
|
|
367
|
+
* documents for a `telegram_ask` that never delivered — re-deliver with
|
|
368
|
+
* `telegram_send` — so a session has one answer to "the ask did not happen",
|
|
369
|
+
* not two. The last clause is the actual hazard #114 exposed: a turn that knows
|
|
370
|
+
* it must ask, and cannot, is one inference away from recording an approval
|
|
371
|
+
* nobody gave.
|
|
372
|
+
*
|
|
373
|
+
* Unlike {@link TICK_DELIVERY_RULE} this is appended to a configured `message`
|
|
374
|
+
* too. An operator's prompt owns the reporting contract and is theirs to get
|
|
375
|
+
* wrong; it cannot consent, on the orchestrator's behalf, to a tool being
|
|
376
|
+
* absent from the surface the turn actually runs on.
|
|
377
|
+
*/
|
|
378
|
+
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
379
|
+
`The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
|
|
380
|
+
`If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
|
|
381
|
+
`never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
382
|
+
|
|
339
383
|
function frictionLabel(kind: FrictionSignal["kind"]): string {
|
|
340
384
|
if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
|
|
341
385
|
if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
|
|
@@ -983,6 +1027,15 @@ interface TickSession {
|
|
|
983
1027
|
* file every interval, for as long as the session lives.
|
|
984
1028
|
*/
|
|
985
1029
|
scopeFallbackLogged: boolean;
|
|
1030
|
+
/**
|
|
1031
|
+
* Whether the missing {@link TELEGRAM_APPROVAL_TOOL} has been logged. Latched
|
|
1032
|
+
* for {@link TickSession.scopeFallbackLogged}'s reason and then some: on the
|
|
1033
|
+
* surface #114 reports, the tool is absent on *every* locally injected tick,
|
|
1034
|
+
* so an unguarded line would be one error per interval — 144 a day at the
|
|
1035
|
+
* ten-minute heartbeat this fleet runs — which is how a real fault becomes
|
|
1036
|
+
* background noise.
|
|
1037
|
+
*/
|
|
1038
|
+
approvalToolMissingLogged: boolean;
|
|
986
1039
|
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
987
1040
|
pendingSkips: number;
|
|
988
1041
|
}
|
|
@@ -1062,6 +1115,37 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1062
1115
|
}
|
|
1063
1116
|
}
|
|
1064
1117
|
|
|
1118
|
+
// The floor's approval primitive, checked against the live mounted set at the
|
|
1119
|
+
// moment this tick is composed — not once at session start, because the
|
|
1120
|
+
// mounted set is exactly what differed between two consecutive turns of the
|
|
1121
|
+
// same session on 2026-08-09 (#114). Appended last, after the friction
|
|
1122
|
+
// digest: the digest is what provokes an amendment, so the sentence that says
|
|
1123
|
+
// the amendment cannot be approved here is the one that should read last.
|
|
1124
|
+
if (!pi.getActiveTools().includes(TELEGRAM_APPROVAL_TOOL)) {
|
|
1125
|
+
content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
|
|
1126
|
+
if (!session.approvalToolMissingLogged) {
|
|
1127
|
+
session.approvalToolMissingLogged = true;
|
|
1128
|
+
// Error level, and once: `status` reported `telegram ok (@tbcoder_bot;
|
|
1129
|
+
// inbound configured)` throughout the incident, so nothing else told the
|
|
1130
|
+
// operator the approval contract was unsatisfiable. A line buried at
|
|
1131
|
+
// info, beside one "tick sent" per interval, would not be found.
|
|
1132
|
+
//
|
|
1133
|
+
// It names the cause, not just the symptom, because the remedy is a file
|
|
1134
|
+
// the operator owns: omp-telegram mounts the tool only for a turn whose
|
|
1135
|
+
// prompt resolves a notify target, and a locally injected tick resolves
|
|
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
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1065
1149
|
try {
|
|
1066
1150
|
pi.sendMessage(
|
|
1067
1151
|
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
@@ -1136,10 +1220,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1136
1220
|
// is the line that tells an operator which session is driving the fleet.
|
|
1137
1221
|
let decided = false;
|
|
1138
1222
|
// 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 = {
|
|
1223
|
+
// scope, because ..." line and the missing-approval-tool line are each logged
|
|
1224
|
+
// once for this heartbeat, and the stall counter is about this session's own
|
|
1225
|
+
// queue. A second session in the same process starts with all three at zero.
|
|
1226
|
+
const session: TickSession = {
|
|
1227
|
+
scopeFallbackLogged: false,
|
|
1228
|
+
approvalToolMissingLogged: false,
|
|
1229
|
+
pendingSkips: 0,
|
|
1230
|
+
};
|
|
1143
1231
|
let releaseGateArmed = false;
|
|
1144
1232
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1145
1233
|
// 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.
|
package/src/unblock.ts
CHANGED
|
@@ -43,19 +43,38 @@ export interface UnblockOutcome {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Drop
|
|
46
|
+
* Drop the state labels the issue could be carrying, as far as the store can
|
|
47
|
+
* prove they are droppable.
|
|
47
48
|
*
|
|
48
|
-
*
|
|
49
|
-
* which one is set and this process cannot read that
|
|
50
|
-
* port — inferring it from the newest run row would be
|
|
51
|
-
* exactly when a human has relabelled something by
|
|
52
|
-
* issue does not carry is a no-op: `gh issue edit
|
|
53
|
-
* absent label (verified against gh 2.97.0), and
|
|
54
|
-
* older paths return for one.
|
|
49
|
+
* The two terminal labels come off unconditionally, because the tracker is the
|
|
50
|
+
* only source of truth for which one is set and this process cannot read that
|
|
51
|
+
* back through the Tracker port — inferring it from the newest run row would be
|
|
52
|
+
* a guess that goes wrong exactly when a human has relabelled something by
|
|
53
|
+
* hand. Removing a label an issue does not carry is a no-op: `gh issue edit
|
|
54
|
+
* --remove-label` exits 0 on an absent label (verified against gh 2.97.0), and
|
|
55
|
+
* the adapter swallows the 404 older paths return for one.
|
|
55
56
|
*
|
|
56
|
-
* `agent:in-progress`
|
|
57
|
-
*
|
|
58
|
-
* from under a live run is how two workers end up on
|
|
57
|
+
* `agent:in-progress` comes off only when the newest run row is terminal, and
|
|
58
|
+
* that condition is the whole safety argument. The label means a worker process
|
|
59
|
+
* exists, and clearing it from under a live run is how two workers end up on
|
|
60
|
+
* one issue, so it used to be excluded outright. What that cost showed up on
|
|
61
|
+
* 2026-08-09: veltro#331's newest row had already settled to `failed` (its PR
|
|
62
|
+
* #332 was closed unmerged), `unblock 331` reported `cleared agent:blocked,
|
|
63
|
+
* agent:failed` and `next tick eligible again`, and an immediate authoritative
|
|
64
|
+
* `gh issue view 331` still showed `agent:in-progress` — the one label that
|
|
65
|
+
* actually decides eligibility, with no verb anywhere able to remove it (#18).
|
|
66
|
+
* A terminal newest row is proof the process is gone rather than an opinion
|
|
67
|
+
* about it: it is written when the worker exits, by `reconcileOrphanedRuns` at
|
|
68
|
+
* startup for a worker that died with its daemon, or by settlement once the PR
|
|
69
|
+
* resolved. Once it is there, the interlock has nothing left to guard.
|
|
70
|
+
*
|
|
71
|
+
* No run row at all is deliberately *not* that proof, so that case still leaves
|
|
72
|
+
* the label alone. A missing row means the store never saw the run, which is
|
|
73
|
+
* indistinguishable from here to a claim that raced a store write — and being
|
|
74
|
+
* wrong in that direction is the two-workers bug, where being wrong in the
|
|
75
|
+
* other direction is a stuck issue an operator is already looking at. On the
|
|
76
|
+
* paths the daemon does own it releases the label itself, next to the store
|
|
77
|
+
* transition that proves the run ended (`releaseInProgress` in `daemon.ts`).
|
|
59
78
|
*/
|
|
60
79
|
export async function unblockIssue(
|
|
61
80
|
project: ProjectConfig,
|
|
@@ -63,13 +82,21 @@ export async function unblockIssue(
|
|
|
63
82
|
store: Store,
|
|
64
83
|
issue: number,
|
|
65
84
|
): Promise<UnblockOutcome> {
|
|
85
|
+
// Read before any label is touched: terminality is the whole of the argument
|
|
86
|
+
// for clearing in-progress, so the row that carries it decides the set.
|
|
87
|
+
const latest = store.latestRun(project.name, issue);
|
|
88
|
+
const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
|
|
89
|
+
|
|
66
90
|
const cleared: string[] = [];
|
|
67
|
-
for (const label of new Set([
|
|
91
|
+
for (const label of new Set([
|
|
92
|
+
project.stateLabels.blocked,
|
|
93
|
+
project.stateLabels.failed,
|
|
94
|
+
...(terminal ? [project.stateLabels.inProgress] : []),
|
|
95
|
+
])) {
|
|
68
96
|
await tracker.removeLabel(issue, label);
|
|
69
97
|
cleared.push(label);
|
|
70
98
|
}
|
|
71
99
|
|
|
72
|
-
const latest = store.latestRun(project.name, issue);
|
|
73
100
|
return {
|
|
74
101
|
cleared,
|
|
75
102
|
attemptsUsed: store.attemptsFor(project.name, issue),
|
|
@@ -81,9 +108,14 @@ export async function unblockIssue(
|
|
|
81
108
|
|
|
82
109
|
/**
|
|
83
110
|
* What the operator reads back. It promises a re-claim only when one can
|
|
84
|
-
* actually happen
|
|
85
|
-
*
|
|
86
|
-
*
|
|
111
|
+
* actually happen, because a promise made blindly sends someone away believing
|
|
112
|
+
* work had resumed and they find out by waiting for it. Three things withhold
|
|
113
|
+
* it: a live run still owns the issue through `agent:in-progress`, a spent
|
|
114
|
+
* attempt budget makes the next tick escalate rather than dispatch, and — since
|
|
115
|
+
* the in-progress label is only released for a terminal run — an issue with no
|
|
116
|
+
* run row at all, where that label may still be sitting there unread. #18 was
|
|
117
|
+
* filed against this function saying `next tick eligible again` in a case where
|
|
118
|
+
* it was not, so the wording is a contract rather than prose.
|
|
87
119
|
*/
|
|
88
120
|
export function formatUnblock(
|
|
89
121
|
issue: number,
|
|
@@ -95,7 +127,7 @@ export function formatUnblock(
|
|
|
95
127
|
const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
|
|
96
128
|
|
|
97
129
|
if (latest === undefined) {
|
|
98
|
-
lines.push(" runs none recorded — the labels were cleared anyway; eligibility is read off the tracker");
|
|
130
|
+
lines.push(" runs none recorded — the terminal labels were cleared anyway; eligibility is read off the tracker");
|
|
99
131
|
} else {
|
|
100
132
|
lines.push(` runs ${o.attemptsUsed}, newest ${latest.state}`);
|
|
101
133
|
lines.push(` failures ${o.failuresUsed} of ${caps.maxAttemptsPerIssue}`);
|
|
@@ -117,6 +149,12 @@ export function formatUnblock(
|
|
|
117
149
|
` next tick not eligible: the ${caps.maxContinuationsPerIssue}-continuation budget was exceeded. ` +
|
|
118
150
|
"Inspect progress or raise maxContinuationsPerIssue.",
|
|
119
151
|
);
|
|
152
|
+
} else if (latest === undefined) {
|
|
153
|
+
lines.push(
|
|
154
|
+
` next tick eligible once the issue carries "${project.queueLabel}" and no state label — with no ` +
|
|
155
|
+
`run row to prove the worker is gone, "${project.stateLabels.inProgress}" was left in place, and ` +
|
|
156
|
+
"on its own it keeps the issue ineligible",
|
|
157
|
+
);
|
|
120
158
|
} else {
|
|
121
159
|
lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
|
|
122
160
|
}
|