@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.
Files changed (56) hide show
  1. package/dist/assets/config/config.example.yaml +17 -24
  2. package/dist/assets/deploy/cloudflare/worker.ts +17 -56
  3. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  7. package/dist/assets/deploy/profile.example.json +2 -1
  8. package/dist/assets/package-lock.json +3 -3
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +3 -5
  12. package/dist/assets/src/core/authz/policy.ts +5 -0
  13. package/dist/assets/src/core/budgets.ts +19 -6
  14. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  15. package/dist/assets/src/core/coordinator/driver.ts +395 -128
  16. package/dist/assets/src/core/memory/types.ts +5 -0
  17. package/dist/assets/src/core/pipelineStanding.ts +13 -4
  18. package/dist/assets/src/core/plane/decide.ts +53 -12
  19. package/dist/assets/src/core/provider.ts +5 -0
  20. package/dist/assets/src/core/runEvents.ts +22 -15
  21. package/dist/assets/src/core/runLedger/types.ts +3 -3
  22. package/dist/assets/src/core/runRecord.ts +64 -11
  23. package/dist/assets/src/core/ship/coordinator.ts +202 -53
  24. package/dist/assets/src/core/ship/renewal.ts +23 -8
  25. package/dist/assets/src/core/types.ts +11 -0
  26. package/dist/assets/src/deploy/liveGate.ts +8 -0
  27. package/dist/assets/src/deploy/profile.ts +8 -0
  28. package/dist/assets/src/deploy/restart.ts +49 -35
  29. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  30. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
  31. package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  33. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
  38. package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
  42. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
  44. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  45. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
  47. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
  48. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
  49. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  50. package/dist/cli.js +9136 -8197
  51. package/package.json +1 -1
  52. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  54. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
  55. package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
  56. package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
@@ -9,6 +9,7 @@
9
9
  * live in thread history; duplicating them defeats the token win. Every record
10
10
  * keeps provenance (`sourceThreadKey`/`sourceRunId`) so a distilled fact can be
11
11
  * re-grounded in the run that produced it. */
12
+ import type { Effort } from "../../effort.js";
12
13
  import type { TraceOptions } from "../trace/types.js";
13
14
 
