@coreplane/switchboard 1.255.0 → 1.256.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/deploy/cloudflare-resident/drain.ts +55 -1
- package/dist/assets/deploy/cloudflare-resident/worker.ts +177 -32
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +31 -0
- package/dist/assets/src/core/coordinator/driver.ts +11 -1
- package/dist/assets/src/core/pipelineStanding.ts +57 -0
- package/dist/assets/src/core/reviewVerdict.ts +49 -0
- package/dist/assets/src/core/ship/contract.ts +20 -30
- package/dist/assets/src/core/ship/coordinator.ts +195 -68
- package/dist/assets/web/dist/.vite/manifest.json +31 -31
- package/dist/assets/web/dist/assets/{HomePage-Be7jLLnU.js → HomePage-BEiStFwW.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-DW-HHuZa.js → RunsIndexPage-JqVSDOok.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-Viim-bus.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-CYUy8McC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-Dv6UMaPy.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-Bd-5Rypv.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
- package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-Nl3uaxrP.js} +1 -1
- package/dist/assets/web/dist/assets/{main-mAKx_zo9.js → main-DRxWlffc.js} +2 -2
- package/dist/cli.js +319 -64
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
- package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
- package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
|
@@ -23,6 +23,17 @@ export interface DrainRecord {
|
|
|
23
23
|
by: string;
|
|
24
24
|
/** Why, in the words the attach refusal repeats. */
|
|
25
25
|
reason: string;
|
|
26
|
+
/** The residents whose containers still run the pre-deploy image (issue
|
|
27
|
+
* 1931): the deploy's reconcile marks the Durable Objects but the platform
|
|
28
|
+
* replaces the container processes asynchronously, so "reconciled" is not
|
|
29
|
+
* "swapped" — each hold stands until that resident reports its running
|
|
30
|
+
* container on the deploy's image, and the fleet reopens on the LAST
|
|
31
|
+
* report, a fact, never a wait. Absent or empty: no swap outstanding. */
|
|
32
|
+
holds?: string[];
|
|
33
|
+
/** Whether `POST /undrain` already asked for the lift while holds stood:
|
|
34
|
+
* the record then clears itself on the last hold's report instead of
|
|
35
|
+
* waiting for a second lift. */
|
|
36
|
+
liftAsked?: boolean;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/** The longest a drain may run, and the default, from the one clock table
|
|
@@ -88,7 +99,50 @@ export function liveDrain(stored: unknown, now: number): DrainRecord | null {
|
|
|
88
99
|
return null;
|
|
89
100
|
const until = Date.parse(r.until);
|
|
90
101
|
if (!Number.isFinite(until) || until <= now) return null;
|
|
91
|
-
|
|
102
|
+
const holds = Array.isArray(r.holds) ? r.holds.filter((h): h is string => typeof h === "string") : [];
|
|
103
|
+
return {
|
|
104
|
+
since: r.since,
|
|
105
|
+
until: r.until,
|
|
106
|
+
by: r.by,
|
|
107
|
+
reason: r.reason,
|
|
108
|
+
...(holds.length > 0 ? { holds } : {}),
|
|
109
|
+
...(r.liftAsked === true ? { liftAsked: true } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The record with the named residents held (issue 1931): the deploy's
|
|
114
|
+
* reconcile could not verify their running containers on the new image, so
|
|
115
|
+
* the fleet must not reopen onto them until each reports. Deduplicated;
|
|
116
|
+
* an empty set changes nothing. */
|
|
117
|
+
export function holdDrain(record: DrainRecord, resources: readonly string[]): DrainRecord {
|
|
118
|
+
const holds = [...new Set([...(record.holds ?? []), ...resources])];
|
|
119
|
+
if (holds.length === 0) return record;
|
|
120
|
+
return { ...record, holds };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `POST /undrain`'s decision over the stored record: with no holds the drain
|
|
124
|
+
* clears; with holds outstanding the fleet STAYS closed — the record keeps
|
|
125
|
+
* standing with `liftAsked`, so the last container's new-image report lifts
|
|
126
|
+
* it (a fact, never a timer; `until` remains the backstop for a report that
|
|
127
|
+
* never comes). */
|
|
128
|
+
export function liftDrain(record: DrainRecord): { cleared: true } | { cleared: false; record: DrainRecord } {
|
|
129
|
+
const holds = record.holds ?? [];
|
|
130
|
+
if (holds.length === 0) return { cleared: true };
|
|
131
|
+
return { cleared: false, record: { ...record, liftAsked: true } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One resident's word that its running container is on the deploy's image:
|
|
135
|
+
* its hold drops; when it was the last hold and the lift was already asked,
|
|
136
|
+
* the drain lifts here — the reopen fires on the last container's report. */
|
|
137
|
+
export function reportImageCurrent(
|
|
138
|
+
record: DrainRecord,
|
|
139
|
+
resource: string,
|
|
140
|
+
): { lifted: boolean; record: DrainRecord | null } {
|
|
141
|
+
const holds = (record.holds ?? []).filter((h) => h !== resource);
|
|
142
|
+
const next: DrainRecord = { ...record, ...(holds.length > 0 ? { holds } : {}) };
|
|
143
|
+
if (holds.length === 0) delete next.holds;
|
|
144
|
+
if (holds.length === 0 && record.liftAsked === true) return { lifted: true, record: null };
|
|
145
|
+
return { lifted: false, record: next };
|
|
92
146
|
}
|
|
93
147
|
|
|
94
148
|
/** The `/attach` answer while the fleet is drained: a 503 whose body carries
|
|
@@ -343,7 +343,15 @@ import {
|
|
|
343
343
|
} from "../../src/execution/residentDepsStore.js";
|
|
344
344
|
import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
|
|
345
345
|
import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
|
|
346
|
-
import {
|
|
346
|
+
import {
|
|
347
|
+
drainRefusal,
|
|
348
|
+
holdDrain,
|
|
349
|
+
liftDrain,
|
|
350
|
+
liveDrain,
|
|
351
|
+
parseDrainRequest,
|
|
352
|
+
reportImageCurrent,
|
|
353
|
+
type DrainRecord,
|
|
354
|
+
} from "./drain";
|
|
347
355
|
import {
|
|
348
356
|
CGROUP_READ_ARGV,
|
|
349
357
|
MEMORY_PRESSURE_REASON,
|
|
@@ -1434,6 +1442,10 @@ const TEST_OVERRIDES_KEY = "testOverrides";
|
|
|
1434
1442
|
* counts as a slot; it survives the isolate swap a deploy performs, which is
|
|
1435
1443
|
* why the record carries its own end. */
|
|
1436
1444
|
const DRAIN_KEY = "drain";
|
|
1445
|
+
/** Resident-DO key (issue 1931): set when a deploy's reconcile could not
|
|
1446
|
+
* verify this resident's container on the new image; the next reconcile that
|
|
1447
|
+
* finds it current reports to the registry and clears it. */
|
|
1448
|
+
const IMAGE_REPORT_PENDING_KEY = "imageReportPending";
|
|
1437
1449
|
|
|
1438
1450
|
type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
|
|
1439
1451
|
|
|
@@ -1568,12 +1580,50 @@ export class ResidentRegistryDO extends DurableObject<Env> {
|
|
|
1568
1580
|
return record;
|
|
1569
1581
|
}
|
|
1570
1582
|
|
|
1571
|
-
/** Admin-only by construction (POST /undrain):
|
|
1572
|
-
*
|
|
1573
|
-
|
|
1583
|
+
/** Admin-only by construction (POST /undrain): `cleared` when a record was
|
|
1584
|
+
* there and was lifted — a cleared drain posts `below`, the plane's
|
|
1585
|
+
* resident-drain window lifts. With holds outstanding (issue 1931: a
|
|
1586
|
+
* container still to report the deploy's image) the fleet STAYS closed:
|
|
1587
|
+
* the record stands with `liftAsked` and the last container's report lifts
|
|
1588
|
+
* it (`reportContainerImageCurrent`); `until` remains the backstop. */
|
|
1589
|
+
async clearDrain(): Promise<{ cleared: boolean; held: string[] }> {
|
|
1590
|
+
const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
|
|
1591
|
+
if (record !== null) {
|
|
1592
|
+
const lift = liftDrain(record);
|
|
1593
|
+
if (!lift.cleared) {
|
|
1594
|
+
await this.ctx.storage.put(DRAIN_KEY, lift.record);
|
|
1595
|
+
return { cleared: false, held: lift.record.holds ?? [] };
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1574
1598
|
const had = await this.ctx.storage.delete(DRAIN_KEY);
|
|
1575
1599
|
if (had) await this.pushDrainPost("below");
|
|
1576
|
-
return had;
|
|
1600
|
+
return { cleared: had, held: [] };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/** The deploy's reconcile could not verify these residents' containers on
|
|
1604
|
+
* the new image (issue 1931): hold the drain for each — the fleet must not
|
|
1605
|
+
* reopen onto them until they report. No live drain, nothing to hold. */
|
|
1606
|
+
async holdDrainFor(resources: string[]): Promise<void> {
|
|
1607
|
+
const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
|
|
1608
|
+
if (record === null || resources.length === 0) return;
|
|
1609
|
+
await this.ctx.storage.put(DRAIN_KEY, holdDrain(record, resources));
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** One resident's word that its running container is on the deploy's image:
|
|
1613
|
+
* its hold drops, and when it was the last hold of a lift already asked the
|
|
1614
|
+
* drain lifts here — the reopen fires on the last container's report. */
|
|
1615
|
+
async reportContainerImageCurrent(resource: string): Promise<{ lifted: boolean }> {
|
|
1616
|
+
const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
|
|
1617
|
+
if (record === null) return { lifted: false };
|
|
1618
|
+
const report = reportImageCurrent(record, resource);
|
|
1619
|
+
if (report.record === null) {
|
|
1620
|
+
await this.ctx.storage.delete(DRAIN_KEY);
|
|
1621
|
+
await this.pushDrainPost("below");
|
|
1622
|
+
console.log(`[drain] fleet reopened — ${resource} was the last container to report the deploy's image`);
|
|
1623
|
+
return { lifted: true };
|
|
1624
|
+
}
|
|
1625
|
+
await this.ctx.storage.put(DRAIN_KEY, report.record);
|
|
1626
|
+
return { lifted: false };
|
|
1577
1627
|
}
|
|
1578
1628
|
|
|
1579
1629
|
/** The one alarm, at the drain's `until` (record 0064): a drain past
|
|
@@ -4936,41 +4986,121 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4936
4986
|
* registration whose release never came defers it only until the clean-idle
|
|
4937
4987
|
* sweep drains that registration. Answers `restarted` when a stop was
|
|
4938
4988
|
* issued, else why not. */
|
|
4939
|
-
private async reconcileImage(where: string): Promise<ImageReconcileResult> {
|
|
4940
|
-
if (!(await this.isRuntimeActive().catch(() => false)))
|
|
4989
|
+
private async reconcileImage(where: string, force = false): Promise<ImageReconcileResult> {
|
|
4990
|
+
if (!(await this.isRuntimeActive().catch(() => false))) {
|
|
4991
|
+
// An inactive container's next start is on the deployed image by
|
|
4992
|
+
// construction — and a held resident whose container idled out has no
|
|
4993
|
+
// other reporter left: new attaches are refused by the very drain the
|
|
4994
|
+
// hold keeps standing, so without this report the fleet stays closed
|
|
4995
|
+
// until the drain's `until` backstop (issue 1931).
|
|
4996
|
+
await this.reportPendingImageCurrent(where);
|
|
4997
|
+
return "inactive";
|
|
4998
|
+
}
|
|
4941
4999
|
const last = THREAD_USERS[THREAD_USERS.length - 1];
|
|
4942
|
-
|
|
4943
|
-
|
|
5000
|
+
// A pending report marker (issue 1931) means a deploy could not verify this
|
|
5001
|
+
// container on its image: the probe shortcut below is a POOL-USER check the
|
|
5002
|
+
// pre-deploy image passes when the pool did not change, so it can never
|
|
5003
|
+
// satisfy the marker — the container is treated as stale until it is cycled
|
|
5004
|
+
// post-deploy and the fresh start reported.
|
|
5005
|
+
const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
|
|
5006
|
+
if (!force && pending === undefined) {
|
|
5007
|
+
const probe = await this.run(["id", "-u", last]);
|
|
5008
|
+
if (probe.exitCode === 0) return "current";
|
|
5009
|
+
}
|
|
5010
|
+
const stale = force
|
|
5011
|
+
? "the container predates the deploy"
|
|
5012
|
+
: pending !== undefined
|
|
5013
|
+
? "a deploy's new-image report is pending, so the running container cannot be trusted current"
|
|
5014
|
+
: `${last} missing in the running container`;
|
|
4944
5015
|
const busy = this.inFlightCount();
|
|
4945
5016
|
if (busy > 0) {
|
|
4946
|
-
console.log(
|
|
4947
|
-
`image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
|
|
4948
|
-
);
|
|
5017
|
+
console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
|
|
4949
5018
|
return "deferred";
|
|
4950
5019
|
}
|
|
4951
5020
|
const registered = await this.registeredRunsBeyondOps();
|
|
4952
5021
|
if (registered > 0) {
|
|
4953
5022
|
console.log(
|
|
4954
|
-
`image-stale (${where}): ${
|
|
5023
|
+
`image-stale (${where}): ${stale} but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
|
|
4955
5024
|
);
|
|
4956
5025
|
return "deferred";
|
|
4957
5026
|
}
|
|
4958
|
-
console.log(
|
|
4959
|
-
`image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
|
|
4960
|
-
);
|
|
5027
|
+
console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
|
|
4961
5028
|
this.swapIncarnation(); // deliberate incarnation swap
|
|
4962
5029
|
await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
|
|
5030
|
+
if (pending !== undefined) {
|
|
5031
|
+
// The post-deploy cycle just happened: probe the FRESH container — the
|
|
5032
|
+
// SDK boots it for this command on the image the deployed Worker pins,
|
|
5033
|
+
// so a fresh start answering after the deploy is the verified fact
|
|
5034
|
+
// (issue 1931), never the pool-user probe on the old process. A failed
|
|
5035
|
+
// probe keeps the marker: the next reconcile tries the cycle again.
|
|
5036
|
+
const fresh = await this.run(["id", "-u", last])
|
|
5037
|
+
.then((p) => p.exitCode === 0)
|
|
5038
|
+
.catch(() => false);
|
|
5039
|
+
if (fresh) await this.reportPendingImageCurrent(where);
|
|
5040
|
+
else
|
|
5041
|
+
console.log(`image-stale (${where}): the fresh container's probe failed — the new-image report stays pending`);
|
|
5042
|
+
}
|
|
4963
5043
|
return "restarted";
|
|
4964
5044
|
}
|
|
4965
5045
|
|
|
5046
|
+
/** The report a held drain waits for (issue 1931): when the deploy's
|
|
5047
|
+
* reconcile could not verify this resident's fresh container on the new
|
|
5048
|
+
* image, a marker stays in storage and the registry holds the drain. The
|
|
5049
|
+
* fact the report stands on is a fresh container start AFTER the deploy —
|
|
5050
|
+
* the marker makes `reconcileImage` treat the container as stale until the
|
|
5051
|
+
* cycle happens, so this is called only behind that fresh start (or on an
|
|
5052
|
+
* inactive resident, whose next start is on the new image by construction).
|
|
5053
|
+
* The marker is deleted only after a successful report: a transient failure
|
|
5054
|
+
* keeps it for the next reconcile, and the drain's `until` is the backstop
|
|
5055
|
+
* for a report that never lands. */
|
|
5056
|
+
private async reportPendingImageCurrent(where: string): Promise<boolean> {
|
|
5057
|
+
const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
|
|
5058
|
+
if (pending === undefined) return true;
|
|
5059
|
+
try {
|
|
5060
|
+
const { lifted } = await registryStub(this.env).reportContainerImageCurrent(pending.resource);
|
|
5061
|
+
await this.ctx.storage.delete(IMAGE_REPORT_PENDING_KEY);
|
|
5062
|
+
console.log(
|
|
5063
|
+
`[reconcile] ${pending.resource} reports the deploy's image (${where})${lifted ? " — drain lifted" : ""}`,
|
|
5064
|
+
);
|
|
5065
|
+
return true;
|
|
5066
|
+
} catch (err) {
|
|
5067
|
+
console.log(
|
|
5068
|
+
`[reconcile] ${pending.resource}: the new-image report failed (${where}): ${errMsg(err)} — retried on the next reconcile`,
|
|
5069
|
+
);
|
|
5070
|
+
return false;
|
|
5071
|
+
}
|
|
5072
|
+
}
|
|
5073
|
+
|
|
4966
5074
|
/** The deploy's reconcile, inside the drain window (item 69's order: the
|
|
4967
5075
|
* runs in flight end, the swap lands, the containers reconcile, the fleet
|
|
4968
5076
|
* reopens): `POST /reconcile` calls this on every resident after the Worker
|
|
4969
|
-
* deploy landed and BEFORE the drain is lifted
|
|
4970
|
-
*
|
|
4971
|
-
*
|
|
4972
|
-
|
|
4973
|
-
|
|
5077
|
+
* deploy landed and BEFORE the drain is lifted. The pool-user probe cannot
|
|
5078
|
+
* tell the pre-deploy image from the new one when the pool did not change,
|
|
5079
|
+
* and the platform replaces the container processes asynchronously after an
|
|
5080
|
+
* image-changing deploy (issue 1931) — so this path never trusts "current":
|
|
5081
|
+
* an active, quiet container is always cycled, and the fresh start —
|
|
5082
|
+
* necessarily on the deploy's image, which landed before this call — is
|
|
5083
|
+
* probed. `verified` true is the fact the reopen may stand on; anything
|
|
5084
|
+
* else (deferred, a failed fresh probe) leaves a hold on the drain, cleared
|
|
5085
|
+
* by this resident's later report (`reportPendingImageCurrent`), never by a
|
|
5086
|
+
* timer.
|
|
5087
|
+
*
|
|
5088
|
+
* The hold lands BEFORE the marker: the marker is what lets any concurrent
|
|
5089
|
+
* attach/refresh reconcile report, and a report that reaches the registry
|
|
5090
|
+
* before the hold is a no-op — the marker would be consumed and the hold
|
|
5091
|
+
* added afterwards would wait for a report nothing sends (until the drain's
|
|
5092
|
+
* `until`). Held first, every report finds its hold. */
|
|
5093
|
+
async reconcileForDeploy(resource: string): Promise<{ result: ImageReconcileResult; verified: boolean }> {
|
|
5094
|
+
await registryStub(this.env).holdDrainFor([resource]);
|
|
5095
|
+
await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
|
|
5096
|
+
const result = await this.reconcileImage("deploy", true);
|
|
5097
|
+
if (result === "deferred") return { result, verified: false };
|
|
5098
|
+
// Inactive: the inactive early-return reported (the next start is on the
|
|
5099
|
+
// new image by construction). Restarted: the cycle's fresh probe and
|
|
5100
|
+
// report ran inside reconcileImage. Either way the marker gone is the
|
|
5101
|
+
// verification.
|
|
5102
|
+
const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
|
|
5103
|
+
return { result, verified };
|
|
4974
5104
|
}
|
|
4975
5105
|
|
|
4976
5106
|
// -- watchdog (the sparse cron; it re-arms nothing) --------------------------
|
|
@@ -9118,32 +9248,47 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
|
|
|
9118
9248
|
return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
|
|
9119
9249
|
}
|
|
9120
9250
|
|
|
9121
|
-
/** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says
|
|
9122
|
-
* a drain stood.
|
|
9251
|
+
/** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says
|
|
9252
|
+
* whether a drain stood and was lifted. With containers still to report the
|
|
9253
|
+
* deploy's image (issue 1931) the fleet STAYS closed: `held` names them, the
|
|
9254
|
+
* record stands with the lift asked, and the last container's report reopens
|
|
9255
|
+
* the fleet — a fact, never a timer; the drain's `until` is the backstop. */
|
|
9123
9256
|
async function handleUndrain(env: Env): Promise<Response> {
|
|
9124
|
-
const
|
|
9125
|
-
|
|
9126
|
-
|
|
9257
|
+
const lift = await registryStub(env).clearDrain();
|
|
9258
|
+
if (!lift.cleared && lift.held.length > 0) {
|
|
9259
|
+
console.log(`[drain] fleet stays closed — containers still to report the deploy's image: ${lift.held.join(", ")}`);
|
|
9260
|
+
const draining = liveDrain(await registryStub(env).getDrain(), systemClock());
|
|
9261
|
+
return json({ draining, cleared: false, held: lift.held, planeOutbox: await registryStub(env).getDrainOutbox() });
|
|
9262
|
+
}
|
|
9263
|
+
console.log(`[drain] fleet reopened (${lift.cleared ? "a drain stood" : "no drain stood"})`);
|
|
9264
|
+
return json({ draining: null, cleared: lift.cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
|
|
9127
9265
|
}
|
|
9128
9266
|
|
|
9129
9267
|
/** POST /reconcile (drain scope): reconcile every resident's container onto
|
|
9130
9268
|
* the current image — the deploy runner posts it after its Worker deploy
|
|
9131
9269
|
* landed and BEFORE its `/undrain`, so a stale container restarts inside the
|
|
9132
9270
|
* drain window (item 69's order) and never under a run the reopened fleet
|
|
9133
|
-
* admits. Each resident answers what its reconcile decided
|
|
9134
|
-
*
|
|
9135
|
-
*
|
|
9271
|
+
* admits. Each resident answers what its reconcile decided and whether its
|
|
9272
|
+
* fresh container was VERIFIED on the deploy's image (issue 1931); one not
|
|
9273
|
+
* verified — deferred, a failed fresh probe, an error — leaves a hold on the
|
|
9274
|
+
* drain, so the fleet reopens only on that container's later report. */
|
|
9136
9275
|
async function handleReconcile(env: Env): Promise<Response> {
|
|
9137
9276
|
const residents = await registryStub(env).list();
|
|
9138
9277
|
const settled = await Promise.allSettled(
|
|
9139
|
-
residents.map((record) => residentStub(env, record.resource).reconcileForDeploy()),
|
|
9278
|
+
residents.map((record) => residentStub(env, record.resource).reconcileForDeploy(record.resource)),
|
|
9140
9279
|
);
|
|
9141
9280
|
const reconciled = residents.map((record, i) => {
|
|
9142
9281
|
const s = settled[i];
|
|
9143
9282
|
return s.status === "fulfilled"
|
|
9144
|
-
? { resource: record.resource, result: s.value.result }
|
|
9145
|
-
: { resource: record.resource, result: "error" as const, error: errMsg(s.reason) };
|
|
9283
|
+
? { resource: record.resource, result: s.value.result, verified: s.value.verified }
|
|
9284
|
+
: { resource: record.resource, result: "error" as const, verified: false, error: errMsg(s.reason) };
|
|
9146
9285
|
});
|
|
9286
|
+
// Each resident held ITSELF before its marker (reconcileForDeploy), so no
|
|
9287
|
+
// report can outrun its hold; this pass is the backstop for a resident whose
|
|
9288
|
+
// call rejected before it could — holdDrain deduplicates, so re-holding a
|
|
9289
|
+
// deferred resident changes nothing.
|
|
9290
|
+
const unverified = reconciled.filter((r) => !r.verified).map((r) => r.resource);
|
|
9291
|
+
if (unverified.length > 0) await registryStub(env).holdDrainFor(unverified);
|
|
9147
9292
|
console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
|
|
9148
9293
|
return json({ reconciled });
|
|
9149
9294
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.256.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.256.0",
|
|
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.
|
|
20448
|
+
"version": "1.256.0",
|
|
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.
|
|
3
|
+
"version": "1.256.0",
|
|
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
|
@@ -254,6 +254,31 @@ Keep notes with the \`notes\` tool: one short document, replaced whole each time
|
|
|
254
254
|
// classes are by duration, the project's own scripts and CI say which is which.
|
|
255
255
|
export const CHECKS_BY_COST = `CHECKS BY COST — push before the expensive ones. Every check you might run has a cost class: seconds (a formatter or a linter on the files you touched, one test file, a docs, link or spec check, the typecheck of one package) or minutes (the whole test suite, a build, a dependency install, an end-to-end or full verification). Know a command's class before you run it — from the project's own scripts and CI configuration, from how long it took last time, or by the class above when you have nothing better. Prove each change with the cheapest check that can prove it, matched to the change's scope and scoped to the changed set — the tests nearest your change, the touched project's typecheck, the changed files' formatting, never the whole tree: a documentation change gets the documentation checks, one module gets its own tests, a shared type gets the typecheck. Every CI pipeline runs the tests, the types, the formatting and the full verification on your push, so you never run them again: you validate and fix your own change before pushing, at the changed-set scope. Passing the full test suite and the full typecheck is NOT part of your criteria: CI is that gate and the only place they run — on a shared machine they cost minutes that every other run pays for. As soon as the change exists and those checks pass, commit and push — the pushed branch is the deliverable, and an unpushed tree does not survive the run's end. Beyond the changed set, use judgement about what this change needs rather than a checklist, fixing forward with further commits and pushes. Never start an operation whose expected duration does not fit the time you have left minus what a commit, a push and the description need: push what there is and say plainly what is unverified instead. At the wind-down note, commit and push what compiles, say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS} The description's validation names exactly what ran; what did not run is CI's to gate, and you say so.`;
|
|
256
256
|
|
|
257
|
+
// Every coding prompt carries this verbatim, right after the checks-by-cost
|
|
258
|
+
// rule (docs/reference/specs/agent-coding.md item 13; issue 1796): the fast
|
|
259
|
+
// gates before every push, each the changed-set form with its command named,
|
|
260
|
+
// and the full verification named as CI's gate. The paragraph lived in the
|
|
261
|
+
// ship contract's first instruction alone, so every ask that was not a plan
|
|
262
|
+
// unit had to repeat it by hand or watch the run spend most of its budget on
|
|
263
|
+
// the whole suite or the full verification before its first push (issue 1909
|
|
264
|
+
// measured three such command shapes at 85–95 % of a child's life). One
|
|
265
|
+
// constant, one text: the three coding variants carry the same bytes, the
|
|
266
|
+
// contract's first instruction points here instead of re-stating it
|
|
267
|
+
// (src/core/ship/contract.ts), and no requester repeats it. Unlike the
|
|
268
|
+
// checks-by-cost rule above, this paragraph names its commands on purpose —
|
|
269
|
+
// children handed only the classes chose wrong in both directions — and a
|
|
270
|
+
// repository on another stack maps each gate by its class.
|
|
271
|
+
export const TOUCHED_TESTS_COMMAND = "`npx vitest run` on the test files you touched, by name,";
|
|
272
|
+
|
|
273
|
+
export const FAST_GATES_BEFORE_PUSH =
|
|
274
|
+
"THE FAST GATES, before every push — each scoped to the changed set, never the whole project: " +
|
|
275
|
+
`${TOUCHED_TESTS_COMMAND} once (never \`--changed\`, never a directory: on a moving base that is most of the suite), ` +
|
|
276
|
+
"`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
|
|
277
|
+
"`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
|
|
278
|
+
"then your judgement on what else this change needs, not a longer checklist. The full verification is " +
|
|
279
|
+
"CI's gate — `npm run verify` runs there on your push, never here: push a head early and let CI judge it, " +
|
|
280
|
+
"fixing forward with further commits and pushes.";
|
|
281
|
+
|
|
257
282
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
258
283
|
|
|
259
284
|
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
@@ -276,6 +301,8 @@ Workflow for shipping a PR:
|
|
|
276
301
|
|
|
277
302
|
${CHECKS_BY_COST}
|
|
278
303
|
|
|
304
|
+
${FAST_GATES_BEFORE_PUSH}
|
|
305
|
+
|
|
279
306
|
${NEVER_MERGE}
|
|
280
307
|
|
|
281
308
|
${UNIT_CONTRACT}
|
|
@@ -323,6 +350,8 @@ Workflow for shipping a change:
|
|
|
323
350
|
|
|
324
351
|
${CHECKS_BY_COST}
|
|
325
352
|
|
|
353
|
+
${FAST_GATES_BEFORE_PUSH}
|
|
354
|
+
|
|
326
355
|
${NEVER_MERGE}
|
|
327
356
|
|
|
328
357
|
${UNIT_CONTRACT}
|
|
@@ -366,6 +395,8 @@ Workflow for shipping a change:
|
|
|
366
395
|
|
|
367
396
|
${CHECKS_BY_COST}
|
|
368
397
|
|
|
398
|
+
${FAST_GATES_BEFORE_PUSH}
|
|
399
|
+
|
|
369
400
|
${NEVER_MERGE}
|
|
370
401
|
|
|
371
402
|
${UNIT_CONTRACT}
|
|
@@ -316,7 +316,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
316
316
|
// The hard stop's mark, as the bot's answer carries it (record 0060; issue
|
|
317
317
|
// 1924): a finished child's unit ends stopped on it.
|
|
318
318
|
const stopped = a.body.stopped === true ? { stopped: true as const } : {};
|
|
319
|
-
|
|
319
|
+
// The interrupted child restarted from its request (issue 1903): the bot
|
|
320
|
+
// answers the live successor's id, and the machine keeps the wait on it.
|
|
321
|
+
const restarted = typeof a.body.restartedAs === "string" ? { restartedAs: a.body.restartedAs } : {};
|
|
322
|
+
if (!run.finished)
|
|
323
|
+
return { type: "read-record", step, run: { finished: false }, ...stopped, ...restarted, at: a.body.at };
|
|
320
324
|
if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
|
|
321
325
|
// The typed artifacts as the bot's record carries them — shape-checked where
|
|
322
326
|
// they were written (the run record's validator), read here as they are.
|
|
@@ -337,6 +341,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
337
341
|
costUsd,
|
|
338
342
|
handoffLists,
|
|
339
343
|
failure,
|
|
344
|
+
interruption,
|
|
340
345
|
} = facts;
|
|
341
346
|
return {
|
|
342
347
|
type: "read-record",
|
|
@@ -363,6 +368,11 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
363
368
|
// The failure by name (run-history item 57), shape-checked: a
|
|
364
369
|
// `provider_transient` drives the round-0 re-run (agent-ship item 9).
|
|
365
370
|
...(isRecord(failure) && typeof failure.kind === "string" ? { failure: { kind: failure.kind } } : {}),
|
|
371
|
+
// What ended an interrupted child (issue 1876): the ending's sentence
|
|
372
|
+
// names the cause instead of claiming a bot restart for every one.
|
|
373
|
+
...(interruption === "bot_restart" || interruption === "container_replaced" || interruption === "sandbox_fault"
|
|
374
|
+
? { interruption }
|
|
375
|
+
: {}),
|
|
366
376
|
},
|
|
367
377
|
at: a.body.at,
|
|
368
378
|
};
|
|
@@ -130,6 +130,63 @@ export const ENDING_STAGE = {
|
|
|
130
130
|
blocked: "ended",
|
|
131
131
|
} as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
|
|
132
132
|
|
|
133
|
+
/** The user's words for a unit's ending (record 0066): what a card, a plan
|
|
134
|
+
* summary and the web unit page print instead of the internal token —
|
|
135
|
+
* `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
|
|
136
|
+
* never the snake_case kind. Pinned to the union plus `blocked` (the driver's
|
|
137
|
+
* never-started status), so a new ending kind fails the build here. */
|
|
138
|
+
export const ENDING_WORDS = {
|
|
139
|
+
merged: "merged",
|
|
140
|
+
already_landed: "merged",
|
|
141
|
+
merge_ready: "merge-ready",
|
|
142
|
+
held: "held",
|
|
143
|
+
merge_refused: "not merged",
|
|
144
|
+
round_cap: "round cap reached",
|
|
145
|
+
wall_clock_cap: "out of budget",
|
|
146
|
+
review_pending: "review pending",
|
|
147
|
+
stopped: "stopped",
|
|
148
|
+
aborted: "aborted",
|
|
149
|
+
transient: "aborted",
|
|
150
|
+
continued: "continuing",
|
|
151
|
+
no_verdict: "no verdict",
|
|
152
|
+
interrupted: "interrupted",
|
|
153
|
+
refused: "refused",
|
|
154
|
+
idle: "idle",
|
|
155
|
+
blocked: "blocked",
|
|
156
|
+
} as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
|
|
157
|
+
|
|
158
|
+
/** The user's words for a round outcome (record 0066): the card's unit line
|
|
159
|
+
* prints these beside the round header, never `checks_failed` or another
|
|
160
|
+
* internal token. Pinned to the union: a new outcome fails the build here. */
|
|
161
|
+
export const ROUND_OUTCOME_WORDS = {
|
|
162
|
+
started: "started",
|
|
163
|
+
pr_opened: "pull request opened",
|
|
164
|
+
completed: "done",
|
|
165
|
+
approve: "approved",
|
|
166
|
+
request_changes: "changes requested",
|
|
167
|
+
no_verdict: "no verdict",
|
|
168
|
+
checks_failed: "checks failed",
|
|
169
|
+
transient: "retried",
|
|
170
|
+
enqueued: "queued to merge",
|
|
171
|
+
dequeued: "removed from the merge queue",
|
|
172
|
+
aborted: "aborted",
|
|
173
|
+
stopped: "stopped",
|
|
174
|
+
continued: "continuing",
|
|
175
|
+
idle: "idle",
|
|
176
|
+
} as const satisfies Record<ShipRoundOutcome, string>;
|
|
177
|
+
|
|
178
|
+
/** The user's word for any ending or unit-status token — total over strings
|
|
179
|
+
* read off a stored row, so an unknown word prints as itself rather than
|
|
180
|
+
* throwing on an older record. */
|
|
181
|
+
export function endingWordOf(kind: string): string {
|
|
182
|
+
return (ENDING_WORDS as Record<string, string>)[kind] ?? kind;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The user's word for any round-outcome token, total like `endingWordOf`. */
|
|
186
|
+
export function roundOutcomeWordOf(outcome: string): string {
|
|
187
|
+
return (ROUND_OUTCOME_WORDS as Record<string, string>)[outcome] ?? outcome;
|
|
188
|
+
}
|
|
189
|
+
|
|
133
190
|
type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
|
|
134
191
|
|
|
135
192
|
function isShipEvent(e: RunEvent): e is ShipEvent {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { normalizeHead } from "./reviewedHead.js";
|
|
35
35
|
import { redactSecrets } from "./redact.js";
|
|
36
|
+
import { shows, type Verbosity } from "./verbosity.js";
|
|
36
37
|
|
|
37
38
|
export type ReviewVerdictKind = "approve" | "request_changes";
|
|
38
39
|
|
|
@@ -248,6 +249,37 @@ export function verdictLine(verdict: ReviewVerdict | undefined): string {
|
|
|
248
249
|
return summary ? `${token} ${summary}` : token;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/** The counted plural for a severity: `blocker` and `nit` inflect, `major`
|
|
253
|
+
* and `minor` read as adjectives and stay uninflected. */
|
|
254
|
+
function severityCount(sev: FindingSeverity, n: number): string {
|
|
255
|
+
if (sev === "blocking") return `${n} blocker${n === 1 ? "" : "s"}`;
|
|
256
|
+
if (sev === "nit") return `${n} nit${n === 1 ? "" : "s"}`;
|
|
257
|
+
return `${n} ${sev}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The verdict as ONE line in the user's words (record 0066): `LGTM` for an
|
|
261
|
+
* approve, `Changes requested: 2 blockers, 1 major, 2 minor, 3 nits` — only
|
|
262
|
+
* the non-zero counts, most severe first — for a request for changes, the
|
|
263
|
+
* bare token word when the findings were not itemized or none were filed.
|
|
264
|
+
* The quiet thread reply prints this and nothing more; the full verdict line
|
|
265
|
+
* and the finding bullets are `verbose` material and stay on the pull
|
|
266
|
+
* request, where the post-step put them. */
|
|
267
|
+
export function verdictCountsLine(verdict: ReviewVerdict): string {
|
|
268
|
+
if (verdict.verdict === "approve") return "LGTM";
|
|
269
|
+
const counts = severityCounts(verdict.findings ?? []);
|
|
270
|
+
return counts ? `Changes requested: ${counts}` : "Changes requested";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The non-zero severity counts of a finding list, most severe first —
|
|
274
|
+
* `2 blockers, 1 major, 2 minor, 3 nits` — or the empty string for none.
|
|
275
|
+
* Shared by the quiet verdict line and the quiet round-cap report. */
|
|
276
|
+
export function severityCounts(findings: readonly Finding[]): string {
|
|
277
|
+
return FINDING_SEVERITIES.map((sev) => [sev, findings.filter((f) => f.severity === sev).length] as const)
|
|
278
|
+
.filter(([, n]) => n > 0)
|
|
279
|
+
.map(([sev, n]) => severityCount(sev, n))
|
|
280
|
+
.join(", ");
|
|
281
|
+
}
|
|
282
|
+
|
|
251
283
|
/** One compact finding line — `[severity] id file[:line] — title` — shared by
|
|
252
284
|
* the posted body's list (bulleted below) and ship's synthesized child turns. */
|
|
253
285
|
export function formatFinding(f: Finding): string {
|
|
@@ -381,6 +413,13 @@ export function buildReviewPostBody(
|
|
|
381
413
|
* opt-out, a guard refusal) or findings not itemized (a list that says
|
|
382
414
|
* nothing is no substitute) — so the review's text is always somewhere a
|
|
383
415
|
* person reads it. No verdict → the bare answer with the link, as before.
|
|
416
|
+
*
|
|
417
|
+
* The request's verbosity decides how much of the verdict the thread hears
|
|
418
|
+
* (routing-and-config item 28, record 0066): below `verbose`, a verdict whose
|
|
419
|
+
* findings are itemized and posted to GitHub is ONE line — `verdictCountsLine`
|
|
420
|
+
* with the pull request link — because the full verdict, the finding lines and
|
|
421
|
+
* the prose already stand on the pull request; at `verbose`, and whenever the
|
|
422
|
+
* text would otherwise land nowhere a person reads it, the full render above.
|
|
384
423
|
*/
|
|
385
424
|
export function buildReviewChannelReply(input: {
|
|
386
425
|
answer: string;
|
|
@@ -388,8 +427,18 @@ export function buildReviewChannelReply(input: {
|
|
|
388
427
|
/** The PR the post landed on, or undefined when nothing was posted. */
|
|
389
428
|
posted: { repo: string; number: number } | undefined;
|
|
390
429
|
liveUrl: string | undefined;
|
|
430
|
+
/** The request's level (default `verbose`: the full render, the row's shape). */
|
|
431
|
+
verbosity?: Verbosity;
|
|
391
432
|
}): string {
|
|
392
433
|
const { answer, verdict, posted, liveUrl } = input;
|
|
434
|
+
if (
|
|
435
|
+
!shows(input.verbosity ?? "verbose", "verbose") &&
|
|
436
|
+
verdict !== undefined &&
|
|
437
|
+
verdict.findings !== undefined &&
|
|
438
|
+
posted !== undefined
|
|
439
|
+
) {
|
|
440
|
+
return `${verdictCountsLine(verdict)} — https://github.com/${posted.repo}/pull/${posted.number}`;
|
|
441
|
+
}
|
|
393
442
|
const tail = [
|
|
394
443
|
...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
|
|
395
444
|
...(liveUrl ? [`[Live run](${liveUrl})`] : []),
|