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/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
- * 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`.
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: string | undefined;
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
- 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) {
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} from ${latest.state} run`);
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
- return { kind: "ok", detail: `${username}; inbound configured` };
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 {
@@ -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 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 };
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
@@ -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.
package/src/unblock.ts CHANGED
@@ -43,19 +43,38 @@ export interface UnblockOutcome {
43
43
  }
44
44
 
45
45
  /**
46
- * Drop both terminal state labels, whichever the issue is actually carrying.
46
+ * Drop the state labels the issue could be carrying, as far as the store can
47
+ * prove they are droppable.
47
48
  *
48
- * Both unconditionally, because the tracker is the only source of truth for
49
- * which one is set and this process cannot read that back through the Tracker
50
- * port — inferring it from the newest run row would be a guess that goes wrong
51
- * exactly when a human has relabelled something by hand. Removing a label an
52
- * issue does not carry is a no-op: `gh issue edit --remove-label` exits 0 on an
53
- * absent label (verified against gh 2.97.0), and the adapter swallows the 404
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` is deliberately not in the set. It means a worker process
57
- * exists, which is not something an operator can answer away, and clearing it
58
- * from under a live run is how two workers end up on one issue.
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([project.stateLabels.blocked, project.stateLabels.failed])) {
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: a live run still owns the issue through `agent:in-progress`,
85
- * and a spent attempt budget makes the next tick escalate rather than dispatch.
86
- * Either promised blindly would send someone away believing work had resumed.
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
  }