@coreplane/switchboard 1.259.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 +17 -24
- package/dist/assets/deploy/cloudflare/worker.ts +17 -56
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
- package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
- 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/agents/registry.ts +3 -5
- package/dist/assets/src/core/authz/policy.ts +5 -0
- package/dist/assets/src/core/budgets.ts +19 -6
- package/dist/assets/src/core/coordinator/contract.ts +93 -5
- package/dist/assets/src/core/coordinator/driver.ts +395 -128
- package/dist/assets/src/core/memory/types.ts +5 -0
- package/dist/assets/src/core/pipelineStanding.ts +13 -4
- package/dist/assets/src/core/plane/decide.ts +53 -12
- package/dist/assets/src/core/provider.ts +5 -0
- package/dist/assets/src/core/runEvents.ts +22 -15
- package/dist/assets/src/core/runLedger/types.ts +3 -3
- package/dist/assets/src/core/runRecord.ts +64 -11
- package/dist/assets/src/core/ship/coordinator.ts +202 -53
- package/dist/assets/src/core/ship/renewal.ts +23 -8
- package/dist/assets/src/core/types.ts +11 -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-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
- package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
- package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
- package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
- package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
- package/dist/cli.js +9136 -8197
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
- package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
|
@@ -1221,9 +1221,12 @@ interface ThreadBinding {
|
|
|
1221
1221
|
/** What one image reconcile decided (`reconcileImage`): the container was
|
|
1222
1222
|
* stopped to restart on the current image (`restarted`), already runs it
|
|
1223
1223
|
* (`current`), is not running so the next start uses it anyway (`inactive`),
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
|
|
1224
|
+
* is busy — an operation, an attach or a registered run in flight — and the
|
|
1225
|
+
* restart is deferred to the next quiet check (`deferred`), or predates the
|
|
1226
|
+
* deploy on the attach path, which never stops a container (issue 2101): the
|
|
1227
|
+
* attach is refused so the new run takes the fallback sandbox, and the
|
|
1228
|
+
* restart stays the refresh cycle's or the deploy's (`stale`). */
|
|
1229
|
+
type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred" | "stale";
|
|
1227
1230
|
|
|
1228
1231
|
interface AttachOk {
|
|
1229
1232
|
workspace: string;
|
|
@@ -3406,6 +3409,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
3406
3409
|
if ((await this.restoreCheckout(snap, deadlineMs)).done) {
|
|
3407
3410
|
// Raced a container start that already had the right disk.
|
|
3408
3411
|
await this.setResidentState("warm");
|
|
3412
|
+
await this.reportPendingImageCurrent("hydrate");
|
|
3409
3413
|
return;
|
|
3410
3414
|
}
|
|
3411
3415
|
// The snapshot carries the checkout's tree, not the store (item 59): adopt
|
|
@@ -3479,6 +3483,11 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
3479
3483
|
lastRestore: { at: new Date(systemClock()).toISOString(), ms: systemClock() - t0 },
|
|
3480
3484
|
} satisfies RepoFacts);
|
|
3481
3485
|
await this.setResidentState("warm");
|
|
3486
|
+
// This is the deploy reconcile's missing fact: the replacement itself has
|
|
3487
|
+
// started, restored its snapshot and become usable. Reporting after `warm`
|
|
3488
|
+
// avoids both clearing the marker on stop and probing while restore still
|
|
3489
|
+
// owns the fresh container (issue 2101).
|
|
3490
|
+
await this.reportPendingImageCurrent("hydrate");
|
|
3482
3491
|
}
|
|
3483
3492
|
|
|
3484
3493
|
// -- freshness (the refresh cycle's phases, one per instance step) -----------
|
|
@@ -4177,8 +4186,8 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4177
4186
|
}
|
|
4178
4187
|
|
|
4179
4188
|
/** Run one step of the refresh instance: counted in flight once past the
|
|
4180
|
-
* entry gates (so
|
|
4181
|
-
* under it — while the gates themselves, `isIdle`, `reconcileImage("refresh")`
|
|
4189
|
+
* entry gates (so a concurrent reconcile — the deploy's — never stops the
|
|
4190
|
+
* container under it — while the gates themselves, `isIdle`, `reconcileImage("refresh")`
|
|
4182
4191
|
* and the disk-full recycle, must not see the probing cycle as an operation
|
|
4183
4192
|
* in flight, or no resident would ever park, restart a stale image or
|
|
4184
4193
|
* recycle a full disk; the step is handed `cycle.count` and calls it once
|
|
@@ -5011,26 +5020,26 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5011
5020
|
* restart decided on the op counters alone stops the container under a live
|
|
5012
5021
|
* run — stop the container so it restarts on the current image (state is DO
|
|
5013
5022
|
* storage + R2 — the disk is a cache). A deferred restart re-checks on
|
|
5014
|
-
* every later
|
|
5015
|
-
*
|
|
5016
|
-
*
|
|
5017
|
-
*
|
|
5018
|
-
|
|
5023
|
+
* every later refresh cycle until the resident is quiet; a registration
|
|
5024
|
+
* whose release never came defers it only until the clean-idle sweep drains
|
|
5025
|
+
* that registration. The attach path never stops the container (issue 2101,
|
|
5026
|
+
* the `stale` answer below); the stop is the refresh cycle's or the
|
|
5027
|
+
* deploy's alone. Answers `restarted` when a stop was issued, else why
|
|
5028
|
+
* not. */
|
|
5029
|
+
private async reconcileImage(where: "attach" | "refresh" | "deploy", force = false): Promise<ImageReconcileResult> {
|
|
5019
5030
|
if (!(await this.isRuntimeActive().catch(() => false))) {
|
|
5020
|
-
//
|
|
5021
|
-
//
|
|
5022
|
-
//
|
|
5023
|
-
//
|
|
5024
|
-
// until the drain's `until` backstop (issue 1931).
|
|
5025
|
-
await this.reportPendingImageCurrent(where);
|
|
5031
|
+
// Inactivity proves only that the old process is gone, not that its
|
|
5032
|
+
// replacement started successfully. Keep the marker: the next refresh's
|
|
5033
|
+
// hydration starts the deployed image and reports only after it reaches
|
|
5034
|
+
// `warm` (issue 2101).
|
|
5026
5035
|
return "inactive";
|
|
5027
5036
|
}
|
|
5028
5037
|
const last = THREAD_USERS[THREAD_USERS.length - 1];
|
|
5029
5038
|
// A pending report marker (issue 1931) means a deploy could not verify this
|
|
5030
5039
|
// container on its image: the probe shortcut below is a POOL-USER check the
|
|
5031
5040
|
// pre-deploy image passes when the pool did not change, so it can never
|
|
5032
|
-
// satisfy the marker — the container is treated as stale until it is
|
|
5033
|
-
// post-deploy
|
|
5041
|
+
// satisfy the marker — the container is treated as stale until it is
|
|
5042
|
+
// cycled post-deploy.
|
|
5034
5043
|
const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
|
|
5035
5044
|
if (!force && pending === undefined) {
|
|
5036
5045
|
const probe = await this.run(["id", "-u", last]);
|
|
@@ -5041,6 +5050,22 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5041
5050
|
: pending !== undefined
|
|
5042
5051
|
? "a deploy's new-image report is pending, so the running container cannot be trusted current"
|
|
5043
5052
|
: `${last} missing in the running container`;
|
|
5053
|
+
if (where === "attach") {
|
|
5054
|
+
// An attach is never what restarts the container (issue 2101): the
|
|
5055
|
+
// post-deploy restart loop was exactly this — each attach stopped the
|
|
5056
|
+
// container the previous attach had stopped and probed a fresh start
|
|
5057
|
+
// still restoring, so the marker never cleared. While the marker is
|
|
5058
|
+
// pending every NEW attach is refused (`stale`) — the run takes the
|
|
5059
|
+
// fallback sandbox and the resident can go quiet for the refresh cycle's
|
|
5060
|
+
// restart; a stale pool with no marker keeps admitting onto a busy
|
|
5061
|
+
// container, as the deferral always did.
|
|
5062
|
+
if (pending === undefined && (this.inFlightCount() > 0 || (await this.registeredRunsBeyondOps()) > 0)) {
|
|
5063
|
+
console.log(`image-stale (attach): ${stale} but the container is busy — deferring to the refresh cycle`);
|
|
5064
|
+
return "deferred";
|
|
5065
|
+
}
|
|
5066
|
+
console.log(`image-stale (attach): ${stale} — the attach is refused; the refresh cycle restarts the container`);
|
|
5067
|
+
return "stale";
|
|
5068
|
+
}
|
|
5044
5069
|
const busy = this.inFlightCount();
|
|
5045
5070
|
if (busy > 0) {
|
|
5046
5071
|
console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
|
|
@@ -5056,32 +5081,22 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5056
5081
|
console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
|
|
5057
5082
|
this.swapIncarnation(); // deliberate incarnation swap
|
|
5058
5083
|
await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
// (issue 1931), never the pool-user probe on the old process. A failed
|
|
5064
|
-
// probe keeps the marker: the next reconcile tries the cycle again.
|
|
5065
|
-
const fresh = await this.run(["id", "-u", last])
|
|
5066
|
-
.then((p) => p.exitCode === 0)
|
|
5067
|
-
.catch(() => false);
|
|
5068
|
-
if (fresh) await this.reportPendingImageCurrent(where);
|
|
5069
|
-
else
|
|
5070
|
-
console.log(`image-stale (${where}): the fresh container's probe failed — the new-image report stays pending`);
|
|
5071
|
-
}
|
|
5084
|
+
// A successful stop proves only that the old process is gone. The pending
|
|
5085
|
+
// marker deliberately survives it: the replacement's own hydration reports
|
|
5086
|
+
// after reaching `warm`, so `imageReport: current` can never precede a
|
|
5087
|
+
// successful post-deploy start (issue 2101).
|
|
5072
5088
|
return "restarted";
|
|
5073
5089
|
}
|
|
5074
5090
|
|
|
5075
5091
|
/** The report a held drain waits for (issue 1931): when the deploy's
|
|
5076
5092
|
* reconcile could not verify this resident's fresh container on the new
|
|
5077
5093
|
* image, a marker stays in storage and the registry holds the drain. The
|
|
5078
|
-
* fact the report stands on is
|
|
5079
|
-
* the
|
|
5080
|
-
*
|
|
5081
|
-
*
|
|
5082
|
-
*
|
|
5083
|
-
*
|
|
5084
|
-
* for a report that never lands. */
|
|
5094
|
+
* fact the report stands on is the replacement's own completed hydration:
|
|
5095
|
+
* the fresh container has restored its snapshot and reached `warm`. A stop
|
|
5096
|
+
* or inactive runtime does not report; each keeps the marker for that next
|
|
5097
|
+
* start (issue 2101). The marker is deleted only after a successful report:
|
|
5098
|
+
* a transient failure keeps it for the next reconcile, and the drain's
|
|
5099
|
+
* `until` is the backstop for a report that never lands. */
|
|
5085
5100
|
private async reportPendingImageCurrent(where: string): Promise<boolean> {
|
|
5086
5101
|
const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
|
|
5087
5102
|
if (pending === undefined) return true;
|
|
@@ -5107,12 +5122,12 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5107
5122
|
* tell the pre-deploy image from the new one when the pool did not change,
|
|
5108
5123
|
* and the platform replaces the container processes asynchronously after an
|
|
5109
5124
|
* image-changing deploy (issue 1931) — so this path never trusts "current":
|
|
5110
|
-
* an active, quiet container is always cycled,
|
|
5111
|
-
*
|
|
5112
|
-
*
|
|
5113
|
-
*
|
|
5114
|
-
*
|
|
5115
|
-
* timer.
|
|
5125
|
+
* an active, quiet container is always cycled, but that stop is not a report.
|
|
5126
|
+
* The marker survives until the replacement hydrates and reaches `warm` on
|
|
5127
|
+
* the deploy's image (issue 2101: the old probe raced that restore and lost).
|
|
5128
|
+
* `verified` true is the fact the reopen may stand on; a cycle or inactive
|
|
5129
|
+
* result leaves a hold on the drain, cleared by this resident's later
|
|
5130
|
+
* `reportPendingImageCurrent`, never by a timer.
|
|
5116
5131
|
*
|
|
5117
5132
|
* The hold lands BEFORE the marker: the marker is what lets any concurrent
|
|
5118
5133
|
* attach/refresh reconcile report, and a report that reaches the registry
|
|
@@ -5124,10 +5139,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5124
5139
|
await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
|
|
5125
5140
|
const result = await this.reconcileImage("deploy", true);
|
|
5126
5141
|
if (result === "deferred") return { result, verified: false };
|
|
5127
|
-
//
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
//
|
|
5142
|
+
// Neither inactivity nor a successful stop is verification: only the
|
|
5143
|
+
// replacement's completed hydration clears the marker. A concurrent fresh
|
|
5144
|
+
// start may have done so while this reconcile yielded, hence the storage
|
|
5145
|
+
// read rather than an unconditional false.
|
|
5131
5146
|
const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
|
|
5132
5147
|
return { result, verified };
|
|
5133
5148
|
}
|
|
@@ -5471,12 +5486,19 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5471
5486
|
const memory = await this.memoryGate("attach", registered);
|
|
5472
5487
|
if (memory) return memory;
|
|
5473
5488
|
const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
5474
|
-
|
|
5489
|
+
// An attach never restarts the container (issue 2101): a `stale` verdict
|
|
5490
|
+
// refuses the NEW run — it falls back to the seeded sandbox — while a
|
|
5491
|
+
// registered run's re-attach passes exactly as it passes the drain and
|
|
5492
|
+
// the memory gate; the restart itself is the refresh cycle's or the
|
|
5493
|
+
// deploy's.
|
|
5494
|
+
if ((await this.reconcileImage("attach")) === "stale" && !registered) {
|
|
5495
|
+
const s = await this.getStatus();
|
|
5475
5496
|
return {
|
|
5476
|
-
error:
|
|
5497
|
+
error:
|
|
5498
|
+
"image-stale: the container predates the deploy and restarts on the next quiet refresh; new runs use the fallback sandbox until then",
|
|
5477
5499
|
status: 503,
|
|
5478
|
-
state:
|
|
5479
|
-
stateReason:
|
|
5500
|
+
state: s.state,
|
|
5501
|
+
stateReason: s.reason,
|
|
5480
5502
|
reason: "image-stale",
|
|
5481
5503
|
};
|
|
5482
5504
|
}
|
|
@@ -48,11 +48,14 @@ ENV NODE_OPTIONS=--max-old-space-size=8192
|
|
|
48
48
|
# item 25) restores the resident's snapshot with the SDK's presigned restore,
|
|
49
49
|
# which MOUNTS the archive (squashfuse + fuse-overlayfs) instead of extracting
|
|
50
50
|
# it; the Worker extracts it onto the disk the way the resident does
|
|
51
|
-
# (resident-repos item 61) so the checkout is one plain ext4 tree.
|
|
52
|
-
#
|
|
51
|
+
# (resident-repos item 61) so the checkout is one plain ext4 tree.
|
|
52
|
+
# ripgrep (`rg`): the search binary is an image invariant for both fresh and
|
|
53
|
+
# seeded sandboxes; restoring a seed changes the disk, not the image tools.
|
|
54
|
+
# The build asserts both binaries with the probes their callers use.
|
|
53
55
|
RUN apt-get update \
|
|
54
|
-
&& apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools \
|
|
56
|
+
&& apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools ripgrep \
|
|
55
57
|
&& command -v unsquashfs >/dev/null \
|
|
58
|
+
&& command -v rg >/dev/null \
|
|
56
59
|
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
57
60
|
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
|
58
61
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -47,11 +47,9 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
|
|
|
47
47
|
export const IDENTITIES = ["none", "read", "write"] as const;
|
|
48
48
|
export type Identity = (typeof IDENTITIES)[number];
|
|
49
49
|
|
|
50
|
-
/** The model tiers a preset may run on
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* a parent choosing a child's model at spawn is held to the child preset's
|
|
54
|
-
* set, and escalation is a new run — a run's tier is fixed at dispatch. */
|
|
50
|
+
/** The model tiers a preset may run on. The retired classifier leaves no
|
|
51
|
+
* configured fast-tier ref, so current run models resolve strong; declarations
|
|
52
|
+
* remain explicit for model-card tiering and future configured catalogues. */
|
|
55
53
|
export const MODEL_TIERS = ["fast", "strong"] as const;
|
|
56
54
|
export type ModelTier = (typeof MODEL_TIERS)[number];
|
|
57
55
|
|
|
@@ -118,6 +118,11 @@ export const POLICY: readonly Rule[] = [
|
|
|
118
118
|
// `pulls rebase` force-pushes the pipeline's branches and can start a paid
|
|
119
119
|
// fix round (record 0071): a grant, never a chat baseline.
|
|
120
120
|
{ action: "pulls:write", resource: "command", when: [grant("pulls:write")] },
|
|
121
|
+
// `pulls merge|enqueue` (record 0070, criterion 4): a person's merge through
|
|
122
|
+
// the door — the grant, never a baseline, so a grant-less person has a
|
|
123
|
+
// thread that reads everything and merges nothing. The release pull
|
|
124
|
+
// request's handoff and the approval fences are the handler's own refusals.
|
|
125
|
+
{ action: "merge:write", resource: "command", when: [grant("merge:write")] },
|
|
121
126
|
|
|
122
127
|
// ── repos ────────────────────────────────────────────────────────────────
|
|
123
128
|
{ action: "repo:read", resource: "command", when: [grant("repo:read")] },
|
|
@@ -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
|
|
@@ -236,6 +242,13 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
|
|
|
236
242
|
* and the pause before a busy spawn is asked again. */
|
|
237
243
|
export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
|
|
238
244
|
|
|
245
|
+
/** How long a restarting close gives its replacement dispatch to claim the
|
|
246
|
+
* same run before a read with no successor treats the close as final
|
|
247
|
+
* (run-history item 47a). One whole coordinator wait chunk lets an ordinary
|
|
248
|
+
* replacement claim without shortening the parent's first wait; a successor
|
|
249
|
+
* found after this deadline still wins because read-record searches first. */
|
|
250
|
+
export const RESTART_CLAIM_GRACE_MS = minutesToMs(SHIP_WAIT.chunkMinutes);
|
|
251
|
+
|
|
239
252
|
/** The plane's table (docs/decisions/0064): how long a finished run stays on
|
|
240
253
|
* it, a reservation's window, and the default re-ask cadence — how often a
|
|
241
254
|
* silent resident something waits on is probed (`plane.reaskMinutes`
|
|
@@ -111,12 +111,24 @@ export interface ThreadEventAttachment {
|
|
|
111
111
|
mediaType: string;
|
|
112
112
|
data: string;
|
|
113
113
|
name?: string;
|
|
114
|
+
/** The accepted channel file's by-reference source, when one exists, so the
|
|
115
|
+
* coding child can stage it without moving the inline bytes through a
|
|
116
|
+
* second request. */
|
|
117
|
+
staged?: {
|
|
118
|
+
name: string;
|
|
119
|
+
size: number;
|
|
120
|
+
type: string;
|
|
121
|
+
url: string;
|
|
122
|
+
messageId: string;
|
|
123
|
+
workspaceIndex?: number;
|
|
124
|
+
};
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
export interface ThreadEvent {
|
|
117
128
|
/** Assigned by the store's append, in arrival order, per unit. */
|
|
118
129
|
seq: number;
|
|
119
|
-
/** The channel's message id,
|
|
130
|
+
/** The channel's message id, or another producer's stable event id. An
|
|
131
|
+
* append that repeats it on one unit returns the original row. */
|
|
120
132
|
id?: string;
|
|
121
133
|
/** The sender (platform-namespaced) and the display name the channel knew. */
|
|
122
134
|
sender: string;
|
|
@@ -174,11 +186,24 @@ export function capThreadEvent<T extends Omit<ThreadEvent, "seq"> & { seq?: numb
|
|
|
174
186
|
}
|
|
175
187
|
}
|
|
176
188
|
|
|
189
|
+
const isThreadEventStagedFile = (v: unknown): v is NonNullable<ThreadEventAttachment["staged"]> =>
|
|
190
|
+
isObject(v) &&
|
|
191
|
+
typeof v.name === "string" &&
|
|
192
|
+
typeof v.size === "number" &&
|
|
193
|
+
Number.isInteger(v.size) &&
|
|
194
|
+
v.size > 0 &&
|
|
195
|
+
typeof v.type === "string" &&
|
|
196
|
+
typeof v.url === "string" &&
|
|
197
|
+
typeof v.messageId === "string" &&
|
|
198
|
+
(v.workspaceIndex === undefined ||
|
|
199
|
+
(typeof v.workspaceIndex === "number" && Number.isInteger(v.workspaceIndex) && v.workspaceIndex >= 0));
|
|
200
|
+
|
|
177
201
|
const isThreadEventAttachment = (v: unknown): v is ThreadEventAttachment =>
|
|
178
202
|
isObject(v) &&
|
|
179
203
|
typeof v.mediaType === "string" &&
|
|
180
204
|
typeof v.data === "string" &&
|
|
181
|
-
(v.name === undefined || typeof v.name === "string")
|
|
205
|
+
(v.name === undefined || typeof v.name === "string") &&
|
|
206
|
+
(v.staged === undefined || isThreadEventStagedFile(v.staged));
|
|
182
207
|
|
|
183
208
|
const isThreadEventMode = (v: unknown): v is ThreadEventMode => v === "steer" || v === "wake" || v === "interrupt";
|
|
184
209
|
|
|
@@ -399,6 +424,26 @@ export interface UnitIdle {
|
|
|
399
424
|
wakes: number;
|
|
400
425
|
}
|
|
401
426
|
|
|
427
|
+
/** The durable answer to one indexed idle wait. The wait id is the key on the
|
|
428
|
+
* unit row, so a reclaimed runner receives byte-for-byte the decision the
|
|
429
|
+
* first caller stored instead of spending a renewal or consuming an event
|
|
430
|
+
* twice. */
|
|
431
|
+
export type UnitWakeAnswer =
|
|
432
|
+
| {
|
|
433
|
+
kind: "segment";
|
|
434
|
+
index: number;
|
|
435
|
+
from?: string;
|
|
436
|
+
runId?: string;
|
|
437
|
+
spendUsd: number | null;
|
|
438
|
+
handoff?: Handoff;
|
|
439
|
+
texts: string[];
|
|
440
|
+
senders: string[];
|
|
441
|
+
leaseMs?: number;
|
|
442
|
+
}
|
|
443
|
+
| { kind: "answered"; reply: string }
|
|
444
|
+
| { kind: "stopped" }
|
|
445
|
+
| { kind: "expired" };
|
|
446
|
+
|
|
402
447
|
/** What the severity gate caught on a round: the level in force and the gated findings as `id (severity)`. */
|
|
403
448
|
export interface RoundGate {
|
|
404
449
|
level: AddressSeverity;
|
|
@@ -456,14 +501,20 @@ export interface CoordinatorUnit {
|
|
|
456
501
|
* (null once any run's cost is unknown), `handoff` that child's lists, and
|
|
457
502
|
* `wakes` how many wakes this idle has answered — zero at the write. */
|
|
458
503
|
idle?: UnitIdle;
|
|
504
|
+
/** Answers to indexed idle waits, keyed by the wait step's durable identity. */
|
|
505
|
+
wakes?: Record<string, UnitWakeAnswer>;
|
|
459
506
|
/** The round boundaries the coordinator reported, oldest first (the `ship_round`
|
|
460
507
|
* vocabulary). `gate` rides an approve the machine's severity check caught
|
|
461
508
|
* carrying a finding at or above the level in force ([agent-ship](../../../docs/reference/specs/agent-ship.md)
|
|
462
509
|
* item 9) — a mismatch to be seen, since the child's parser holds an approve
|
|
463
510
|
* to the same level. */
|
|
464
511
|
rounds: Array<{ index: number; agent: string; outcome: string; at: number; gate?: RoundGate }>;
|
|
465
|
-
/** How the unit ended: the ending's kind and the thread's report, when it
|
|
466
|
-
|
|
512
|
+
/** How the unit ended: the ending's kind and the thread's report, when it
|
|
513
|
+
* has. `cause` names the machine's reason behind a driver-posted kind;
|
|
514
|
+
* `step` and `round` locate that reason without parsing the report. For a
|
|
515
|
+
* `step_threw` failure the driver records all available fields before it
|
|
516
|
+
* rethrows (issue 2100); unit-start has no round yet. */
|
|
517
|
+
ending?: { kind: string; report: string; at: number; cause?: string; step?: string; round?: number };
|
|
467
518
|
startedAt?: number;
|
|
468
519
|
}
|
|
469
520
|
|
|
@@ -548,6 +599,27 @@ const isSegment = (v: unknown): boolean =>
|
|
|
548
599
|
(v.runId === undefined || isText(v.runId)) &&
|
|
549
600
|
typeof v.at === "number";
|
|
550
601
|
|
|
602
|
+
export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
|
|
603
|
+
if (!isObject(v)) return false;
|
|
604
|
+
if (v.kind === "answered") return typeof v.reply === "string";
|
|
605
|
+
if (v.kind === "stopped" || v.kind === "expired") return true;
|
|
606
|
+
return (
|
|
607
|
+
v.kind === "segment" &&
|
|
608
|
+
typeof v.index === "number" &&
|
|
609
|
+
Number.isInteger(v.index) &&
|
|
610
|
+
v.index >= 1 &&
|
|
611
|
+
(v.from === undefined || isText(v.from)) &&
|
|
612
|
+
(v.runId === undefined || isText(v.runId)) &&
|
|
613
|
+
(v.spendUsd === null || isFinite(v.spendUsd)) &&
|
|
614
|
+
(v.handoff === undefined || isHandoffShape(v.handoff)) &&
|
|
615
|
+
Array.isArray(v.texts) &&
|
|
616
|
+
v.texts.every((text) => typeof text === "string") &&
|
|
617
|
+
Array.isArray(v.senders) &&
|
|
618
|
+
v.senders.every((sender) => isText(sender)) &&
|
|
619
|
+
(v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0))
|
|
620
|
+
);
|
|
621
|
+
};
|
|
622
|
+
|
|
551
623
|
export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
552
624
|
if (!isObject(v)) return false;
|
|
553
625
|
const r = v;
|
|
@@ -562,6 +634,13 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
|
562
634
|
if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
|
|
563
635
|
if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
|
|
564
636
|
if (r.idle !== undefined && !isUnitIdle(r.idle)) return false;
|
|
637
|
+
if (
|
|
638
|
+
r.wakes !== undefined &&
|
|
639
|
+
(!isObject(r.wakes) ||
|
|
640
|
+
Array.isArray(r.wakes) ||
|
|
641
|
+
!Object.entries(r.wakes).every(([waitId, answer]) => STEP_NAME_PATTERN.test(waitId) && isUnitWakeAnswer(answer)))
|
|
642
|
+
)
|
|
643
|
+
return false;
|
|
565
644
|
if (
|
|
566
645
|
!Array.isArray(r.rounds) ||
|
|
567
646
|
r.rounds.length > MAX_ROUNDS ||
|
|
@@ -578,7 +657,16 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
|
578
657
|
return false;
|
|
579
658
|
if (
|
|
580
659
|
r.ending !== undefined &&
|
|
581
|
-
!(
|
|
660
|
+
!(
|
|
661
|
+
isObject(r.ending) &&
|
|
662
|
+
isText(r.ending.kind) &&
|
|
663
|
+
isText(r.ending.report, MAX_REPORT) &&
|
|
664
|
+
isFinite(r.ending.at) &&
|
|
665
|
+
(r.ending.cause === undefined || isText(r.ending.cause, 64)) &&
|
|
666
|
+
(r.ending.step === undefined || (typeof r.ending.step === "string" && STEP_NAME_PATTERN.test(r.ending.step))) &&
|
|
667
|
+
(r.ending.round === undefined ||
|
|
668
|
+
(typeof r.ending.round === "number" && Number.isInteger(r.ending.round) && r.ending.round >= 0))
|
|
669
|
+
)
|
|
582
670
|
)
|
|
583
671
|
return false;
|
|
584
672
|
if (r.startedAt !== undefined && !isFinite(r.startedAt)) return false;
|