14
15
  export interface MemoryRecord {
@@ -170,6 +171,10 @@ export interface MemoryConfig {
170
171
  /** `<provider>/<model>` ref for the post-run reflection (write path) — a cheap
171
172
  * tier. Absent → the run's own resolved model. Never hardcoded (invariant 7). */
172
173
  model?: string;
174
+ /** How hard the extractor's model thinks, one of the five tiers; unset → the
175
+ * model's own default. Applied through the model card like a preset's
176
+ * effort (`turnEffort`, memory.md item 11). */
177
+ effort?: Effort;
173
178
  /** The durable store: the Memory Worker (deploy/cloudflare-memory/). Absent →
174
179
  * an in-process store that a restart loses (dev only; startup warns). */
175
180
  worker?: {
@@ -94,6 +94,7 @@ export const ROUND_STAGE = {
94
94
  request_changes: "fix",
95
95
  no_verdict: "hold",
96
96
  checks_failed: "fix",
97
+ checks_restarted: "approved",
97
98
  transient: "hold",
98
99
  enqueued: "approved",
99
100
  dequeued: "fix",
@@ -110,7 +111,9 @@ export const ROUND_STAGE = {
110
111
  * and holds the stage, and every other kind closes the unit as `ended` with
111
112
  * the kind as detail. `blocked` — a unit status the driver posts to
112
113
  * `unit-end` for a unit that never started — ends the same way. Pinned to
113
- * the union: a new ending kind fails the build here. */
114
+ * the union: a new ending kind fails the build here. `failed` — the ending
115
+ * the driver posts when a step threw inside the walk (cause `step_threw`,
116
+ * issue 2100) — closes the unit the same way. */
114
117
  export const ENDING_STAGE = {
115
118
  merged: "merged",
116
119
  already_landed: "merged",
@@ -128,14 +131,17 @@ export const ENDING_STAGE = {
128
131
  interrupted: "ended",
129
132
  refused: "ended",
130
133
  idle: "idle",
134
+ idle_expired: "ended",
131
135
  blocked: "ended",
132
- } as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
136
+ failed: "ended",
137
+ } as const satisfies Record<UnitEnding["kind"] | "blocked" | "failed", Stage | "hold">;
133
138
 
134
139
  /** The user's words for a unit's ending (record 0066): what a card, a plan
135
140
  * summary and the web unit page print instead of the internal token —
136
141
  * `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
137
142
  * never the snake_case kind. Pinned to the union plus `blocked` (the driver's
138
- * never-started status), so a new ending kind fails the build here. */
143
+ * never-started status) and `failed` (the driver's step-threw ending, issue
144
+ * 2100), so a new ending kind fails the build here. */
139
145
  export const ENDING_WORDS = {
140
146
  merged: "merged",
141
147
  already_landed: "merged",
@@ -153,8 +159,10 @@ export const ENDING_WORDS = {
153
159
  interrupted: "interrupted",
154
160
  refused: "refused",
155
161
  idle: "idle",
162
+ idle_expired: "idle expired",
156
163
  blocked: "blocked",
157
- } as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
164
+ failed: "failed",
165
+ } as const satisfies Record<UnitEnding["kind"] | "blocked" | "failed", string>;
158
166
 
159
167
  /** The user's words for a round outcome (record 0066): the card's unit line
160
168
  * prints these beside the round header, never `checks_failed` or another
@@ -167,6 +175,7 @@ export const ROUND_OUTCOME_WORDS = {
167
175
  request_changes: "changes requested",
168
176
  no_verdict: "no verdict",
169
177
  checks_failed: "checks failed",
178
+ checks_restarted: "checks restarted",
170
179
  transient: "retried",
171
180
  enqueued: "queued to merge",
172
181
  dequeued: "removed from the merge queue",
@@ -193,6 +193,11 @@ export interface PlaneQueueRow {
193
193
  export interface PlaneState {
194
194
  queue: PlaneQueueRow[];
195
195
  liveThreads: string[];
196
+ /** The live runs and their message route: provider recovery only steers a
197
+ * run whose durable row still exists, and its row remains readable after a reclaim. */
198
+ liveRuns: Record<string, { channelId: string; threadKey: string }>;
199
+ /** The greatest durable inbox sequence per live run, read in the decision's transaction. */
200
+ inboxSeqs: Record<string, number>;
196
201
  /** Threads an admitted ask holds before its claim lands (or after, until the seal). */
197
202
  reservations: PlaneReservation[];
198
203
  /** Open window kinds; `deploy` is the pending-deploy window (`deploy_settled` is its absence). */
@@ -202,7 +207,7 @@ export interface PlaneState {
202
207
  }
203
208
 
204
209
  export function emptyPlaneState(): PlaneState {
205
- return { queue: [], liveThreads: [], reservations: [], openWindows: [], levels: [] };
210
+ return { queue: [], liveThreads: [], liveRuns: {}, inboxSeqs: {}, reservations: [], openWindows: [], levels: [] };
206
211
  }
207
212
 
208
213
  /** The window kind behind `deploy_settled`: opened while a deploy is pending,
@@ -374,6 +379,10 @@ export type PlaneEffect =
374
379
  * (record 0064): the id is `probe:<resident>`, so the object holds at most one
375
380
  * open probe per resident and a duplicate offer is the same effect. */
376
381
  | { id: string; kind: "probe"; resident: string }
382
+ /** Deliver an already-durable provider-recovery steer to the owning live
383
+ * run. The inbox sequence is the idempotency key across push, heartbeat and
384
+ * reclaim; execution never writes the durable row again. */
385
+ | { id: string; kind: "steer"; runId: string; seq: number; message: PlaneSteerMessage }
377
386
  /** The `unit_title` move (record 0064): retitle the pull request under the
378
387
  * title rule — the bot re-reads the title before acting, so a person's own
379
388
  * retitle first makes this a `skipped`. Id `retitle:<repo>#<number>`. */
@@ -599,12 +608,40 @@ function onRunnerStatus(
599
608
  return { state, effects: [effect], writes: [{ table: "plane_effects", op: "offer", effect, at: event.at }] };
600
609
  }
601
610
 
611
+ /** The typed provider-recovery row carried by both the durable inbox and its
612
+ * live-delivery effect. */
613
+ export interface PlaneSteerMessage {
614
+ [key: string]: unknown;
615
+ channelId: string;
616
+ threadKey: string;
617
+ text: string;
618
+ at: number;
619
+ userId: "plane";
620
+ userName: "plane";
621
+ plane: { steer: "reissue"; provider: string };
622
+ }
623
+
602
624
  /** The inbox row a plane steer writes: sender `plane` (record 0064), the sentence as
603
625
  * the text, and the cause under `plane` so the run page can say why. */
604
626
  function planeInboxMessage(text: string, at: number, plane: Record<string, unknown>): Record<string, unknown> {
605
627
  return { text, at, userId: "plane", userName: "plane", plane };
606
628
  }
607
629
 
630
+ function reissueInboxMessage(
631
+ provider: string,
632
+ at: number,
633
+ route: { channelId: string; threadKey: string },
634
+ ): PlaneSteerMessage {
635
+ return {
636
+ ...route,
637
+ text: reissueSteerSentence(provider),
638
+ at,
639
+ userId: "plane",
640
+ userName: "plane",
641
+ plane: { steer: "reissue", provider },
642
+ };
643
+ }
644
+
608
645
  /** The checkpoint steer (record 0064, "The backpressure contract"): a stalled
609
646
  * or push-less coding run reads one fixed sentence at its next boundary. At
610
647
  * most one reservation per run per round per cause, and — the sentence being
@@ -669,21 +706,25 @@ function onProviderLevel(
669
706
  let next = { ...state, levels };
670
707
  if (event.level === "down") return { state: next, effects: [], writes };
671
708
  const parked = next.reservations.filter((r) => r.kind === "park" && r.key.startsWith(`${event.provider}#`));
709
+ const steers: PlaneEffect[] = [];
710
+ const inboxSeqs = { ...next.inboxSeqs };
672
711
  for (const p of parked) {
673
- writes.push({
674
- table: "run_inbox",
675
- op: "push",
676
- runId: p.runId,
677
- message: planeInboxMessage(reissueSteerSentence(event.provider), event.at, {
678
- steer: "reissue",
679
- provider: event.provider,
680
- }),
681
- });
712
+ const route = next.liveRuns[p.runId];
713
+ if (route) {
714
+ const seq = (inboxSeqs[p.runId] ?? 0) + 1;
715
+ inboxSeqs[p.runId] = seq;
716
+ const message = reissueInboxMessage(event.provider, event.at, route);
717
+ const effect: PlaneEffect = { id: `steer:${p.runId}:${seq}`, kind: "steer", runId: p.runId, seq, message };
718
+ steers.push(effect);
719
+ writes.push({ table: "run_inbox", op: "push", runId: p.runId, message });
720
+ writes.push({ table: "plane_effects", op: "offer", effect, at: event.at });
721
+ }
682
722
  writes.push({ table: "plane_reservations", op: "del", key: p.key, kind: "park" });
683
723
  }
684
- if (parked.length > 0) next = { ...next, reservations: next.reservations.filter((r) => !parked.includes(r)) };
724
+ if (parked.length > 0)
725
+ next = { ...next, inboxSeqs, reservations: next.reservations.filter((r) => !parked.includes(r)) };
685
726
  const walked = walk(next, event.at);
686
- return { ...walked, writes: [...writes, ...walked.writes] };
727
+ return { ...walked, effects: [...steers, ...walked.effects], writes: [...writes, ...walked.writes] };
687
728
  }
688
729
 
689
730
  /** A run parked on its provider: one park row — a second park of the same run
@@ -50,6 +50,11 @@ export interface CompletionRequest {
50
50
  maxTokens: number;
51
51
  /** model effort hint; providers apply it only where the model supports it */
52
52
  effort?: Effort;
53
+ /** The wire word the model card decided for `effort` (`turnEffort`,
54
+ * routing-and-config item 2) — vouched or degraded by the card's levels
55
+ * map. Rides only beside `effort`; absent, a provider that applies effort
56
+ * sends the tier's own word. */
57
+ effortWord?: string;
53
58
  /** Cancellation for a hard run stop: providers pass it to their HTTP
54
59
  * call so an aborted run stops billing/streaming now. Absent → never aborts. */
55
60
  signal?: AbortSignal;
@@ -294,6 +294,10 @@ export type RunNoteKind =
294
294
  * there was nothing to push), so a re-issue starts from the partial work
295
295
  * (docs/reference/specs/agent-ship.md item 8). */
296
296
  | "budget_salvage"
297
+ /** A coordinator's coding child ended failed, stopped or interrupted with
298
+ * work in its tree: before teardown the run loop made a WIP commit and
299
+ * pushed the unit branch, or recorded why no push was possible. */
300
+ | "work_salvage"
297
301
  /** A pi run's compaction failed for good — the provider refused the summary
298
302
  * (harness-pi.md item 7) — and the run loop treated it as a checkpoint
299
303
  * signal: the tracked changes were committed and pushed to the run's own
@@ -363,6 +367,7 @@ export const RUN_NOTE_KINDS = [
363
367
  "tool_unnamed",
364
368
  "directory_reached",
365
369
  "budget_salvage",
370
+ "work_salvage",
366
371
  "compaction_salvage",
367
372
  "stuck_loop",
368
373
  "decline_cascade",
@@ -432,6 +437,9 @@ export type ShipRoundOutcome =
432
437
  * 0055): the failures become check findings and the findings step runs as
433
438
  * for any changes-requested round. */
434
439
  | "checks_failed"
440
+ /** No required check appeared during the checks step's grace chunk, so the
441
+ * runner re-fired the pull_request event once by closing and reopening it. */
442
+ | "checks_restarted"
435
443
  /** The round's coding child died on a provider transient with nothing
436
444
  * pushed (issue 1932): the first such boundary marks the round's one
437
445
  * re-run, a second the `transient` ending. */
@@ -1070,15 +1078,13 @@ export type RunEvent =
1070
1078
  seq?: number;
1071
1079
  at?: number;
1072
1080
  }
1073
- /** The operator's decision beside the routed request ([record 0057](../../docs/decisions/0057-the-operator-is-the-one-door-a-model-binds-every-chat-input-and-deterministic-code-authorizes-fences-and-executes.md);
1074
- * the one-door plan's operator unit; run-history item 60): one per admitted chat
1075
- * event under `routing.operator: shadow` or `on`, published beside the
1076
- * `route` event. The decision is binds, a question, a refusal or — the
1077
- * structured seam's floor ([record 0067](../../docs/decisions/0067-one-seam-for-a-structured-answer-a-violation-is-re-asked-with-the-violation-named-and-the-callers-declared-floor-holds-never-a-refusal-shown-to-the-person.md)),
1078
- * never the model's decision — `non_decision`: under `on` the dispatcher
1079
- * falls back to the readers' route for that event, this event recorded on
1080
- * the run that then runs; `attempts` lists what each answer violated or
1081
- * that it was accepted. A bind's `line` is redacted and cut like the
1081
+ /** The operator's decision ([record 0057](../../docs/decisions/0057-the-operator-is-the-one-door-a-model-binds-every-chat-input-and-deterministic-code-authorizes-fences-and-executes.md);
1082
+ * run-history item 60): one per admitted chat event under
1083
+ * `routing.operator: shadow` or `on`. A no-call turn is repaired once and a
1084
+ * second becomes a typed general bind with reason `no_decision`; another
1085
+ * exhausted violation may remain `non_decision` and resolves on the
1086
+ * configured default with no second model. `attempts` lists what each
1087
+ * answer violated or that it was accepted. A bind's `line` is redacted and cut like the
1082
1088
  * receipt (`ROUTE_RECEIPT_CAP`), never the message text; `intake` carries
1083
1089
  * the intake gate's verdict when the gate is present; `latencyMs` and
1084
1090
  * `outputTokens` feed the replay's median rows. Under `shadow` nothing
@@ -1088,16 +1094,17 @@ export type RunEvent =
1088
1094
  mode: "shadow" | "on";
1089
1095
  outcome: "binds" | "question" | "refusal" | "non_decision";
1090
1096
  reason: string;
1091
- /** The decision was the loop's floor (record 0069, as amended): a turn
1092
- * that ended with no tool call, or the bounded re-asks ran out — the
1093
- * readers' route ran the person's own request, and the event never
1094
- * re-enters the loop. Additive: unknown → ignored. */
1097
+ /** Historical marker from records written before the readers' router
1098
+ * retired. New events never emit it. Additive: unknown → ignored. */
1095
1099
  floored?: true;
1096
1100
  /** A bind marked `confirmed` is a pending question's confirmed proposal
1097
1101
  * (`bindFromAnswer`): the line itself carries the task — the person's
1098
1102
  * message was the word "yes" — so a confirmed preset line routes its
1099
- * own tail as the request. Additive: unknown → a fresh bind. */
1100
- binds?: ReadonlyArray<{ line: string; reason: string; confirmed?: true }>;
1103
+ * own tail as the request. Additive: unknown → a fresh bind. A bind's
1104
+ * `model` is the ref the run uses, resolved from a model the person
1105
+ * named in plain words (the plain-words model unit) — applied at
1106
+ * directive precedence. Additive: unknown → no model. */
1107
+ binds?: ReadonlyArray<{ line: string; reason: string; model?: string; repo?: string; confirmed?: true }>;
1101
1108
  question?: string;
1102
1109
  /** A question's proposed line, redacted and cut like the receipt — what
1103
1110
  * the next turn's "yes" binds (`bindFromAnswer`). */
@@ -128,9 +128,9 @@ export interface LiveRunMeta {
128
128
  export type RunState = Record<string, unknown>;
129
129
 
130
130
  /** A hosted ship parent's hosting fact on the row's state (record 0060): the
131
- * runner instance the run hosts and the deadline past which a reclaim closes
132
- * the row `interrupted` instead of re-hosting it. Set by the ship branch once
133
- * the instance exists; read by the boot reclaim. */
131
+ * runner instance the run hosts and its original wall-clock estimate. Reclaim
132
+ * may outlive that estimate while the Workflow or one of its child rows is
133
+ * live; only no durable owner beyond it closes the row. */
134
134
  export interface HostingState {
135
135
  instanceId: string;
136
136
  /** Epoch ms: the hand-off time plus the instance's `caps.maxMinutes` plus
@@ -15,6 +15,7 @@ import {
15
15
  } from "./reviewVerdict.js";
16
16
  import { IDEMPOTENCY_KEY_PATTERN, INSTANCE_ID_PATTERN } from "./coordinator/contract.js";
17
17
  import { isPipelineSummaryShape, type PipelineSummary } from "./pipelineStanding.js";
18
+ import { RESTART_CLAIM_GRACE_MS } from "./budgets.js";
18
19
  import {
19
20
  FRICTION_CATEGORIES,
20
21
  type CategoryTotals,
@@ -231,6 +232,11 @@ export interface RunRecord {
231
232
  * waiting for `child_resumed` instead of ending its unit on this record.
232
233
  * Absent on every ending that is final. */
233
234
  restarting?: true;
235
+ /** Epoch ms through which a `restarting` close with no visible successor is
236
+ * projected as running. Current writers stamp one central grace from the
237
+ * close; absent on legacy restarting records, which retain the old
238
+ * unbounded compatibility behavior, and on every final ending. */
239
+ restartUntil?: number;
234
240
  /** Where the run's conversation started (item 52): `channel` — its own
235
241
  * thread's history, as for every run a person, a schedule or a coordinator
236
242
  * started — or `parent` — a spawned child seeded from its parent's text
@@ -283,19 +289,23 @@ export interface RunLease {
283
289
  loopEndsAt: number;
284
290
  }
285
291
 
286
- /** A head a run pushed, as the record names it (item 2): the branch and the sha. */
292
+ /** A head a run pushed, as the record names it (item 2): the branch, sha,
293
+ * and whether the agent pushed it or the run's mechanical WIP salvage did.
294
+ * `by` is optional for records written before that distinction survived the
295
+ * event fold. */
287
296
  export interface PushedHead {
288
297
  ref: string;
289
298
  sha: string;
299
+ by?: "push" | "salvage";
290
300
  }
291
301
 
292
302
  /** The heads a run's events say it pushed — one per branch, the last event's
293
303
  * sha winning, in first-seen order — or nothing when it pushed none. */
294
304
  export function pushedHeadsOf(events: readonly RunEvent[]): PushedHead[] | undefined {
295
- const byRef = new Map<string, string>();
296
- for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, e.sha);
305
+ const byRef = new Map<string, { sha: string; by: "push" | "salvage" }>();
306
+ for (const e of events) if (e.type === "pushed_head") byRef.set(e.ref, { sha: e.sha, by: e.by });
297
307
  if (byRef.size === 0) return undefined;
298
- return [...byRef].map(([ref, sha]) => ({ ref, sha }));
308
+ return [...byRef].map(([ref, pushed]) => ({ ref, ...pushed }));
299
309
  }
300
310
 
301
311
  /** The lease a run's events say its harness started — the first `lease`
@@ -434,7 +444,7 @@ export interface RunOperatorDecision {
434
444
  mode: "shadow" | "on";
435
445
  outcome: "binds" | "question" | "refusal" | "non_decision";
436
446
  reason: string;
437
- binds?: { line: string; reason: string }[];
447
+ binds?: { line: string; reason: string; model?: string; repo?: string; confirmed?: true }[];
438
448
  question?: string;
439
449
  /** A question's proposed line — what the next turn's "yes" binds. */
440
450
  proposal?: string;
@@ -462,7 +472,17 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
462
472
  mode: e.mode,
463
473
  outcome: e.outcome,
464
474
  reason: e.reason,
465
- ...(e.binds ? { binds: e.binds.map((b) => ({ line: b.line, reason: b.reason })) } : {}),
475
+ ...(e.binds
476
+ ? {
477
+ binds: e.binds.map((b) => ({
478
+ line: b.line,
479
+ reason: b.reason,
480
+ ...(b.model !== undefined ? { model: b.model } : {}),
481
+ ...(b.repo !== undefined ? { repo: b.repo } : {}),
482
+ ...(b.confirmed ? { confirmed: true as const } : {}),
483
+ })),
484
+ }
485
+ : {}),
466
486
  ...(e.question !== undefined ? { question: e.question } : {}),
467
487
  ...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
468
488
  ...(e.request !== undefined ? { request: e.request } : {}),
@@ -944,11 +964,34 @@ export function normalizeDiagnosis(d: FrictionDiagnosis): FrictionDiagnosis {
944
964
  /** `normalizeDiagnosis` applied to anything carrying a `diagnosis` — a record or
945
965
  * a listing row — on its way out of a store, and the `channelVisibility` stamp
946
966
  * filled with `unknown` for a row written before it existed (fail-closed:
947
- * `unknown` is never public). */
967
+ * `unknown` is never public). A restart deadline is trusted only inside the
968
+ * central grace after its persisted close stamp; malformed or overlong
969
+ * values drop the restarting projection, while a legacy row with no deadline
970
+ * keeps its compatibility behavior. */
948
971
  export function normalizeStored<T extends { diagnosis: FrictionDiagnosis; channelVisibility?: ChannelVisibility }>(
949
972
  v: T,
950
973
  ): T & { channelVisibility: ChannelVisibility } {
951
- return { ...v, diagnosis: normalizeDiagnosis(v.diagnosis), channelVisibility: v.channelVisibility ?? "unknown" };
974
+ const normalized = {
975
+ ...v,
976
+ diagnosis: normalizeDiagnosis(v.diagnosis),
977
+ channelVisibility: v.channelVisibility ?? "unknown",
978
+ } as T & { channelVisibility: ChannelVisibility };
979
+ const restart = normalized as T & { restarting?: unknown; restartUntil?: unknown; finishedAt?: unknown };
980
+ if (restart.restartUntil !== undefined) {
981
+ const bounded =
982
+ restart.restarting === true &&
983
+ typeof restart.restartUntil === "number" &&
984
+ Number.isFinite(restart.restartUntil) &&
985
+ typeof restart.finishedAt === "number" &&
986
+ Number.isFinite(restart.finishedAt) &&
987
+ restart.restartUntil >= restart.finishedAt &&
988
+ restart.restartUntil <= restart.finishedAt + RESTART_CLAIM_GRACE_MS;
989
+ if (!bounded) {
990
+ delete restart.restarting;
991
+ delete restart.restartUntil;
992
+ }
993
+ }
994
+ return normalized;
952
995
  }
953
996
 
954
997
  /** Structural check on a record from outside the process (a Worker response, a
@@ -990,8 +1033,14 @@ export function isRunRecord(v: unknown): v is RunRecord {
990
1033
  Array.isArray(r.pushed) &&
991
1034
  r.pushed.every((h) => {
992
1035
  if (typeof h !== "object" || h === null) return false;
993
- const { ref, sha } = h as Record<string, unknown>;
994
- return typeof ref === "string" && ref.length > 0 && typeof sha === "string" && /^[0-9a-f]{7,40}$/.test(sha);
1036
+ const { ref, sha, by } = h as Record<string, unknown>;
1037
+ return (
1038
+ typeof ref === "string" &&
1039
+ ref.length > 0 &&
1040
+ typeof sha === "string" &&
1041
+ /^[0-9a-f]{7,40}$/.test(sha) &&
1042
+ (by === undefined || by === "push" || by === "salvage")
1043
+ );
995
1044
  })
996
1045
  )
997
1046
  )
@@ -1027,8 +1076,12 @@ export function isRunRecord(v: unknown): v is RunRecord {
1027
1076
  // marker, like `provisional`, is the literal `true` or absent.
1028
1077
  if (r.pipeline !== undefined && !isPipelineSummaryShape(r.pipeline)) return false;
1029
1078
  if (r.hosted !== undefined && r.hosted !== true) return false;
1030
- // A restarting close carries the literal `true` or nothing (record 0064).
1079
+ // A restarting close carries the literal `true` and, on current writers,
1080
+ // one finite deadline; legacy closes carry no deadline. The persisted close
1081
+ // stamp bounds a future value in `normalizeStored`, where it can fail closed
1082
+ // without making the whole record unreadable.
1031
1083
  if (r.restarting !== undefined && r.restarting !== true) return false;
1084
+ if (r.restartUntil !== undefined && (!isFiniteNumber(r.restartUntil) || r.restarting !== true)) return false;
1032
1085
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
1033
1086
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
1034
1087
  if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;