omp-conductor 0.12.0 → 0.14.0

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.
@@ -14,8 +14,9 @@
14
14
  * enough to actually write.
15
15
  */
16
16
 
17
- import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor } from "../gitops.ts";
18
- import type { ProjectConfig, ReleaseShape } from "../types.ts";
17
+ import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
18
+ import { ensureMirror } from "../worktree.ts";
19
+ import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
19
20
  import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
20
21
 
21
22
  /**
@@ -47,6 +48,25 @@ export type CommandRunner = (
47
48
  opts: { cwd?: string; env?: Record<string, string> },
48
49
  ) => Promise<CommandRun>;
49
50
 
51
+ export type MirrorPreparer = (repo: RepoTarget, mirrorRoot: string) => Promise<string>;
52
+
53
+ type CommitOutcome = { ok: true; sha: string } | { ok: false; stderr: string };
54
+
55
+ const FULL_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
56
+ const RELEASE_TAG_CONFIG = [
57
+ "-c",
58
+ "user.name=conductor",
59
+ "-c",
60
+ "user.email=conductor@invalid",
61
+ "-c",
62
+ "tag.gpgSign=false",
63
+ ];
64
+
65
+ function commitFrom(stdout: string): string | undefined {
66
+ const value = stdout.trim().split(/\s+/, 1)[0];
67
+ return value !== undefined && FULL_COMMIT.test(value) ? value.toLowerCase() : undefined;
68
+ }
69
+
50
70
  const spawnCommand: CommandRunner = async (argv, opts) => {
51
71
  const proc = Bun.spawn(argv, {
52
72
  // Always a closed stream, matching the tracker adapter: a command that does
@@ -65,8 +85,11 @@ const spawnCommand: CommandRunner = async (argv, opts) => {
65
85
  return { ok: code === 0 && !proc.signalCode, stdout, stderr };
66
86
  };
67
87
 
68
- function failed(run: CommandRun, argv: string[]): ActionOutcome {
69
- return { ok: false, stderr: run.stderr.trim() || `\`${argv.join(" ")}\` failed with no stderr` };
88
+ function failed(run: CommandRun, argv: string[]): { ok: false; stderr: string } {
89
+ return {
90
+ ok: false,
91
+ stderr: scrubUserinfo(run.stderr.trim() || `\`${argv.join(" ")}\` failed with no stderr`),
92
+ };
70
93
  }
71
94
 
72
95
  /**
@@ -75,7 +98,11 @@ function failed(run: CommandRun, argv: string[]): ActionOutcome {
75
98
  * `run` is injected so the release and tracker paths can be exercised without
76
99
  * a live repository; production callers never pass it.
77
100
  */
