omp-conductor 0.12.0 → 0.13.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.
package/src/types.ts CHANGED
@@ -61,6 +61,9 @@ export interface Caps {
61
61
  /** Turn ceiling for one worker — catches loops that are burning tokens
62
62
  * without converging. */
63
63
  workerMaxTurns: number;
64
+ /** Maximum turn budget an operator may assign to one issue's next attempt.
65
+ * Defaults to twice that issue's effective `workerMaxTurns`. */
66
+ workerMaxTurnsCeiling: number;
64
67
  /** Wall-clock ceiling for one worker (90 min): a session that is merely
65
68
  * stuck spends no turns, so turns alone cannot detect it. */
66
69
  workerWallClockMs: number;
@@ -358,6 +361,8 @@ export const RELEASE_REQUIREMENTS = [
358
361
  "no-open-prs",
359
362
  /** Nothing still carries the queue label: the batch is finished, not paused. */
360
363
  "queue-drained",
364
+ /** The newest observed post-merge check for the released repo's base branch is green. */
365
+ "base-branch-green",
361
366
  /** The epic this release closes has no open children left. */
362
367
  "epic-children-closed",
363
368
  ] as const;
@@ -661,6 +666,8 @@ export type PrState = "merged" | "closed" | "open";
661
666
  /** Result of independently checking a worker's claimed green pull request. */
662
667
  export interface PrVerification {
663
668
  status: "green" | "pending" | "failed";
669
+ /** Full live head SHA read in the same tracker request as the verdict. */
670
+ headSha: string;
664
671
  reason: string;
665
672
  }
666
673
 
@@ -711,6 +718,10 @@ export const SETTLEMENT_FLAG_KINDS = [
711
718
  "assertions-removed",
712
719
  /** A named timeout in a test file went up. */
713
720
  "test-timeout-raised",
721
+ /** A terminal run recovered an OPEN PR by exact issue, repo and branch identity. */
722
+ "pr-adopted",
723
+ /** A workflow on the merge commit's base branch failed after settlement. */
724
+ "base-branch-red",
714
725
  ] as const;
715
726
 
716
727
  export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
@@ -722,7 +733,8 @@ export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
722
733
  */
723
734
  export interface SettlementFlag {
724
735
  kind: SettlementFlagKind;
725
- /** Repo-relative path, or `(report)` for a finding about the report itself. */
736
+ /** Repo-relative path, `(report)` for report evidence, or `(base branch)` for
737
+ * a post-merge workflow result. */
726
738
  file: string;
727
739
  /** 1-based line in the side of the diff the evidence came from: the
728
740
  * post-image for an added line, the pre-image for a removed one. */
@@ -736,24 +748,52 @@ export interface SettlementFlag {
736
748
  /** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
737
749
  export type IssueState = "open" | "closed";
738
750
 
751
+ /** Read-after-write issue facts used to validate an eventually-consistent queue candidate. */
752
+ export interface IssueSnapshot {
753
+ readonly state: IssueState;
754
+ readonly labels: readonly string[];
755
+ }
756
+
739
757
  /**
740
758
  * An OPEN pull request that already closes an issue, as admission sees it.
741
759
  *
742
- * The head branch travels with the URL because admission has two different
743
- * questions to answer about the same PR, and only one of them the URL can
744
- * answer. "Is there finished work here" is a URL question. "Is this *our*
745
- * retained work, resumed" is a branch question, because a run can retain a
746
- * branch and never produce a PR: on 2026-08-09T00:47Z veltro#324 attempt 1 was
747
- * killed at the turns cap before its worker opened one, and the PR that later
748
- * appeared on that exact branch (chad#438) could never be matched by URL
749
- * equality against a `prUrl` the terminal run never recorded (#50).
760
+ * The repository travels with the URL because recovery has to prove three
761
+ * independent identities before adopting a PR onto a terminal run: closing
762
+ * issue, retained branch, and routed repository (#245). Admission needs only
763
+ * the URL/branch pair, but weakening the shared answer would make recovery
764
+ * parse provenance back out of a presentation URL.
765
+ *
766
+ * The head branch recognises a continuation the store never saw a PR for: on
767
+ * 2026-08-09T00:47Z veltro#324 attempt 1 was killed at the turns cap before its
768
+ * worker opened one, and the PR that later appeared on that exact branch
769
+ * (chad#438) could never be matched by URL equality against a `prUrl` the
770
+ * terminal run never recorded (#50).
750
771
  */
751
772
  export interface OpenCloser {
752
773
  url: string;
753
774
  /** Head branch of the open PR, for retained-continuation identity. */
754
775
  headRefName: string;
776
+ /** Canonical `owner/repo` identity supplied by the tracker. */
777
+ repo: string;
778
+ }
779
+ /** The merge identity needed to observe the exact base-branch push it produced. */
780
+ export interface MergedPrInfo {
781
+ mergeSha: string;
782
+ baseRef: string;
783
+ }
784
+
785
+ /** One GitHub Actions workflow run, normalised before it crosses the tracker port. */
786
+ export interface WorkflowRun {
787
+ id: number;
788
+ workflowId: number;
789
+ name: string;
790
+ status: string;
791
+ conclusion?: string;
792
+ url: string;
793
+ createdAt: string;
755
794
  }
756
795
 
796
+
757
797
  /**
758
798
  * Deliberately narrow so a Gitea or local-file tracker can drop in later.
759
799
  * Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
@@ -786,44 +826,64 @@ export interface Tracker {
786
826
  */
787
827
  parentOf(issue: number): Promise<number | undefined>;
788
828
  /**
789
- * The OPEN pull request that already closes `issue`, or undefined when none
790
- * does.
829
+ * The first OPEN pull request that already closes `issue`, or undefined when
830
+ * none does.
791
831
  *
792
- * Admission has to ask the tracker because the store cannot answer. The busy
793
- * set is built from run rows, so it only knows work *this* database recorded:
794
- * a migration onto the daemon, a wiped or relocated state directory, a
795
- * restore onto a new host, or simply a database younger than the PRs all
796
- * present pushed-and-open work as an untouched queue item. The tracker is the
797
- * only party that remembers across all of those.
798
- *
799
- * Returns {@link OpenCloser} rather than a bare URL because the branch is the
800
- * half admission needs to recognise a continuation the store never saw a PR
801
- * for; see that type for the veltro#324 case that forced the widening.
832
+ * Admission asks this any-open question because the store only knows work
833
+ * recorded in this database. A migrated daemon, restored host, or younger
834
+ * database can otherwise dispatch duplicate work onto an issue whose PR is
835
+ * already open.
802
836
  */
803
837
  openCloserFor(issue: number): Promise<OpenCloser | undefined>;
838
+ /**
839
+ * Every OPEN pull request that closes `issue`.
840
+ *
841
+ * Salvage recovery must inspect the full answer: an unrelated closer may be
842
+ * listed before the PR whose branch and repository exactly match the
843
+ * recovered run. An empty array means no OPEN closer; a thrown lookup remains
844
+ * fail-closed.
845
+ */
846
+ openClosersFor(issue: number): Promise<OpenCloser[]>;
804
847
  /**
805
848
  * Whether an issue is still open, or undefined when tracker/network state is
806
849
  * ambiguous. Cleanup must never interpret undefined as permission to delete.
807
850
  */
808
851
  issueState(issue: number): Promise<IssueState | undefined>;
852
+ /**
853
+ * Direct REST state and labels for final admission validation, or undefined
854
+ * when either fact cannot be read safely.
855
+ */
856
+ issueSnapshot(issue: number): Promise<IssueSnapshot | undefined>;
809
857
  /**
810
858
  * The state of one specific pull request, or undefined when this adapter
811
859
  * could not tell — a network failure, a deleted PR, a URL it cannot parse.
812
860
  * Undefined never means "no".
813
861
  *
814
- * Deliberately separate from {@link Tracker.openCloserFor}, which answers a
815
- * different question: that one asks "should this candidate be admitted", and
816
- * answers undefined for merged, closed-unmerged, no PR at all and a deleted
817
- * PR alike, because admission treats all four the same. This one asks "did
862
+ * Deliberately separate from {@link Tracker.openCloserFor}, which asks
863
+ * whether any OPEN closing PR exists and answers undefined for merged,
864
+ * closed-unmerged, no PR at all and a deleted PR alike. This one asks "did
818
865
  * *this* run's PR land", where those answers are opposites. Collapsing them
819
866
  * is how a PR a human rejected gets recorded as merged.
820
867
  */
821
868
  prState(url: string): Promise<PrState | undefined>;
869
+ /** The merge commit and base ref for a merged pull request. */
870
+ mergedPrInfo(url: string): Promise<MergedPrInfo | undefined>;
871
+ /** Workflow runs GitHub associated with one exact commit SHA. */
872
+ workflowRunsAt(repo: string, sha: string): Promise<WorkflowRun[] | undefined>;
873
+ /** The newest run of this workflow on the base branch before `before`.
874
+ * `null` means no earlier run; `undefined` means the history could not be read. */
875
+ previousWorkflowRun(
876
+ repo: string,
877
+ workflowId: number,
878
+ branch: string,
879
+ before: string,
880
+ ): Promise<WorkflowRun | null | undefined>;
822
881
  /**
823
- * Verify that a worker's pull request is open, ready, still at the reported
824
- * head, and has a non-empty terminal-success check rollup.
882
+ * Verify that a pull request is open, ready, and has a non-empty
883
+ * terminal-success check rollup. When `expectedHead` is supplied, also
884
+ * require the live head to match it.
825
885
  */
826
- verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
886
+ verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined>;
827
887
  /**
828
888
  * The pull request's diff, or undefined when this adapter could not produce
829
889
  * one — an unparseable URL, a deleted PR, a flaky network.
@@ -923,11 +983,23 @@ export const RECOVERY_ACTIONS = [
923
983
 
924
984
  export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number];
925
985
 
986
+ /** What the daemon learned about workflows on a mediated merge's base commit. */
987
+ export const BASE_CHECK_STATES = [
988
+ "pending",
989
+ "green",
990
+ "red",
991
+ "red-preexisting",
992
+ "unknown",
993
+ ] as const;
994
+
995
+ export type BaseCheckState = (typeof BASE_CHECK_STATES)[number];
996
+
926
997
  /**
927
998
  * Execution state is separate from the tracker's own labels on purpose: labels
928
999
  * are coarse and human-editable, while the loop needs to distinguish "pushed
929
1000
  * and green, waiting on merge" from "merged" to decide what to do on restart.
930
1001
  */
1002
+
931
1003
  export type RunState =
932
1004
  | "claimed"
933
1005
  | "running"
@@ -940,6 +1012,9 @@ export type RunState =
940
1012
  | "blocked"
941
1013
  | "failed"
942
1014
  | "killed"
1015
+ /** Terminal operator action. The worker cannot resume and the attempt does
1016
+ * not consume implementation-failure or continuation budgets. */
1017
+ | "stopped"
943
1018
  /** In flight when its daemon process died; reconciled at the next startup. */
944
1019
  | "orphaned";
945
1020
 
@@ -967,6 +1042,12 @@ export interface RunRecord {
967
1042
  prUrl?: string;
968
1043
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
969
1044
  headSha?: string;
1045
+ /** Merge commit and actual base ref read from the merged pull request. */
1046
+ mergeSha?: string;
1047
+ baseRef?: string;
1048
+ /** Post-merge workflow verdict. Pending is revisited by the tick sweep. */
1049
+ baseCheck?: BaseCheckState;
1050
+ baseCheckAt?: number;
970
1051
  /** Commit this run's uncommitted work was preserved as before its worktree
971
1052
  * was removed, on the run's own branch. Absent means the daemon found
972
1053
  * nothing to save, or never looked — see {@link RunRecord.salvageError}. */
@@ -1005,11 +1086,22 @@ export interface RunRecord {
1005
1086
  recoveredAt?: number;
1006
1087
  }
1007
1088
 
1089
+ /** A one-shot turn budget waiting for one issue's next claimed attempt. */
1090
+ export interface TurnOverride {
1091
+ project: string;
1092
+ issue: number;
1093
+ maxTurns: number;
1094
+ setAt: number;
1095
+ }
1096
+
1008
1097
  export type AdmissionHoldReason =
1009
1098
  | "capacity"
1010
1099
  | "issue-active"
1011
1100
  | "failed-attempts"
1012
1101
  | "continuations"
1102
+ | "issue-closed"
1103
+ | "issue-state-lookup-error"
1104
+ | "issue-dequeued"
1013
1105
  | "parent-lookup-error"
1014
1106
  | "sibling-active"
1015
1107
  | "repo-active"
@@ -1049,6 +1141,7 @@ export type FrictionAdmissionReason =
1049
1141
  | "failed-attempts"
1050
1142
  | "continuations"
1051
1143
  | "parent-lookup-error"
1144
+ | "issue-state-lookup-error"
1052
1145
  | "open-pr-lookup-error"
1053
1146
  | "unroutable:no-repo-label"
1054
1147
  | "unroutable:multiple-repo-labels"
@@ -1251,6 +1344,7 @@ export interface LabelOp {
1251
1344
  * the two disagree.
1252
1345
  */
1253
1346
  export interface Store {
1347
+ /** A claimed run atomically consumes and applies its issue's pending turn override. */
1254
1348
  createRun(r: Omit<RunRecord, "id">): RunRecord;
1255
1349
  updateRun(id: string, patch: Partial<RunRecord>): void;
1256
1350
  getRun(id: string): RunRecord | undefined;
@@ -1263,6 +1357,10 @@ export interface Store {
1263
1357
  /** Newest attempt per issue for the live board. Non-merged work remains
1264
1358
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
1265
1359
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1360
+ /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
1361
+ runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
1362
+ /** Newest checked merge per routed repository inside the supplied history window. */
1363
+ latestBaseChecks(project: string, sinceEpochMs: number): RunRecord[];
1266
1364
  /** Newest attempt per issue that preserved work or failed to, so `status`
1267
1365
  * can name every WIP tip a re-claim would build on and every tree that is
1268
1366
  * still the only copy. */
@@ -1281,6 +1379,17 @@ export interface Store {
1281
1379
  * tail` resolves an issue number to a transcript through this; the number is
1282
1380
  * what an operator has, the run id is not. */
1283
1381
  latestRun(project: string, issue: number): RunRecord | undefined;
1382
+ /** Persist a one-shot ceiling and append the operator action to its audit ledger. */
1383
+ setTurnOverride(project: string, issue: number, maxTurns: number, setAt?: number): void;
1384
+ turnOverride(project: string, issue: number): number | undefined;
1385
+ /** Administrative deletion; claimed runs consume inside `createRun`'s transaction. */
1386
+ clearTurnOverride(project: string, issue: number): void;
1387
+ listTurnOverrides(project: string): TurnOverride[];
1388
+ /** Durable override actions, newest first; consumption never removes these rows. */
1389
+ turnOverrideLedger(
1390
+ project: string,
1391
+ opts?: { issue?: number; limit?: number },
1392
+ ): TurnOverride[];
1284
1393
  /** Every attempt of one issue that settled with a report, in attempt order.
1285
1394
  * The settlement audit pools these as prior disclosures when a later
1286
1395
  * attempt is reconciled (#199). Rows with no report (pre-#199, or killed
@@ -1468,6 +1577,9 @@ export interface Escalation {
1468
1577
  * escalation pages now or is held for the digest under the project's
1469
1578
  * `reporting.interruptOn` policy (#229). */
1470
1579
  category?: InterruptCategory;
1580
+ /** Deliver now even when policy would defer this category to the digest.
1581
+ * Used only when the digest's own orchestrator loop is unavailable. */
1582
+ urgent?: true;
1471
1583
  }
1472
1584
 
1473
1585
  /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
@@ -1501,6 +1613,7 @@ export const DEFAULT_CAPS: Caps = {
1501
1613
  // guard silently watches the wrong meter (#110).
1502
1614
  planUsage: null,
1503
1615
  workerMaxTurns: 120,
1616
+ workerMaxTurnsCeiling: 240,
1504
1617
  workerWallClockMs: 90 * 60 * 1000,
1505
1618
  maxAttemptsPerIssue: 2,
1506
1619
  maxContinuationsPerIssue: 2,
@@ -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,45 @@ 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 releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
136
+ ok: false,
137
+ stderr:
138
+ `refusing release: target ${target} is behind live ${repo.defaultBranch} ${live}. ` +
139
+ "Refresh the release status and retry so the tag contains every merged change.",
140
+ });
141
+
142
+ const releaseMirror = async (repo: RepoTarget): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> => {
143
+ try {
144
+ return { ok: true, path: await prepareMirror(repo, project.mirrorRoot) };
145
+ } catch (err) {
146
+ return {
147
+ ok: false,
148
+ stderr: `could not refresh ${repo.name}'s release mirror: ${err instanceof Error ? err.message : String(err)}`,
149
+ };
150
+ }
151
+ };
152
+
87
153
  return {
88
154
  releasableShapes: GITHUB_RELEASABLE_SHAPES,
89
155
 
@@ -133,18 +199,64 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
133
199
  if (execution.shape === "github-release") {
134
200
  return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
135
201
  }
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`;
202
+ // The two git shapes act on the project's serialized, freshly fetched
203
+ // mirror. The live read on either side of tag creation closes the stale
204
+ // mirror and branch-moved-during-release windows (#258); the push path
205
+ // repeats the guard because a branch may move between the two verbs.
206
+ const prepared = await releaseMirror(execution.repo);
207
+ if (!prepared.ok) return prepared;
208
+ const mirror = prepared.path;
140
209
  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);
210
+ const target = await readCommit(
211
+ mirror,
212
+ ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
213
+ `refreshed ${execution.repo.defaultBranch}`,
214
+ );
215
+ if (!target.ok) return target;
216
+ const liveBefore = await liveDefaultHead(mirror, execution.repo);
217
+ if (!liveBefore.ok) return liveBefore;
218
+ if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
219
+
220
+ // An unattended daemon cannot depend on a host-level Git identity or a
221
+ // signing key with an interactive passphrase.
222
+ const tagged = await git(mirror, [
223
+ ...RELEASE_TAG_CONFIG,
224
+ "tag",
225
+ "-a",
226
+ tag,
227
+ "-m",
228
+ `release ${tag}`,
229
+ target.sha,
230
+ ]);
231
+ if (!tagged.result.ok) return failed(tagged.result, tagged.argv);
232
+
233
+ const liveAfter = await liveDefaultHead(mirror, execution.repo);
234
+ if (!liveAfter.ok || liveAfter.sha !== target.sha) {
235
+ const removed = await git(mirror, ["tag", "-d", tag]);
236
+ const reason = liveAfter.ok
237
+ ? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr
238
+ : `created ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
239
+ return {
240
+ ok: false,
241
+ stderr:
242
+ reason +
243
+ (removed.result.ok
244
+ ? " The unpushed local tag was deleted."
245
+ : ` WARNING: the unpushed local tag could not be deleted: ${failed(removed.result, removed.argv).stderr}`),
246
+ };
247
+ }
248
+ return { ok: true, sha: target.sha, detail: `tagged ${tag} at ${target.sha}` };
144
249
  }
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);
250
+
251
+ const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
252
+ if (!target.ok) return target;
253
+ const live = await liveDefaultHead(mirror, execution.repo);
254
+ if (!live.ok) return live;
255
+ if (target.sha !== live.sha) return releaseTargetMoved(execution.repo, target.sha, live.sha);
256
+ const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]);
257
+ return pushed.result.ok
258
+ ? { ok: true, sha: target.sha, detail: `pushed refs/tags/${tag} at ${target.sha}` }
259
+ : failed(pushed.result, pushed.argv);
148
260
  },
149
261
  };
150
262
  }
@@ -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.`,