@coreplane/switchboard 1.259.0 → 1.260.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +16 -24
- 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/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 +7 -0
- package/dist/assets/src/core/coordinator/contract.ts +93 -5
- package/dist/assets/src/core/coordinator/driver.ts +386 -124
- package/dist/assets/src/core/memory/types.ts +5 -0
- package/dist/assets/src/core/pipelineStanding.ts +11 -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 +14 -15
- package/dist/assets/src/core/runRecord.ts +36 -3
- package/dist/assets/src/core/ship/coordinator.ts +58 -18
- package/dist/assets/src/core/ship/renewal.ts +21 -8
- package/dist/assets/src/core/types.ts +11 -0
- package/dist/assets/web/dist/.vite/manifest.json +67 -67
- package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-DvMrWUg7.js} +1 -1
- package/dist/assets/web/dist/assets/HomePage-CcGEJ4w0.js +1 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-BzGVxDYs.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-Arj9cyd5.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-CBPPe4Sj.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-CEpXgGo7.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-BJq7tnLr.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-DWiny7LN.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-Cxufw5sV.js +1 -0
- package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-SBcuDdVg.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-DrD_vWdp.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-Bjl7EPEN.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-BqteQTav.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-KRqJXEPN.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-Cv28FV4I.js} +1 -1
- package/dist/assets/web/dist/assets/{budgets-BRLm6oID.js → budgets-DQXVllsm.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-CTASUSno.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-B1YBy_IL.js} +1 -1
- package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-eTAe9-hk.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-BjlKmQsY.js → sseReplay-BcsNbn2j.js} +1 -1
- package/dist/cli.js +8299 -7929
- 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
|
@@ -97,7 +97,8 @@ defaults:
|
|
|
97
97
|
# thinks per turn; lower = much faster turns). Same layering as models —
|
|
98
98
|
# per-channel/user `effort` / `efforts.<agent>` and a per-request `effort:`
|
|
99
99
|
# directive override these; unset → the agent's built-in effort, else the
|
|
100
|
-
# provider default. Skipped for models without effort support.
|
|
100
|
+
# provider default. Skipped for models without effort support. The `general`
|
|
101
|
+
# entry also controls the operator turn on `defaults.models.general`.
|
|
101
102
|
# efforts:
|
|
102
103
|
# coding: medium
|
|
103
104
|
# How much the bot says about its own doing (docs/reference/specs/routing-and-config.md
|
|
@@ -368,7 +369,10 @@ workspaceDir: ./workspaces
|
|
|
368
369
|
# # records (soft delete, status `evicted`) in the same write.
|
|
369
370
|
# model: anthropic/claude-haiku-4-5
|
|
370
371
|
# # <provider>/<model> for the reflection pass — pick a
|
|
371
|
-
# # cheap
|
|
372
|
+
# # cheap level. Absent → the run's own resolved model.
|
|
373
|
+
# effort: low # how hard the reflection model thinks per distillation —
|
|
374
|
+
# # low | medium | high | xhigh | max, applied through the
|
|
375
|
+
# # model card; unset → the model's own default.
|
|
372
376
|
|
|
373
377
|
# Self-improvement proposals (docs/reference/specs/run-friction.md). Every finished run's friction
|
|
374
378
|
# diagnosis (docs/reference/specs/run-friction.md) is stored with its run record, so the
|
|
@@ -479,26 +483,11 @@ workspaceDir: ./workspaces
|
|
|
479
483
|
# spawn:
|
|
480
484
|
# maxChildren: 3
|
|
481
485
|
|
|
482
|
-
# The
|
|
483
|
-
#
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
# `ship` is never routed. The one way off, so every plain message runs
|
|
487
|
-
# `defaults.agent` as before the router: `auto: false`. `model` is the router's
|
|
488
|
-
# model; default `defaults.models.general`. `answer` is how that model answers:
|
|
489
|
-
# `tool` (default) forces a call to the router's `route` tool, whose schema is
|
|
490
|
-
# the answer, so prose cannot occur; `text` is the one-JSON-object text contract
|
|
491
|
-
# alone — set it for a provider or model that cannot take a forced tool call.
|
|
492
|
-
# `operator` is the one door's flag (routing-and-config item 29): `on`
|
|
493
|
-
# (default) — the operator's decision is what runs; `shadow` — one operator
|
|
494
|
-
# turn per admitted chat event, ahead of stage A, its decision written beside
|
|
495
|
-
# the routed request in the run store and nothing run from it; `off` — the
|
|
496
|
-
# rollback lever: it turns the operator back off after a bad day, not a choice
|
|
497
|
-
# to make up front. `shadow` and `on` run even under `auto: false`.
|
|
486
|
+
# The one door's mode (routing-and-config item 29): `on` (default) — the
|
|
487
|
+
# operator's typed decision is what runs; `shadow` — one operator turn per
|
|
488
|
+
# admitted chat event, its decision recorded and nothing run from it; `off` —
|
|
489
|
+
# the rollback lever. The retired readers' router has no model or effort key.
|
|
498
490
|
# routing:
|
|
499
|
-
# auto: false
|
|
500
|
-
# model: anthropic/claude-haiku-4-5
|
|
501
|
-
# answer: text
|
|
502
491
|
# operator: off
|
|
503
492
|
|
|
504
493
|
# The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
|
|
@@ -512,14 +501,17 @@ workspaceDir: ./workspaces
|
|
|
512
501
|
# model call) or `always` (today's answer-every-reply path, byte for byte).
|
|
513
502
|
# Also settable per channel and per user: `intake: { threadReplies: <mode> }`
|
|
514
503
|
# on a `channels.<id>` or `users.<id>` scope, user over channel over here.
|
|
515
|
-
# `model` is the verdict's model; default `
|
|
516
|
-
#
|
|
517
|
-
# through the
|
|
504
|
+
# `model` is the verdict's model; default `defaults.models.general`. `effort`
|
|
505
|
+
# is how hard that model thinks per verdict — low | medium | high | xhigh |
|
|
506
|
+
# max, applied through the model card; unset → the model's own default. A
|
|
507
|
+
# mention, a DM and a top-level post never pass through the gate in any mode.
|
|
508
|
+
# A model whose operator card (`providers.<p>.
|
|
518
509
|
# models.<id>.answers`) declares it answers neither a forced tool call nor the
|
|
519
510
|
# text contract is refused at load when the default mode is `classify`.
|
|
520
511
|
# intake:
|
|
521
512
|
# threadReplies: classify
|
|
522
513
|
# model: anthropic/claude-haiku-4-5
|
|
514
|
+
# effort: low
|
|
523
515
|
|
|
524
516
|
# The orchestration plane (docs/decisions/0064-the-plane-owns-every-runs-state-a-refusal-becomes-a-queue-position-an-ending-is-judged-by-the-ledger-that-saw-it-and-a-release-is-a-quiet-window-a-person-closes.md;
|
|
525
517
|
# docs/reference/specs/routing-and-config.md item 31; orchestration-plane.md).
|
|
@@ -109,13 +109,16 @@ import {
|
|
|
109
109
|
isCoordinatorInstance,
|
|
110
110
|
isCoordinatorUnit,
|
|
111
111
|
isThreadEvent,
|
|
112
|
+
isUnitWakeAnswer,
|
|
112
113
|
sendChildSignal,
|
|
113
114
|
sendRunFinished,
|
|
115
|
+
STEP_NAME_PATTERN,
|
|
114
116
|
UNIT_PATTERN,
|
|
115
117
|
type CoordinatorInstance,
|
|
116
118
|
type CoordinatorUnit,
|
|
117
119
|
type RunFinishedSend,
|
|
118
120
|
type ThreadEvent,
|
|
121
|
+
type UnitWakeAnswer,
|
|
119
122
|
} from "../../src/core/coordinator/contract.ts";
|
|
120
123
|
import {
|
|
121
124
|
GEN_PATTERN,
|
|
@@ -1848,10 +1851,27 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1848
1851
|
queuedAt: r.queued_at,
|
|
1849
1852
|
state: r.state as PlaneQueueRow["state"],
|
|
1850
1853
|
}));
|
|
1851
|
-
const
|
|
1852
|
-
.exec<{
|
|
1853
|
-
|
|
1854
|
-
|
|
1854
|
+
const liveRows = this.sql
|
|
1855
|
+
.exec<{ run_id: string; thread_key: string; meta_json: string }>(
|
|
1856
|
+
`SELECT run_id, thread_key, meta_json FROM live_runs WHERE run_id IS NOT ?`,
|
|
1857
|
+
excludeRunId ?? null,
|
|
1858
|
+
)
|
|
1859
|
+
.toArray();
|
|
1860
|
+
const liveThreads = liveRows.map((r) => r.thread_key);
|
|
1861
|
+
const liveRuns = Object.fromEntries(
|
|
1862
|
+
liveRows.flatMap((r) => {
|
|
1863
|
+
const meta = JSON.parse(r.meta_json) as Record<string, unknown>;
|
|
1864
|
+
return typeof meta.channelId === "string"
|
|
1865
|
+
? [[r.run_id, { channelId: meta.channelId, threadKey: r.thread_key }] as const]
|
|
1866
|
+
: [];
|
|
1867
|
+
}),
|
|
1868
|
+
);
|
|
1869
|
+
const inboxSeqs = Object.fromEntries(
|
|
1870
|
+
this.sql
|
|
1871
|
+
.exec<{ run_id: string; seq: number }>(`SELECT run_id, MAX(seq) AS seq FROM run_inbox GROUP BY run_id`)
|
|
1872
|
+
.toArray()
|
|
1873
|
+
.map((r) => [r.run_id, r.seq]),
|
|
1874
|
+
);
|
|
1855
1875
|
const levels = this.sql
|
|
1856
1876
|
.exec<{ resident: string; name: string; side: string; reported_at: number; generation: string }>(
|
|
1857
1877
|
`SELECT * FROM plane_levels`,
|
|
@@ -1864,7 +1884,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1864
1884
|
reportedAt: r.reported_at,
|
|
1865
1885
|
generation: r.generation,
|
|
1866
1886
|
}));
|
|
1867
|
-
return { queue, liveThreads, reservations, openWindows, levels };
|
|
1887
|
+
return { queue, liveThreads, liveRuns, inboxSeqs, reservations, openWindows, levels };
|
|
1868
1888
|
}
|
|
1869
1889
|
|
|
1870
1890
|
/** The decider's writes, applied inside the same `transactionSync` that read
|
|
@@ -1971,21 +1991,19 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1971
1991
|
this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL`).toArray()[0]
|
|
1972
1992
|
?.n ?? 0,
|
|
1973
1993
|
);
|
|
1994
|
+
const effectRunId = w.effect.kind === "admit" || w.effect.kind === "steer" ? w.effect.runId : undefined;
|
|
1974
1995
|
const forRun =
|
|
1975
|
-
|
|
1976
|
-
?
|
|
1996
|
+
effectRunId === undefined
|
|
1997
|
+
? 0
|
|
1998
|
+
: Number(
|
|
1977
1999
|
this.sql
|
|
1978
|
-
.exec<{
|
|
1979
|
-
n
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
// run ids, and a LIKE would read `%`/`_` in one as wildcards.
|
|
1983
|
-
`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`,
|
|
1984
|
-
w.effect.runId,
|
|
2000
|
+
.exec<{ n: number }>(
|
|
2001
|
+
`SELECT COUNT(*) AS n FROM plane_effects
|
|
2002
|
+
WHERE acked_at IS NULL AND json_extract(body_json, '$.runId') = ?`,
|
|
2003
|
+
effectRunId,
|
|
1985
2004
|
)
|
|
1986
2005
|
.toArray()[0]?.n ?? 0,
|
|
1987
|
-
)
|
|
1988
|
-
: 0;
|
|
2006
|
+
);
|
|
1989
2007
|
const refusal = effectCapRefusal({ total, forRun }, w.effect);
|
|
1990
2008
|
if (refusal !== undefined) throw new Error(refusal);
|
|
1991
2009
|
// A re-offer lands after an ack for any kind — a probe re-probes after
|
|
@@ -2330,6 +2348,13 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2330
2348
|
// Exact id matching (`admit:<runId>`), never LIKE: a bot-minted run id
|
|
2331
2349
|
// can carry `%` or `_`, which a pattern would read as wildcards.
|
|
2332
2350
|
this.sql.exec(`DELETE FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`, runId);
|
|
2351
|
+
this.sql.exec(
|
|
2352
|
+
`DELETE FROM plane_effects
|
|
2353
|
+
WHERE acked_at IS NULL
|
|
2354
|
+
AND json_extract(body_json, '$.kind') = 'steer'
|
|
2355
|
+
AND json_extract(body_json, '$.runId') = ?`,
|
|
2356
|
+
runId,
|
|
2357
|
+
);
|
|
2333
2358
|
// The run's steer dedupe rows and any park go with it (record 0064):
|
|
2334
2359
|
// a sealed run holds no turn and reads no steer.
|
|
2335
2360
|
this.sql.exec(`DELETE FROM plane_reservations WHERE kind IN ('steer', 'park') AND run_id = ?`, runId);
|
|
@@ -2434,12 +2459,54 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2434
2459
|
this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_disagreements', ?)`, JSON.stringify(counts));
|
|
2435
2460
|
}
|
|
2436
2461
|
|
|
2462
|
+
/** Fence a pushed steer to the owning live generation before its local
|
|
2463
|
+
* registry is touched. The effect check, owner check and lease renewal are
|
|
2464
|
+
* one transaction: an expired owner may renew before reclaim, but reclaim
|
|
2465
|
+
* can never cross the synchronous local delivery that follows a success. */
|
|
2466
|
+
async planeFenceSteer(
|
|
2467
|
+
id: string,
|
|
2468
|
+
runId: string,
|
|
2469
|
+
gen: string,
|
|
2470
|
+
leaseMs: number,
|
|
2471
|
+
now: number,
|
|
2472
|
+
): Promise<{ accepted: boolean }> {
|
|
2473
|
+
let accepted = false;
|
|
2474
|
+
this.ctx.storage.transactionSync(() => {
|
|
2475
|
+
const offered = this.sql
|
|
2476
|
+
.exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
|
|
2477
|
+
.toArray()[0];
|
|
2478
|
+
if (!offered) return;
|
|
2479
|
+
const effect = JSON.parse(offered.body_json) as PlaneEffect;
|
|
2480
|
+
if (effect.kind !== "steer" || effect.runId !== runId) return;
|
|
2481
|
+
const live = this.liveRow(runId);
|
|
2482
|
+
if (!live || live.ownerGen !== gen || live.phase !== "live") return;
|
|
2483
|
+
this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
|
|
2484
|
+
accepted = true;
|
|
2485
|
+
});
|
|
2486
|
+
if (accepted) await this.ensurePlaneAlarm(now);
|
|
2487
|
+
return { accepted };
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2437
2490
|
/** An effect's acknowledgement by id (orchestration-plane item 7): `done` and `skipped` close it,
|
|
2438
|
-
* `deferred` leaves it offered for the next answer.
|
|
2439
|
-
*
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2491
|
+
* `deferred` leaves it offered for the next answer. A steer is special: the
|
|
2492
|
+
* acknowledging generation must still own its live row. A stale process
|
|
2493
|
+
* may retain registry state during reclaim overlap, but it cannot close the
|
|
2494
|
+
* durable offer. An unknown id is a no-op. */
|
|
2495
|
+
planeAck(id: string, outcome: PlaneAckOutcome, now: number, owner?: { runId: string; gen: string }): { ok: true } {
|
|
2496
|
+
if (outcome === "deferred") return { ok: true };
|
|
2497
|
+
const offered = this.sql
|
|
2498
|
+
.exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
|
|
2499
|
+
.toArray()[0];
|
|
2500
|
+
if (!offered) return { ok: true };
|
|
2501
|
+
const effect = JSON.parse(offered.body_json) as PlaneEffect;
|
|
2502
|
+
if (effect.kind === "steer") {
|
|
2503
|
+
if (!owner || owner.runId !== effect.runId) return { ok: true };
|
|
2504
|
+
const live = this.sql
|
|
2505
|
+
.exec<{ owner_gen: string }>(`SELECT owner_gen FROM live_runs WHERE run_id = ?`, owner.runId)
|
|
2506
|
+
.toArray()[0];
|
|
2507
|
+
if (live?.owner_gen !== owner.gen) return { ok: true };
|
|
2508
|
+
}
|
|
2509
|
+
this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
|
|
2443
2510
|
return { ok: true };
|
|
2444
2511
|
}
|
|
2445
2512
|
|
|
@@ -2543,7 +2610,8 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2543
2610
|
// ---- the thread events of a unit-owned thread (record 0051's reply-as-event rule) --------------
|
|
2544
2611
|
|
|
2545
2612
|
/** The next sequence assigned in one transaction, the per-event cap applied
|
|
2546
|
-
* (attachments dropped whole, the row saying how many).
|
|
2613
|
+
* (attachments dropped whole, the row saying how many). A stable event id
|
|
2614
|
+
* already on the unit returns its first sequence without another row. */
|
|
2547
2615
|
async appendUnitEvent(
|
|
2548
2616
|
instanceId: string,
|
|
2549
2617
|
unit: string,
|
|
@@ -2551,6 +2619,20 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2551
2619
|
): Promise<{ ok: true; seq: number }> {
|
|
2552
2620
|
let seq = 1;
|
|
2553
2621
|
this.ctx.storage.transactionSync(() => {
|
|
2622
|
+
if (event.id !== undefined) {
|
|
2623
|
+
const existing = this.sql
|
|
2624
|
+
.exec<{ seq: number; json: string }>(
|
|
2625
|
+
`SELECT seq, json FROM coordinator_unit_events WHERE instance_id = ? AND unit = ? ORDER BY seq`,
|
|
2626
|
+
instanceId,
|
|
2627
|
+
unit,
|
|
2628
|
+
)
|
|
2629
|
+
.toArray()
|
|
2630
|
+
.find((row) => (JSON.parse(row.json) as ThreadEvent).id === event.id);
|
|
2631
|
+
if (existing !== undefined) {
|
|
2632
|
+
seq = existing.seq;
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2554
2636
|
const max = this.sql
|
|
2555
2637
|
.exec<{
|
|
2556
2638
|
m: number | null;
|
|
@@ -2602,6 +2684,38 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2602
2684
|
return { ok: true };
|
|
2603
2685
|
}
|
|
2604
2686
|
|
|
2687
|
+
/** One transaction is the wake's decision: the indexed answer on the unit
|
|
2688
|
+
* row and every event it consumed become visible together. */
|
|
2689
|
+
async answerUnitWake(
|
|
2690
|
+
unit: CoordinatorUnit,
|
|
2691
|
+
waitId: string,
|
|
2692
|
+
answer: UnitWakeAnswer,
|
|
2693
|
+
seqs: number[],
|
|
2694
|
+
by: string,
|
|
2695
|
+
now: number,
|
|
2696
|
+
): Promise<{ ok: true }> {
|
|
2697
|
+
this.ctx.storage.transactionSync(() => {
|
|
2698
|
+
const updated = { ...unit, wakes: { ...(unit.wakes ?? {}), [waitId]: answer } };
|
|
2699
|
+
this.sql.exec(
|
|
2700
|
+
`INSERT INTO coordinator_units (instance_id, unit, json, updated_at) VALUES (?, ?, ?, ?)
|
|
2701
|
+
ON CONFLICT(instance_id, unit) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at`,
|
|
2702
|
+
unit.instanceId,
|
|
2703
|
+
unit.unit,
|
|
2704
|
+
JSON.stringify(updated),
|
|
2705
|
+
now,
|
|
2706
|
+
);
|
|
2707
|
+
for (const seq of seqs)
|
|
2708
|
+
this.sql.exec(
|
|
2709
|
+
`UPDATE coordinator_unit_events SET consumed_by = ? WHERE instance_id = ? AND unit = ? AND seq = ? AND consumed_by IS NULL`,
|
|
2710
|
+
by,
|
|
2711
|
+
unit.instanceId,
|
|
2712
|
+
unit.unit,
|
|
2713
|
+
seq,
|
|
2714
|
+
);
|
|
2715
|
+
});
|
|
2716
|
+
return { ok: true };
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2605
2719
|
// ---- the live-run ledger (run-history items 28–34) --------------------------
|
|
2606
2720
|
|
|
2607
2721
|
private liveRow(runId: string): LiveRunRow | undefined {
|
|
@@ -4820,6 +4934,7 @@ const LEDGER_ROUTES = new Set([
|
|
|
4820
4934
|
"/runs/coordinator/events/append",
|
|
4821
4935
|
"/runs/coordinator/events/list",
|
|
4822
4936
|
"/runs/coordinator/events/mark-consumed",
|
|
4937
|
+
"/runs/coordinator/wake",
|
|
4823
4938
|
"/runs/claim",
|
|
4824
4939
|
"/runs/heartbeat",
|
|
4825
4940
|
"/runs/append",
|
|
@@ -4854,12 +4969,13 @@ const LEDGER_ROUTES = new Set([
|
|
|
4854
4969
|
"/runs/session/notepad/write",
|
|
4855
4970
|
]);
|
|
4856
4971
|
|
|
4857
|
-
/** The plane's routes (record 0064; orchestration-plane items 7 and 8):
|
|
4858
|
-
* effect
|
|
4859
|
-
* key, like every `/runs/*` route. */
|
|
4972
|
+
/** The plane's routes (record 0064; orchestration-plane items 7 and 8):
|
|
4973
|
+
* outcomes, effect delivery fencing and acknowledgements land on the ledger
|
|
4974
|
+
* object of the given store key, like every `/runs/*` route. */
|
|
4860
4975
|
const PLANE_ROUTES = new Set([
|
|
4861
4976
|
"/plane/outcome",
|
|
4862
4977
|
"/plane/ack",
|
|
4978
|
+
"/plane/steer/fence",
|
|
4863
4979
|
"/plane/admit",
|
|
4864
4980
|
"/plane/withdraw",
|
|
4865
4981
|
"/plane/deploy",
|
|
@@ -4915,11 +5031,32 @@ async function handlePlane(pathname: string, body: unknown, env: Env): Promise<R
|
|
|
4915
5031
|
);
|
|
4916
5032
|
return json(r);
|
|
4917
5033
|
}
|
|
5034
|
+
if (pathname === "/plane/steer/fence") {
|
|
5035
|
+
if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
|
|
5036
|
+
const runId = parseRunId(b.runId);
|
|
5037
|
+
if (!runId.ok) return json({ error: runId.error }, 400);
|
|
5038
|
+
const g = gen(b.gen);
|
|
5039
|
+
if (!g.ok) return json({ error: g.error }, 400);
|
|
5040
|
+
const lease = parseLeaseMs(b.leaseMs);
|
|
5041
|
+
if (!lease.ok) return json({ error: lease.error }, 400);
|
|
5042
|
+
return json(await stub.planeFenceSteer(b.id, runId.value, g.value, lease.value, now));
|
|
5043
|
+
}
|
|
4918
5044
|
if (pathname === "/plane/ack") {
|
|
4919
5045
|
if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
|
|
4920
5046
|
if (typeof b.outcome !== "string" || !PLANE_ACK_OUTCOMES.has(b.outcome))
|
|
4921
5047
|
return json({ error: "outcome must be done, skipped or deferred" }, 400);
|
|
4922
|
-
|
|
5048
|
+
let owner: { runId: string; gen: string } | undefined;
|
|
5049
|
+
if (b.owner !== undefined) {
|
|
5050
|
+
if (typeof b.owner !== "object" || b.owner === null || Array.isArray(b.owner))
|
|
5051
|
+
return json({ error: "owner must name a runId and generation" }, 400);
|
|
5052
|
+
const rawOwner = b.owner as Record<string, unknown>;
|
|
5053
|
+
const runId = parseRunId(rawOwner.runId);
|
|
5054
|
+
if (!runId.ok) return json({ error: runId.error }, 400);
|
|
5055
|
+
if (typeof rawOwner.gen !== "string" || rawOwner.gen.length === 0)
|
|
5056
|
+
return json({ error: "owner generation must be a non-empty string" }, 400);
|
|
5057
|
+
owner = { runId: runId.value, gen: rawOwner.gen };
|
|
5058
|
+
}
|
|
5059
|
+
return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now, owner));
|
|
4923
5060
|
}
|
|
4924
5061
|
if (pathname === "/plane/admit") {
|
|
4925
5062
|
// The admission-stage ask (record 0064, "The queue"): the thread key, the
|
|
@@ -5457,8 +5594,22 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
5457
5594
|
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
5458
5595
|
return json({ units: await stub.listUnits(b.instanceId) });
|
|
5459
5596
|
}
|
|
5597
|
+
if (pathname === "/runs/coordinator/wake") {
|
|
5598
|
+
if (!isCoordinatorUnit(b.unit)) return json({ error: "unit must be a coordinator unit row" }, 400);
|
|
5599
|
+
if (typeof b.waitId !== "string" || !STEP_NAME_PATTERN.test(b.waitId))
|
|
5600
|
+
return json({ error: "waitId must be a step name" }, 400);
|
|
5601
|
+
if (!isUnitWakeAnswer(b.answer)) return json({ error: "answer must be a unit wake answer" }, 400);
|
|
5602
|
+
if (!Array.isArray(b.seqs) || !b.seqs.every((s) => typeof s === "number" && Number.isInteger(s) && s >= 1))
|
|
5603
|
+
return json({ error: "seqs must be an array of sequence numbers" }, 400);
|
|
5604
|
+
if (typeof b.by !== "string" || b.by.length === 0 || b.by.length > 200)
|
|
5605
|
+
return json({ error: "by must name the consumer" }, 400);
|
|
5606
|
+
const r = await stub.answerUnitWake(b.unit, b.waitId, b.answer, b.seqs as number[], b.by, now);
|
|
5607
|
+
console.log(`[runs/coordinator/wake] ${key.value} ${b.unit.instanceId}:${b.unit.unit} ${b.waitId}`);
|
|
5608
|
+
return json(r);
|
|
5609
|
+
}
|
|
5460
5610
|
// The thread events of a unit-owned thread (record 0051's reply-as-event rule): append assigns
|
|
5461
|
-
// the sequence
|
|
5611
|
+
// the sequence (or returns the row with the same stable id), list filters
|
|
5612
|
+
// unconsumed, mark-consumed is idempotent.
|
|
5462
5613
|
if (pathname.startsWith("/runs/coordinator/events/")) {
|
|
5463
5614
|
if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
|
|
5464
5615
|
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
@@ -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" \
|