@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.
Files changed (130) hide show
  1. package/dist/assets/config/config.example.yaml +40 -11
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +310 -7
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/grants.ts +6 -0
  7. package/dist/assets/src/core/authz/policy.ts +11 -0
  8. package/dist/assets/src/core/budgets.ts +8 -0
  9. package/dist/assets/src/core/coordinator/driver.ts +9 -0
  10. package/dist/assets/src/core/costs.ts +221 -2
  11. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  12. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  13. package/dist/assets/src/core/plane/decide.ts +203 -0
  14. package/dist/assets/src/core/provider.ts +11 -0
  15. package/dist/assets/src/core/runEvents.ts +27 -1
  16. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  17. package/dist/assets/src/core/runRecord.ts +15 -0
  18. package/dist/assets/src/core/runUsage.ts +41 -9
  19. package/dist/assets/src/core/ship/coordinator.ts +45 -9
  20. package/dist/assets/web/dist/.vite/manifest.json +450 -430
  21. package/dist/assets/web/dist/assets/{AppShell-Bmz8nzz3.js → AppShell-CSXDCV4x.js} +1 -1
  22. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  23. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  24. package/dist/assets/web/dist/assets/{DeliveryPage-C7jaEJip.js → DeliveryPage-ngPsO2to.js} +1 -1
  25. package/dist/assets/web/dist/assets/{HomePage-cWbupQzy.js → HomePage-DvxTHzPx.js} +3 -3
  26. package/dist/assets/web/dist/assets/{InputMenu-C9E7YoII.js → InputMenu-BdZXnibG.js} +1 -1
  27. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  28. package/dist/assets/web/dist/assets/{NotFoundPage-4nlrq7DP.js → NotFoundPage-DHuWyjlq.js} +1 -1
  29. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  30. package/dist/assets/web/dist/assets/{PlanePage-BNy4tSgJ.js → PlanePage-DpWfiX4C.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-Dbtiq6_J.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CKtz9H1o.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  34. package/dist/assets/web/dist/assets/{RunRoutePage-Clh68nw9.js → RunRoutePage-ysJBY8xQ.js} +4 -4
  35. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  36. package/dist/assets/web/dist/assets/{RunsTabs-DotpuCSP.js → RunsTabs-kHbbxVHx.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-C8FYoZy5.js → ScheduledPage-BuLmfcbG.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingSelect-CSiBQCSX.js → SettingSelect-BfID5nF4.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SettingsPage-CkYIhLUl.js → SettingsPage-BujWkdU_.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-C0TUgnC1.js → StatusDot-C8Bc0pTX.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-CN5cZdl3.js → Tooltip-BWwJx27K.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-D_EtAl8r.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  43. package/dist/assets/web/dist/assets/{angular-html-qW40ZL9x.js → angular-html-C8b5YTHf.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-PUtZU8Cq.js → angular-ts-C3f4WYNe.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-D3fC7Qjd.js → apl-DSHxUJc3.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-Bi3JFHhh.js → astro-BSGCCBP6.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-CSpsf9Z9.js → blade-DFeYrMKk.js} +1 -1
  48. package/dist/assets/web/dist/assets/{c-DE_b1oWP.js → c-L_QOnFVv.js} +1 -1
  49. package/dist/assets/web/dist/assets/{chapel-CWXd9giL.js → chapel-DLQWUwhu.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cobol-CnMOmWNF.js → cobol-1DlMmrEO.js} +1 -1
  51. package/dist/assets/web/dist/assets/{coffee-B9WvURu8.js → coffee-BTB6os6N.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cpp-BZAJXlWI.js → cpp-BvJI2z3u.js} +1 -1
  53. package/dist/assets/web/dist/assets/{crystal-Bm48wAlb.js → crystal-B11wGW_h.js} +1 -1
  54. package/dist/assets/web/dist/assets/{css-WtMeC3ab.js → css-BiIWx5Pw.js} +1 -1
  55. package/dist/assets/web/dist/assets/{dist-B1Mf2JbM.js → dist-CpnyQGOb.js} +2 -2
  56. package/dist/assets/web/dist/assets/{edge-BmwNa6Ss.js → edge-nSglEXaD.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elixir-BotfP-Mn.js → elixir-x7o8qMl8.js} +1 -1
  58. package/dist/assets/web/dist/assets/{elm-DAFc6laN.js → elm-DGrRyqTQ.js} +1 -1
  59. package/dist/assets/web/dist/assets/{erb-QkOON3bB.js → erb--_dh58Nw.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-BPs-dd73.js → git-rebase-DbpUXwyg.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-D8eooepk.js → glimmer-js-BG0CuZG5.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-B0bRiDVg.js → glimmer-ts-CQIl5qyR.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Bv6bMe1n.js → glsl-9PJNQTcw.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-Bh9y-n9o.js → graphql-CzVTzjEI.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-DSEbn7Ir.js → hack-BhCmAz3Q.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-0NW1-z1f.js → haml-BmI12COR.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-BfgCHFbK.js → handlebars-Cib-ISTS.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-CQHc0gQ5.js → html-D3lDapvI.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-B0Tm7wLP.js → html-derivative-DRPY8vOK.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-GAuU3aOF.js → http-B9VttK6m.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-CrDFbF5B.js → hurl-C0saNJYj.js} +1 -1
  72. package/dist/assets/web/dist/assets/{indexRow-BxKswdlK.js → indexRow-DABQtONT.js} +1 -1
  73. package/dist/assets/web/dist/assets/{java-DYv5KhUe.js → java-BCFbtYAj.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-32SiLGg0.js → javascript-nwBjnrhh.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja--gJDUGYa.js → jinja-CMYO9byN.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-BlKLGzZH.js → jison-Cko8HnE_.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-BICanD1N.js → json-Dzv__YsX.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-CgW3-uFT.js → jsx-CTFxagih.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-BErRpwwL.js → julia-_Xvten1-.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-CordHv0h.js → just-bLps4qOu.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-D7LVPLbi.js → latex-D6UaYsUP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-3ehz2i4U.js → liquid-CoDIeULa.js} +1 -1
  83. package/dist/assets/web/dist/assets/{lua-C9ppSVrm.js → lua-D1KFRnVf.js} +1 -1
  84. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  85. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  86. package/dist/assets/web/dist/assets/{marko-xBZLUBEJ.js → marko-B5N5XcOw.js} +1 -1
  87. package/dist/assets/web/dist/assets/{mdc-BJxGoVgD.js → mdc-uxzjQO3q.js} +1 -1
  88. package/dist/assets/web/dist/assets/{nginx-B-sZmpsb.js → nginx-BMJxs2TK.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nim--s9wRABK.js → nim-DqQ3-8p1.js} +1 -1
  90. package/dist/assets/web/dist/assets/{org-DoUvssoI.js → org-DsY6LK59.js} +1 -1
  91. package/dist/assets/web/dist/assets/{perl-B-vGHKOy.js → perl--pq9iJFp.js} +1 -1
  92. package/dist/assets/web/dist/assets/{php-CerWx_Xb.js → php-RHCMbsM2.js} +1 -1
  93. package/dist/assets/web/dist/assets/{pug-CzKrBMb1.js → pug-CFNq_FCp.js} +1 -1
  94. package/dist/assets/web/dist/assets/{qml-Bs8L8I6k.js → qml-C9Acx9E8.js} +1 -1
  95. package/dist/assets/web/dist/assets/{r-w6uG6mob.js → r-DxlU8FBd.js} +1 -1
  96. package/dist/assets/web/dist/assets/{razor-dz2oj2bd.js → razor-CLJ2kKih.js} +1 -1
  97. package/dist/assets/web/dist/assets/{regexp-CfQdprnY.js → regexp-CzmdGoEp.js} +1 -1
  98. package/dist/assets/web/dist/assets/{rst-DAOaX2tb.js → rst-D9yuxBET.js} +1 -1
  99. package/dist/assets/web/dist/assets/{ruby-QcCe4udB.js → ruby-CIRcEvnD.js} +1 -1
  100. package/dist/assets/web/dist/assets/{sas-BrM4lAAc.js → sas-LOl-uUPh.js} +1 -1
  101. package/dist/assets/web/dist/assets/{scss-DVbJXoUb.js → scss-BX2_sZHY.js} +1 -1
  102. package/dist/assets/web/dist/assets/{shellscript-09fP2hZf.js → shellscript-4d-QvKkY.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellsession-Qgr8xjZF.js → shellsession-DhB16UGy.js} +1 -1
  104. package/dist/assets/web/dist/assets/{soy-DIYeNTuz.js → soy-Cm1xTUBr.js} +1 -1
  105. package/dist/assets/web/dist/assets/{sql-CzLR9msV.js → sql-BsfcBJdu.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sseReplay-BYo2EZKy.js → sseReplay-IzTdD4-3.js} +6 -6
  107. package/dist/assets/web/dist/assets/{stata-zCY1ZzH0.js → stata-Dwat3BSw.js} +1 -1
  108. package/dist/assets/web/dist/assets/{surrealql-CJIjTRV4.js → surrealql-P4rtxzAW.js} +1 -1
  109. package/dist/assets/web/dist/assets/{svelte-C5OTPFZg.js → svelte-DTcsi8S8.js} +1 -1
  110. package/dist/assets/web/dist/assets/{templ-DYj-UICF.js → templ-D-kP0Tg5.js} +1 -1
  111. package/dist/assets/web/dist/assets/{tex-xcie-3Gd.js → tex-D-4vuedK.js} +1 -1
  112. package/dist/assets/web/dist/assets/{ts-tags-CoN86VTt.js → ts-tags-jEEWYxsu.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tsx-n3-No_Br.js → tsx-CFnlrVzT.js} +1 -1
  114. package/dist/assets/web/dist/assets/{twig-DPiJ7Vp1.js → twig-D5rGrPjs.js} +1 -1
  115. package/dist/assets/web/dist/assets/{typescript-BP6HBaOp.js → typescript-nYIQ35or.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typst-Do9ZjsVP.js → typst-3-UX8dna.js} +1 -1
  117. package/dist/assets/web/dist/assets/{vue-CzAeXNRY.js → vue-CLIPIX03.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-html-D7iYmYjd.js → vue-html-Ci9CpkHU.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-vine-DtrMRsJx.js → vue-vine-DL5pEYYk.js} +1 -1
  120. package/dist/assets/web/dist/assets/{xml-DGJHo1c0.js → xml-bO80qikC.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xsl-Q00kvNgW.js → xsl-BPze60Ej.js} +1 -1
  122. package/dist/assets/web/dist/assets/{yaml-F-UGKaiO.js → yaml-BQUSon_o.js} +1 -1
  123. package/dist/cli.js +35752 -33944
  124. package/package.json +1 -1
  125. package/dist/assets/web/dist/assets/CostsPage-CxTKdpzU.js +0 -2
  126. package/dist/assets/web/dist/assets/PendingTurnRow-C-pUgyyX.js +0 -1
  127. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +0 -1
  128. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +0 -1
  129. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +0 -28
  130. 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
- | { complete: true; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[] }
129
- | { complete: false; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[]; gap: string };
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
- // a turn that counted none (an upstream error, a retry the harness's SDK
63
- // spent, a stream broken before its usage) has nothing a sum could leave
64
- // out, so it never turns the model unpriced; a model none of whose turns
65
- // carried price attrs stays without the fields (a record from before the
66
- // meter row — the table prices it, costs.md item 4b).
67
- const priced = new Map<string, { usd: number; pricedTurns: number; tokenTurns: number; sources: Set<string> }>();
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) ?? { usd: 0, pricedTurns: 0, tokenTurns: 0, sources: new Set<string>() };
91
- if (counted > 0) p.tokenTurns += 1;
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.pricedTurns >= p.tokenTurns ? p.usd : null;
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 at the child's own last push (the
1863
- // previous attempt ended `review_pending`): nothing to code, so the
1864
- // attempt adopts it and starts at the review round — never a fresh
1865
- // coding round on an already-shipped pull request.
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
- if (head !== undefined && lastPush !== undefined && sameCommit(head, lastPush))
1870
- return nextReview({
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: head,
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
  }