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/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
- * Tracker labels are deliberately not touched, exactly as
893
- * {@link reconcileOrphanedRuns} does not touch them. A merge normally closes the
894
- * issue, and a human who closed a PR is already looking at it; deciding what an
895
- * issue's labels should say next is the orchestrator's drain duty, which reads
896
- * these very rows through `omp-conductor status`.
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: string | undefined;
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
- const retainedContinuation =
1245
- latest?.prUrl === closer &&
1246
- (latest.state === "blocked" ||
1247
- latest.state === "failed" ||
1248
- latest.state === "killed" ||
1249
- latest.state === "orphaned");
1250
- if (!retainedContinuation) {
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} from ${latest.state} run`);
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
- return { kind: "ok", detail: `${username}; inbound configured` };
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 {
@@ -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, with exactly
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, or the pairing revoked. Distinguishing them would only
882
- * tempt a future reader into treating one of them as benign, and none of them
883
- * are: each one means a tier-2 escalation lands nowhere.
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 rather than cached at session start, because the bridge
886
- * is reconfigured by a long-lived operator out-of-band and a heartbeat that
887
- * trusted a startup snapshot would keep dispatching for days after the channel
888
- * went away.
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 is logged once for this heartbeat, and the stall
1140
- // counter is about this session's own queue. A second session in the same
1141
- // process starts with both at zero.
1142
- const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
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
@@ -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?: ({ state: string; isDraft: boolean; url: string } | null)[] | null;
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 URL of the first OPEN closer in a `gh api graphql` reply, if any.
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): string | undefined {
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
- return nodes.find((n) => n !== null && n.state === "OPEN")?.url;
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<string | undefined> {
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 URL of an OPEN pull request that already closes `issue`, or undefined
275
- * when none does.
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<string | undefined>;
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.