@coreplane/switchboard 1.253.0 → 1.254.1
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/dist/assets/config/config.example.yaml +40 -11
- package/dist/assets/deploy/cloudflare-memory/worker.ts +310 -7
- 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/core/authz/grants.ts +6 -0
- package/dist/assets/src/core/authz/policy.ts +11 -0
- package/dist/assets/src/core/budgets.ts +8 -0
- package/dist/assets/src/core/coordinator/driver.ts +9 -0
- package/dist/assets/src/core/costs.ts +221 -2
- package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
- package/dist/assets/src/core/pipelineStanding.ts +294 -0
- package/dist/assets/src/core/plane/decide.ts +203 -0
- package/dist/assets/src/core/provider.ts +11 -0
- package/dist/assets/src/core/runEvents.ts +27 -1
- package/dist/assets/src/core/runLedger/transcript.ts +23 -3
- package/dist/assets/src/core/runRecord.ts +15 -0
- package/dist/assets/src/core/runUsage.ts +41 -9
- package/dist/assets/src/core/ship/coordinator.ts +45 -9
- package/dist/assets/web/dist/.vite/manifest.json +450 -430
- package/dist/assets/web/dist/assets/{AppShell-Bmz8nzz3.js → AppShell-CSXDCV4x.js} +1 -1
- package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-C7jaEJip.js → DeliveryPage-ngPsO2to.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-cWbupQzy.js → HomePage-DvxTHzPx.js} +3 -3
- package/dist/assets/web/dist/assets/{InputMenu-C9E7YoII.js → InputMenu-BdZXnibG.js} +1 -1
- package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
- package/dist/assets/web/dist/assets/{NotFoundPage-4nlrq7DP.js → NotFoundPage-DHuWyjlq.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
- package/dist/assets/web/dist/assets/{PlanePage-BNy4tSgJ.js → PlanePage-DpWfiX4C.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Dbtiq6_J.js → ResidentDetailPage-DG86v39Y.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CKtz9H1o.js → ResidentsIndexPage-x6p689VH.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-Clh68nw9.js → RunRoutePage-ysJBY8xQ.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-DotpuCSP.js → RunsTabs-kHbbxVHx.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-C8FYoZy5.js → ScheduledPage-BuLmfcbG.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingSelect-CSiBQCSX.js → SettingSelect-BfID5nF4.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-CkYIhLUl.js → SettingsPage-BujWkdU_.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C0TUgnC1.js → StatusDot-C8Bc0pTX.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CN5cZdl3.js → Tooltip-BWwJx27K.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-D_EtAl8r.js → UnitRoutePage-B9kjA1AT.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-html-qW40ZL9x.js → angular-html-C8b5YTHf.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-PUtZU8Cq.js → angular-ts-C3f4WYNe.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-D3fC7Qjd.js → apl-DSHxUJc3.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-Bi3JFHhh.js → astro-BSGCCBP6.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-CSpsf9Z9.js → blade-DFeYrMKk.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DE_b1oWP.js → c-L_QOnFVv.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-CWXd9giL.js → chapel-DLQWUwhu.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-CnMOmWNF.js → cobol-1DlMmrEO.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-B9WvURu8.js → coffee-BTB6os6N.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-BZAJXlWI.js → cpp-BvJI2z3u.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-Bm48wAlb.js → crystal-B11wGW_h.js} +1 -1
- package/dist/assets/web/dist/assets/{css-WtMeC3ab.js → css-BiIWx5Pw.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-B1Mf2JbM.js → dist-CpnyQGOb.js} +2 -2
- package/dist/assets/web/dist/assets/{edge-BmwNa6Ss.js → edge-nSglEXaD.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-BotfP-Mn.js → elixir-x7o8qMl8.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-DAFc6laN.js → elm-DGrRyqTQ.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-QkOON3bB.js → erb--_dh58Nw.js} +1 -1
- package/dist/assets/web/dist/assets/{git-rebase-BPs-dd73.js → git-rebase-DbpUXwyg.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-D8eooepk.js → glimmer-js-BG0CuZG5.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-B0bRiDVg.js → glimmer-ts-CQIl5qyR.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-Bv6bMe1n.js → glsl-9PJNQTcw.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-Bh9y-n9o.js → graphql-CzVTzjEI.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-DSEbn7Ir.js → hack-BhCmAz3Q.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-0NW1-z1f.js → haml-BmI12COR.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-BfgCHFbK.js → handlebars-Cib-ISTS.js} +1 -1
- package/dist/assets/web/dist/assets/{html-CQHc0gQ5.js → html-D3lDapvI.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-B0Tm7wLP.js → html-derivative-DRPY8vOK.js} +1 -1
- package/dist/assets/web/dist/assets/{http-GAuU3aOF.js → http-B9VttK6m.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-CrDFbF5B.js → hurl-C0saNJYj.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BxKswdlK.js → indexRow-DABQtONT.js} +1 -1
- package/dist/assets/web/dist/assets/{java-DYv5KhUe.js → java-BCFbtYAj.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-32SiLGg0.js → javascript-nwBjnrhh.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja--gJDUGYa.js → jinja-CMYO9byN.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-BlKLGzZH.js → jison-Cko8HnE_.js} +1 -1
- package/dist/assets/web/dist/assets/{json-BICanD1N.js → json-Dzv__YsX.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-CgW3-uFT.js → jsx-CTFxagih.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-BErRpwwL.js → julia-_Xvten1-.js} +1 -1
- package/dist/assets/web/dist/assets/{just-CordHv0h.js → just-bLps4qOu.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-D7LVPLbi.js → latex-D6UaYsUP.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-3ehz2i4U.js → liquid-CoDIeULa.js} +1 -1
- package/dist/assets/web/dist/assets/{lua-C9ppSVrm.js → lua-D1KFRnVf.js} +1 -1
- package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
- package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
- package/dist/assets/web/dist/assets/{marko-xBZLUBEJ.js → marko-B5N5XcOw.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-BJxGoVgD.js → mdc-uxzjQO3q.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-B-sZmpsb.js → nginx-BMJxs2TK.js} +1 -1
- package/dist/assets/web/dist/assets/{nim--s9wRABK.js → nim-DqQ3-8p1.js} +1 -1
- package/dist/assets/web/dist/assets/{org-DoUvssoI.js → org-DsY6LK59.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-B-vGHKOy.js → perl--pq9iJFp.js} +1 -1
- package/dist/assets/web/dist/assets/{php-CerWx_Xb.js → php-RHCMbsM2.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-CzKrBMb1.js → pug-CFNq_FCp.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-Bs8L8I6k.js → qml-C9Acx9E8.js} +1 -1
- package/dist/assets/web/dist/assets/{r-w6uG6mob.js → r-DxlU8FBd.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-dz2oj2bd.js → razor-CLJ2kKih.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-CfQdprnY.js → regexp-CzmdGoEp.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-DAOaX2tb.js → rst-D9yuxBET.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-QcCe4udB.js → ruby-CIRcEvnD.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-BrM4lAAc.js → sas-LOl-uUPh.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-DVbJXoUb.js → scss-BX2_sZHY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellscript-09fP2hZf.js → shellscript-4d-QvKkY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-Qgr8xjZF.js → shellsession-DhB16UGy.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-DIYeNTuz.js → soy-Cm1xTUBr.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-CzLR9msV.js → sql-BsfcBJdu.js} +1 -1
- package/dist/assets/web/dist/assets/{sseReplay-BYo2EZKy.js → sseReplay-IzTdD4-3.js} +6 -6
- package/dist/assets/web/dist/assets/{stata-zCY1ZzH0.js → stata-Dwat3BSw.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-CJIjTRV4.js → surrealql-P4rtxzAW.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-C5OTPFZg.js → svelte-DTcsi8S8.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-DYj-UICF.js → templ-D-kP0Tg5.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-xcie-3Gd.js → tex-D-4vuedK.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-CoN86VTt.js → ts-tags-jEEWYxsu.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-n3-No_Br.js → tsx-CFnlrVzT.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-DPiJ7Vp1.js → twig-D5rGrPjs.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BP6HBaOp.js → typescript-nYIQ35or.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-Do9ZjsVP.js → typst-3-UX8dna.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-CzAeXNRY.js → vue-CLIPIX03.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-D7iYmYjd.js → vue-html-Ci9CpkHU.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-DtrMRsJx.js → vue-vine-DL5pEYYk.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-DGJHo1c0.js → xml-bO80qikC.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-Q00kvNgW.js → xsl-BPze60Ej.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-F-UGKaiO.js → yaml-BQUSon_o.js} +1 -1
- package/dist/cli.js +35752 -33944
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-CxTKdpzU.js +0 -2
- package/dist/assets/web/dist/assets/PendingTurnRow-C-pUgyyX.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +0 -1
- package/dist/assets/web/dist/assets/main-Cv0q9y56.js +0 -28
- package/dist/assets/web/dist/assets/main-DKFCM-sy.css +0 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The plane's decider (docs/reference/specs/orchestration-plane.md, the
|
|
2
|
+
// decider items; record 0064, "Where it lives"): one pure function over a
|
|
3
|
+
// closed event union and a closed effect union. The ledger object reads its
|
|
4
|
+
// state, calls `decide` and commits the writes and the effects inside ONE
|
|
5
|
+
// `transactionSync`, so a decision and its consequences land together or not
|
|
6
|
+
// at all. Node-free by design, like `table.ts`: no clock, no io, no ids it
|
|
7
|
+
// did not derive from its inputs — the same event over the same state is the
|
|
8
|
+
// same answer, which is what the shadow comparison and the tests rest on.
|
|
9
|
+
|
|
10
|
+
/** The three stages an ask is judged at (record 0064): the bot's admission
|
|
11
|
+
* door, the plan runner's seed door, the resident's seat. this unit decides the
|
|
12
|
+
* admission stage alone; the others' conditions arrive with their units. */
|
|
13
|
+
export type PlaneStage = "admission" | "runner" | "resident";
|
|
14
|
+
|
|
15
|
+
/** A queue condition: what must become true before the row may run. Each is
|
|
16
|
+
* flipped by an event the plane already sees, never polled. The union grows
|
|
17
|
+
* one member per stage as the later units land. */
|
|
18
|
+
export type PlaneCondition = { kind: "thread_free"; threadKey: string; met: boolean };
|
|
19
|
+
|
|
20
|
+
/** One queued ask: the `plane_queue` row. `position` counts the waiting rows
|
|
21
|
+
* ahead of it on the same conditions when it queued — the number a person is
|
|
22
|
+
* told, never recomputed for them. */
|
|
23
|
+
export interface PlaneQueueRow {
|
|
24
|
+
runId: string;
|
|
25
|
+
requester: string;
|
|
26
|
+
threadKey: string;
|
|
27
|
+
stage: PlaneStage;
|
|
28
|
+
/** The stored request, replayed verbatim when the row is admitted (the transport unit). */
|
|
29
|
+
request: Record<string, unknown>;
|
|
30
|
+
conditions: PlaneCondition[];
|
|
31
|
+
position: number;
|
|
32
|
+
queuedAt: number;
|
|
33
|
+
state: "waiting" | "admitted" | "withdrawn";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the decider knows: the queue and the threads with a live run. The
|
|
37
|
+
* object builds it from its own tables inside the same transaction that
|
|
38
|
+
* commits the answer. */
|
|
39
|
+
export interface PlaneState {
|
|
40
|
+
queue: PlaneQueueRow[];
|
|
41
|
+
liveThreads: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function emptyPlaneState(): PlaneState {
|
|
45
|
+
return { queue: [], liveThreads: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The closed event union. `ask`: may this run start now; `sealed`: a thread's
|
|
49
|
+
* live run ended (the ledger's seal, the event that flips `thread_free`);
|
|
50
|
+
* `withdraw`: the requester gave the wait up (`runs stop` on a queued id, the transport unit). */
|
|
51
|
+
export interface PlaneAskEvent {
|
|
52
|
+
kind: "ask";
|
|
53
|
+
at: number;
|
|
54
|
+
runId: string;
|
|
55
|
+
requester: string;
|
|
56
|
+
threadKey: string;
|
|
57
|
+
stage: PlaneStage;
|
|
58
|
+
request: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
export type PlaneEvent =
|
|
61
|
+
PlaneAskEvent | { kind: "sealed"; at: number; threadKey: string } | { kind: "withdraw"; at: number; runId: string };
|
|
62
|
+
|
|
63
|
+
/** The closed effect union: what the bot is asked to do, offered on its
|
|
64
|
+
* heartbeat and reclaim answers and acknowledged by id (`/plane/ack`). The
|
|
65
|
+
* id is derived from the run, so a duplicate offer after a roll is the same
|
|
66
|
+
* effect, acknowledged once. The transport unit adds execution; this one only shapes and stores. */
|
|
67
|
+
export type PlaneEffect = {
|
|
68
|
+
id: string;
|
|
69
|
+
kind: "admit";
|
|
70
|
+
runId: string;
|
|
71
|
+
threadKey: string;
|
|
72
|
+
request: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** What the object must persist beside the returned state — the decider names
|
|
76
|
+
* the rows, the object owns the SQL, both inside one `transactionSync`. */
|
|
77
|
+
export type PlaneWrite =
|
|
78
|
+
| { table: "plane_queue"; op: "put"; row: PlaneQueueRow }
|
|
79
|
+
| { table: "plane_queue"; op: "state"; runId: string; state: PlaneQueueRow["state"] }
|
|
80
|
+
| { table: "plane_effects"; op: "offer"; effect: PlaneEffect; at: number };
|
|
81
|
+
|
|
82
|
+
/** The bot's own outcome for one dispatch, posted to `POST /plane/outcome`
|
|
83
|
+
* under `plane.admission: shadow` (orchestration-plane item 8): `proceeded`, `refused:<code>` or
|
|
84
|
+
* `fell_cold:<token>` — the ledger object logs the decider's word beside it.
|
|
85
|
+
* `runId` is absent when the dispatch never minted one (a refusal). */
|
|
86
|
+
export interface PlaneOutcomePost {
|
|
87
|
+
runId?: string;
|
|
88
|
+
requester: string;
|
|
89
|
+
threadKey: string;
|
|
90
|
+
stage: PlaneStage;
|
|
91
|
+
outcome: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How the bot answers an offered effect (orchestration-plane item 7): `done` and `skipped` close it,
|
|
95
|
+
* `deferred` leaves it on the next heartbeat or reclaim answer. */
|
|
96
|
+
export type PlaneAckOutcome = "done" | "skipped" | "deferred";
|
|
97
|
+
|
|
98
|
+
export interface PlaneDecision {
|
|
99
|
+
state: PlaneState;
|
|
100
|
+
effects: PlaneEffect[];
|
|
101
|
+
writes: PlaneWrite[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One event in, the next state out, with the writes and effects that carry
|
|
105
|
+
* it — and nothing else: no transition, no writes (the state comes back as
|
|
106
|
+
* given). Never throws: an event about a run or thread the state does not
|
|
107
|
+
* know is a no-op, because the object replays outcomes from a bot whose view
|
|
108
|
+
* can be older than the tables. */
|
|
109
|
+
export function decide(state: PlaneState, event: PlaneEvent): PlaneDecision {
|
|
110
|
+
switch (event.kind) {
|
|
111
|
+
case "ask":
|
|
112
|
+
return onAsk(state, event);
|
|
113
|
+
case "sealed":
|
|
114
|
+
return onSealed(state, event);
|
|
115
|
+
case "withdraw":
|
|
116
|
+
return onWithdraw(state, event);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The shadow word for an ask the decider just judged (orchestration-plane item 8): `queued` when the
|
|
121
|
+
* decision holds a waiting row for the run, `proceed` when it holds none —
|
|
122
|
+
* what the object logs beside the bot's own outcome. */
|
|
123
|
+
export function planeAskWordOf(decision: PlaneDecision, runId: string): "proceed" | "queued" {
|
|
124
|
+
const row = decision.state.queue.find((r) => r.runId === runId);
|
|
125
|
+
return row && row.state === "waiting" ? "queued" : "proceed";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onAsk(state: PlaneState, event: PlaneAskEvent): PlaneDecision {
|
|
129
|
+
const threadLive = state.liveThreads.includes(event.threadKey);
|
|
130
|
+
if (!threadLive) return { state, effects: [], writes: [] };
|
|
131
|
+
const waitingAhead = state.queue.filter((r) => r.state === "waiting" && r.threadKey === event.threadKey).length;
|
|
132
|
+
const row: PlaneQueueRow = {
|
|
133
|
+
runId: event.runId,
|
|
134
|
+
requester: event.requester,
|
|
135
|
+
threadKey: event.threadKey,
|
|
136
|
+
stage: event.stage,
|
|
137
|
+
request: event.request,
|
|
138
|
+
conditions: [{ kind: "thread_free", threadKey: event.threadKey, met: false }],
|
|
139
|
+
position: waitingAhead + 1,
|
|
140
|
+
queuedAt: event.at,
|
|
141
|
+
state: "waiting",
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
state: { ...state, queue: [...state.queue, row] },
|
|
145
|
+
effects: [],
|
|
146
|
+
writes: [{ table: "plane_queue", op: "put", row }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onSealed(state: PlaneState, event: { kind: "sealed"; at: number; threadKey: string }): PlaneDecision {
|
|
151
|
+
const liveThreads = state.liveThreads.filter((t) => t !== event.threadKey);
|
|
152
|
+
if (liveThreads.length === state.liveThreads.length && !hasWaiting(state, event.threadKey))
|
|
153
|
+
return { state, effects: [], writes: [] };
|
|
154
|
+
return walk({ ...state, liveThreads }, event.at);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onWithdraw(state: PlaneState, event: { kind: "withdraw"; at: number; runId: string }): PlaneDecision {
|
|
158
|
+
const row = state.queue.find((r) => r.runId === event.runId && r.state === "waiting");
|
|
159
|
+
if (!row) return { state, effects: [], writes: [] };
|
|
160
|
+
const queue = state.queue.map((r) => (r === row ? { ...r, state: "withdrawn" as const } : r));
|
|
161
|
+
return {
|
|
162
|
+
state: { ...state, queue },
|
|
163
|
+
effects: [],
|
|
164
|
+
writes: [{ table: "plane_queue", op: "state", runId: event.runId, state: "withdrawn" }],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasWaiting(state: PlaneState, threadKey: string): boolean {
|
|
169
|
+
return state.queue.some((r) => r.state === "waiting" && r.threadKey === threadKey);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The queue walk (record 0064): oldest first, and the state is re-evaluated
|
|
173
|
+
* after each admission — an admitted run's thread is live again, so a second
|
|
174
|
+
* row on the same thread keeps waiting for the next seal. */
|
|
175
|
+
function walk(state: PlaneState, at: number): PlaneDecision {
|
|
176
|
+
let next = state;
|
|
177
|
+
const effects: PlaneEffect[] = [];
|
|
178
|
+
const writes: PlaneWrite[] = [];
|
|
179
|
+
for (;;) {
|
|
180
|
+
const row = next.queue.find((r) => r.state === "waiting" && conditionsMet(next, r));
|
|
181
|
+
if (!row) break;
|
|
182
|
+
const admitted: PlaneQueueRow = { ...row, state: "admitted" };
|
|
183
|
+
const effect: PlaneEffect = {
|
|
184
|
+
id: `admit:${row.runId}`,
|
|
185
|
+
kind: "admit",
|
|
186
|
+
runId: row.runId,
|
|
187
|
+
threadKey: row.threadKey,
|
|
188
|
+
request: row.request,
|
|
189
|
+
};
|
|
190
|
+
next = {
|
|
191
|
+
queue: next.queue.map((r) => (r === row ? admitted : r)),
|
|
192
|
+
liveThreads: [...next.liveThreads, row.threadKey],
|
|
193
|
+
};
|
|
194
|
+
effects.push(effect);
|
|
195
|
+
writes.push({ table: "plane_queue", op: "state", runId: row.runId, state: "admitted" });
|
|
196
|
+
writes.push({ table: "plane_effects", op: "offer", effect, at });
|
|
197
|
+
}
|
|
198
|
+
return { state: next, effects, writes };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function conditionsMet(state: PlaneState, row: PlaneQueueRow): boolean {
|
|
202
|
+
return row.conditions.every((c) => !state.liveThreads.includes(c.threadKey));
|
|
203
|
+
}
|
|
@@ -155,8 +155,19 @@ export interface ProviderConfig {
|
|
|
155
155
|
apiKeyEnv?: string;
|
|
156
156
|
/** Base URL for openai-compatible providers (e.g. http://localhost:11434/v1). */
|
|
157
157
|
baseUrl?: string;
|
|
158
|
+
/** The invoice API the biller's daily tie-out reads (docs/reference/specs/costs.md
|
|
159
|
+
* item 4d): Anthropic's cost report, OpenRouter's activity endpoint, or
|
|
160
|
+
* OpenAI's organization costs. Requires `invoiceKeyEnv`. */
|
|
161
|
+
invoiceApi?: InvoiceApi;
|
|
162
|
+
/** Env var holding that API's key — an admin or management key, never the
|
|
163
|
+
* block's inference key (`apiKeyEnv`). Requires `invoiceApi`. */
|
|
164
|
+
invoiceKeyEnv?: string;
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
/** The invoice APIs a provider block may name (`invoiceApi`). */
|
|
168
|
+
export const INVOICE_APIS = ["anthropic-cost-report", "openrouter-activity", "openai-costs"] as const;
|
|
169
|
+
export type InvoiceApi = (typeof INVOICE_APIS)[number];
|
|
170
|
+
|
|
160
171
|
/** The wire a block speaks: its `wire` when declared, else its legacy `type`. */
|
|
161
172
|
export function wireOf(block: Pick<ProviderConfig, "type" | "wire">): Wire {
|
|
162
173
|
return block.wire ?? WIRE_ALIASES[block.type] ?? "openai-chat";
|
|
@@ -207,6 +207,13 @@ export type RunNoteKind =
|
|
|
207
207
|
* the summary names the branch. Published by the post-step, so a unit
|
|
208
208
|
* that ends without a pull request says why on the record and the card. */
|
|
209
209
|
| "pr_not_opened"
|
|
210
|
+
/** A coding run's pull request was opened or edited, but its head moved
|
|
211
|
+
* before the post-step's head pin and the second identity rewrite answered
|
|
212
|
+
* `unreadable` (docs/reference/specs/agent-coding.md item 2, record 0062):
|
|
213
|
+
* the open cannot be undone, so the summary names the reason the new tip's
|
|
214
|
+
* identities could not be verified and the reply carries the same warning.
|
|
215
|
+
* Published by the post-step. */
|
|
216
|
+
| "pr_head_unverified"
|
|
210
217
|
/** A review run's post-step posted nothing to the pull request — a guard's
|
|
211
218
|
* refusal, an opt-out, no pull request resolved, GitHub's own error — and
|
|
212
219
|
* the summary names the pull request (when one was resolved) and the
|
|
@@ -275,6 +282,14 @@ export type RunNoteKind =
|
|
|
275
282
|
* there was nothing to push), so a re-issue starts from the partial work
|
|
276
283
|
* (docs/reference/specs/agent-ship.md item 8). */
|
|
277
284
|
| "budget_salvage"
|
|
285
|
+
/** A pi run's compaction failed for good — the provider refused the summary
|
|
286
|
+
* (harness-pi.md item 7) — and the run loop treated it as a checkpoint
|
|
287
|
+
* signal: the tracked changes were committed and pushed to the run's own
|
|
288
|
+
* branch (a `pushed_head` event, `by: "salvage"`), or the note says plainly
|
|
289
|
+
* that the tree held nothing, so a context that overflows before the
|
|
290
|
+
* wind-down loses no work. The summary names the compaction failure.
|
|
291
|
+
* Published by the run loop's hook, which the pi harness awaits. */
|
|
292
|
+
| "compaction_salvage"
|
|
278
293
|
/** The loop's end found a tool call in flight and ended it, so the write-up
|
|
279
294
|
* keeps its allowance (decision 0046, unit seven; harness-pi item 6). The
|
|
280
295
|
* summary names the tools. Published by the harness beside the budget note. */
|
|
@@ -323,6 +338,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
323
338
|
"work_left_behind",
|
|
324
339
|
"workspace_torn_down",
|
|
325
340
|
"pr_not_opened",
|
|
341
|
+
"pr_head_unverified",
|
|
326
342
|
"review_not_posted",
|
|
327
343
|
"compacted",
|
|
328
344
|
"harness_error",
|
|
@@ -333,6 +349,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
333
349
|
"tool_unnamed",
|
|
334
350
|
"directory_reached",
|
|
335
351
|
"budget_salvage",
|
|
352
|
+
"compaction_salvage",
|
|
336
353
|
"stuck_loop",
|
|
337
354
|
"decline_cascade",
|
|
338
355
|
] as const satisfies readonly RunNoteKind[];
|
|
@@ -345,7 +362,7 @@ void _everyKindListed;
|
|
|
345
362
|
* the message, the thread's sticky preset, the user or the channel scope's
|
|
346
363
|
* `agent`, `defaults.agent`, or the request router. The replay harness
|
|
347
364
|
* (`load route`) reads it to tell a requester's own choice from a fallback. */
|
|
348
|
-
export type AgentSource = "directive" | "sticky" | "user" | "channel" | "default" | "route";
|
|
365
|
+
export type AgentSource = "directive" | "sticky" | "user" | "channel" | "default" | "route" | "operator";
|
|
349
366
|
|
|
350
367
|
/** How an operator asked a run to stop: `soft` — take no new steps and
|
|
351
368
|
* wrap up through the normal finale; `hard` — abort the in-flight call now, no
|
|
@@ -848,6 +865,10 @@ export type RunEvent =
|
|
|
848
865
|
* its own branches past the tree (docs/reference/specs/resident-repos.md
|
|
849
866
|
* item 16). Absent from an event a build before it recorded. */
|
|
850
867
|
head?: string;
|
|
868
|
+
/** How many of the run's commits the identity rewrite re-authored before
|
|
869
|
+
* the open (record 0062; docs/reference/specs/agent-coding.md item 2).
|
|
870
|
+
* Absent when none were, and from an event a build before it recorded. */
|
|
871
|
+
rewritten?: number;
|
|
851
872
|
seq?: number;
|
|
852
873
|
at?: number;
|
|
853
874
|
}
|
|
@@ -1027,6 +1048,11 @@ export type RunEvent =
|
|
|
1027
1048
|
proposal?: string;
|
|
1028
1049
|
refusalCause?: string;
|
|
1029
1050
|
refusalText?: string;
|
|
1051
|
+
/** A refusal the seam itself produced (a non-decision answer, a wrong
|
|
1052
|
+
* tool, a transport failure) — never the model's decision: under `on`
|
|
1053
|
+
* the dispatcher falls back to the readers' route for that event, this
|
|
1054
|
+
* event recorded on the run that then runs. */
|
|
1055
|
+
fallback?: true;
|
|
1030
1056
|
intake?: { verdict: string; reason: string };
|
|
1031
1057
|
latencyMs?: number;
|
|
1032
1058
|
outputTokens?: number;
|
|
@@ -125,8 +125,24 @@ export interface AssembledCompaction {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
export type AssembledTranscript =
|
|
128
|
-
| {
|
|
129
|
-
|
|
128
|
+
| {
|
|
129
|
+
complete: true;
|
|
130
|
+
turns: number;
|
|
131
|
+
messages: ChatMessage[];
|
|
132
|
+
compactions: AssembledCompaction[];
|
|
133
|
+
/** Each message's author (`StoredPart.actor`), aligned with `messages`;
|
|
134
|
+
* present only when at least one row carries one — the verifier selects
|
|
135
|
+
* the author's own turns by it (record 0057). */
|
|
136
|
+
actors?: (string | undefined)[];
|
|
137
|
+
}
|
|
138
|
+
| {
|
|
139
|
+
complete: false;
|
|
140
|
+
turns: number;
|
|
141
|
+
messages: ChatMessage[];
|
|
142
|
+
compactions: AssembledCompaction[];
|
|
143
|
+
actors?: (string | undefined)[];
|
|
144
|
+
gap: string;
|
|
145
|
+
};
|
|
130
146
|
|
|
131
147
|
/** Rows (any order) → the turns, contiguous from `base` (a log index; 0 for a
|
|
132
148
|
* run's own object), as a conversation counted from 0. `turns` counts every
|
|
@@ -150,6 +166,8 @@ export function assembleTranscript(
|
|
|
150
166
|
}
|
|
151
167
|
const messages: ChatMessage[] = [];
|
|
152
168
|
const compactions: AssembledCompaction[] = [];
|
|
169
|
+
const actorList: (string | undefined)[] = [];
|
|
170
|
+
const actorsOrNone = () => (actorList.some((a) => a !== undefined) ? { actors: actorList } : {});
|
|
153
171
|
/** The `messages` index each turn index landed at (a compaction row lands nowhere). */
|
|
154
172
|
const messageIndexOf = new Map<number, number>();
|
|
155
173
|
const gap = (why: string): AssembledTranscript => ({
|
|
@@ -157,6 +175,7 @@ export function assembleTranscript(
|
|
|
157
175
|
turns: messages.length + compactions.length,
|
|
158
176
|
messages,
|
|
159
177
|
compactions,
|
|
178
|
+
...actorsOrNone(),
|
|
160
179
|
gap: why,
|
|
161
180
|
});
|
|
162
181
|
const turnCount = byTurn.size === 0 ? 0 : Math.max(...byTurn.keys()) + 1;
|
|
@@ -187,11 +206,12 @@ export function assembleTranscript(
|
|
|
187
206
|
}
|
|
188
207
|
messageIndexOf.set(idx, messages.length);
|
|
189
208
|
messages.push({ role: role ?? "user", content });
|
|
209
|
+
actorList.push(first && !("compaction" in first) ? first.actor : undefined);
|
|
190
210
|
}
|
|
191
211
|
for (const c of compactions) {
|
|
192
212
|
if (c.entry.keptFrom === undefined) continue;
|
|
193
213
|
const kept = messageIndexOf.get(c.entry.keptFrom - base);
|
|
194
214
|
if (kept !== undefined) c.keptBefore = kept;
|
|
195
215
|
}
|
|
196
|
-
return { complete: true, turns: messages.length + compactions.length, messages, compactions };
|
|
216
|
+
return { complete: true, turns: messages.length + compactions.length, messages, compactions, ...actorsOrNone() };
|
|
197
217
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type ReviewVerdict,
|
|
15
15
|
} from "./reviewVerdict.js";
|
|
16
16
|
import { IDEMPOTENCY_KEY_PATTERN, INSTANCE_ID_PATTERN } from "./coordinator/contract.js";
|
|
17
|
+
import { isPipelineSummaryShape, type PipelineSummary } from "./pipelineStanding.js";
|
|
17
18
|
import {
|
|
18
19
|
FRICTION_CATEGORIES,
|
|
19
20
|
type CategoryTotals,
|
|
@@ -215,6 +216,15 @@ export interface RunRecord {
|
|
|
215
216
|
* coordinator tag. Present only on a ship run whose hand-off succeeded;
|
|
216
217
|
* a record written before the event has none. */
|
|
217
218
|
instanceId?: string;
|
|
219
|
+
/** The pipeline's standing (record 0065): the fold of the run's own
|
|
220
|
+
* `ship_round`/`ship_unit` events, summarized at the seal so the stored row
|
|
221
|
+
* reads it without the events. Present only on a run that published ship
|
|
222
|
+
* facts; a record written before the field carries none and reads as today. */
|
|
223
|
+
pipeline?: PipelineSummary;
|
|
224
|
+
/** A ship pipeline's parent run (record 0060; record 0065): `RunMeta.hosted`
|
|
225
|
+
* carried onto the record at the seal, so a history reader draws the run as
|
|
226
|
+
* a pipeline. Absent on every other run and on older records. */
|
|
227
|
+
hosted?: true;
|
|
218
228
|
/** Where the run's conversation started (item 52): `channel` — its own
|
|
219
229
|
* thread's history, as for every run a person, a schedule or a coordinator
|
|
220
230
|
* started — or `parent` — a spawned child seeded from its parent's text
|
|
@@ -985,6 +995,11 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
985
995
|
// The instance a ship run's hand-off created (record 0051 R2; item 2).
|
|
986
996
|
if (r.instanceId !== undefined && (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)))
|
|
987
997
|
return false;
|
|
998
|
+
// The pipeline's standing and the hosted marker (record 0065): the summary
|
|
999
|
+
// is checked for shape — an older reader accepts a newer record — and the
|
|
1000
|
+
// marker, like `provisional`, is the literal `true` or absent.
|
|
1001
|
+
if (r.pipeline !== undefined && !isPipelineSummaryShape(r.pipeline)) return false;
|
|
1002
|
+
if (r.hosted !== undefined && r.hosted !== true) return false;
|
|
988
1003
|
if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
|
|
989
1004
|
if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
|
|
990
1005
|
if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
|
|
@@ -31,6 +31,11 @@ export interface ModelUsage {
|
|
|
31
31
|
* a record whose turns predate the meter row (the price table then prices
|
|
32
32
|
* the tokens, costs.md item 4b). */
|
|
33
33
|
usd?: number | null;
|
|
34
|
+
/** The model's BYOK aggregator fees summed from its turns' `feeUsd` attrs —
|
|
35
|
+
* the share of `usd` that is the aggregator's own charge, what the biller's
|
|
36
|
+
* invoice tie-out compares against its `usage` column (costs.md item 4d).
|
|
37
|
+
* Absent when no turn carried one. */
|
|
38
|
+
feeUsd?: number;
|
|
34
39
|
/** The distinct `priceSource` words the turns carried, sorted — what the
|
|
35
40
|
* by-model view names as the source. Absent when no turn carried one. */
|
|
36
41
|
priceSources?: string[];
|
|
@@ -59,12 +64,24 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
|
|
|
59
64
|
const usage = emptyUsage();
|
|
60
65
|
// The meter row per model (model-proxy item 6): the turns' own dollars and
|
|
61
66
|
// sources. `usd` sums only when every turn that counted tokens carried one —
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
67
|
+
// the token-counting turns that lack a figure are counted directly
|
|
68
|
+
// (`unpricedTokenTurns`), so a priced zero-token turn can never stand in for
|
|
69
|
+
// an unpriced one; a turn that counted none (an upstream error, a retry the
|
|
70
|
+
// harness's SDK spent, a stream broken before its usage) has nothing a sum
|
|
71
|
+
// could leave out, so it never turns the model unpriced; a model none of
|
|
72
|
+
// whose turns carried price attrs stays without the fields (a record from
|
|
73
|
+
// before the meter row — the table prices it, costs.md item 4b).
|
|
74
|
+
const priced = new Map<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
usd: number;
|
|
78
|
+
feeUsd: number;
|
|
79
|
+
feeTurns: number;
|
|
80
|
+
pricedTurns: number;
|
|
81
|
+
unpricedTokenTurns: number;
|
|
82
|
+
sources: Set<string>;
|
|
83
|
+
}
|
|
84
|
+
>();
|
|
68
85
|
for (const e of events) {
|
|
69
86
|
if (e.type !== "span_end" || e.name !== MODEL_TURN) continue;
|
|
70
87
|
const attrs = (e.attrs ?? {}) as Record<string, unknown>;
|
|
@@ -87,19 +104,31 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
|
|
|
87
104
|
usage.turns += 1;
|
|
88
105
|
const hasUsd = typeof attrs.usd === "number" && Number.isFinite(attrs.usd);
|
|
89
106
|
const hasSource = typeof attrs.priceSource === "string" && attrs.priceSource !== "";
|
|
90
|
-
const p = priced.get(model) ?? {
|
|
91
|
-
|
|
107
|
+
const p = priced.get(model) ?? {
|
|
108
|
+
usd: 0,
|
|
109
|
+
feeUsd: 0,
|
|
110
|
+
feeTurns: 0,
|
|
111
|
+
pricedTurns: 0,
|
|
112
|
+
unpricedTokenTurns: 0,
|
|
113
|
+
sources: new Set<string>(),
|
|
114
|
+
};
|
|
115
|
+
if (counted > 0 && !hasUsd) p.unpricedTokenTurns += 1;
|
|
92
116
|
if (hasUsd) {
|
|
93
117
|
p.usd += attrs.usd as number;
|
|
94
118
|
p.pricedTurns += 1;
|
|
95
119
|
}
|
|
120
|
+
if (typeof attrs.feeUsd === "number" && Number.isFinite(attrs.feeUsd)) {
|
|
121
|
+
p.feeUsd += attrs.feeUsd;
|
|
122
|
+
p.feeTurns += 1;
|
|
123
|
+
}
|
|
96
124
|
if (hasSource) p.sources.add(attrs.priceSource as string);
|
|
97
125
|
priced.set(model, p);
|
|
98
126
|
}
|
|
99
127
|
for (const [model, p] of priced) {
|
|
100
128
|
if (p.pricedTurns === 0 && p.sources.size === 0) continue;
|
|
101
129
|
const m = usage.byModel[model]!;
|
|
102
|
-
m.usd = p.
|
|
130
|
+
m.usd = p.unpricedTokenTurns > 0 ? null : p.usd;
|
|
131
|
+
if (p.feeTurns > 0) m.feeUsd = p.feeUsd;
|
|
103
132
|
if (p.sources.size > 0) m.priceSources = [...p.sources].sort();
|
|
104
133
|
}
|
|
105
134
|
return usage;
|
|
@@ -128,6 +157,8 @@ export function addUsage(a: RunUsage, b: RunUsage): RunUsage {
|
|
|
128
157
|
const usd = foldUsd(acc.usd, m.usd);
|
|
129
158
|
if (usd === undefined) delete acc.usd;
|
|
130
159
|
else acc.usd = usd;
|
|
160
|
+
// A side without a fee spent none — a fee of nothing, never an unknown.
|
|
161
|
+
if (acc.feeUsd !== undefined || m.feeUsd !== undefined) acc.feeUsd = (acc.feeUsd ?? 0) + (m.feeUsd ?? 0);
|
|
131
162
|
const sources = new Set([...(acc.priceSources ?? []), ...(m.priceSources ?? [])]);
|
|
132
163
|
if (sources.size > 0) acc.priceSources = [...sources].sort();
|
|
133
164
|
}
|
|
@@ -145,6 +176,7 @@ const isModelUsage = (v: unknown): v is ModelUsage => {
|
|
|
145
176
|
)
|
|
146
177
|
return false;
|
|
147
178
|
if (m.usd !== undefined && m.usd !== null && typeof m.usd !== "number") return false;
|
|
179
|
+
if (m.feeUsd !== undefined && typeof m.feeUsd !== "number") return false;
|
|
148
180
|
return (
|
|
149
181
|
m.priceSources === undefined ||
|
|
150
182
|
(Array.isArray(m.priceSources) && m.priceSources.every((s) => typeof s === "string"))
|
|
@@ -461,6 +461,12 @@ export type CoordinatorAction =
|
|
|
461
461
|
| {
|
|
462
462
|
type: "pr-check";
|
|
463
463
|
step: string;
|
|
464
|
+
/** The unit-start's pre-check (issue 1689): the bot reads the entry facts
|
|
465
|
+
* beside the listing — the branch's own tip, whether its approval
|
|
466
|
+
* stands at the head, and the checks there — so a re-issued plan's
|
|
467
|
+
* unit resumes at review, or at the merge decision, never at coding
|
|
468
|
+
* over an already-shipped pull request. */
|
|
469
|
+
entry?: true;
|
|
464
470
|
/** Set after a coding child died: the bot opens the pull request from the
|
|
465
471
|
* pushed branch itself (title from the unit, body from this run's
|
|
466
472
|
* submitted description when the record holds one) instead of answering
|
|
@@ -545,6 +551,15 @@ export type PrCheck =
|
|
|
545
551
|
prNumber: number;
|
|
546
552
|
url: string;
|
|
547
553
|
headSha?: string;
|
|
554
|
+
/** The unit branch's own tip, read beside the listing on the entry check
|
|
555
|
+
* (issue 1689): the listing's `headSha` can lag a force-push, so the
|
|
556
|
+
* machine resumes at review only when the pull request heads the
|
|
557
|
+
* branch's actual tip — a head behind the branch is round 0's. */
|
|
558
|
+
branchHead?: string;
|
|
559
|
+
/** Whether an approving review by the bot stands at the head (the entry
|
|
560
|
+
* check, issue 1689): with green checks the attempt resumes straight at
|
|
561
|
+
* the merge decision instead of a review round. */
|
|
562
|
+
approved?: boolean;
|
|
548
563
|
autoMergeEnabled?: boolean;
|
|
549
564
|
/** The check runs at the head, when the read asked for them (the ending's facts). */
|
|
550
565
|
checks?: CommitChecksFacts;
|
|
@@ -1081,7 +1096,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1081
1096
|
const p = s.phase;
|
|
1082
1097
|
switch (p.at) {
|
|
1083
1098
|
case "pre-check":
|
|
1084
|
-
return { type: "pr-check", step: `${unit}/pr-check
|
|
1099
|
+
return { type: "pr-check", step: `${unit}/pr-check`, entry: true };
|
|
1085
1100
|
case "branch":
|
|
1086
1101
|
return { type: "branch", step: `${unit}/branch`, branch: s.input.unit.branch, from: s.input.base };
|
|
1087
1102
|
case "spawn": {
|
|
@@ -1859,19 +1874,40 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1859
1874
|
// by the coding child and adopted at the round's own pr-check.
|
|
1860
1875
|
const r = ret as Extract<StepReturn, { type: "pr-check" }>;
|
|
1861
1876
|
if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
|
|
1862
|
-
// The open pull request still heads
|
|
1863
|
-
//
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1877
|
+
// The open pull request still heads the branch — its head is the
|
|
1878
|
+
// branch's own tip (the entry facts, issue 1689) or the child's own last
|
|
1879
|
+
// push (the previous attempt ended `review_pending`, the row's
|
|
1880
|
+
// `lastPush`): nothing to code, so the attempt adopts it and starts at
|
|
1881
|
+
// the review round — never a fresh coding round on an already-shipped
|
|
1882
|
+
// pull request. Approved at that very head with green checks, there is
|
|
1883
|
+
// nothing to review either: the attempt resumes straight at the merge
|
|
1884
|
+
// decision — `merge_ready` for a person, the merge door under
|
|
1885
|
+
// `merge: runner`, which re-verifies the approval and the checks itself.
|
|
1866
1886
|
if (r.pr.state === "open") {
|
|
1867
1887
|
const head = normalizeHead(r.pr.headSha);
|
|
1888
|
+
const branchHead = normalizeHead(r.pr.branchHead);
|
|
1868
1889
|
const lastPush = normalizeHead(s.input.lastPush);
|
|
1869
|
-
|
|
1870
|
-
|
|
1890
|
+
const atBranchHead = head !== undefined && branchHead !== undefined && sameCommit(head, branchHead);
|
|
1891
|
+
const atLastPush = head !== undefined && lastPush !== undefined && sameCommit(head, lastPush);
|
|
1892
|
+
if (atBranchHead || atLastPush) {
|
|
1893
|
+
// The entry facts — the approval and the checks — were read at the
|
|
1894
|
+
// branch's own tip when GitHub answered it (`branchHead`, the
|
|
1895
|
+
// fresher read of the same head ref), else at the listing's sha.
|
|
1896
|
+
// The resume pins the review — and the merge decision — at that
|
|
1897
|
+
// same head, so the approved+green shortcut never fires at a stale
|
|
1898
|
+
// listing sha the facts do not describe.
|
|
1899
|
+
const entryHead = branchHead ?? head;
|
|
1900
|
+
const adopted: UnitPipelineState = {
|
|
1871
1901
|
...clocked,
|
|
1872
1902
|
pr: { number: r.pr.prNumber, url: r.pr.url },
|
|
1873
|
-
lastReviewHead:
|
|
1874
|
-
}
|
|
1903
|
+
lastReviewHead: entryHead,
|
|
1904
|
+
};
|
|
1905
|
+
const checks = r.pr.checks;
|
|
1906
|
+
const green =
|
|
1907
|
+
checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
|
|
1908
|
+
if (r.pr.approved === true && green) return approveOutcome(adopted, []);
|
|
1909
|
+
return nextReview(adopted);
|
|
1910
|
+
}
|
|
1875
1911
|
}
|
|
1876
1912
|
return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
|
|
1877
1913
|
}
|