@coreplane/switchboard 1.260.0 → 1.260.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 +1 -0
- package/dist/assets/deploy/cloudflare/worker.ts +17 -56
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
- package/dist/assets/deploy/profile.example.json +2 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/budgets.ts +12 -6
- package/dist/assets/src/core/coordinator/driver.ts +9 -4
- package/dist/assets/src/core/pipelineStanding.ts +2 -0
- package/dist/assets/src/core/runEvents.ts +9 -1
- package/dist/assets/src/core/runLedger/types.ts +3 -3
- package/dist/assets/src/core/runRecord.ts +28 -8
- package/dist/assets/src/core/ship/coordinator.ts +145 -36
- package/dist/assets/src/core/ship/renewal.ts +2 -0
- package/dist/assets/src/deploy/liveGate.ts +8 -0
- package/dist/assets/src/deploy/profile.ts +8 -0
- package/dist/assets/src/deploy/restart.ts +49 -35
- package/dist/assets/web/dist/.vite/manifest.json +67 -67
- package/dist/assets/web/dist/assets/{DeliveryPage-DvMrWUg7.js → DeliveryPage-62qCSLvk.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-CcGEJ4w0.js → HomePage-Dk3HRBc3.js} +1 -1
- package/dist/assets/web/dist/assets/{PendingTurnRow-BzGVxDYs.js → PendingTurnRow-CeTGOXUy.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-Arj9cyd5.js → PlanePage-RD0M6O6W.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CBPPe4Sj.js → ResidentDetailPage-Dl2KId95.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CEpXgGo7.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-BJq7tnLr.js → RunFoldRow-CwHDL-Mo.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-DWiny7LN.js → RunRoutePage-Du1HqLZs.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-Cxufw5sV.js → RunsIndexPage-BZphy3Hr.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-SBcuDdVg.js → ScheduledPage-B7jh898t.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-DrD_vWdp.js → SettingsPage-3uLEsiiE.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-Bjl7EPEN.js → SilentTurn-CKxSxnQC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BqteQTav.js → StatusDot-x7vcK0iE.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-KRqJXEPN.js → Tooltip-wVXCWhMp.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-Cv28FV4I.js → UnitRoutePage-V5BXK7FH.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CTASUSno.js → dist-C-XGnvM4.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-B1YBy_IL.js → indexRow-Dxg7C8s9.js} +1 -1
- package/dist/assets/web/dist/assets/{main-eTAe9-hk.js → main-DAL--hyj.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
- package/dist/cli.js +857 -288
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/budgets-DQXVllsm.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-BcsNbn2j.js +0 -11
|
@@ -172,6 +172,7 @@ defaults:
|
|
|
172
172
|
# "config set channel ..." are stored in data/overrides.json and win over these.
|
|
173
173
|
# channels:
|
|
174
174
|
# slack:C012345:
|
|
175
|
+
# repo: acme/api # default repository for repo-bound asks in this channel
|
|
175
176
|
# agent: review
|
|
176
177
|
# models:
|
|
177
178
|
# review: anthropic/claude-opus-5
|
|
@@ -28,14 +28,10 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
authenticateIngressBearer,
|
|
30
30
|
authenticateRestart,
|
|
31
|
+
authorizeRestartDeployer,
|
|
31
32
|
decideRestart,
|
|
32
|
-
parseRestartAuthorization,
|
|
33
33
|
parseRestartRequest,
|
|
34
|
-
RESTART_AUTHORIZE_PATH,
|
|
35
|
-
RESTART_SUBJECT_HEADER,
|
|
36
34
|
restartResponse,
|
|
37
|
-
stripRestartSubject,
|
|
38
|
-
type RestartAuth,
|
|
39
35
|
type RestartOutcome,
|
|
40
36
|
} from "../../src/deploy/restart.ts";
|
|
41
37
|
import {
|
|
@@ -89,7 +85,8 @@ export interface Env {
|
|
|
89
85
|
ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
|
|
90
86
|
ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
|
|
91
87
|
DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
|
|
92
|
-
SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
|
|
88
|
+
SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
|
|
89
|
+
SWITCHBOARD_RESTART_DEPLOYER?: string; // deployment-profile grant: the token-map subject allowed to POST /admin/restart; Worker-only, never runtime config
|
|
93
90
|
BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
|
|
94
91
|
GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
|
|
95
92
|
CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
|
|
@@ -211,37 +208,6 @@ export class SwitchboardServer extends Container<Env> {
|
|
|
211
208
|
return this.restartRunning(opts);
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
/**
|
|
215
|
-
* `deploy restart`, authorized: the Worker knows WHO the bearer is (the token
|
|
216
|
-
* map); WHETHER that identity may restart is the bot's config (`grants` —
|
|
217
|
-
* authorization.md item 9), which only the container holds. So ask it —
|
|
218
|
-
* `POST /admin/restart/authorize` with the authenticated subject in
|
|
219
|
-
* `RESTART_SUBJECT_HEADER` (never the bearer: the container may still hold the
|
|
220
|
-
* token map from before a rotation) — and stop only on a 200. A container that
|
|
221
|
-
* is not running is started first (the bot must answer): if the bearer is
|
|
222
|
-
* allowed, that start already put the current env live, so nothing is
|
|
223
|
-
* stopped and the outcome is `not-running`, exactly as before; if not, the
|
|
224
|
-
* refusal is relayed and the started container simply keeps serving.
|
|
225
|
-
*/
|
|
226
|
-
async restartAuthorized(
|
|
227
|
-
subject: string,
|
|
228
|
-
opts: { force: boolean },
|
|
229
|
-
): Promise<{ auth: RestartAuth; outcome?: RestartOutcome }> {
|
|
230
|
-
const wasRunning = this.ctx.container?.running === true;
|
|
231
|
-
await this.startBot();
|
|
232
|
-
const answer = await this.containerFetch(
|
|
233
|
-
new Request(`${INTERNAL}${RESTART_AUTHORIZE_PATH}`, {
|
|
234
|
-
method: "POST",
|
|
235
|
-
headers: { [RESTART_SUBJECT_HEADER]: subject },
|
|
236
|
-
}),
|
|
237
|
-
this.defaultPort,
|
|
238
|
-
);
|
|
239
|
-
const auth = parseRestartAuthorization(answer.status, await answer.text().catch(() => ""));
|
|
240
|
-
if (!auth.ok) return { auth };
|
|
241
|
-
if (!wasRunning) return { auth, outcome: { kind: "not-running" } };
|
|
242
|
-
return { auth, outcome: await this.restartRunning(opts) };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
211
|
/** The stop itself, for a running container: the preflight's refusal rules over `/healthz`, then SIGTERM. */
|
|
246
212
|
private async restartRunning(opts: { force: boolean }): Promise<RestartOutcome> {
|
|
247
213
|
const health = await this.containerFetch(new Request(`${INTERNAL}/healthz`), this.defaultPort);
|
|
@@ -265,12 +231,12 @@ export class SwitchboardServer extends Container<Env> {
|
|
|
265
231
|
}
|
|
266
232
|
}
|
|
267
233
|
|
|
268
|
-
/** `POST /admin/restart` — the operator surface behind `deploy restart
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
234
|
+
/** `POST /admin/restart` — the operator surface behind `deploy restart`.
|
|
235
|
+
* The Worker authenticates the bearer against its token map, then authorizes
|
|
236
|
+
* that subject against SWITCHBOARD_RESTART_DEPLOYER, rendered from the
|
|
237
|
+
* deployment profile (or set directly as a Worker var). Runtime config is
|
|
238
|
+
* never consulted: this route must remain usable when that document is what
|
|
239
|
+
* the restart is repairing.
|
|
274
240
|
* Body `{ "force": true }` bypasses the fail-closed refusals (no JSON body,
|
|
275
241
|
* impossible `inFlight`); runs in flight or a drain warn and never refuse. */
|
|
276
242
|
async function handleAdminRestart(request: Request, env: Env): Promise<Response> {
|
|
@@ -283,14 +249,16 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
|
|
|
283
249
|
console.warn(`[restart] ${authn.status} — ${authn.reason}`);
|
|
284
250
|
return json(authn.status, { ok: false, error: authn.reason });
|
|
285
251
|
}
|
|
252
|
+
const auth = authorizeRestartDeployer(authn.identity.subject, env.SWITCHBOARD_RESTART_DEPLOYER);
|
|
253
|
+
if (!auth.ok) {
|
|
254
|
+
console.warn(`[restart] ${auth.status} — ${auth.reason}`);
|
|
255
|
+
return json(auth.status, { ok: false, error: auth.reason });
|
|
256
|
+
}
|
|
286
257
|
const parsed = parseRestartRequest(await request.text().catch(() => ""));
|
|
287
258
|
if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
|
|
288
|
-
let
|
|
289
|
-
let outcome: RestartOutcome | undefined;
|
|
259
|
+
let outcome: RestartOutcome;
|
|
290
260
|
try {
|
|
291
|
-
|
|
292
|
-
force: parsed.force,
|
|
293
|
-
}));
|
|
261
|
+
outcome = await getContainer(env.SWITCHBOARD, INSTANCE).restart({ force: parsed.force });
|
|
294
262
|
} catch (err) {
|
|
295
263
|
// The container's /healthz probe or the DO call threw (container mid-transition,
|
|
296
264
|
// port not answering): fail closed in the route's own JSON shape so the CLI reads
|
|
@@ -299,11 +267,6 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
|
|
|
299
267
|
console.error(`[restart] ${authn.identity.subject} → error before stop: ${reason}`);
|
|
300
268
|
return json(500, { ok: false, error: `restart failed before stopping anything: ${reason}` });
|
|
301
269
|
}
|
|
302
|
-
if (!auth.ok || outcome === undefined) {
|
|
303
|
-
const refusal = auth.ok ? { status: 503 as const, reason: "restart disabled: no outcome" } : auth;
|
|
304
|
-
console.warn(`[restart] ${refusal.status} — ${refusal.reason}`);
|
|
305
|
-
return json(refusal.status, { ok: false, error: refusal.reason });
|
|
306
|
-
}
|
|
307
270
|
console.log(
|
|
308
271
|
`[restart] ${auth.subject} → ${outcome.kind}${outcome.kind === "refused" ? `: ${outcome.problems.join("; ")}` : ""}`,
|
|
309
272
|
);
|
|
@@ -494,9 +457,7 @@ export default {
|
|
|
494
457
|
// caller sent is stripped, and what the container sees carries this
|
|
495
458
|
// Worker's own root. A static asset or the live view's SSE stream gets no
|
|
496
459
|
// root; a refusal's line is dropped by the sink's filter.
|
|
497
|
-
|
|
498
|
-
// authorize call below); a caller cannot be allowed to speak it.
|
|
499
|
-
const inbound = stripRestartSubject(stripTraceContext(request));
|
|
460
|
+
const inbound = stripTraceContext(request);
|
|
500
461
|
const route = shimRoute(pathname);
|
|
501
462
|
if (route === undefined) return withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(inbound));
|
|
502
463
|
const root = tracer.start("bot-shim.fetch", { sinks: traceSinks, attrs: { route } });
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
|
|
32
32
|
"ACCESS_AUD": "{{access.aud}}",
|
|
33
33
|
// {{/if}}
|
|
34
|
+
// The restart route's deployment-side grant. The Worker authenticates a
|
|
35
|
+
// bearer through SWITCHBOARD_INGRESS_TOKENS and compares its subject here;
|
|
36
|
+
// it never asks the runtime config it may be restarting to repair.
|
|
37
|
+
// {{#if restart}}
|
|
38
|
+
"SWITCHBOARD_RESTART_DEPLOYER": "{{restart.deployer}}",
|
|
39
|
+
// {{/if}}
|
|
34
40
|
// The state Worker (deploy/cloudflare-memory/): where worker.ts records each
|
|
35
41
|
// scheduled firing for the /runs Scheduled panel, with MEMORY_TOKEN.
|
|
36
42
|
// A profile without a memory Worker renders no var: firings go unrecorded.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
|
|
2
|
+
"$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `restart.deployer` is the SWITCHBOARD_INGRESS_TOKENS subject the Worker itself allows to restart the bot, independent of runtime config so config recovery cannot lock itself out; `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
|
|
3
3
|
"account": "00000000000000000000000000000000",
|
|
4
4
|
"zone": "example.com",
|
|
5
5
|
"workers": {
|
|
@@ -9,5 +9,6 @@
|
|
|
9
9
|
"sandbox": { "script": "switchboard-sandbox", "hostname": "switchboard-sandbox.example.com" }
|
|
10
10
|
},
|
|
11
11
|
"configSource": "config/config.yaml",
|
|
12
|
+
"restart": { "deployer": "deployer" },
|
|
12
13
|
"images": "registry"
|
|
13
14
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.260.
|
|
3
|
+
"version": "1.260.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.260.
|
|
9
|
+
"version": "1.260.1",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -20445,7 +20445,7 @@
|
|
|
20445
20445
|
},
|
|
20446
20446
|
"packages/switchboard": {
|
|
20447
20447
|
"name": "@coreplane/switchboard",
|
|
20448
|
-
"version": "1.260.
|
|
20448
|
+
"version": "1.260.1",
|
|
20449
20449
|
"license": "Apache-2.0",
|
|
20450
20450
|
"dependencies": {
|
|
20451
20451
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.260.
|
|
3
|
+
"version": "1.260.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -104,11 +104,18 @@ export const HARNESS_PROBE_WAIT_MS = 5 * MINUTE_MS;
|
|
|
104
104
|
* `maxMinutes` so one nobody lifted is an hour and a half, not a day. A run
|
|
105
105
|
* asked during a drain waits at its attach one `pollMs` at a time under its
|
|
106
106
|
* own lease less `leaseReserveMs` (what the attach and the work after it
|
|
107
|
-
* need), `waitMaxMs` with no lease to clip it
|
|
107
|
+
* need), `waitMaxMs` with no lease to clip it — unless a fallback stands
|
|
108
|
+
* behind the attach: `fallbackWaitMs` bounds the wait by the FALLBACK's own
|
|
109
|
+
* cost, never the deploy's (issue 2101: a run refused by the drain waited
|
|
110
|
+
* 18 minutes for the whole deploy, then did the job in a seeded sandbox that
|
|
111
|
+
* stands up in about two), so a drain refusal on a first attach falls to the
|
|
112
|
+
* seeded sandbox after at most a few minutes; a re-attach with no fallback —
|
|
113
|
+
* a resumed run, a mid-run recovery — keeps the lease's bound. */
|
|
108
114
|
export const DRAIN = {
|
|
109
115
|
pollMs: 30_000,
|
|
110
116
|
waitMaxMs: 60 * MINUTE_MS,
|
|
111
117
|
leaseReserveMs: 10 * MINUTE_MS,
|
|
118
|
+
fallbackWaitMs: 3 * MINUTE_MS,
|
|
112
119
|
deployWaitMaxMs: 60 * MINUTE_MS,
|
|
113
120
|
marginMinutes: 5,
|
|
114
121
|
maxMinutes: 90,
|
|
@@ -218,11 +225,10 @@ export const MERGE_WAIT_ASK_MINUTES = 60;
|
|
|
218
225
|
* request's line with the conflict named, never a retry loop. */
|
|
219
226
|
export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
|
|
220
227
|
|
|
221
|
-
/** The provider retry ladder
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* because the observed blips are edge transients of seconds. */
|
|
228
|
+
/** The provider retry ladder: the first backoffs for a transport-class
|
|
229
|
+
* model-call failure. The final rung repeats while the run's loop lease has
|
|
230
|
+
* time, so these are pacing intervals rather than a three-attempt terminal
|
|
231
|
+
* budget. */
|
|
226
232
|
export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
|
|
227
233
|
|
|
228
234
|
/** A hosted ship parent's deadline margin past the pipeline's wall clock
|
|
@@ -469,7 +469,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
469
469
|
* ask's answer carries `retried` instead: whether the re-run was dispatched,
|
|
470
470
|
* so the machine never waits on a head an undispatched re-run left unchanged. */
|
|
471
471
|
function checksReturn(step: string, a: BotAnswer): StepReturn {
|
|
472
|
-
const { ok, checks, draft, retried, at } = a.body;
|
|
472
|
+
const { ok, checks, draft, retried, refired, at } = a.body;
|
|
473
473
|
if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
|
|
474
474
|
return {
|
|
475
475
|
type: "checks",
|
|
@@ -479,6 +479,7 @@ function checksReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
479
479
|
// unit for the ready event instead of merging or ending without a cause.
|
|
480
480
|
...(draft === true ? { draft: true } : {}),
|
|
481
481
|
...(typeof retried === "boolean" ? { retried } : {}),
|
|
482
|
+
...(typeof refired === "boolean" ? { refired } : {}),
|
|
482
483
|
at,
|
|
483
484
|
};
|
|
484
485
|
}
|
|
@@ -489,7 +490,10 @@ const isRoundChecks = (v: unknown): v is RoundChecks =>
|
|
|
489
490
|
Array.isArray(v.pending) &&
|
|
490
491
|
v.pending.every((n: unknown) => typeof n === "string") &&
|
|
491
492
|
Array.isArray(v.failed) &&
|
|
492
|
-
v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string")
|
|
493
|
+
v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string") &&
|
|
494
|
+
(v.required === undefined ||
|
|
495
|
+
(Array.isArray(v.required) && v.required.every((n: unknown) => typeof n === "string"))) &&
|
|
496
|
+
(v.expected === undefined || (Array.isArray(v.expected) && v.expected.every((n: unknown) => typeof n === "string")));
|
|
493
497
|
|
|
494
498
|
function mergeReturn(step: string, a: BotAnswer): StepReturn {
|
|
495
499
|
const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
|
|
@@ -670,8 +674,8 @@ async function perform(
|
|
|
670
674
|
}
|
|
671
675
|
case "checks":
|
|
672
676
|
// The round's checks step (record 0055): the bot reads the check runs at
|
|
673
|
-
// the reviewed head with the merge door's own reading — or
|
|
674
|
-
//
|
|
677
|
+
// the reviewed head with the merge door's own reading — or performs one
|
|
678
|
+
// of the step's bounded recovery effects first.
|
|
675
679
|
return checksReturn(
|
|
676
680
|
action.step,
|
|
677
681
|
answerOf(
|
|
@@ -682,6 +686,7 @@ async function perform(
|
|
|
682
686
|
prNumber: action.prNumber,
|
|
683
687
|
headSha: action.headSha,
|
|
684
688
|
...(action.retry !== undefined ? { retry: action.retry } : {}),
|
|
689
|
+
...(action.refire === true ? { refire: true } : {}),
|
|
685
690
|
}),
|
|
686
691
|
),
|
|
687
692
|
),
|
|
@@ -94,6 +94,7 @@ export const ROUND_STAGE = {
|
|
|
94
94
|
request_changes: "fix",
|
|
95
95
|
no_verdict: "hold",
|
|
96
96
|
checks_failed: "fix",
|
|
97
|
+
checks_restarted: "approved",
|
|
97
98
|
transient: "hold",
|
|
98
99
|
enqueued: "approved",
|
|
99
100
|
dequeued: "fix",
|
|
@@ -174,6 +175,7 @@ export const ROUND_OUTCOME_WORDS = {
|
|
|
174
175
|
request_changes: "changes requested",
|
|
175
176
|
no_verdict: "no verdict",
|
|
176
177
|
checks_failed: "checks failed",
|
|
178
|
+
checks_restarted: "checks restarted",
|
|
177
179
|
transient: "retried",
|
|
178
180
|
enqueued: "queued to merge",
|
|
179
181
|
dequeued: "removed from the merge queue",
|
|
@@ -294,6 +294,10 @@ export type RunNoteKind =
|
|
|
294
294
|
* there was nothing to push), so a re-issue starts from the partial work
|
|
295
295
|
* (docs/reference/specs/agent-ship.md item 8). */
|
|
296
296
|
| "budget_salvage"
|
|
297
|
+
/** A coordinator's coding child ended failed, stopped or interrupted with
|
|
298
|
+
* work in its tree: before teardown the run loop made a WIP commit and
|
|
299
|
+
* pushed the unit branch, or recorded why no push was possible. */
|
|
300
|
+
| "work_salvage"
|
|
297
301
|
/** A pi run's compaction failed for good — the provider refused the summary
|
|
298
302
|
* (harness-pi.md item 7) — and the run loop treated it as a checkpoint
|
|
299
303
|
* signal: the tracked changes were committed and pushed to the run's own
|
|
@@ -363,6 +367,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
363
367
|
"tool_unnamed",
|
|
364
368
|
"directory_reached",
|
|
365
369
|
"budget_salvage",
|
|
370
|
+
"work_salvage",
|
|
366
371
|
"compaction_salvage",
|
|
367
372
|
"stuck_loop",
|
|
368
373
|
"decline_cascade",
|
|
@@ -432,6 +437,9 @@ export type ShipRoundOutcome =
|
|
|
432
437
|
* 0055): the failures become check findings and the findings step runs as
|
|
433
438
|
* for any changes-requested round. */
|
|
434
439
|
| "checks_failed"
|
|
440
|
+
/** No required check appeared during the checks step's grace chunk, so the
|
|
441
|
+
* runner re-fired the pull_request event once by closing and reopening it. */
|
|
442
|
+
| "checks_restarted"
|
|
435
443
|
/** The round's coding child died on a provider transient with nothing
|
|
436
444
|
* pushed (issue 1932): the first such boundary marks the round's one
|
|
437
445
|
* re-run, a second the `transient` ending. */
|
|
@@ -1096,7 +1104,7 @@ export type RunEvent =
|
|
|
1096
1104
|
* `model` is the ref the run uses, resolved from a model the person
|
|
1097
1105
|
* named in plain words (the plain-words model unit) — applied at
|
|
1098
1106
|
* directive precedence. Additive: unknown → no model. */
|
|
1099
|
-
binds?: ReadonlyArray<{ line: string; reason: string; model?: string; confirmed?: true }>;
|
|
1107
|
+
binds?: ReadonlyArray<{ line: string; reason: string; model?: string; repo?: string; confirmed?: true }>;
|
|
1100
1108
|
question?: string;
|
|
1101
1109
|
/** A question's proposed line, redacted and cut like the receipt — what
|
|
1102
1110
|
* the next turn's "yes" binds (`bindFromAnswer`). */
|
|
@@ -128,9 +128,9 @@ export interface LiveRunMeta {
|
|
|
128
128
|
export type RunState = Record<string, unknown>;
|
|
129
129
|
|
|
130
130
|
/** A hosted ship parent's hosting fact on the row's state (record 0060): the
|
|
131
|
-
* runner instance the run hosts and
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* runner instance the run hosts and its original wall-clock estimate. Reclaim
|
|
132
|
+
* may outlive that estimate while the Workflow or one of its child rows is
|
|
133
|
+
* live; only no durable owner beyond it closes the row. */
|
|
134
134
|
export interface HostingState {
|
|
135
135
|
instanceId: string;
|
|
136
136
|
/** Epoch ms: the hand-off time plus the instance's `caps.maxMinutes` plus
|
|
@@ -289,19 +289,23 @@ export interface RunLease {
|
|
|
289
289
|
loopEndsAt: number;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
/** A head a run pushed, as the record names it (item 2): the branch
|
|
292
|
+
/** A head a run pushed, as the record names it (item 2): the branch, sha,
|
|
293
|
+
* and whether the agent pushed it or the run's mechanical WIP salvage did.
|
|
294
|
+
* `by` is optional for records written before that distinction survived the
|
|
295
|
+
* event fold. */
|
|
293
296
|
export interface PushedHead {
|
|
294
297
|
ref: string;
|
|
295
298
|
sha: string;
|
|
299
|
+
by?: "push" | "salvage";
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
/** The heads a run's events say it pushed — one per branch, the last event's
|
|
299
303
|
* sha winning, in first-seen order — or nothing when it pushed none. */
|
|
300
304
|
export function pushedHeadsOf(events: readonly RunEvent[]): PushedHead[] | undefined {
|
|
301
|
-
const byRef = new Map<string, string>();
|
|
302
|
-
for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, e.sha);
|
|
305
|
+
const byRef = new Map<string, { sha: string; by: "push" | "salvage" }>();
|
|
306
|
+
for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, { sha: e.sha, by: e.by });
|
|
303
307
|
if (byRef.size === 0) return undefined;
|
|
304
|
-
return [...byRef].map(([ref,
|
|
308
|
+
return [...byRef].map(([ref, pushed]) => ({ ref, ...pushed }));
|
|
305
309
|
}
|
|
306
310
|
|
|
307
311
|
/** The lease a run's events say its harness started — the first `lease`
|
|
@@ -440,7 +444,7 @@ export interface RunOperatorDecision {
|
|
|
440
444
|
mode: "shadow" | "on";
|
|
441
445
|
outcome: "binds" | "question" | "refusal" | "non_decision";
|
|
442
446
|
reason: string;
|
|
443
|
-
binds?: { line: string; reason: string }[];
|
|
447
|
+
binds?: { line: string; reason: string; model?: string; repo?: string; confirmed?: true }[];
|
|
444
448
|
question?: string;
|
|
445
449
|
/** A question's proposed line — what the next turn's "yes" binds. */
|
|
446
450
|
proposal?: string;
|
|
@@ -468,7 +472,17 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
|
|
|
468
472
|
mode: e.mode,
|
|
469
473
|
outcome: e.outcome,
|
|
470
474
|
reason: e.reason,
|
|
471
|
-
...(e.binds
|
|
475
|
+
...(e.binds
|
|
476
|
+
? {
|
|
477
|
+
binds: e.binds.map((b) => ({
|
|
478
|
+
line: b.line,
|
|
479
|
+
reason: b.reason,
|
|
480
|
+
...(b.model !== undefined ? { model: b.model } : {}),
|
|
481
|
+
...(b.repo !== undefined ? { repo: b.repo } : {}),
|
|
482
|
+
...(b.confirmed ? { confirmed: true as const } : {}),
|
|
483
|
+
})),
|
|
484
|
+
}
|
|
485
|
+
: {}),
|
|
472
486
|
...(e.question !== undefined ? { question: e.question } : {}),
|
|
473
487
|
...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
|
|
474
488
|
...(e.request !== undefined ? { request: e.request } : {}),
|
|
@@ -1019,8 +1033,14 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
1019
1033
|
Array.isArray(r.pushed) &&
|
|
1020
1034
|
r.pushed.every((h) => {
|
|
1021
1035
|
if (typeof h !== "object" || h === null) return false;
|
|
1022
|
-
const { ref, sha } = h as Record<string, unknown>;
|
|
1023
|
-
return
|
|
1036
|
+
const { ref, sha, by } = h as Record<string, unknown>;
|
|
1037
|
+
return (
|
|
1038
|
+
typeof ref === "string" &&
|
|
1039
|
+
ref.length > 0 &&
|
|
1040
|
+
typeof sha === "string" &&
|
|
1041
|
+
/^[0-9a-f]{7,40}$/.test(sha) &&
|
|
1042
|
+
(by === undefined || by === "push" || by === "salvage")
|
|
1043
|
+
);
|
|
1024
1044
|
})
|
|
1025
1045
|
)
|
|
1026
1046
|
)
|