omp-conductor 0.10.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;
@@ -114,6 +117,14 @@ export interface RepoTarget {
114
117
  * the worker brief.
115
118
  */
116
119
  graphProject?: string;
120
+ /**
121
+ * Ordered-migration-chain guard (#227): repo-relative directory whose *.py
122
+ * files declare `revision`/`down_revision`. When set, `conductor_pr_merge`
123
+ * refuses a merge that would collide with the base branch's chain (id reuse,
124
+ * a deleted migration, or a combined graph with more than one head). Absent
125
+ * = no chain check.
126
+ */
127
+ migrations?: { dir: string };
117
128
  }
118
129
 
119
130
  /**
@@ -140,6 +151,59 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
140
151
  */
141
152
  export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
142
153
 
154
+ /**
155
+ * The four answers the wizard offers for "how loud should the orchestrator be".
156
+ * Three are legacy presets; `quiet` is the explicit form: interrupt only for
157
+ * escalations, fleet stops and confirmed failures, with one daily rollup
158
+ * (#229).
159
+ */
160
+ export type ReportScopeChoice = ReportScope | "quiet";
161
+
162
+ /**
163
+ * Which categories may interrupt the operator's phone. Each maps to a tier-2
164
+ * escalation channel: `tier2` (a plain decision), `decision-needed`, a
165
+ * fleet-stopping condition, a confirmed failure, or any material event. What an
166
+ * `interruptOn` list omits is not dropped — it accumulates for the digest.
167
+ * Data rather than booleans so the config validator, the escalator and the
168
+ * orchestrator prompt all enumerate the same vocabulary (#229).
169
+ */
170
+ export const INTERRUPT_CATEGORIES = [
171
+ "tier2",
172
+ "decision-needed",
173
+ "fleet-stopped",
174
+ "confirmed-failure",
175
+ "material",
176
+ ] as const;
177
+
178
+ export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
179
+
180
+ /** When the daily/report rollup is due. */
181
+ export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
182
+
183
+ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
184
+
185
+ /**
186
+ * Explicit reporting policy: which categories interrupt, and when the daily
187
+ * rollup happens. `scopePreset` is set when the config came from a legacy
188
+ * `scope:` preset, and drives the preset tick prompt text.
189
+ */
190
+ export interface ReportingPolicy {
191
+ interruptOn: InterruptCategory[];
192
+ digest: { cadence: DigestCadence; at?: string; timezone?: string };
193
+ /** Set when the policy came from a legacy `scope` preset. */
194
+ scopePreset?: ReportScope;
195
+ }
196
+
197
+ /**
198
+ * The default policy: the legacy `material` preset, spelled out so an explicit
199
+ * config can name it and the wizard can show it.
200
+ */
201
+ export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
202
+ interruptOn: [...INTERRUPT_CATEGORIES],
203
+ digest: { cadence: "per-tick" },
204
+ scopePreset: DEFAULT_REPORT_SCOPE,
205
+ };
206
+
143
207
  /**
144
208
  * The irreversible tool-call shapes this package recognises and gates. Data
145
209
  * here rather than in `release-policy.ts` because the config validator, the
@@ -297,6 +361,8 @@ export const RELEASE_REQUIREMENTS = [
297
361
  "no-open-prs",
298
362
  /** Nothing still carries the queue label: the batch is finished, not paused. */
299
363
  "queue-drained",
364
+ /** The newest observed post-merge check for the released repo's base branch is green. */
365
+ "base-branch-green",
300
366
  /** The epic this release closes has no open children left. */
301
367
  "epic-children-closed",
302
368
  ] as const;
