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/README.md +40 -19
- package/package.json +1 -1
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +32 -2
- package/src/cli.ts +104 -23
- package/src/config.ts +10 -2
- package/src/daemon.ts +818 -131
- package/src/diff-flags.ts +4 -0
- package/src/escalate.ts +5 -5
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +7 -2
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +35 -20
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +28 -2
- package/src/release-policy.ts +66 -6
- package/src/session-host.ts +4 -3
- package/src/setup.ts +4 -2
- package/src/store.ts +190 -29
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +141 -28
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +95 -13
- package/src/worker.ts +33 -5
- package/src/worktree.ts +5 -0
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,
|
|
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
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
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
|
|
790
|
-
* does.
|
|
829
|
+
* The first OPEN pull request that already closes `issue`, or undefined when
|
|
830
|
+
* none does.
|
|
791
831
|
*
|
|
792
|
-
* Admission
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
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
|
|
815
|
-
*
|
|
816
|
-
*
|
|
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
|
|
824
|
-
*
|
|
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
|
|
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,
|
package/src/verbs/actions.ts
CHANGED
|
@@ -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
|
|
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[]):
|
|
69
|
-
return {
|
|
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(
|
|
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
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
}
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -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
|
|
297
|
-
"
|
|
298
|
-
"
|
|
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:
|
|
308
|
+
required: false,
|
|
308
309
|
description:
|
|
309
|
-
"
|
|
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.`,
|