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/README.md +73 -19
- package/package.json +1 -1
- package/src/board.ts +125 -11
- package/src/briefs/orchestrator.md +50 -4
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +188 -19
- package/src/config.ts +195 -15
- package/src/daemon.ts +1017 -119
- package/src/diff-flags.ts +48 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +31 -4
- package/src/fleet.ts +8 -3
- package/src/gitops.ts +49 -0
- package/src/omp.ts +44 -9
- package/src/orchestrator-tick.ts +94 -9
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +46 -4
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +5 -6
- package/src/session-host.ts +23 -6
- package/src/setup.ts +43 -10
- package/src/store.ts +263 -31
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +251 -32
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +144 -13
- package/src/worker.ts +183 -17
- 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;
|
|
@@ -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
|
|
538
|
-
*
|
|
539
|
-
*
|
|
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?:
|
|
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,
|
|
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
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
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
|
|
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
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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
|
|
749
|
-
*
|
|
750
|
-
*
|
|
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
|
|
758
|
-
*
|
|
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
|
|
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;
|
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
|
}
|