78
- export function githubVerbActions(project: ProjectConfig, run: CommandRunner = spawnCommand): VerbActions {
101
+ export function githubVerbActions(
102
+ project: ProjectConfig,
103
+ run: CommandRunner = spawnCommand,
104
+ prepareMirror: MirrorPreparer = ensureMirror,
105
+ ): VerbActions {
79
106
  const env = (): Record<string, string> => credentialedEnv();
80
107
 
81
108
  const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
@@ -84,6 +111,69 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
84
111
  return result.ok ? { ok: true, detail: result.stdout.trim() || undefined } : failed(result, full);
85
112
  };
86
113
 
114
+ const git = async (mirror: string, args: string[]): Promise<{ argv: string[]; result: CommandRun }> => {
115
+ const argv = ["git", "-C", mirror, ...args];
116
+ return { argv, result: await run(argv, { env: env() }) };
117
+ };
118
+
119
+ const readCommit = async (mirror: string, args: string[], fact: string): Promise<CommitOutcome> => {
120
+ const { argv, result } = await git(mirror, args);
121
+ if (!result.ok) return failed(result, argv);
122
+ const sha = commitFrom(result.stdout);
123
+ return sha === undefined
124
+ ? { ok: false, stderr: `${fact} returned no full commit SHA` }
125
+ : { ok: true, sha };
126
+ };
127
+
128
+ const liveDefaultHead = (mirror: string, repo: RepoTarget): Promise<CommitOutcome> =>
129
+ readCommit(
130
+ mirror,
131
+ ["ls-remote", "origin", `refs/heads/${repo.defaultBranch}`],
132
+ `live ${repo.defaultBranch} lookup`,
133
+ );
134
+
135
+ const remoteTagCommit = async (
136
+ mirror: string,
137
+ tag: string,
138
+ ): Promise<{ ok: true; sha: string | undefined } | { ok: false; stderr: string }> => {
139
+ const directRef = `refs/tags/${tag}`;
140
+ const peeledRef = `${directRef}^{}`;
141
+ const { argv, result } = await git(mirror, ["ls-remote", "origin", directRef, peeledRef]);
142
+ if (!result.ok) return failed(result, argv);
143
+ if (result.stdout.trim() === "") return { ok: true, sha: undefined };
144
+
145
+ const refs = new Map(
146
+ result.stdout
147
+ .trim()
148
+ .split("\n")
149
+ .map((line) => line.trim().split(/\s+/, 2))
150
+ .filter((entry): entry is [string, string] => entry.length === 2)
151
+ .map(([sha, ref]) => [ref, sha] as const),
152
+ );
153
+ const sha = commitFrom(refs.get(peeledRef) ?? refs.get(directRef) ?? "");
154
+ return sha === undefined
155
+ ? { ok: false, stderr: `remote tag ${tag} lookup returned no full commit SHA` }
156
+ : { ok: true, sha };
157
+ };
158
+
159
+ const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
160
+ ok: false,
161
+ stderr:
162
+ `refusing release: target ${target} is behind live ${repo.defaultBranch} ${live}. ` +
163
+ "Refresh the release status and retry so the tag contains every merged change.",
164
+ });
165
+
166
+ const releaseMirror = async (repo: RepoTarget): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> => {
167
+ try {
168
+ return { ok: true, path: await prepareMirror(repo, project.mirrorRoot) };
169
+ } catch (err) {
170
+ return {
171
+ ok: false,
172
+ stderr: `could not refresh ${repo.name}'s release mirror: ${err instanceof Error ? err.message : String(err)}`,
173
+ };
174
+ }
175
+ };
176
+
87
177
  return {
88
178
  releasableShapes: GITHUB_RELEASABLE_SHAPES,
89
179
 
@@ -133,18 +223,158 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
133
223
  if (execution.shape === "github-release") {
134
224
  return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
135
225
  }
136
- // The two git shapes act on the project's mirror of the repo, which is
137
- // the only checkout the daemon owns. A tag is cut against the mirror's
138
- // view of the default branch and pushed from there.
139
- const mirror = `${project.mirrorRoot}/${execution.repo.name}.git`;
226
+ // The two git shapes act on the project's serialized, freshly fetched
227
+ // mirror. The live read on either side of tag creation closes the stale
228
+ // mirror and branch-moved-during-release windows (#258); the push path
229
+ // repeats the guard because a branch may move between the two verbs.
230
+ const prepared = await releaseMirror(execution.repo);
231
+ if (!prepared.ok) return prepared;
232
+ const mirror = prepared.path;
140
233
  if (execution.shape === "git-tag") {
141
- const argv = ["git", "-C", mirror, "tag", "-a", tag, "-m", `release ${tag}`, `origin/${execution.repo.defaultBranch}`];
142
- const result = await run(argv, { env: env() });
143
- return result.ok ? { ok: true, detail: `tagged ${tag}` } : failed(result, argv);
234
+ const target = await readCommit(
235
+ mirror,
236
+ ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
237
+ `refreshed ${execution.repo.defaultBranch}`,
238
+ );
239
+ if (!target.ok) return target;
240
+ const liveBefore = await liveDefaultHead(mirror, execution.repo);
241
+ if (!liveBefore.ok) return liveBefore;
242
+ if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
243
+
244
+ const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]);
245
+ let previousSha: string | undefined;
246
+ if (localTag.result.ok) {
247
+ previousSha = commitFrom(localTag.result.stdout);
248
+ if (previousSha === undefined) {
249
+ return { ok: false, stderr: `tag ${tag} returned no full commit SHA` };
250
+ }
251
+
252
+ const remoteTag = await remoteTagCommit(mirror, tag);
253
+ if (!remoteTag.ok) return remoteTag;
254
+ if (remoteTag.sha === target.sha) {
255
+ return {
256
+ ok: true,
257
+ sha: target.sha,
258
+ detail: `tag ${tag} already exists at ${target.sha} and is already on origin`,
259
+ };
260
+ }
261
+ if (remoteTag.sha !== undefined) {
262
+ return {
263
+ ok: false,
264
+ stderr:
265
+ `refusing release: ${tag} already exists on origin at ${remoteTag.sha}. ` +
266
+ "A published tag is never re-cut or moved; choose a new tag name.",
267
+ };
268
+ }
269
+ }
270
+
271
+ const repointed = previousSha !== undefined;
272
+ // An unattended daemon cannot depend on a host-level Git identity or a
273
+ // signing key with an interactive passphrase.
274
+ const tagged = await git(mirror, [
275
+ ...RELEASE_TAG_CONFIG,
276
+ "tag",
277
+ ...(repointed ? ["-f"] : []),
278
+ "-a",
279
+ tag,
280
+ "-m",
281
+ `release ${tag}`,
282
+ target.sha,
283
+ ]);
284
+ if (!tagged.result.ok) return failed(tagged.result, tagged.argv);
285
+
286
+ const liveAfter = await liveDefaultHead(mirror, execution.repo);
287
+ if (!liveAfter.ok || liveAfter.sha !== target.sha) {
288
+ const rollback = previousSha !== undefined
289
+ ? await git(mirror, [
290
+ ...RELEASE_TAG_CONFIG,
291
+ "tag",
292
+ "-f",
293
+ "-a",
294
+ tag,
295
+ "-m",
296
+ `release ${tag}`,
297
+ previousSha,
298
+ ])
299
+ : await git(mirror, ["tag", "-d", tag]);
300
+ const reason = liveAfter.ok
301
+ ? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr
302
+ : `${repointed ? "re-pointed" : "created"} ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
303
+ const rollbackDetail = repointed
304
+ ? rollback.result.ok
305
+ ? ` The tag was restored to ${previousSha}.`
306
+ : ` WARNING: the tag could not be restored to ${previousSha}: ${failed(rollback.result, rollback.argv).stderr}`
307
+ : rollback.result.ok
308
+ ? " The unpushed local tag was deleted."
309
+ : ` WARNING: the unpushed local tag could not be deleted: ${failed(rollback.result, rollback.argv).stderr}`;
310
+ return { ok: false, stderr: reason + rollbackDetail };
311
+ }
312
+ return {
313
+ ok: true,
314
+ sha: target.sha,
315
+ detail: repointed
316
+ ? `re-pointed unpushed ${tag} from ${previousSha} to ${target.sha}`
317
+ : `tagged ${tag} at ${target.sha}`,
318
+ };
144
319
  }
145
- const argv = ["git", "-C", mirror, "push", "origin", `refs/tags/${tag}`];
146
- const result = await run(argv, { env: env() });
147
- return result.ok ? { ok: true, detail: `pushed refs/tags/${tag}` } : failed(result, argv);
320
+
321
+ const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
322
+ if (!target.ok) return target;
323
+ let tagSha = target.sha;
324
+ const live = await liveDefaultHead(mirror, execution.repo);
325
+ if (!live.ok) return live;
326
+
327
+ const remoteTag = await remoteTagCommit(mirror, tag);
328
+ if (!remoteTag.ok) return remoteTag;
329
+ if (remoteTag.sha === tagSha) {
330
+ return { ok: true, sha: tagSha, detail: `refs/tags/${tag} already on origin at ${tagSha}` };
331
+ }
332
+ if (remoteTag.sha !== undefined) {
333
+ return {
334
+ ok: false,
335
+ stderr:
336
+ `refusing release: origin already has ${tag} at ${remoteTag.sha}, ` +
337
+ `which differs from the local tag at ${tagSha}. ` +
338
+ "A published tag is never force-moved; cut a new tag instead.",
339
+ };
340
+ }
341
+
342
+ let oldSha: string | undefined;
343
+ if (tagSha !== live.sha) {
344
+ const fresh = await readCommit(
345
+ mirror,
346
+ ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
347
+ `refreshed ${execution.repo.defaultBranch}`,
348
+ );
349
+ if (!fresh.ok) return fresh;
350
+ if (fresh.sha !== live.sha) return releaseTargetMoved(execution.repo, fresh.sha, live.sha);
351
+
352
+ oldSha = tagSha;
353
+ const retagged = await git(mirror, [
354
+ ...RELEASE_TAG_CONFIG,
355
+ "tag",
356
+ "-f",
357
+ "-a",
358
+ tag,
359
+ "-m",
360
+ `release ${tag}`,
361
+ live.sha,
362
+ ]);
363
+ if (!retagged.result.ok) return failed(retagged.result, retagged.argv);
364
+ tagSha = live.sha;
365
+ }
366
+
367
+ const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]);
368
+ return pushed.result.ok
369
+ ? {
370
+ ok: true,
371
+ sha: tagSha,
372
+ detail:
373
+ oldSha === undefined
374
+ ? `pushed refs/tags/${tag} at ${tagSha}`
375
+ : `re-pointed unpushed ${tag} from ${oldSha} to ${tagSha}; pushed refs/tags/${tag} at ${tagSha}`,
376
+ }
377
+ : failed(pushed.result, pushed.argv);
148
378
  },
149
379
  };
150
380
  }
@@ -293,9 +293,10 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
293
293
  mutating: false,
294
294
  allowedRoles: ["worker", "orchestrator"],
295
295
  description:
296
- "Read one pull request's live state: is it open, is its head still the sha you pushed, and are " +
297
- "its checks green. This is the daemon's own merge-gate verdict, not a summary of it. A poll, not " +
298
- "a watcher call it again after waiting. A worker may omit prUrl and gets its own run's.",
296
+ "Read one pull request's live state and current head: is it open and are its checks green. " +
297
+ "Optionally compare that head with one the caller already observed. This is the daemon's own " +
298
+ "merge-gate verdict, not a summary of it. A poll, not a watcher call it again after waiting. " +
299
+ "A worker may omit prUrl and gets its own run's.",
299
300
  args: {
300
301
  prUrl: {
301
302
  type: "string",
@@ -304,10 +305,10 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
304
305
  },
305
306
  headSha: {
306
307
  type: "string",
307
- required: true,
308
+ required: false,
308
309
  description:
309
- "The head you believe is on the branch the sha conductor_push returned. If the branch has " +
310
- "moved, the answer names both shas.",
310
+ "Optional: an expected head to compare against; omit it to read the current head. If the " +
311
+ "branch has moved, the answer names both shas.",
311
312
  },
312
313
  },
313
314
  roleRefusalText: (role) => `conductor_pr_status is not open to a ${role} session.`,
@@ -265,6 +265,10 @@ export interface ReleaseFacts {
265
265
  openPrs: number;
266
266
  /** Queue depth, or `undefined` when the tracker could not be read. */
267
267
  queueDepth: number | undefined;
268
+ /** Current live-head workflow verdict for the released routed repository. */
269
+ baseCheck?: RunRecord["baseCheck"];
270
+ /** Evidence attached to a current red verdict. */
271
+ redBase?: string;
268
272
  }
269
273
 
270
274
  export function releaseRequirementRefusal(
@@ -286,6 +290,19 @@ export function releaseRequirementRefusal(
286
290
  return `policy.release.requires includes queue-drained and ${facts.queueDepth} issue(s) are still queued`;
287
291
  }
288
292
  }
293
+ if (requirement === "base-branch-green") {
294
+ if (facts.baseCheck === "green") continue;
295
+ if (facts.baseCheck === "pending") {
296
+ return "policy.release.requires includes base-branch-green and the newest merge's base workflows are still pending";
297
+ }
298
+ if (facts.baseCheck === "red" || facts.baseCheck === "red-preexisting") {
299
+ return (
300
+ "policy.release.requires includes base-branch-green and the newest observed base branch is red" +
301
+ (facts.redBase === undefined ? "" : `: ${facts.redBase}`)
302
+ );
303
+ }
304
+ return "policy.release.requires includes base-branch-green and no green base-branch verdict is available; refusing rather than assuming the base is healthy";
305
+ }
289
306
  if (requirement === "epic-children-closed") {
290
307
  return "policy.release.requires includes epic-children-closed, which nothing in a release request names an epic for. The daemon cannot settle it, so it refuses: take the requirement off this project's policy, or cut this release by hand.";
291
308
  }
@@ -423,19 +440,32 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
423
440
  }
424
441
  }
425
442
 
426
- // 4. Stop always wins, and is re-read here rather than remembered. Reads are
427
- // exempt: a run finishing its report during a `hold` still has to be able
428
- // to say what it saw, and refusing that would only make it guess. A run
429
- // admitted *before* the pause may finish its in-flight mutations pause
430
- // stops new claims, not the work already running (#174) so only a call
431
- // whose run started at or after the pause (or an orchestrator's call, with
432
- // no run row at all) is refused.
433
- const stopped = spec.mutating ? deps.fleetStop() : undefined;
443
+ // 4. Stop always wins for work-starting mutations, and is re-read here
444
+ // rather than remembered. Reads are exempt. A run admitted before the
445
+ // pause may finish its own mutations (#174). Orchestrator completion
446
+ // verbs resolve their target run inside the handler, where they can prove
447
+ // that run also predates the pause (#251). Release remains available:
448
+ // its authority, grant, and runs-settled gates still fail closed below.
449
+ const orchestratorCompletion =
450
+ channel.kind === "orchestrator" &&
451
+ (verb === "conductor_pr_merge" ||
452
+ verb === "conductor_pr_update_branch" ||
453
+ verb === "conductor_pr_update" ||
454
+ verb === "conductor_label");
455
+ const stopped =
456
+ spec.mutating && verb !== "conductor_release" && !orchestratorCompletion
457
+ ? deps.fleetStop()
458
+ : undefined;
434
459
  if (stopped !== undefined) {
435
460
  const at = deps.pausedAt();
436
461
  const admittedBeforePause = run !== undefined && at !== undefined && run.startedAt < at;
437
462
  if (!admittedBeforePause) {
438
- return refuse("fleet-paused", `refused: ${stopped}. The pause stops new claims and new mutations; a run admitted before the pause may finish.`);
463
+ return refuse(
464
+ "fleet-paused",
465
+ `refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
466
+ "Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
467
+ "conductor_pr_update, conductor_label) and conductor_release remain available.",
468
+ );
439
469
  }
