@coreplane/switchboard 1.260.0 → 1.260.2
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/bin/switchboard.js +15 -11
- package/dist/assets/config/config.example.yaml +1 -0
- package/dist/assets/deploy/cloudflare/worker.ts +17 -56
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
- package/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +25 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +36 -16
- package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -18
- package/dist/assets/deploy/profile.example.json +2 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +2 -2
- package/dist/assets/src/core/budgets.ts +19 -6
- package/dist/assets/src/core/coordinator/driver.ts +47 -9
- package/dist/assets/src/core/pipelineStanding.ts +2 -0
- package/dist/assets/src/core/reviewedHead.ts +16 -2
- package/dist/assets/src/core/runEvents.ts +9 -1
- package/dist/assets/src/core/runLedger/types.ts +3 -3
- package/dist/assets/src/core/runRecord.ts +28 -8
- package/dist/assets/src/core/ship/coordinator.ts +248 -44
- package/dist/assets/src/core/ship/renewal.ts +2 -0
- package/dist/assets/src/deploy/liveGate.ts +8 -0
- package/dist/assets/src/deploy/profile.ts +8 -0
- package/dist/assets/src/deploy/restart.ts +49 -35
- package/dist/assets/web/dist/.vite/manifest.json +67 -67
- package/dist/assets/web/dist/assets/{DeliveryPage-DvMrWUg7.js → DeliveryPage-62qCSLvk.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-CcGEJ4w0.js → HomePage-Dk3HRBc3.js} +1 -1
- package/dist/assets/web/dist/assets/{PendingTurnRow-BzGVxDYs.js → PendingTurnRow-CeTGOXUy.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-Arj9cyd5.js → PlanePage-RD0M6O6W.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CBPPe4Sj.js → ResidentDetailPage-Dl2KId95.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CEpXgGo7.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-BJq7tnLr.js → RunFoldRow-CwHDL-Mo.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-DWiny7LN.js → RunRoutePage-Du1HqLZs.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-Cxufw5sV.js → RunsIndexPage-BZphy3Hr.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-SBcuDdVg.js → ScheduledPage-B7jh898t.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-DrD_vWdp.js → SettingsPage-3uLEsiiE.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-Bjl7EPEN.js → SilentTurn-CKxSxnQC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BqteQTav.js → StatusDot-x7vcK0iE.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-KRqJXEPN.js → Tooltip-wVXCWhMp.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-Cv28FV4I.js → UnitRoutePage-V5BXK7FH.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CTASUSno.js → dist-C-XGnvM4.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-B1YBy_IL.js → indexRow-Dxg7C8s9.js} +1 -1
- package/dist/assets/web/dist/assets/{main-eTAe9-hk.js → main-DAL--hyj.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
- package/dist/cli.js +1405 -436
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/budgets-DQXVllsm.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-BcsNbn2j.js +0 -11
|
@@ -458,7 +458,7 @@ export function cursorFinished(cursor: PlanCursor): boolean {
|
|
|
458
458
|
* findings dispatched into the unit thread as `agent:coding`, so the coding
|
|
459
459
|
* session there continues with them (record 0034). Never a `fix` child briefed
|
|
460
460
|
* from the review. */
|
|
461
|
-
export type RoundKind = "coding" | "review" | "findings";
|
|
461
|
+
export type RoundKind = "coding" | "review" | "findings" | "rebase";
|
|
462
462
|
export interface RoundRef {
|
|
463
463
|
/** Round 0 is the coding round; review round n and its findings step share n. */
|
|
464
464
|
index: number;
|
|
@@ -508,6 +508,13 @@ export type Brief =
|
|
|
508
508
|
/** The round's check findings (record 0055), carried by value — the
|
|
509
509
|
* review run's record holds only the reviewer's own findings. */
|
|
510
510
|
checks?: Finding[];
|
|
511
|
+
}
|
|
512
|
+
| {
|
|
513
|
+
kind: "rebase";
|
|
514
|
+
unit: string;
|
|
515
|
+
pr: number;
|
|
516
|
+
headSha: string;
|
|
517
|
+
base: string;
|
|
511
518
|
};
|
|
512
519
|
|
|
513
520
|
export interface PrRef {
|
|
@@ -549,12 +556,15 @@ export type CoordinatorAction =
|
|
|
549
556
|
* the queue's outcome (merged, still queued, or removed with the reason)
|
|
550
557
|
* instead of attempting the squash. */
|
|
551
558
|
| { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
|
|
559
|
+
/** Rung one of an approved head's rebase: git first. A conflict alone buys
|
|
560
|
+
* the runner's coding round; a clean changed patch returns to review. */
|
|
561
|
+
| { type: "rebase"; step: string; prNumber: number; headSha: string }
|
|
552
562
|
/** Read the check runs at the reviewed head (record 0055, the round verdict):
|
|
553
563
|
* the merge door's own reading, folded into the round. `retry` names the
|
|
554
|
-
* failed checks whose one flake re-run the machine is spending
|
|
555
|
-
*
|
|
556
|
-
* of
|
|
557
|
-
| { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[] }
|
|
564
|
+
* failed checks whose one flake re-run the machine is spending; `refire`
|
|
565
|
+
* asks for the one close/reopen recovery when no required check launched.
|
|
566
|
+
* Either effect runs instead of a read. */
|
|
567
|
+
| { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[]; refire?: true }
|
|
558
568
|
/** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
|
|
559
569
|
| { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
|
|
560
570
|
| { type: "sleep"; step: string; ms: number }
|
|
@@ -678,12 +688,28 @@ export type StepReturn =
|
|
|
678
688
|
| { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
|
|
679
689
|
/** The door enqueued the pull request (or found it still queued), or the
|
|
680
690
|
* queue removed it — the reason is the queue's own (issue 2011). */
|
|
681
|
-
| {
|
|
691
|
+
| {
|
|
692
|
+
type: "merge";
|
|
693
|
+
step: string;
|
|
694
|
+
outcome: "pending" | "refused" | "conflict" | "enqueued" | "removed";
|
|
695
|
+
reason: string;
|
|
696
|
+
at: number;
|
|
697
|
+
}
|
|
698
|
+
| { type: "rebase"; step: string; outcome: "carried" | "changed"; headSha: string; at: number }
|
|
699
|
+
| { type: "rebase"; step: string; outcome: "conflict" | "refused"; reason: string; at: number }
|
|
682
700
|
/** The checks read at the reviewed head; `checks` absent means GitHub could
|
|
683
|
-
* not be read, which the machine treats as pending (record 0055).
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
| {
|
|
701
|
+
* not be read, which the machine treats as pending (record 0055). Effect
|
|
702
|
+
* asks answer `retried` or `refired` instead; false still spends that one
|
|
703
|
+
* recovery so an unchanged head cannot loop on the write. */
|
|
704
|
+
| {
|
|
705
|
+
type: "checks";
|
|
706
|
+
step: string;
|
|
707
|
+
checks?: RoundChecks;
|
|
708
|
+
draft?: boolean;
|
|
709
|
+
retried?: boolean;
|
|
710
|
+
refired?: boolean;
|
|
711
|
+
at: number;
|
|
712
|
+
}
|
|
687
713
|
| { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
|
|
688
714
|
| { type: "sleep"; step: string };
|
|
689
715
|
|
|
@@ -699,14 +725,18 @@ export interface CheckFailure {
|
|
|
699
725
|
}
|
|
700
726
|
|
|
701
727
|
/** The check runs at the reviewed head as the round's checks step reads them.
|
|
702
|
-
* `
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
728
|
+
* `required` carries the base's whole required-context set and `expected`
|
|
729
|
+
* its unreported subset. The whole set lets the table distinguish no required
|
|
730
|
+
* check launching from an unrelated run that did report; an ordinary missing
|
|
731
|
+
* subset reads exactly as pending (issue 2063). */
|
|
706
732
|
export interface RoundChecks {
|
|
707
733
|
total: number;
|
|
708
734
|
pending: string[];
|
|
709
735
|
failed: CheckFailure[];
|
|
736
|
+
/** Every check context required by the base. Together with `expected`, this
|
|
737
|
+
* distinguishes an empty required-check launch from an unrelated check
|
|
738
|
+
* that did report (for example the title workflow). */
|
|
739
|
+
required?: string[];
|
|
710
740
|
expected?: string[];
|
|
711
741
|
}
|
|
712
742
|
|
|
@@ -831,6 +861,8 @@ export type UnitEnding =
|
|
|
831
861
|
finalReply?: string;
|
|
832
862
|
/** A changes-requested review posted this round before the stop. */
|
|
833
863
|
postedReview?: boolean;
|
|
864
|
+
/** The mechanical WIP checkpoint a stopped coding child pushed before teardown. */
|
|
865
|
+
checkpoint?: { branch: string; sha: string };
|
|
834
866
|
}
|
|
835
867
|
| {
|
|
836
868
|
kind: "aborted";
|
|
@@ -874,7 +906,14 @@ export type UnitEnding =
|
|
|
874
906
|
* `transient` so it reads as a condition beside `checks_failed` and `held`
|
|
875
907
|
* in the plane's table, never as the child failing on its task. */
|
|
876
908
|
| { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
|
|
877
|
-
| {
|
|
909
|
+
| {
|
|
910
|
+
kind: "interrupted";
|
|
911
|
+
round: RoundRef;
|
|
912
|
+
runId: string;
|
|
913
|
+
reviewRounds: number;
|
|
914
|
+
cause?: InterruptionCause;
|
|
915
|
+
checkpoint?: { branch: string; sha: string };
|
|
916
|
+
}
|
|
878
917
|
| { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
|
|
879
918
|
/** The unit idles instead of ending (record 0051): with the resolved
|
|
880
919
|
* `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
|
|
@@ -1069,12 +1108,14 @@ type Phase =
|
|
|
1069
1108
|
* only through a merge queue (issue 2011) — so every later ask reads the
|
|
1070
1109
|
* queue's outcome instead of attempting the squash again. */
|
|
1071
1110
|
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
1111
|
+
| { at: "rebase"; pr: PrRef; headSha: string; n: number }
|
|
1072
1112
|
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
1073
1113
|
/** The round's checks step (record 0055): the check runs at the reviewed head
|
|
1074
1114
|
* are read after an approve settles, before merge_ready or the merge door.
|
|
1075
|
-
* `graced`: a head with no check reported has had its one-chunk
|
|
1076
|
-
* `
|
|
1077
|
-
* the
|
|
1115
|
+
* `graced`: a head with no required check reported has had its one-chunk
|
|
1116
|
+
* grace; `refired`: the one pull_request close/reopen recovery is spent;
|
|
1117
|
+
* `retried`: the one flake re-run is spent. `retry` and `refire` make the
|
|
1118
|
+
* next ask perform that effect instead of reading. */
|
|
1078
1119
|
| {
|
|
1079
1120
|
at: "checks";
|
|
1080
1121
|
round: RoundRef;
|
|
@@ -1085,7 +1126,9 @@ type Phase =
|
|
|
1085
1126
|
waitMs: number;
|
|
1086
1127
|
graced: boolean;
|
|
1087
1128
|
retried: boolean;
|
|
1129
|
+
refired: boolean;
|
|
1088
1130
|
retry?: string[];
|
|
1131
|
+
refire?: true;
|
|
1089
1132
|
}
|
|
1090
1133
|
/** Waiting on `checks-settled-<head>` between two checks reads, in the merge
|
|
1091
1134
|
* wait's own chunks — the intake wakes the machine when the head settles. */
|
|
@@ -1099,6 +1142,7 @@ type Phase =
|
|
|
1099
1142
|
waitMs: number;
|
|
1100
1143
|
graced: boolean;
|
|
1101
1144
|
retried: boolean;
|
|
1145
|
+
refired: boolean;
|
|
1102
1146
|
}
|
|
1103
1147
|
| { at: "ended" };
|
|
1104
1148
|
|
|
@@ -1110,6 +1154,9 @@ export interface UnitPipelineState {
|
|
|
1110
1154
|
readonly phase: Phase;
|
|
1111
1155
|
/** Review rounds started so far. */
|
|
1112
1156
|
readonly reviewRounds: number;
|
|
1157
|
+
/** Approved-head rebases attempted in this run. Each fresh conflict may buy
|
|
1158
|
+
* one fix round; the run's remaining lease, not pull-request lifetime, is the bound. */
|
|
1159
|
+
readonly rebaseAttempts: number;
|
|
1113
1160
|
readonly pr?: PrRef;
|
|
1114
1161
|
readonly lastReviewHead?: string;
|
|
1115
1162
|
readonly lastVerdictSummary?: string;
|
|
@@ -1167,6 +1214,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
|
|
|
1167
1214
|
clock: at,
|
|
1168
1215
|
phase: { at: "pre-check" },
|
|
1169
1216
|
reviewRounds: 0,
|
|
1217
|
+
rebaseAttempts: 0,
|
|
1170
1218
|
spentMs: { coding: 0, review: 0, waiting: 0 },
|
|
1171
1219
|
spendUsd: input.session?.spendUsd ?? 0,
|
|
1172
1220
|
findingsByRound: {},
|
|
@@ -1205,11 +1253,8 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
|
|
|
1205
1253
|
* A review round `n` and the findings step that follows it share `n`; the
|
|
1206
1254
|
* module's positions are the loop's own. */
|
|
1207
1255
|
function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
{ kind: round.kind, index: loopPosition(loopOf(s), round.kind, round.index) },
|
|
1211
|
-
loopOf(s),
|
|
1212
|
-
);
|
|
1256
|
+
const kind = round.kind === "rebase" ? "findings" : round.kind;
|
|
1257
|
+
return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
|
|
1213
1258
|
}
|
|
1214
1259
|
|
|
1215
1260
|
/** The prefix every step of this pipeline is named under: the unit id, a
|
|
@@ -1245,6 +1290,9 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
|
1245
1290
|
};
|
|
1246
1291
|
}
|
|
1247
1292
|
const pr = s.pr!.number;
|
|
1293
|
+
if (round.kind === "rebase") {
|
|
1294
|
+
return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
|
|
1295
|
+
}
|
|
1248
1296
|
if (round.kind === "findings") {
|
|
1249
1297
|
const checks = checkFindingsOf(s.findingsByRound[round.index]);
|
|
1250
1298
|
return {
|
|
@@ -1338,6 +1386,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1338
1386
|
prNumber: p.prNumber,
|
|
1339
1387
|
headSha: p.headSha,
|
|
1340
1388
|
...(p.retry !== undefined ? { retry: p.retry } : {}),
|
|
1389
|
+
...(p.refire === true ? { refire: true as const } : {}),
|
|
1341
1390
|
};
|
|
1342
1391
|
case "checks-wait":
|
|
1343
1392
|
return {
|
|
@@ -1354,6 +1403,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1354
1403
|
headSha: p.headSha,
|
|
1355
1404
|
...(p.queued === true ? { queued: true as const } : {}),
|
|
1356
1405
|
};
|
|
1406
|
+
case "rebase":
|
|
1407
|
+
return {
|
|
1408
|
+
type: "rebase",
|
|
1409
|
+
step: `${unit}/rebase/${p.n}`,
|
|
1410
|
+
prNumber: p.pr.number,
|
|
1411
|
+
headSha: p.headSha,
|
|
1412
|
+
};
|
|
1357
1413
|
case "merge-wait":
|
|
1358
1414
|
return {
|
|
1359
1415
|
type: "wait-checks",
|
|
@@ -1508,6 +1564,13 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
|
|
|
1508
1564
|
};
|
|
1509
1565
|
}
|
|
1510
1566
|
|
|
1567
|
+
/** A rebase changed the approved patch, so review is mandatory even when
|
|
1568
|
+
* the ordinary findings-round count is spent. The run's remaining lease is
|
|
1569
|
+
* the bound: `enterRound` refuses when another review no longer fits. */
|
|
1570
|
+
function reviewAfterRebase(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
|
|
1571
|
+
return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1511
1574
|
/** The next review round, or the round cap. */
|
|
1512
1575
|
function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
|
|
1513
1576
|
if (s.reviewRounds >= s.input.caps.maxRounds)
|
|
@@ -1518,6 +1581,16 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi
|
|
|
1518
1581
|
const stopMode = (status: RunStatus): "soft" | "hard" | undefined =>
|
|
1519
1582
|
status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined;
|
|
1520
1583
|
|
|
1584
|
+
/** The last mechanical WIP push to this unit branch. Unlike an ordinary push,
|
|
1585
|
+
* it says the child ended before its work was ready for review. */
|
|
1586
|
+
function interruptedCheckpoint(
|
|
1587
|
+
s: UnitPipelineState,
|
|
1588
|
+
pushed: readonly PushedHeadFact[] | undefined,
|
|
1589
|
+
): { branch: string; sha: string } | undefined {
|
|
1590
|
+
const found = pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1);
|
|
1591
|
+
return found ? { branch: found.ref, sha: found.sha } : undefined;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1521
1594
|
/** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
|
|
1522
1595
|
function settleCoding(
|
|
1523
1596
|
s: UnitPipelineState,
|
|
@@ -1545,6 +1618,7 @@ function settleCoding(
|
|
|
1545
1618
|
}
|
|
1546
1619
|
: {}),
|
|
1547
1620
|
};
|
|
1621
|
+
const checkpoint = interruptedCheckpoint(next, facts.pushed);
|
|
1548
1622
|
const mode = stopMode(facts.status);
|
|
1549
1623
|
if (mode !== undefined)
|
|
1550
1624
|
return end(
|
|
@@ -1555,13 +1629,33 @@ function settleCoding(
|
|
|
1555
1629
|
round,
|
|
1556
1630
|
reviewRounds: next.reviewRounds,
|
|
1557
1631
|
...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
|
|
1632
|
+
...(checkpoint !== undefined ? { checkpoint } : {}),
|
|
1558
1633
|
},
|
|
1559
1634
|
[roundNote(round, "stopped")],
|
|
1560
1635
|
);
|
|
1636
|
+
// A mechanical WIP push means the child did not declare the work ready for
|
|
1637
|
+
// review. Keep the unit branch as the resumption point and abort this
|
|
1638
|
+
// attempt; an ordinary push still takes the established recover-PR path.
|
|
1639
|
+
if (facts.status === "failed" && checkpoint !== undefined) {
|
|
1640
|
+
const cause =
|
|
1641
|
+
facts.failure?.kind === "provider_transient"
|
|
1642
|
+
? "the model provider's transport retry budget was spent"
|
|
1643
|
+
: "it failed before finishing";
|
|
1644
|
+
return end(
|
|
1645
|
+
next,
|
|
1646
|
+
{
|
|
1647
|
+
kind: "aborted",
|
|
1648
|
+
reason: `⚠️ The coding child ended because ${cause}; the branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`. Re-issue the request to resume from it instead of starting over.`,
|
|
1649
|
+
round,
|
|
1650
|
+
reviewRounds: next.reviewRounds,
|
|
1651
|
+
},
|
|
1652
|
+
[roundNote(round, "aborted")],
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1561
1655
|
// A failed coding child no longer aborts outright: the pr-check looks at the
|
|
1562
|
-
// branch first —
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1656
|
+
// branch first — an ordinary push before the death is recovered as the
|
|
1657
|
+
// round's pull request, and only a branch with nothing on it ends the unit
|
|
1658
|
+
// with the child's own reason.
|
|
1565
1659
|
if (facts.status === "failed")
|
|
1566
1660
|
return {
|
|
1567
1661
|
state: {
|
|
@@ -1745,6 +1839,7 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
|
|
|
1745
1839
|
waitMs,
|
|
1746
1840
|
graced: false,
|
|
1747
1841
|
retried: false,
|
|
1842
|
+
refired: false,
|
|
1748
1843
|
},
|
|
1749
1844
|
},
|
|
1750
1845
|
notes,
|
|
@@ -1764,14 +1859,16 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
|
|
|
1764
1859
|
* (`expected`: a required check, or the repository's approve workflow
|
|
1765
1860
|
* whose run does not exist yet), or GitHub unreadable: the head waits on
|
|
1766
1861
|
* the settled event and is read again;
|
|
1767
|
-
* (—) no check reported
|
|
1862
|
+
* (—) no required check reported: one chunk of grace, then the pull_request
|
|
1863
|
+
* event is re-fired once by closing and reopening the pull request;
|
|
1768
1864
|
* (c) every expected check reported green: the round proceeds with no wait. */
|
|
1769
1865
|
function checksVerdict(
|
|
1770
1866
|
checks: RoundChecks | undefined,
|
|
1771
|
-
p: { retried: boolean; graced: boolean },
|
|
1867
|
+
p: { retried: boolean; graced: boolean; refired: boolean },
|
|
1772
1868
|
draft?: boolean,
|
|
1773
1869
|
):
|
|
1774
1870
|
| { kind: "retry"; names: string[] }
|
|
1871
|
+
| { kind: "refire" }
|
|
1775
1872
|
| { kind: "failed"; failed: CheckFailure[] }
|
|
1776
1873
|
| { kind: "draft" }
|
|
1777
1874
|
| { kind: "pending" }
|
|
@@ -1791,14 +1888,18 @@ function checksVerdict(
|
|
|
1791
1888
|
// person's "ready" changes anything — a red check above still gets its fix
|
|
1792
1889
|
// round, since that work stands whether or not the pull request is a draft.
|
|
1793
1890
|
if (draft === true) return { kind: "draft" };
|
|
1891
|
+
const required = checks?.required ?? [];
|
|
1892
|
+
const missing = checks?.expected ?? [];
|
|
1893
|
+
const noRequiredReported = required.length > 0 && missing.length === required.length;
|
|
1894
|
+
// A check launcher may report an unrelated workflow while every required
|
|
1895
|
+
// context is still absent. Give it one normal chunk; if it remains empty,
|
|
1896
|
+
// re-fire pull_request once. This is the same recovery a person performs for
|
|
1897
|
+
// a CI run that exists but started no workflows.
|
|
1898
|
+
if ((checks?.total === 0 || noRequiredReported) && !p.graced) return { kind: "grace" };
|
|
1899
|
+
if (noRequiredReported && !p.refired) return { kind: "refire" };
|
|
1794
1900
|
// GitHub unreadable answers as pending and is re-read at the chunk's end;
|
|
1795
1901
|
// an expected check not yet reported (issue 2063) is pending the same way.
|
|
1796
|
-
if (checks === undefined || checks.pending.length > 0 ||
|
|
1797
|
-
return { kind: "pending" };
|
|
1798
|
-
// No check reported at the head: one chunk of grace — the first check starts
|
|
1799
|
-
// within minutes where CI exists — then the round proceeds, so a repository
|
|
1800
|
-
// without CI costs one chunk per round and never idles (record 0055).
|
|
1801
|
-
if (checks.total === 0 && !p.graced) return { kind: "grace" };
|
|
1902
|
+
if (checks === undefined || checks.pending.length > 0 || missing.length > 0) return { kind: "pending" };
|
|
1802
1903
|
return { kind: "green" };
|
|
1803
1904
|
}
|
|
1804
1905
|
|
|
@@ -1810,14 +1911,15 @@ function checksVerdict(
|
|
|
1810
1911
|
* is still pending (issue 1991); only a head with no failed check waits a
|
|
1811
1912
|
* chunk inside the step's ask on what is unreadable or pending and then
|
|
1812
1913
|
* proceeds — the ending's facts read names what is still pending; a head with
|
|
1813
|
-
* no check reported
|
|
1814
|
-
* proceeds with no wait added. */
|
|
1914
|
+
* no required check reported gets one grace chunk and one event re-fire; a
|
|
1915
|
+
* green head proceeds with no wait added. */
|
|
1815
1916
|
function settleChecks(
|
|
1816
1917
|
s: UnitPipelineState,
|
|
1817
1918
|
p: Extract<Phase, { at: "checks" }>,
|
|
1818
1919
|
checks: RoundChecks | undefined,
|
|
1819
1920
|
retried?: boolean,
|
|
1820
1921
|
draft?: boolean,
|
|
1922
|
+
refired?: boolean,
|
|
1821
1923
|
): Transition {
|
|
1822
1924
|
const { round } = p;
|
|
1823
1925
|
const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
|
|
@@ -1833,11 +1935,23 @@ function settleChecks(
|
|
|
1833
1935
|
waitMs: p.waitMs,
|
|
1834
1936
|
graced: p.graced,
|
|
1835
1937
|
retried: p.retried,
|
|
1938
|
+
refired: p.refired,
|
|
1836
1939
|
...over,
|
|
1837
1940
|
},
|
|
1838
1941
|
},
|
|
1839
1942
|
notes: [],
|
|
1840
1943
|
});
|
|
1944
|
+
// The pull_request re-fire is an effect ask after a whole grace chunk with
|
|
1945
|
+
// no required check. Whether GitHub accepted it or not, spend the one attempt
|
|
1946
|
+
// and read immediately: that read registers the ordinary bounded wait. A
|
|
1947
|
+
// successful effect adds the round boundary the card renders.
|
|
1948
|
+
if (p.refire === true) {
|
|
1949
|
+
const { refire: _refire, ...read } = p;
|
|
1950
|
+
return {
|
|
1951
|
+
state: { ...s, phase: { ...read, n: p.n + 1, refired: true } },
|
|
1952
|
+
notes: refired === true ? [roundNote(p.round, "checks_restarted")] : [],
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1841
1955
|
// The retry ask. Dispatched: wait for the head to settle, then read again;
|
|
1842
1956
|
// a second failure is the finding. NOT dispatched (`retried: false` — no
|
|
1843
1957
|
// re-runnable run behind the checks, or GitHub refused): the one re-run is
|
|
@@ -1858,6 +1972,11 @@ function settleChecks(
|
|
|
1858
1972
|
state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
|
|
1859
1973
|
notes: [],
|
|
1860
1974
|
};
|
|
1975
|
+
case "refire":
|
|
1976
|
+
return {
|
|
1977
|
+
state: { ...s, phase: { ...p, n: p.n + 1, refire: true } },
|
|
1978
|
+
notes: [],
|
|
1979
|
+
};
|
|
1861
1980
|
case "failed": {
|
|
1862
1981
|
const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
|
|
1863
1982
|
const next: UnitPipelineState = {
|
|
@@ -1905,6 +2024,21 @@ function settleChecks(
|
|
|
1905
2024
|
}
|
|
1906
2025
|
}
|
|
1907
2026
|
|
|
2027
|
+
/** Re-enter the approved head's rebase step. The attempt count belongs to
|
|
2028
|
+
* this run, not to the pull request: every later base move is a fresh
|
|
2029
|
+
* conflict, while the one pipeline lease remains the hard bound. */
|
|
2030
|
+
export function reenterApprovedRebase(s: UnitPipelineState): UnitPipelineState {
|
|
2031
|
+
if (s.pr === undefined || s.lastReviewHead === undefined)
|
|
2032
|
+
throw new Error("an approved rebase needs the pull request and reviewed head");
|
|
2033
|
+
const { ending: _ending, ...open } = s;
|
|
2034
|
+
const n = s.rebaseAttempts + 1;
|
|
2035
|
+
return {
|
|
2036
|
+
...open,
|
|
2037
|
+
rebaseAttempts: n,
|
|
2038
|
+
phase: { at: "rebase", pr: s.pr, headSha: s.lastReviewHead, n },
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1908
2042
|
/** An approve past the checks read: merge_ready for a person, the merge door
|
|
1909
2043
|
* under `merge: runner` — the tail the round verdict guards (record 0055). */
|
|
1910
2044
|
function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
|
|
@@ -2155,6 +2289,22 @@ function roundOnOpenPr(
|
|
|
2155
2289
|
const { round } = phase;
|
|
2156
2290
|
const head = pr.headSha ?? phase.childHead;
|
|
2157
2291
|
const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
|
|
2292
|
+
if (round.kind === "rebase") {
|
|
2293
|
+
const pushed = normalizeHead(head);
|
|
2294
|
+
const approved = normalizeHead(s.lastReviewHead);
|
|
2295
|
+
if (pushed === undefined || (approved !== undefined && sameCommit(pushed, approved)))
|
|
2296
|
+
return end(
|
|
2297
|
+
next,
|
|
2298
|
+
{
|
|
2299
|
+
kind: "aborted",
|
|
2300
|
+
reason: `⚠️ Rebase round ${round.attempt ?? 1} left the approved head unchanged, so the conflict still has no resolution to review.`,
|
|
2301
|
+
round,
|
|
2302
|
+
reviewRounds: next.reviewRounds,
|
|
2303
|
+
},
|
|
2304
|
+
[roundNote(round, "aborted")],
|
|
2305
|
+
);
|
|
2306
|
+
return reviewAfterRebase({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
|
|
2307
|
+
}
|
|
2158
2308
|
if (round.kind === "findings") {
|
|
2159
2309
|
// Nothing repushed → nothing to re-review, unless every finding of the
|
|
2160
2310
|
// last review was declined on the record: that re-review verifies the
|
|
@@ -2387,7 +2537,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2387
2537
|
// The hosted parent's hard stop landed while this child ran (record 0060;
|
|
2388
2538
|
// issue 1924): the unit ends stopped as the child ends, whatever the
|
|
2389
2539
|
// child's own status — the runner runs nothing more of it.
|
|
2390
|
-
if (r.stopped === true)
|
|
2540
|
+
if (r.stopped === true) {
|
|
2541
|
+
const checkpoint = p.round.kind === "review" ? undefined : interruptedCheckpoint(clocked, r.run.pushed);
|
|
2391
2542
|
return end(
|
|
2392
2543
|
clocked,
|
|
2393
2544
|
{
|
|
@@ -2396,14 +2547,33 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2396
2547
|
round: p.round,
|
|
2397
2548
|
reviewRounds: s.reviewRounds,
|
|
2398
2549
|
...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
|
|
2550
|
+
...(checkpoint !== undefined ? { checkpoint } : {}),
|
|
2399
2551
|
},
|
|
2400
2552
|
[roundNote(p.round, "stopped")],
|
|
2401
2553
|
);
|
|
2554
|
+
}
|
|
2402
2555
|
if (r.run.status === "interrupted") {
|
|
2403
2556
|
const cause = r.run.interruption;
|
|
2404
|
-
|
|
2405
|
-
//
|
|
2406
|
-
//
|
|
2557
|
+
const checkpoint = interruptedCheckpoint(clocked, r.run.pushed);
|
|
2558
|
+
// A mechanical WIP checkpoint is deliberately not opened for review:
|
|
2559
|
+
// the interrupted child never declared it finished. The next attempt
|
|
2560
|
+
// starts from the unit branch at that head.
|
|
2561
|
+
if (p.round.kind !== "review" && checkpoint !== undefined)
|
|
2562
|
+
return end(
|
|
2563
|
+
clocked,
|
|
2564
|
+
{
|
|
2565
|
+
kind: "interrupted",
|
|
2566
|
+
round: p.round,
|
|
2567
|
+
runId: p.runId,
|
|
2568
|
+
reviewRounds: s.reviewRounds,
|
|
2569
|
+
...(cause !== undefined ? { cause } : {}),
|
|
2570
|
+
checkpoint,
|
|
2571
|
+
},
|
|
2572
|
+
[roundNote(p.round, "aborted")],
|
|
2573
|
+
);
|
|
2574
|
+
// A dead CODING child may have pushed normally before the ledger closed
|
|
2575
|
+
// it: the pr-check recovers that branch. A review child has nothing on
|
|
2576
|
+
// the branch to recover, so its interruption still ends the unit at once.
|
|
2407
2577
|
if (p.round.kind !== "review")
|
|
2408
2578
|
return {
|
|
2409
2579
|
state: {
|
|
@@ -2438,7 +2608,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2438
2608
|
return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
|
|
2439
2609
|
case "checks": {
|
|
2440
2610
|
const r = ret as Extract<StepReturn, { type: "checks" }>;
|
|
2441
|
-
return settleChecks(clocked, p, r.checks, r.retried, r.draft);
|
|
2611
|
+
return settleChecks(clocked, p, r.checks, r.retried, r.draft, r.refired);
|
|
2442
2612
|
}
|
|
2443
2613
|
case "checks-wait":
|
|
2444
2614
|
// The event fired or the chunk elapsed either way the head is read again.
|
|
@@ -2455,6 +2625,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2455
2625
|
waitMs: p.waitMs,
|
|
2456
2626
|
graced: p.graced,
|
|
2457
2627
|
retried: p.retried,
|
|
2628
|
+
refired: p.refired,
|
|
2458
2629
|
},
|
|
2459
2630
|
},
|
|
2460
2631
|
notes: [],
|
|
@@ -2474,6 +2645,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2474
2645
|
reviewRounds: s.reviewRounds,
|
|
2475
2646
|
})
|
|
2476
2647
|
: end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
|
|
2648
|
+
if (r.outcome === "conflict") return { state: reenterApprovedRebase(clocked), notes: [] };
|
|
2477
2649
|
if (r.outcome === "refused")
|
|
2478
2650
|
return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
|
|
2479
2651
|
if (r.outcome === "removed") {
|
|
@@ -2538,6 +2710,32 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2538
2710
|
: [],
|
|
2539
2711
|
};
|
|
2540
2712
|
}
|
|
2713
|
+
case "rebase": {
|
|
2714
|
+
const r = ret as Extract<StepReturn, { type: "rebase" }>;
|
|
2715
|
+
if (r.outcome === "refused")
|
|
2716
|
+
return end(clocked, {
|
|
2717
|
+
kind: "merge_refused",
|
|
2718
|
+
pr: p.pr,
|
|
2719
|
+
reason: r.reason,
|
|
2720
|
+
reviewRounds: s.reviewRounds,
|
|
2721
|
+
});
|
|
2722
|
+
if (r.outcome === "conflict")
|
|
2723
|
+
return enterRound(clocked, {
|
|
2724
|
+
index: s.reviewRounds,
|
|
2725
|
+
kind: "rebase",
|
|
2726
|
+
attempt: p.n,
|
|
2727
|
+
});
|
|
2728
|
+
if (!("headSha" in r))
|
|
2729
|
+
return end(clocked, {
|
|
2730
|
+
kind: "merge_refused",
|
|
2731
|
+
pr: p.pr,
|
|
2732
|
+
reason: "the rebase resolver returned no head",
|
|
2733
|
+
reviewRounds: s.reviewRounds,
|
|
2734
|
+
});
|
|
2735
|
+
const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
|
|
2736
|
+
if (r.outcome === "changed") return reviewAfterRebase(rebased);
|
|
2737
|
+
return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
|
|
2738
|
+
}
|
|
2541
2739
|
case "merge-wait":
|
|
2542
2740
|
return {
|
|
2543
2741
|
state: {
|
|
@@ -2661,7 +2859,7 @@ export interface CommitChecksFacts {
|
|
|
2661
2859
|
* the line is unchanged. */
|
|
2662
2860
|
function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
|
|
2663
2861
|
if (facts?.mergeableState === "dirty")
|
|
2664
|
-
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}
|
|
2862
|
+
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`; the pipeline runner owns the rebase (an unchanged patch carries the approval). The approved work stands.`;
|
|
2665
2863
|
const fixups = facts?.fixupCommits ?? [];
|
|
2666
2864
|
if (fixups.length > 0)
|
|
2667
2865
|
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — ${fixups.length} unsquashed fix-up commit${fixups.length === 1 ? "" : "s"} on the head (${fixups.join("; ")}): squash into the unit's commit, push, and re-review.`;
|
|
@@ -2725,6 +2923,10 @@ export function renderUnitReport(
|
|
|
2725
2923
|
? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
|
|
2726
2924
|
: undefined;
|
|
2727
2925
|
const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
|
|
2926
|
+
const checkpointLine = (checkpoint: { branch: string; sha: string } | undefined) =>
|
|
2927
|
+
checkpoint === undefined
|
|
2928
|
+
? undefined
|
|
2929
|
+
: `The branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`; re-issue to resume from it instead of starting over.`;
|
|
2728
2930
|
switch (e.kind) {
|
|
2729
2931
|
case "merged":
|
|
2730
2932
|
if (e.by === "other")
|
|
@@ -2873,9 +3075,11 @@ export function renderUnitReport(
|
|
|
2873
3075
|
aside(reissue),
|
|
2874
3076
|
]);
|
|
2875
3077
|
case "stopped":
|
|
2876
|
-
if (!shows(verbosity, "verbose"))
|
|
3078
|
+
if (!shows(verbosity, "verbose"))
|
|
3079
|
+
return join([`${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`, checkpointLine(e.checkpoint)]);
|
|
2877
3080
|
return join([
|
|
2878
3081
|
`${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
|
|
3082
|
+
checkpointLine(e.checkpoint),
|
|
2879
3083
|
writeUpPointer(
|
|
2880
3084
|
s,
|
|
2881
3085
|
e.round.kind,
|
|
@@ -2930,7 +3134,7 @@ export function renderUnitReport(
|
|
|
2930
3134
|
reissue,
|
|
2931
3135
|
]);
|
|
2932
3136
|
case "interrupted":
|
|
2933
|
-
return shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0);
|
|
3137
|
+
return join([shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0), checkpointLine(e.checkpoint)]);
|
|
2934
3138
|
case "idle_expired":
|
|
2935
3139
|
return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
|
|
2936
3140
|
case "refused":
|
|
@@ -22,6 +22,9 @@ export interface HealthzBody {
|
|
|
22
22
|
build?: { commit?: unknown; builtAt?: unknown } | unknown;
|
|
23
23
|
/** ISO process start — `deploy restart`'s identity (the image, hence `build.commit`, is unchanged). */
|
|
24
24
|
startedAt?: unknown;
|
|
25
|
+
/** A refusal-only production boot's public reason. Such a generation owns a
|
|
26
|
+
* port so the operator can diagnose it, but is never live. */
|
|
27
|
+
config?: unknown;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** Parse a `/healthz` response body; undefined when it is not a JSON object
|
|
@@ -83,6 +86,11 @@ function decideReady<Identity>(
|
|
|
83
86
|
let reason: string;
|
|
84
87
|
if (!body) {
|
|
85
88
|
reason = "/healthz not answering with JSON (container restarting, or unreachable)";
|
|
89
|
+
} else if (body.ok !== true) {
|
|
90
|
+
reason =
|
|
91
|
+
typeof body.config === "string" && body.config !== ""
|
|
92
|
+
? `config: ${body.config}`
|
|
93
|
+
: "/healthz reports this container is not ready";
|
|
86
94
|
} else {
|
|
87
95
|
const verdict = identify(body);
|
|
88
96
|
if (verdict.live) return { kind: "live", ...verdict.identity };
|
|
@@ -79,6 +79,14 @@ export const profileSchema = z.object({
|
|
|
79
79
|
/** Where the bot's runtime config comes from at deploy time; `deploy all`
|
|
80
80
|
* materializes it into the image's build context. */
|
|
81
81
|
configSource: source,
|
|
82
|
+
/** The ingress-token subject allowed to stop the bot container. Rendered as
|
|
83
|
+
* a Worker var, so config recovery never depends on the runtime document it
|
|
84
|
+
* may be repairing. Absent disables the restart route. */
|
|
85
|
+
restart: z
|
|
86
|
+
.object({
|
|
87
|
+
deployer: z.string().regex(/^[A-Za-z0-9_.:@/-]+$/, "an ingress identity subject, without whitespace"),
|
|
88
|
+
})
|
|
89
|
+
.optional(),
|
|
82
90
|
/** Where `secrets put` reads values from: a directory of `<NAME>` files, or
|
|
83
91
|
* `op://Vault/Item` with the secret's name as the field. Optional: the
|
|
84
92
|
* secrets tooling has its own default directory. */
|