@coreplane/switchboard 1.242.0 → 1.243.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +20 -1
- package/dist/assets/deploy/cloudflare-memory/worker.ts +86 -29
- package/dist/assets/deploy/cloudflare-resident/worker.ts +105 -13
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/project.json +3 -3
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +30 -34
- package/dist/assets/src/config/profile.ts +68 -3
- package/dist/assets/src/core/authz/policy.ts +4 -0
- package/dist/assets/src/core/budgets.ts +313 -0
- package/dist/assets/src/core/coordinator/driver.ts +0 -10
- package/dist/assets/src/core/costs.ts +9 -76
- package/dist/assets/src/core/modelPricing.ts +212 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
- package/dist/assets/src/core/reviewVerdict.ts +7 -0
- package/dist/assets/src/core/runEvents.ts +28 -4
- package/dist/assets/src/core/runFriction.ts +2 -1
- package/dist/assets/src/core/runRecord.ts +47 -0
- package/dist/assets/src/core/runUsage.ts +158 -47
- package/dist/assets/src/core/schedules.ts +3 -0
- package/dist/assets/src/core/ship/coordinator.ts +101 -84
- package/dist/assets/src/core/ship/handoff.ts +9 -0
- package/dist/assets/src/execution/bashTimeout.ts +8 -5
- package/dist/assets/src/execution/residentRefresh.ts +28 -3
- package/dist/assets/src/execution/residentSteps.ts +1 -1
- package/dist/assets/web/dist/.vite/manifest.json +67 -64
- package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +2 -0
- package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +2 -0
- package/dist/assets/web/dist/assets/PendingTurnRow-BT9RhFZ7.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DTMBgnIW.js → ResidentDetailPage-DACalNVF.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-SHPdu6uZ.js +1 -0
- package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +9 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-DJ8HiCPt.js +1 -0
- package/dist/assets/web/dist/assets/{SettingsPage-BIGio8Y0.js → SettingsPage-DLiN5IgY.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-ELoXHlFt.js → StatusDot-Dw0T1M-P.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BoeFwYP2.js → Tooltip-BbLuIAiS.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DicUG96U.js +1 -0
- package/dist/assets/web/dist/assets/{dist-BU5UivXC.js → dist-twkFmSUY.js} +1 -1
- package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
- package/dist/assets/web/dist/assets/indexRow-B_s5tKyq.js +1 -0
- package/dist/assets/web/dist/assets/{main-B2fX10aW.css → main-B4kEF3Sg.css} +1 -1
- package/dist/assets/web/dist/assets/{main-D4EA1g6n.js → main-d-w-tIKt.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-g7ml86LM.js → sseReplay-C9m_EB8J.js} +4 -4
- package/dist/cli.js +2758 -1445
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-CtmKhhOF.js +0 -2
- package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +0 -2
- package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +0 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-CaDvXhzJ.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +0 -1
- package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +0 -6
- package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +0 -1
- package/dist/assets/web/dist/assets/ScheduledPage-DXD2gLJk.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import type { RunEvent } from "./runEvents.js";
|
|
2
2
|
|
|
3
|
-
// What a run cost in tokens, and who it belongs to — the data behind
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
3
|
+
// What a run cost in tokens, and who it belongs to — the data behind the cost
|
|
4
|
+
// dimensions of the costs page (docs/reference/specs/costs.md items 10–10a) and
|
|
5
|
+
// the dollars on a run's own page. Every provider call a run makes is one
|
|
6
|
+
// `model.turn` span with the provider's own token counts as attrs (metered by
|
|
7
|
+
// the model proxy or the runner; docs/reference/specs/tracing.md), so a run's
|
|
8
|
+
// usage is the sum of those spans, per model. It is computed ONCE, at finish,
|
|
9
|
+
// from the events still in memory (`assembleRunRecord`), and rides the record —
|
|
10
|
+
// a record's events may be cut to fit the byte budget, so an aggregate taken
|
|
11
|
+
// then is more faithful than one re-read later. A record written before the
|
|
12
|
+
// field existed has none; the store fills it in from the run's stored events
|
|
13
|
+
// on demand (the lazy backfill), and reports how many still wait.
|
|
14
|
+
//
|
|
15
|
+
// The store answers one row per run (`RunUsageRows`); the bot folds them into
|
|
16
|
+
// cells (`aggregateUsage`) — one per requester, thread, channel, agent and UTC
|
|
17
|
+
// day of finish — so a snapshot holds the cube every dimension is read from
|
|
18
|
+
// and never a row per run.
|
|
13
19
|
|
|
14
20
|
export interface ModelUsage {
|
|
15
21
|
turns: number;
|
|
@@ -28,6 +34,9 @@ export interface RunUsage {
|
|
|
28
34
|
|
|
29
35
|
export const UNKNOWN_MODEL = "unknown";
|
|
30
36
|
|
|
37
|
+
/** The agent of a cell whose record names none (a record written before the field, a hand-built one). */
|
|
38
|
+
export const UNKNOWN_AGENT = "unknown";
|
|
39
|
+
|
|
31
40
|
const MODEL_TURN = "model.turn";
|
|
32
41
|
|
|
33
42
|
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
@@ -96,7 +105,7 @@ export function isRunUsage(v: unknown): v is RunUsage {
|
|
|
96
105
|
return Object.values(u.byModel).every(isModelUsage);
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
// ---- the
|
|
108
|
+
// ---- the store's answer: one row per run -------------------------------------------------
|
|
100
109
|
|
|
101
110
|
/** One finished run as the aggregate sees it — the record's identity fields and its usage. */
|
|
102
111
|
export interface UsageRun {
|
|
@@ -105,22 +114,19 @@ export interface UsageRun {
|
|
|
105
114
|
userName?: string;
|
|
106
115
|
/** A child run is billed to whoever started its parent (run-history item 46). */
|
|
107
116
|
parentRunId?: string;
|
|
117
|
+
/** The thread the run ran in and the channel it belongs to (platform-namespaced, invariant 4). */
|
|
118
|
+
threadKey: string;
|
|
119
|
+
channelId: string;
|
|
120
|
+
/** The agent the run resolved to; absent on a record that names none. */
|
|
121
|
+
agent?: string;
|
|
108
122
|
startedAt: number;
|
|
109
123
|
finishedAt: number;
|
|
110
124
|
/** Absent on a record written before usage existed and not yet backfilled. */
|
|
111
125
|
usage?: RunUsage;
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
userName?: string;
|
|
117
|
-
/** The UTC day the run finished, `YYYY-MM-DD`. */
|
|
118
|
-
day: string;
|
|
119
|
-
runs: number;
|
|
120
|
-
/** Summed wall-clock of the runs (finish − start), for allocating shared cloud spend. */
|
|
121
|
-
wallMs: number;
|
|
122
|
-
usage: RunUsage;
|
|
123
|
-
}
|
|
128
|
+
/** Whose run it is, as far as billing goes. */
|
|
129
|
+
export type UsageIdentity = Pick<UsageRun, "userId" | "userName">;
|
|
124
130
|
|
|
125
131
|
export interface RunUsageQuery {
|
|
126
132
|
/** Runs that finished at or after this epoch ms … */
|
|
@@ -129,8 +135,43 @@ export interface RunUsageQuery {
|
|
|
129
135
|
untilMs: number;
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/** What the store answers (`POST /runs/usage`): the runs that finished in the
|
|
139
|
+
* range, oldest finish first, and the identity of every parent a child names
|
|
140
|
+
* that is outside the batch and known to the store — so the bot can bill the
|
|
141
|
+
* child without a second read. */
|
|
142
|
+
export interface RunUsageRows {
|
|
143
|
+
runs: UsageRun[];
|
|
144
|
+
parents: Record<string, UsageIdentity>;
|
|
145
|
+
/** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
|
|
146
|
+
pending: number;
|
|
147
|
+
/** The oldest finish the store still holds, so a page can bound its range to the data. */
|
|
148
|
+
earliestFinishedAt?: number;
|
|
149
|
+
retentionDays: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- the aggregate: the usage cube --------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** One cell: the runs one requester was billed for in one thread, one channel,
|
|
155
|
+
* on one agent, finishing on one UTC day. Every cost dimension is a sum over
|
|
156
|
+
* these — by user, thread, channel or agent along a key, by model inside `usage`. */
|
|
157
|
+
export interface UsageRow {
|
|
158
|
+
/** The UTC day the runs finished, `YYYY-MM-DD`. */
|
|
159
|
+
day: string;
|
|
160
|
+
/** The requester billed (a child's parent's). */
|
|
161
|
+
userId: string;
|
|
162
|
+
userName?: string;
|
|
163
|
+
threadKey: string;
|
|
164
|
+
channelId: string;
|
|
165
|
+
/** The agent, or `unknown` when the record names none. */
|
|
166
|
+
agent: string;
|
|
167
|
+
runs: number;
|
|
168
|
+
/** Summed wall-clock of the runs (finish − start), for allocating shared cloud spend. */
|
|
169
|
+
wallMs: number;
|
|
170
|
+
usage: RunUsage;
|
|
171
|
+
}
|
|
172
|
+
|
|
132
173
|
export interface RunUsageReport {
|
|
133
|
-
rows:
|
|
174
|
+
rows: UsageRow[];
|
|
134
175
|
/** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
|
|
135
176
|
pending: number;
|
|
136
177
|
/** The oldest finish the store still holds, so a page can bound its range to the data. */
|
|
@@ -140,34 +181,51 @@ export interface RunUsageReport {
|
|
|
140
181
|
|
|
141
182
|
export const dayOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
|
|
142
183
|
|
|
184
|
+
const identityOf = (who: UsageIdentity): UsageIdentity => ({
|
|
185
|
+
userId: who.userId,
|
|
186
|
+
...(who.userName ? { userName: who.userName } : {}),
|
|
187
|
+
});
|
|
188
|
+
|
|
143
189
|
/** Who a run is billed to: its parent's requester when it is a child and the
|
|
144
190
|
* parent is known (in the batch, or through `lookupParent`), else its own. */
|
|
145
191
|
export function billedTo(
|
|
146
192
|
run: UsageRun,
|
|
147
193
|
batch: ReadonlyMap<string, UsageRun>,
|
|
148
|
-
lookupParent: (id: string) =>
|
|
149
|
-
):
|
|
150
|
-
if (!run.parentRunId) return
|
|
194
|
+
lookupParent: (id: string) => UsageIdentity | undefined,
|
|
195
|
+
): UsageIdentity {
|
|
196
|
+
if (!run.parentRunId) return identityOf(run);
|
|
151
197
|
const parent = batch.get(run.parentRunId) ?? lookupParent(run.parentRunId);
|
|
152
|
-
|
|
153
|
-
return { userId: parent.userId, ...(parent.userName ? { userName: parent.userName } : {}) };
|
|
198
|
+
return identityOf(parent ?? run);
|
|
154
199
|
}
|
|
155
200
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
201
|
+
const CELL_ORDER = ["day", "userId", "threadKey", "channelId", "agent"] as const;
|
|
202
|
+
|
|
203
|
+
/** Pure: the runs summed into cells — per (billed user, thread, channel, agent,
|
|
204
|
+
* UTC day of finish). A run without usage counts as pending and contributes its
|
|
205
|
+
* run and wall-clock only. Rows come out oldest day first, then by user id,
|
|
206
|
+
* thread, channel and agent. */
|
|
207
|
+
export function aggregateUsage(
|
|
160
208
|
runs: readonly UsageRun[],
|
|
161
|
-
lookupParent: (id: string) =>
|
|
162
|
-
): { rows:
|
|
209
|
+
lookupParent: (id: string) => UsageIdentity | undefined = () => undefined,
|
|
210
|
+
): { rows: UsageRow[]; pending: number } {
|
|
163
211
|
const batch = new Map(runs.map((r) => [r.id, r]));
|
|
164
|
-
const rows = new Map<string,
|
|
212
|
+
const rows = new Map<string, UsageRow>();
|
|
165
213
|
let pending = 0;
|
|
166
214
|
for (const run of runs) {
|
|
167
215
|
const who = billedTo(run, batch, lookupParent);
|
|
168
216
|
const day = dayOf(run.finishedAt);
|
|
169
|
-
const
|
|
170
|
-
const
|
|
217
|
+
const agent = run.agent ?? UNKNOWN_AGENT;
|
|
218
|
+
const key = JSON.stringify([day, who.userId, run.threadKey, run.channelId, agent]);
|
|
219
|
+
const row = rows.get(key) ?? {
|
|
220
|
+
day,
|
|
221
|
+
userId: who.userId,
|
|
222
|
+
threadKey: run.threadKey,
|
|
223
|
+
channelId: run.channelId,
|
|
224
|
+
agent,
|
|
225
|
+
runs: 0,
|
|
226
|
+
wallMs: 0,
|
|
227
|
+
usage: emptyUsage(),
|
|
228
|
+
};
|
|
171
229
|
if (who.userName && !row.userName) row.userName = who.userName;
|
|
172
230
|
row.runs += 1;
|
|
173
231
|
row.wallMs += Math.max(0, run.finishedAt - run.startedAt);
|
|
@@ -175,25 +233,78 @@ export function aggregateUsageByUser(
|
|
|
175
233
|
else pending += 1;
|
|
176
234
|
rows.set(key, row);
|
|
177
235
|
}
|
|
236
|
+
const sorted = [...rows.values()].sort((a, b) => {
|
|
237
|
+
for (const k of CELL_ORDER) {
|
|
238
|
+
if (a[k] < b[k]) return -1;
|
|
239
|
+
if (a[k] > b[k]) return 1;
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
});
|
|
243
|
+
return { rows: sorted, pending };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The bot's fold over the store's answer: the cells, the parents outside the batch consulted. */
|
|
247
|
+
export function reportOfUsageRows(rows: RunUsageRows): RunUsageReport {
|
|
248
|
+
const { rows: cells, pending } = aggregateUsage(rows.runs, (id) => rows.parents[id]);
|
|
178
249
|
return {
|
|
179
|
-
rows:
|
|
180
|
-
|
|
250
|
+
rows: cells,
|
|
251
|
+
// The store counts what it could not price; the fold sees the same runs without `usage`.
|
|
252
|
+
pending: Math.max(pending, rows.pending),
|
|
253
|
+
...(rows.earliestFinishedAt !== undefined ? { earliestFinishedAt: rows.earliestFinishedAt } : {}),
|
|
254
|
+
retentionDays: rows.retentionDays,
|
|
181
255
|
};
|
|
182
256
|
}
|
|
183
257
|
|
|
258
|
+
const isIdentity = (v: unknown): v is UsageIdentity =>
|
|
259
|
+
typeof v === "object" &&
|
|
260
|
+
v !== null &&
|
|
261
|
+
typeof (v as UsageIdentity).userId === "string" &&
|
|
262
|
+
((v as UsageIdentity).userName === undefined || typeof (v as UsageIdentity).userName === "string");
|
|
263
|
+
|
|
264
|
+
function isUsageRun(v: unknown): v is UsageRun {
|
|
265
|
+
if (!isIdentity(v)) return false;
|
|
266
|
+
const r = v as UsageRun;
|
|
267
|
+
return (
|
|
268
|
+
typeof r.id === "string" &&
|
|
269
|
+
typeof r.threadKey === "string" &&
|
|
270
|
+
typeof r.channelId === "string" &&
|
|
271
|
+
(r.agent === undefined || typeof r.agent === "string") &&
|
|
272
|
+
(r.parentRunId === undefined || typeof r.parentRunId === "string") &&
|
|
273
|
+
typeof r.startedAt === "number" &&
|
|
274
|
+
typeof r.finishedAt === "number" &&
|
|
275
|
+
(r.usage === undefined || isRunUsage(r.usage))
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function hasReportTail(r: Record<string, unknown>): boolean {
|
|
280
|
+
if (typeof r.pending !== "number" || typeof r.retentionDays !== "number") return false;
|
|
281
|
+
return r.earliestFinishedAt === undefined || typeof r.earliestFinishedAt === "number";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The store's wire shape, as the Worker client re-validates it. */
|
|
285
|
+
export function isRunUsageRows(v: unknown): v is RunUsageRows {
|
|
286
|
+
if (typeof v !== "object" || v === null) return false;
|
|
287
|
+
const r = v as Record<string, unknown>;
|
|
288
|
+
if (!Array.isArray(r.runs) || !r.runs.every(isUsageRun)) return false;
|
|
289
|
+
if (typeof r.parents !== "object" || r.parents === null || Array.isArray(r.parents)) return false;
|
|
290
|
+
if (!Object.values(r.parents).every(isIdentity)) return false;
|
|
291
|
+
return hasReportTail(r);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The cells' wire shape, as the snapshot guard re-validates it. */
|
|
184
295
|
export function isRunUsageReport(v: unknown): v is RunUsageReport {
|
|
185
296
|
if (typeof v !== "object" || v === null) return false;
|
|
186
297
|
const r = v as Record<string, unknown>;
|
|
187
|
-
if (!Array.isArray(r.rows) ||
|
|
188
|
-
if (r.earliestFinishedAt !== undefined && typeof r.earliestFinishedAt !== "number") return false;
|
|
298
|
+
if (!Array.isArray(r.rows) || !hasReportTail(r)) return false;
|
|
189
299
|
return r.rows.every(
|
|
190
300
|
(row) =>
|
|
191
|
-
|
|
192
|
-
row
|
|
193
|
-
typeof (row as
|
|
194
|
-
typeof (row as
|
|
195
|
-
typeof (row as
|
|
196
|
-
typeof (row as
|
|
197
|
-
|
|
301
|
+
isIdentity(row) &&
|
|
302
|
+
typeof (row as UsageRow).day === "string" &&
|
|
303
|
+
typeof (row as UsageRow).threadKey === "string" &&
|
|
304
|
+
typeof (row as UsageRow).channelId === "string" &&
|
|
305
|
+
typeof (row as UsageRow).agent === "string" &&
|
|
306
|
+
typeof (row as UsageRow).runs === "number" &&
|
|
307
|
+
typeof (row as UsageRow).wallMs === "number" &&
|
|
308
|
+
isRunUsage((row as UsageRow).usage),
|
|
198
309
|
);
|
|
199
310
|
}
|
|
@@ -7,6 +7,9 @@ import { parseIngressTokenMap, tokenForSubject } from "./ingressTokens.js";
|
|
|
7
7
|
// worker), and each Worker's `scheduled()` looks its firing up HERE — so a
|
|
8
8
|
// schedule can never exist in one place and not the other.
|
|
9
9
|
//
|
|
10
|
+
// Schedules a person adds at runtime, and the hand-over of a firing to the
|
|
11
|
+
// `schedule:<name>` actor declared below, are docs/decisions/0049-a-stored-schedule-is-a-turn-the-minute-tick-fires.md.
|
|
12
|
+
//
|
|
10
13
|
// A schedule has three independent facets:
|
|
11
14
|
// worker — whose wrangler.jsonc carries the cron and whose `scheduled()` fires it
|
|
12
15
|
// action — what a firing does: `run` (the bot shim POSTs the generic /ingress as
|
|
@@ -34,8 +34,9 @@ import type { RunStatus } from "../runRecord.js";
|
|
|
34
34
|
import { normalizeHead, sameCommit } from "../reviewedHead.js";
|
|
35
35
|
import { formatFinding, type Finding, type FindingDisposition, type ReviewVerdictKind } from "../reviewVerdict.js";
|
|
36
36
|
import { parsePlanUnit, planUnitIds } from "./contract.js";
|
|
37
|
+
import { carve, loopPosition, MINUTE_MS, SHIP_WAIT, type Carve, type Loop } from "../budgets.js";
|
|
37
38
|
|
|
38
|
-
const MIN =
|
|
39
|
+
const MIN = MINUTE_MS;
|
|
39
40
|
|
|
40
41
|
/** What a pipeline runs under: the rounds cap from the `ship` config block, and
|
|
41
42
|
* the wall clock from the parent's EFFECTIVE profile — the ship preset's
|
|
@@ -45,34 +46,10 @@ export interface ShipCaps {
|
|
|
45
46
|
maxMinutes: number;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/** A round
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
export const SHIP_ROUND_RESERVE_MS = 3 * MIN;
|
|
53
|
-
|
|
54
|
-
/** What a round-0 coding child must leave on the pipeline's clock: two review
|
|
55
|
-
* rounds (a review and, after a fix, its re-review) and one merge poll — the
|
|
56
|
-
* loop the pipeline exists to run. The coding child's budget is clipped to
|
|
57
|
-
* the remaining wall clock MINUS this reserve (never under the two minutes a
|
|
58
|
-
* spawn accepts), so a child that uses its whole directive still hands the
|
|
59
|
-
* pipeline a clock that holds the review; without the clip a 40-minute
|
|
60
|
-
* pipeline hands 39 minutes to the child and caps out with the pull request
|
|
61
|
-
* shipped and unreviewed. */
|
|
62
|
-
export const SHIP_LOOP_RESERVE_MS = 2 * SHIP_ROUND_RESERVE_MS + 5 * MIN;
|
|
63
|
-
|
|
64
|
-
/** What a findings child (the fix after a review asked for changes) must leave
|
|
65
|
-
* on the clock: the re-review and one merge poll — one round's reserve and
|
|
66
|
-
* five minutes. Never the whole loop's: by the time a fix round runs, the
|
|
67
|
-
* first review has already happened, and holding two rounds back from a late
|
|
68
|
-
* fix would cap a pipeline that still has the time. */
|
|
69
|
-
export const SHIP_FIX_RESERVE_MS = SHIP_ROUND_RESERVE_MS + 5 * MIN;
|
|
70
|
-
|
|
71
|
-
/** The floor `validateShip` holds `ship.maxMinutes` to: the loop's reserve
|
|
72
|
-
* plus one round's — under it no coding child can both work and leave the
|
|
73
|
-
* review its time, so the config is refused at load rather than left to cap
|
|
74
|
-
* out on every unit. */
|
|
75
|
-
export const SHIP_MIN_MAX_MINUTES = (SHIP_LOOP_RESERVE_MS + SHIP_ROUND_RESERVE_MS) / MIN;
|
|
49
|
+
/** A round's minutes come from `carve` in `src/core/budgets.ts` (agent-ship
|
|
50
|
+
* item 8, decision 0046): the remainder minus the reserve derived over the
|
|
51
|
+
* rounds that must still follow, capped at the preset's ask, refused under the
|
|
52
|
+
* round's floor. Nothing here holds a reserve of its own. */
|
|
76
53
|
|
|
77
54
|
/** What a ship pipeline's thread and card say when the bot died under it (run-
|
|
78
55
|
* history item 36): the work it did stands on GitHub with nobody driving it,
|
|
@@ -525,7 +502,14 @@ export type UnitEnding =
|
|
|
525
502
|
| { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
|
|
526
503
|
| { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
|
|
527
504
|
| { kind: "round_cap"; maxRounds: number; reviewRounds: number }
|
|
528
|
-
| {
|
|
505
|
+
| {
|
|
506
|
+
kind: "wall_clock_cap";
|
|
507
|
+
remainingMs: number;
|
|
508
|
+
reviewRounds: number;
|
|
509
|
+
spent: ShipBudgetSpent;
|
|
510
|
+
/** The round the remainder could not hold: what it would have got and the floor it fell under. */
|
|
511
|
+
refused?: { round: RoundKind; minutes: number; floor: number };
|
|
512
|
+
}
|
|
529
513
|
/** The wall clock capped AFTER the coding child opened or updated the pull
|
|
530
514
|
* request: the work stands and only the review is missing, so the ending
|
|
531
515
|
* names the pull request and "review pending" instead of calling the unit a
|
|
@@ -584,9 +568,6 @@ export interface UnitPipelineInput {
|
|
|
584
568
|
/** The pull request's base — the branch the unit is created from and rebased onto. */
|
|
585
569
|
base: string;
|
|
586
570
|
caps: ShipCaps;
|
|
587
|
-
/** Each child preset's own wall-clock budget (its `maxMinutes`), the number a
|
|
588
|
-
* round's budget is clipped from — supplied by the bot, which holds the registry. */
|
|
589
|
-
childMinutes: Readonly<Record<ChildPreset, number>>;
|
|
590
571
|
/** Who merges: the instance's `merge` field as the plan route answers it —
|
|
591
572
|
* `runner` (a seeded plan, under its grant) or `person` (a task, or a
|
|
592
573
|
* record without the field). */
|
|
@@ -614,7 +595,7 @@ type Phase =
|
|
|
614
595
|
/** Before anything is created: what already heads the branch — a merged pull request ends the unit here. */
|
|
615
596
|
| { at: "pre-check" }
|
|
616
597
|
| { at: "branch" }
|
|
617
|
-
| { at: "spawn"; round: RoundRef; busy: number }
|
|
598
|
+
| { at: "spawn"; round: RoundRef; busy: number; minutes: number; holds: number }
|
|
618
599
|
| { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
|
|
619
600
|
/** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */
|
|
620
601
|
| { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
|
|
@@ -630,8 +611,8 @@ type Phase =
|
|
|
630
611
|
* request; with nothing pushed the unit ends with the child's own reason. */
|
|
631
612
|
dead?: "failed" | "interrupted";
|
|
632
613
|
}
|
|
633
|
-
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
|
|
634
|
-
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number }
|
|
614
|
+
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
|
|
615
|
+
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
|
|
635
616
|
| { at: "ended" };
|
|
636
617
|
|
|
637
618
|
export interface UnitPipelineState {
|
|
@@ -662,7 +643,7 @@ export interface UnitPipelineState {
|
|
|
662
643
|
}
|
|
663
644
|
|
|
664
645
|
/** Past a child's budget, the parent asks the bot instead of waiting on. */
|
|
665
|
-
export const WAIT_MARGIN_MS =
|
|
646
|
+
export const WAIT_MARGIN_MS = SHIP_WAIT.marginMinutes * MIN;
|
|
666
647
|
/** One slice of a wait on a child. The child's budget plus the margin is
|
|
667
648
|
* walked in chunks with a `read-record` between them, so an event the engine
|
|
668
649
|
* never delivered — refused, lost, sent to an instance that had ended — costs
|
|
@@ -670,19 +651,18 @@ export const WAIT_MARGIN_MS = 5 * MIN;
|
|
|
670
651
|
* is the machine's one cadence for asking the bot what it cannot be told (the
|
|
671
652
|
* margin and the merge poll are the same number), and it keeps a round to a
|
|
672
653
|
* few steps: a coding child's 45 minutes are ten waits and ten reads. */
|
|
673
|
-
export const WAIT_CHUNK_MS =
|
|
674
|
-
/**
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
export const MERGE_WAIT_MAX_MS = 60 * MIN;
|
|
654
|
+
export const WAIT_CHUNK_MS = SHIP_WAIT.chunkMinutes * MIN;
|
|
655
|
+
/** The merge wait is a round of its own: its minutes are carved from the
|
|
656
|
+
* pipeline's remainder when the door is first asked (the merge wait's ask and
|
|
657
|
+
* floor are rows of `src/core/budgets.ts`), and the wait below is sliced
|
|
658
|
+
* from that carve. */
|
|
679
659
|
/** One merge wait's fallback timeout: the old poll's cadence. The event wakes
|
|
680
660
|
* the machine at once when the intake delivers it; without one (the webhook
|
|
681
661
|
* not configured, a delivery lost) the door is still re-asked every chunk, so
|
|
682
662
|
* a merge is never slower than the poll it replaced. */
|
|
683
|
-
export const MERGE_WAIT_CHUNK_MS =
|
|
663
|
+
export const MERGE_WAIT_CHUNK_MS = SHIP_WAIT.mergeChunkMinutes * MIN;
|
|
684
664
|
/** A `busy` without the live run's id: nothing to wait on, so a short sleep before the spawn is asked again. */
|
|
685
|
-
export const BUSY_RETRY_MS =
|
|
665
|
+
export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
|
|
686
666
|
|
|
687
667
|
// ---- the unit pipeline: opening and the next action -----------------------------------------------
|
|
688
668
|
|
|
@@ -721,28 +701,16 @@ function waitSliceMs(clock: number, until: number): number {
|
|
|
721
701
|
return remaining > 0 ? Math.min(WAIT_CHUNK_MS, remaining) : WAIT_CHUNK_MS;
|
|
722
702
|
}
|
|
723
703
|
|
|
724
|
-
/** The
|
|
725
|
-
|
|
726
|
-
* spawn accepts. */
|
|
727
|
-
function budgetMinutesFor(s: UnitPipelineState, round: RoundRef): number {
|
|
728
|
-
const headroom = remainingMs(s) - reserveFor(round.kind);
|
|
729
|
-
return Math.max(2, Math.min(s.input.childMinutes[presetOf(round.kind)], Math.floor(headroom / MIN)));
|
|
730
|
-
}
|
|
704
|
+
/** The pipeline's loop as the config allows it. */
|
|
705
|
+
const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRounds });
|
|
731
706
|
|
|
732
|
-
/**
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
case "coding":
|
|
740
|
-
return SHIP_LOOP_RESERVE_MS;
|
|
741
|
-
case "findings":
|
|
742
|
-
return SHIP_FIX_RESERVE_MS;
|
|
743
|
-
case "review":
|
|
744
|
-
return 0;
|
|
745
|
-
}
|
|
707
|
+
/** A round's carve (agent-ship item 8): the remainder minus the reserve for
|
|
708
|
+
* the rounds after it, capped at its preset's ask, refused under its floor.
|
|
709
|
+
* A review round `n` and the findings step that follows it share `n`; the
|
|
710
|
+
* module's positions are the loop's own. */
|
|
711
|
+
function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
|
|
712
|
+
const kind = round.kind === "findings" ? "fix" : round.kind;
|
|
713
|
+
return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
|
|
746
714
|
}
|
|
747
715
|
|
|
748
716
|
const roundStep = (s: UnitPipelineState, round: RoundRef) => `${s.input.unit.id}/${round.index}/${round.kind}`;
|
|
@@ -800,7 +768,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
800
768
|
step: roundStep(s, p.round),
|
|
801
769
|
preset,
|
|
802
770
|
round: p.round,
|
|
803
|
-
budgetMinutes:
|
|
771
|
+
budgetMinutes: p.minutes,
|
|
804
772
|
brief: briefFor(s, p.round),
|
|
805
773
|
};
|
|
806
774
|
}
|
|
@@ -831,7 +799,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
831
799
|
type: "wait-checks",
|
|
832
800
|
step: `${unit}/merge/wait/${p.n}`,
|
|
833
801
|
headSha: p.headSha,
|
|
834
|
-
timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS,
|
|
802
|
+
timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
|
|
835
803
|
};
|
|
836
804
|
case "ended":
|
|
837
805
|
return { type: "end", step: `${unit}/end`, ending: s.ending! };
|
|
@@ -861,7 +829,7 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
|
|
|
861
829
|
/** The cap's ending: `review_pending` when the clock ran out entering a
|
|
862
830
|
* review round with the child's pull request standing — the work shipped and
|
|
863
831
|
* only the review is missing — else the wall-clock cap. Both carry the split. */
|
|
864
|
-
function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
|
|
832
|
+
function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): UnitEnding {
|
|
865
833
|
if (round?.kind === "review" && s.pr !== undefined)
|
|
866
834
|
return {
|
|
867
835
|
kind: "review_pending",
|
|
@@ -870,16 +838,28 @@ function capEnding(s: UnitPipelineState, round?: RoundRef): UnitEnding {
|
|
|
870
838
|
reviewRounds: s.reviewRounds,
|
|
871
839
|
spent: s.spentMs,
|
|
872
840
|
};
|
|
873
|
-
return {
|
|
841
|
+
return {
|
|
842
|
+
kind: "wall_clock_cap",
|
|
843
|
+
remainingMs: remainingMs(s),
|
|
844
|
+
reviewRounds: s.reviewRounds,
|
|
845
|
+
spent: s.spentMs,
|
|
846
|
+
...(round !== undefined && refused?.kind === "refused"
|
|
847
|
+
? { refused: { round: round.kind, minutes: refused.minutes, floor: refused.floor } }
|
|
848
|
+
: {}),
|
|
849
|
+
};
|
|
874
850
|
}
|
|
875
851
|
|
|
876
|
-
/** Start a round if
|
|
877
|
-
*
|
|
852
|
+
/** Start a round if its carve holds (agent-ship item 8): a round the
|
|
853
|
+
* remainder cannot carve above its floor is not dispatched, and the unit ends
|
|
854
|
+
* at the cap naming the round, what it would have got and the floor. */
|
|
878
855
|
function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
|
|
879
|
-
const
|
|
880
|
-
if (
|
|
856
|
+
const carved = roundCarve(s, round);
|
|
857
|
+
if (carved.kind === "refused") return end(s, capEnding(s, round, carved), notes);
|
|
881
858
|
const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
|
|
882
|
-
return {
|
|
859
|
+
return {
|
|
860
|
+
state: { ...s, reviewRounds, phase: { at: "spawn", round, busy: 0, minutes: carved.minutes, holds: carved.holds } },
|
|
861
|
+
notes,
|
|
862
|
+
};
|
|
883
863
|
}
|
|
884
864
|
|
|
885
865
|
/** The next review round, or the round cap. */
|
|
@@ -1042,7 +1022,26 @@ function settleReview(
|
|
|
1042
1022
|
{ kind: "merge_refused", pr, reason: "no approved head is known to merge at", reviewRounds: next.reviewRounds },
|
|
1043
1023
|
notes,
|
|
1044
1024
|
);
|
|
1045
|
-
|
|
1025
|
+
const mergeWait = carve(
|
|
1026
|
+
remainingMs(next),
|
|
1027
|
+
{ kind: "merge", index: loopPosition(loopOf(next), "merge") },
|
|
1028
|
+
loopOf(next),
|
|
1029
|
+
);
|
|
1030
|
+
if (mergeWait.kind === "refused")
|
|
1031
|
+
return end(
|
|
1032
|
+
next,
|
|
1033
|
+
{
|
|
1034
|
+
kind: "merge_refused",
|
|
1035
|
+
pr,
|
|
1036
|
+
reason: `the remaining ${mergeWait.minutes} minutes of the pipeline are under the merge wait's floor of ${mergeWait.floor}`,
|
|
1037
|
+
reviewRounds: next.reviewRounds,
|
|
1038
|
+
},
|
|
1039
|
+
notes,
|
|
1040
|
+
);
|
|
1041
|
+
return {
|
|
1042
|
+
state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock, waitMs: mergeWait.minutes * MIN } },
|
|
1043
|
+
notes,
|
|
1044
|
+
};
|
|
1046
1045
|
}
|
|
1047
1046
|
// request_changes: the verdict settled (and posted) — now a stop
|
|
1048
1047
|
// short-circuits the findings step, naming the review standing on the pull request.
|
|
@@ -1252,7 +1251,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1252
1251
|
case "spawned":
|
|
1253
1252
|
case "alreadySpawned": {
|
|
1254
1253
|
// The child's budget runs from the spawn's answer; the wait walks it, plus the margin, in chunks.
|
|
1255
|
-
const until = clocked.clock +
|
|
1254
|
+
const until = clocked.clock + p.minutes * MIN + WAIT_MARGIN_MS;
|
|
1256
1255
|
const runs =
|
|
1257
1256
|
p.round.kind === "review"
|
|
1258
1257
|
? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
|
|
@@ -1270,7 +1269,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1270
1269
|
case "busy": {
|
|
1271
1270
|
// Another run holds the unit's thread: wait for its end, then ask
|
|
1272
1271
|
// again — unless the pipeline's wall clock ran out meanwhile.
|
|
1273
|
-
|
|
1272
|
+
const recarved = roundCarve(clocked, p.round);
|
|
1273
|
+
if (recarved.kind === "refused") return end(clocked, capEnding(clocked, p.round, recarved));
|
|
1274
1274
|
return {
|
|
1275
1275
|
state: {
|
|
1276
1276
|
...clocked,
|
|
@@ -1302,8 +1302,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1302
1302
|
}
|
|
1303
1303
|
break;
|
|
1304
1304
|
}
|
|
1305
|
-
case "busy-wait":
|
|
1306
|
-
|
|
1305
|
+
case "busy-wait": {
|
|
1306
|
+
// The clock moved while the spawn was busy: the round is carved again
|
|
1307
|
+
// from what remains, and a carve now under the floor ends at the cap.
|
|
1308
|
+
const carved = roundCarve(s, p.round);
|
|
1309
|
+
if (carved.kind === "refused") return end(s, capEnding(s, p.round, carved));
|
|
1310
|
+
return {
|
|
1311
|
+
state: {
|
|
1312
|
+
...s,
|
|
1313
|
+
phase: { at: "spawn", round: p.round, busy: p.n, minutes: carved.minutes, holds: carved.holds },
|
|
1314
|
+
},
|
|
1315
|
+
notes: [],
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1307
1318
|
case "wait":
|
|
1308
1319
|
return {
|
|
1309
1320
|
state: { ...s, phase: { at: "read", round: p.round, runId: p.runId, n: p.n, until: p.until } },
|
|
@@ -1356,7 +1367,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1356
1367
|
if (r.outcome === "refused")
|
|
1357
1368
|
return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
|
|
1358
1369
|
const waited = r.at - p.since;
|
|
1359
|
-
if (waited >=
|
|
1370
|
+
if (waited >= p.waitMs)
|
|
1360
1371
|
return end(clocked, {
|
|
1361
1372
|
kind: "merge_refused",
|
|
1362
1373
|
pr: p.pr,
|
|
@@ -1364,13 +1375,19 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1364
1375
|
reviewRounds: s.reviewRounds,
|
|
1365
1376
|
});
|
|
1366
1377
|
return {
|
|
1367
|
-
state: {
|
|
1378
|
+
state: {
|
|
1379
|
+
...clocked,
|
|
1380
|
+
phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since, waitMs: p.waitMs },
|
|
1381
|
+
},
|
|
1368
1382
|
notes: [],
|
|
1369
1383
|
};
|
|
1370
1384
|
}
|
|
1371
1385
|
case "merge-wait":
|
|
1372
1386
|
return {
|
|
1373
|
-
state: {
|
|
1387
|
+
state: {
|
|
1388
|
+
...s,
|
|
1389
|
+
phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since, waitMs: p.waitMs },
|
|
1390
|
+
},
|
|
1374
1391
|
notes: [],
|
|
1375
1392
|
};
|
|
1376
1393
|
case "ended":
|
|
@@ -1505,7 +1522,7 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
|
|
|
1505
1522
|
]);
|
|
1506
1523
|
case "wall_clock_cap":
|
|
1507
1524
|
return join([
|
|
1508
|
-
`🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round — no approval after ${rounds}.${prLine}`,
|
|
1525
|
+
`🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round${e.refused ? ` (the ${e.refused.round} round would get ${e.refused.minutes} min, under its floor of ${e.refused.floor})` : ""} — no approval after ${rounds}.${prLine}`,
|
|
1509
1526
|
budgetSplitLine(e.spent, s.input.caps.maxMinutes),
|
|
1510
1527
|
splitReport(s),
|
|
1511
1528
|
reissue,
|
|
@@ -196,6 +196,15 @@ function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: str
|
|
|
196
196
|
];
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** Each entry as one line, list by list — a deviation as `Deviation: from → to
|
|
200
|
+
* — why`, a follow-up as `what — where`, an unproven criterion as `Unproven:
|
|
201
|
+
* criterion — why` — the same words the ledger rows carry, for a reader that
|
|
202
|
+
* wants the handoff as plain lines (the thread's artifacts block). Empty for
|
|
203
|
+
* an empty handoff. */
|
|
204
|
+
export function handoffLines(h: Handoff): string[] {
|
|
205
|
+
return entryLines(h).map((e) => e.row);
|
|
206
|
+
}
|
|
207
|
+
|
|
199
208
|
function prLink(pr: HandoffRenderContext["pr"]): string | undefined {
|
|
200
209
|
return pr ? `[#${pr.number}](${pr.url})` : undefined;
|
|
201
210
|
}
|