440
470
  }
441
471
 
@@ -599,7 +629,7 @@ async function prCreateVerb(
599
629
  * How far back to look is the recent-history cutoff `status` already uses: a
600
630
  * pull request older than that is not one an orchestrator is mid-flight on.
601
631
  */
602
- const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
632
+ export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
603
633
 
604
634
  function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
605
635
  return deps.store
@@ -607,6 +637,31 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
607
637
  .find((candidate) => candidate.prUrl === prUrl);
608
638
  }
609
639
 
640
+ /** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
641
+ function orchestratorPauseRefusal(
642
+ deps: VerbDeps,
643
+ channel: VerbChannel,
644
+ target: RunRecord | undefined,
645
+ refuse: Refuse,
646
+ ): Verdict | undefined {
647
+ if (channel.kind !== "orchestrator") return undefined;
648
+ const stopped = deps.fleetStop();
649
+ if (stopped === undefined) return undefined;
650
+ const at = deps.pausedAt();
651
+ if (target !== undefined && at !== undefined && target.startedAt < at) return undefined;
652
+ const why =
653
+ target === undefined
654
+ ? "this issue has no recorded run admitted before it."
655
+ : at === undefined
656
+ ? "the pause timestamp cannot prove when this run started."
657
+ : "this run started after it.";
658
+ return refuse(
659
+ "fleet-paused",
660
+ `refused: ${stopped}. Under pause, this verb applies only to runs admitted before the pause; ${why}`,
661
+ target?.issue,
662
+ );
663
+ }
664
+
610
665
  async function prUpdateBranchVerb(
611
666
  deps: VerbDeps,
612
667
  project: ProjectConfig,
@@ -638,6 +693,9 @@ async function prUpdateBranchVerb(
638
693
  }
639
694
  }
640
695
 
696
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
697
+ if (paused !== undefined) return paused;
698
+
641
699
  let state: PrState | undefined;
642
700
  try {
643
701
  state = await deps.tracker.prState(prUrl);
@@ -691,6 +749,9 @@ async function prMergeVerb(
691
749
  return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
692
750
  }
693
751
 
752
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
753
+ if (paused !== undefined) return paused;
754
+
694
755
  // Taken before any network call, so two concurrent callers contend here
695
756
  // rather than both spending a `gh` round trip and racing at the merge.
696
757
  const holderId = randomUUID();
@@ -814,6 +875,14 @@ async function labelVerb(
814
875
  );
815
876
  }
816
877
 
878
+ const paused = orchestratorPauseRefusal(
879
+ deps,
880
+ channel,
881
+ deps.store.latestRun(project.name, ref.issue),
882
+ refuse,
883
+ );
884
+ if (paused !== undefined) return paused;
885
+
817
886
  const label = String(args["label"]);
818
887
  const vocabulary = labelVocabulary(project);
819
888
  if (vocabulary.lifecycle.includes(label)) {
@@ -908,10 +977,18 @@ async function releaseVerb(
908
977
  queueDepth = undefined;
909
978
  }
910
979
  }
980
+ const wantsBase = policy.release.requires.includes("base-branch-green");
981
+ const health = wantsBase
982
+ ? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
983
+ : undefined;
911
984
  const unmet = releaseRequirementRefusal(policy.release.requires, {
912
985
  unsettledRuns: active.length,
913
986
  openPrs: active.filter((r) => r.prUrl !== undefined).length,
914
987
  queueDepth,
988
+ ...(health === undefined ? {} : { baseCheck: health.verdict }),
989
+ ...(health?.verdict === "red" && health.detail !== undefined
990
+ ? { redBase: health.detail }
991
+ : {}),
915
992
  });
916
993
  if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
917
994
 
@@ -991,10 +1068,11 @@ async function prStatusVerb(
991
1068
  issue = target.issue;
992
1069
  }
993
1070
 
994
- const headSha = String(args["headSha"]);
1071
+ const askedHead = args["headSha"];
1072
+ const expectedHead = typeof askedHead === "string" ? askedHead : undefined;
995
1073
  let verification: PrVerification | undefined;
996
1074
  try {
997
- verification = await deps.tracker.verifyPr(prUrl, headSha);
1075
+ verification = await deps.tracker.verifyPr(prUrl, expectedHead);
998
1076
  } catch (err) {
999
1077
  verification = undefined;
1000
1078
  deps.log(`verb pr_status could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1007,7 +1085,7 @@ async function prStatusVerb(
1007
1085
  issue,
1008
1086
  );
1009
1087
  }
1010
- return allow(`${prUrl} at ${headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
1088
+ return allow(`${prUrl} at ${verification.headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
1011
1089
  }
1012
1090
 
1013
1091
  /**
@@ -1054,6 +1132,9 @@ async function prUpdateVerb(
1054
1132
  if (target === undefined) {
1055
1133
  return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
1056
1134
  }
1135
+
1136
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
1137
+ if (paused !== undefined) return paused;
1057
1138
  prUrl = asked;
1058
1139
  issue = target.issue;
1059
1140
  }