@@ -431,6 +497,9 @@ export const LABEL_REASONS = [
431
497
  "promoted-to-queue",
432
498
  /** Re-briefed after a failed attempt, and put back on the queue. */
433
499
  "re-briefed",
500
+ /** Delivered: the PR merged and the issue closed, and the queue label comes
501
+ * off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */
502
+ "completed",
434
503
  /** Parked: it needs a decision only a human can make. */
435
504
  "needs-human",
436
505
  "duplicate",
@@ -534,11 +603,13 @@ export interface ProjectConfig {
534
603
  */
535
604
  policy?: ProjectPolicy;
536
605
  /**
537
- * How loud the orchestrator is. Optional on disk a config written before
538
- * this key existed loads as {@link DEFAULT_REPORT_SCOPE} so read it through
539
- * `resolveReportScope` rather than reaching for `.scope` directly.
606
+ * How loud the orchestrator is, and when the daily rollup happens. Optional
607
+ * on disk a config written before this key existed loads as
608
+ * {@link DEFAULT_REPORT_POLICY} so read it through `resolveReportScope`
609
+ * rather than reaching for `.scope` directly. A legacy `scope:` value is
610
+ * accepted and materialised as a preset (`.scopePreset`).
540
611
  */
541
- reporting?: { scope: ReportScope };
612
+ reporting?: ReportingPolicy;
542
613
  /** Parent directory for per-run worktrees. */
543
614
  workspaceRoot: string;
544
615
  /** Cache of bare clones, so N runs share one fetch instead of N. */
@@ -595,6 +666,8 @@ export type PrState = "merged" | "closed" | "open";
595
666
  /** Result of independently checking a worker's claimed green pull request. */
596
667
  export interface PrVerification {
597
668
  status: "green" | "pending" | "failed";
669
+ /** Full live head SHA read in the same tracker request as the verdict. */
670
+ headSha: string;
598
671
  reason: string;
599
672
  }
600
673
 
@@ -645,6 +718,10 @@ export const SETTLEMENT_FLAG_KINDS = [
645
718
  "assertions-removed",
646
719
  /** A named timeout in a test file went up. */
647
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",
648
725
  ] as const;
649
726
 
650
727
  export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
@@ -656,7 +733,8 @@ export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
656
733
  */
657
734
  export interface SettlementFlag {
658
735
  kind: SettlementFlagKind;
659
- /** 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. */
660
738
  file: string;
661
739
  /** 1-based line in the side of the diff the evidence came from: the
662
740
  * post-image for an added line, the pre-image for a removed one. */
@@ -670,24 +748,52 @@ export interface SettlementFlag {
670
748
  /** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
671
749
  export type IssueState = "open" | "closed";
672
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
+
673
757
  /**
674
758
  * An OPEN pull request that already closes an issue, as admission sees it.
675
759
  *
676
- * The head branch travels with the URL because admission has two different
677
- * questions to answer about the same PR, and only one of them the URL can
678
- * answer. "Is there finished work here" is a URL question. "Is this *our*
679
- * retained work, resumed" is a branch question, because a run can retain a
680
- * branch and never produce a PR: on 2026-08-09T00:47Z veltro#324 attempt 1 was
681
- * killed at the turns cap before its worker opened one, and the PR that later
682
- * appeared on that exact branch (chad#438) could never be matched by URL
683
- * 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).
684
771
  */
685
772
  export interface OpenCloser {
686
773
  url: string;
687
774
  /** Head branch of the open PR, for retained-continuation identity. */
688
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;
689
794
  }
690
795
 
796
+
691
797
  /**
692
798
  * Deliberately narrow so a Gitea or local-file tracker can drop in later.
693
799
  * Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
@@ -720,44 +826,64 @@ export interface Tracker {
720
826
  */
721
827
  parentOf(issue: number): Promise<number | undefined>;
722
828
  /**
723
- * The OPEN pull request that already closes `issue`, or undefined when none
724
- * does.
725
- *
726
- * Admission has to ask the tracker because the store cannot answer. The busy
727
- * set is built from run rows, so it only knows work *this* database recorded:
728
- * a migration onto the daemon, a wiped or relocated state directory, a
729
- * restore onto a new host, or simply a database younger than the PRs all
730
- * present pushed-and-open work as an untouched queue item. The tracker is the
731
- * only party that remembers across all of those.
829
+ * The first OPEN pull request that already closes `issue`, or undefined when
830
+ * none does.
732
831
  *
733
- * Returns {@link OpenCloser} rather than a bare URL because the branch is the
734
- * half admission needs to recognise a continuation the store never saw a PR
735
- * 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.
736
836
  */
737
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[]>;
738
847
  /**
739
848
  * Whether an issue is still open, or undefined when tracker/network state is
740
849
  * ambiguous. Cleanup must never interpret undefined as permission to delete.
741
850
  */
742
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>;
743
857
  /**
744
858
  * The state of one specific pull request, or undefined when this adapter
745
859
  * could not tell — a network failure, a deleted PR, a URL it cannot parse.
746
860
  * Undefined never means "no".
747
861
  *
748
- * Deliberately separate from {@link Tracker.openCloserFor}, which answers a
749
- * different question: that one asks "should this candidate be admitted", and
750
- * answers undefined for merged, closed-unmerged, no PR at all and a deleted
751
- * 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
752
865
  * *this* run's PR land", where those answers are opposites. Collapsing them
753
866
  * is how a PR a human rejected gets recorded as merged.
754
867
  */
755
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>;
756
881
  /**
757
- * Verify that a worker's pull request is open, ready, still at the reported
758
- * 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.
759
885
  */
760
- verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
886
+ verifyPr(url: string, expectedHead?: string): Promise<PrVerification | undefined>;
761
887
  /**
762
888
  * The pull request's diff, or undefined when this adapter could not produce
763
889
  * one — an unparseable URL, a deleted PR, a flaky network.
@@ -832,6 +958,7 @@ export const FAILURE_CLASSES = [
832
958
  "orphan-dirty",
833
959
  "settlement-stuck",
834
960
  "provider-credit",
961
+ "provider-transient",
835
962
  "unknown",
836
963
  ] as const;
837
964
 
@@ -856,11 +983,23 @@ export const RECOVERY_ACTIONS = [
856
983
 
857
984
  export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number];
858
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
+
859
997
  /**
860
998
  * Execution state is separate from the tracker's own labels on purpose: labels
861
999
  * are coarse and human-editable, while the loop needs to distinguish "pushed
862
1000
  * and green, waiting on merge" from "merged" to decide what to do on restart.
863
1001
  */
1002
+
864
1003
  export type RunState =
865
1004
  | "claimed"
866
1005
  | "running"
@@ -873,6 +1012,9 @@ export type RunState =
873
1012
  | "blocked"
874
1013
  | "failed"
875
1014
  | "killed"
1015
+ /** Terminal operator action. The worker cannot resume and the attempt does
1016
+ * not consume implementation-failure or continuation budgets. */
1017
+ | "stopped"
876
1018
  /** In flight when its daemon process died; reconciled at the next startup. */
877
1019
  | "orphaned";
878
1020
 
@@ -900,6 +1042,12 @@ export interface RunRecord {
900
1042
  prUrl?: string;
901
1043
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
902
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;
903
1051
  /** Commit this run's uncommitted work was preserved as before its worktree
904
1052
  * was removed, on the run's own branch. Absent means the daemon found
905
1053
  * nothing to save, or never looked — see {@link RunRecord.salvageError}. */
@@ -938,11 +1086,22 @@ export interface RunRecord {
938
1086
  recoveredAt?: number;
939
1087
  }
940
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
+
941
1097
  export type AdmissionHoldReason =
942
1098
  | "capacity"
943
1099
  | "issue-active"
944
1100
  | "failed-attempts"
945
1101
  | "continuations"
1102
+ | "issue-closed"
1103
+ | "issue-state-lookup-error"
1104
+ | "issue-dequeued"
946
1105
  | "parent-lookup-error"
947
1106
  | "sibling-active"
948
1107
  | "repo-active"
@@ -969,6 +1128,9 @@ export interface DispatchSummary {
969
1128
  ready: number;
970
1129
  routed: number;
971
1130
  admitted: number;
1131
+ /** Ready issues carrying a state label — claimed by a live or settling run.
1132
+ * Optional: persisted old rows lack it, so readers use `?? 0` (#228). */
1133
+ claimed?: number;
972
1134
  /** True only for system/API failures, never ordinary policy holds. */
973
1135
  degraded: boolean;
974
1136
  holds: AdmissionHoldSummary[];
@@ -979,6 +1141,7 @@ export type FrictionAdmissionReason =
979
1141
  | "failed-attempts"
980
1142
  | "continuations"
981
1143
  | "parent-lookup-error"
1144
+ | "issue-state-lookup-error"
982
1145
  | "open-pr-lookup-error"
983
1146
  | "unroutable:no-repo-label"
984
1147
  | "unroutable:multiple-repo-labels"
@@ -1181,6 +1344,7 @@ export interface LabelOp {
1181
1344
  * the two disagree.
1182
1345
  */
1183
1346
  export interface Store {
1347
+ /** A claimed run atomically consumes and applies its issue's pending turn override. */
1184
1348
  createRun(r: Omit<RunRecord, "id">): RunRecord;
1185
1349
  updateRun(id: string, patch: Partial<RunRecord>): void;
1186
1350
  getRun(id: string): RunRecord | undefined;
@@ -1193,6 +1357,10 @@ export interface Store {
1193
1357
  /** Newest attempt per issue for the live board. Non-merged work remains
1194
1358
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
1195
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[];
1196
1364
  /** Newest attempt per issue that preserved work or failed to, so `status`
1197
1365
  * can name every WIP tip a re-claim would build on and every tree that is
1198
1366
  * still the only copy. */
@@ -1211,6 +1379,17 @@ export interface Store {
1211
1379
  * tail` resolves an issue number to a transcript through this; the number is
1212
1380
  * what an operator has, the run id is not. */
1213
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[];
1214
1393
  /** Every attempt of one issue that settled with a report, in attempt order.
1215
1394
  * The settlement audit pools these as prior disclosures when a later
1216
1395
  * attempt is reconciled (#199). Rows with no report (pre-#199, or killed
@@ -1223,6 +1402,15 @@ export interface Store {
1223
1402
  wasNotified(key: string): boolean;
1224
1403
  recordDispatch(project: string, summary: DispatchSummary): void;
1225
1404
  latestDispatch(project: string): DispatchSummary | undefined;
1405
+ /** The newest `digest:` dedupe key a project has run toward without ending in
1406
+ * failure — what the off-schedule-digest refusal compares against (#229). */
1407
+ lastDigestDedupeKey(project: string): string | undefined;
1408
+ /** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
1409
+ addHeldNotice(notice: HeldNoticeDraft): void;
1410
+ /** Held notices still owed (not yet re-surfaced by a digest pass). */
1411
+ undigestedNotices(project: string): HeldNotice[];
1412
+ /** Mark every undigested notice for a project as re-surfaced. */
1413
+ markNoticesDigested(project: string, at: number): void;
1226
1414
  /** Add one bounded observation to the per-day friction rollup. */
1227
1415
  recordFriction(project: string, observation: FrictionObservation): void;
1228
1416
  /** Repeated signals not surfaced within the supplied cooldown window. */
@@ -1385,6 +1573,31 @@ export interface Escalation {
1385
1573
  summary: string;
1386
1574
  detail?: string;
1387
1575
  runId?: string;
1576
+ /** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the
1577
+ * escalation pages now or is held for the digest under the project's
1578
+ * `reporting.interruptOn` policy (#229). */
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;
1583
+ }
1584
+
1585
+ /** A tier-2 escalation the project's interrupt policy deferred to the digest. */
1586
+ export interface HeldNotice {
1587
+ id: string;
1588
+ category: InterruptCategory;
1589
+ summary: string;
1590
+ detail: string;
1591
+ createdAt: number;
1592
+ }
1593
+
1594
+ /** What it takes to persist one held notice. */
1595
+ export interface HeldNoticeDraft {
1596
+ project: string;
1597
+ category: InterruptCategory;
1598
+ summary: string;
1599
+ detail: string;
1600
+ createdAt: number;
1388
1601
  }
1389
1602
 
1390
1603
  /**
@@ -1400,6 +1613,7 @@ export const DEFAULT_CAPS: Caps = {
1400
1613
  // guard silently watches the wrong meter (#110).
1401
1614
  planUsage: null,
1402
1615
  workerMaxTurns: 120,
1616
+ workerMaxTurnsCeiling: 240,
1403
1617
  workerWallClockMs: 90 * 60 * 1000,
1404
1618
  maxAttemptsPerIssue: 2,
1405
1619
  maxContinuationsPerIssue: 2,
@@ -1511,6 +1725,11 @@ export const VERB_REFUSALS = [
1511
1725
  "release-shape-not-executable",
1512
1726
  /** Config unreadable, project unknown, repo unrouted. Fail closed. */
1513
1727
  "config-unreadable",
1728
+ /** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */
1729
+ "chain-conflict",
1730
+ /** The PR diff or the base-branch chain could not be read — fail closed rather
1731
+ * than merge blind into a chain that might be about to fork (#227). */
1732
+ "chain-unreadable",
1514
1733
  /** The privileged half ran and the underlying command failed. */
1515
1734
  "action-failed",
1516
1735
  ] as const;
@@ -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
  }