@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.
Files changed (47) hide show
  1. package/dist/assets/config/config.example.yaml +16 -24
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  4. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +3 -5
  9. package/dist/assets/src/core/authz/policy.ts +5 -0
  10. package/dist/assets/src/core/budgets.ts +7 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  12. package/dist/assets/src/core/coordinator/driver.ts +386 -124
  13. package/dist/assets/src/core/memory/types.ts +5 -0
  14. package/dist/assets/src/core/pipelineStanding.ts +11 -4
  15. package/dist/assets/src/core/plane/decide.ts +53 -12
  16. package/dist/assets/src/core/provider.ts +5 -0
  17. package/dist/assets/src/core/runEvents.ts +14 -15
  18. package/dist/assets/src/core/runRecord.ts +36 -3
  19. package/dist/assets/src/core/ship/coordinator.ts +58 -18
  20. package/dist/assets/src/core/ship/renewal.ts +21 -8
  21. package/dist/assets/src/core/types.ts +11 -0
  22. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  23. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-DvMrWUg7.js} +1 -1
  24. package/dist/assets/web/dist/assets/HomePage-CcGEJ4w0.js +1 -0
  25. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-BzGVxDYs.js} +1 -1
  26. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-Arj9cyd5.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-CBPPe4Sj.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-CEpXgGo7.js} +1 -1
  29. package/dist/assets/web/dist/assets/RunFoldRow-BJq7tnLr.js +1 -0
  30. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-DWiny7LN.js} +4 -4
  31. package/dist/assets/web/dist/assets/RunsIndexPage-Cxufw5sV.js +1 -0
  32. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-SBcuDdVg.js} +1 -1
  33. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-DrD_vWdp.js} +1 -1
  34. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-Bjl7EPEN.js} +1 -1
  35. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-BqteQTav.js} +1 -1
  36. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-KRqJXEPN.js} +1 -1
  37. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-Cv28FV4I.js} +1 -1
  38. package/dist/assets/web/dist/assets/{budgets-BRLm6oID.js → budgets-DQXVllsm.js} +1 -1
  39. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-CTASUSno.js} +1 -1
  40. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-B1YBy_IL.js} +1 -1
  41. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-eTAe9-hk.js} +2 -2
  42. package/dist/assets/web/dist/assets/{sseReplay-BjlKmQsY.js → sseReplay-BcsNbn2j.js} +1 -1
  43. package/dist/cli.js +8299 -7929
  44. package/package.json +1 -1
  45. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  46. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  47. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
@@ -110,7 +110,9 @@ export const ROUND_STAGE = {
110
110
  * and holds the stage, and every other kind closes the unit as `ended` with
111
111
  * the kind as detail. `blocked` — a unit status the driver posts to
112
112
  * `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. */
113
+ * the union: a new ending kind fails the build here. `failed` — the ending
114
+ * the driver posts when a step threw inside the walk (cause `step_threw`,
115
+ * issue 2100) — closes the unit the same way. */
114
116
  export const ENDING_STAGE = {
115
117
  merged: "merged",
116
118
  already_landed: "merged",
@@ -128,14 +130,17 @@ export const ENDING_STAGE = {
128
130
  interrupted: "ended",
129
131
  refused: "ended",
130
132
  idle: "idle",
133
+ idle_expired: "ended",
131
134
  blocked: "ended",
132
- } as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
135
+ failed: "ended",
136
+ } as const satisfies Record<UnitEnding["kind"] | "blocked" | "failed", Stage | "hold">;
133
137
 
134
138
  /** The user's words for a unit's ending (record 0066): what a card, a plan
135
139
  * summary and the web unit page print instead of the internal token —
136
140
  * `merge_ready` reads `merge-ready`, `round_cap` reads `round cap reached`,
137
141
  * 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. */
142
+ * never-started status) and `failed` (the driver's step-threw ending, issue
143
+ * 2100), so a new ending kind fails the build here. */
139
144
  export const ENDING_WORDS = {
140
145
  merged: "merged",
141
146
  already_landed: "merged",
@@ -153,8 +158,10 @@ export const ENDING_WORDS = {
153
158
  interrupted: "interrupted",
154
159
  refused: "refused",
155
160
  idle: "idle",
161
+ idle_expired: "idle expired",
156
162
  blocked: "blocked",
157
- } as const satisfies Record<UnitEnding["kind"] | "blocked", string>;
163
+ failed: "failed",
164
+ } as const satisfies Record<UnitEnding["kind"] | "blocked" | "failed", string>;
158
165
 
159
166
  /** The user's words for a round outcome (record 0066): the card's unit line
160
167
  * prints these beside the round header, never `checks_failed` or another
@@ -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;
@@ -1070,15 +1070,13 @@ export type RunEvent =
1070
1070
  seq?: number;
1071
1071
  at?: number;
1072
1072
  }
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
1073
+ /** 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);
1074
+ * run-history item 60): one per admitted chat event under
1075
+ * `routing.operator: shadow` or `on`. A no-call turn is repaired once and a
1076
+ * second becomes a typed general bind with reason `no_decision`; another
1077
+ * exhausted violation may remain `non_decision` and resolves on the
1078
+ * configured default with no second model. `attempts` lists what each
1079
+ * answer violated or that it was accepted. A bind's `line` is redacted and cut like the
1082
1080
  * receipt (`ROUTE_RECEIPT_CAP`), never the message text; `intake` carries
1083
1081
  * the intake gate's verdict when the gate is present; `latencyMs` and
1084
1082
  * `outputTokens` feed the replay's median rows. Under `shadow` nothing
@@ -1088,16 +1086,17 @@ export type RunEvent =
1088
1086
  mode: "shadow" | "on";
1089
1087
  outcome: "binds" | "question" | "refusal" | "non_decision";
1090
1088
  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. */
1089
+ /** Historical marker from records written before the readers' router
1090
+ * retired. New events never emit it. Additive: unknown → ignored. */
1095
1091
  floored?: true;
1096
1092
  /** A bind marked `confirmed` is a pending question's confirmed proposal
1097
1093
  * (`bindFromAnswer`): the line itself carries the task — the person's
1098
1094
  * 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 }>;
1095
+ * own tail as the request. Additive: unknown → a fresh bind. A bind's
1096
+ * `model` is the ref the run uses, resolved from a model the person
1097
+ * named in plain words (the plain-words model unit) — applied at
1098
+ * directive precedence. Additive: unknown → no model. */
1099
+ binds?: ReadonlyArray<{ line: string; reason: string; model?: string; confirmed?: true }>;
1101
1100
  question?: string;
1102
1101
  /** A question's proposed line, redacted and cut like the receipt — what
1103
1102
  * the next turn's "yes" binds (`bindFromAnswer`). */
@@ -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
@@ -944,11 +950,34 @@ export function normalizeDiagnosis(d: FrictionDiagnosis): FrictionDiagnosis {
944
950
  /** `normalizeDiagnosis` applied to anything carrying a `diagnosis` — a record or
945
951
  * a listing row — on its way out of a store, and the `channelVisibility` stamp
946
952
  * filled with `unknown` for a row written before it existed (fail-closed:
947
- * `unknown` is never public). */
953
+ * `unknown` is never public). A restart deadline is trusted only inside the
954
+ * central grace after its persisted close stamp; malformed or overlong
955
+ * values drop the restarting projection, while a legacy row with no deadline
956
+ * keeps its compatibility behavior. */
948
957
  export function normalizeStored<T extends { diagnosis: FrictionDiagnosis; channelVisibility?: ChannelVisibility }>(
949
958
  v: T,
950
959
  ): T & { channelVisibility: ChannelVisibility } {
951
- return { ...v, diagnosis: normalizeDiagnosis(v.diagnosis), channelVisibility: v.channelVisibility ?? "unknown" };
960
+ const normalized = {
961
+ ...v,
962
+ diagnosis: normalizeDiagnosis(v.diagnosis),
963
+ channelVisibility: v.channelVisibility ?? "unknown",
964
+ } as T & { channelVisibility: ChannelVisibility };
965
+ const restart = normalized as T & { restarting?: unknown; restartUntil?: unknown; finishedAt?: unknown };
966
+ if (restart.restartUntil !== undefined) {
967
+ const bounded =
968
+ restart.restarting === true &&
969
+ typeof restart.restartUntil === "number" &&
970
+ Number.isFinite(restart.restartUntil) &&
971
+ typeof restart.finishedAt === "number" &&
972
+ Number.isFinite(restart.finishedAt) &&
973
+ restart.restartUntil >= restart.finishedAt &&
974
+ restart.restartUntil <= restart.finishedAt + RESTART_CLAIM_GRACE_MS;
975
+ if (!bounded) {
976
+ delete restart.restarting;
977
+ delete restart.restartUntil;
978
+ }
979
+ }
980
+ return normalized;
952
981
  }
953
982
 
954
983
  /** Structural check on a record from outside the process (a Worker response, a
@@ -1027,8 +1056,12 @@ export function isRunRecord(v: unknown): v is RunRecord {
1027
1056
  // marker, like `provisional`, is the literal `true` or absent.
1028
1057
  if (r.pipeline !== undefined && !isPipelineSummaryShape(r.pipeline)) return false;
1029
1058
  if (r.hosted !== undefined && r.hosted !== true) return false;
1030
- // A restarting close carries the literal `true` or nothing (record 0064).
1059
+ // A restarting close carries the literal `true` and, on current writers,
1060
+ // one finite deadline; legacy closes carry no deadline. The persisted close
1061
+ // stamp bounds a future value in `normalizeStored`, where it can fail closed
1062
+ // without making the whole record unreadable.
1031
1063
  if (r.restarting !== undefined && r.restarting !== true) return false;
1064
+ if (r.restartUntil !== undefined && (!isFiniteNumber(r.restartUntil) || r.restarting !== true)) return false;
1032
1065
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
1033
1066
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
1034
1067
  if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
@@ -32,7 +32,14 @@
32
32
  import type { ShipRoundOutcome } from "../runEvents.js";
33
33
  import { shows, type Verbosity } from "../verbosity.js";
34
34
  import type { Handoff, HandoffLanded } from "./handoff.js";
35
- import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
35
+ import {
36
+ progressOf,
37
+ renderRenewal,
38
+ renewalDecision,
39
+ type PushedHeadFact,
40
+ type RenewalDecision,
41
+ type RenewalWhy,
42
+ } from "./renewal.js";
36
43
  import type { RunStatus } from "../runRecord.js";
37
44
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
38
45
  import {
@@ -125,13 +132,15 @@ export function interruptionCauseOfWords(words: string): InterruptionCause | und
125
132
  return undefined;
126
133
  }
127
134
 
128
- export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause): string {
135
+ export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause, idle = false): string {
129
136
  const stands = prUrl
130
137
  ? `Its work stands on GitHub: ${prUrl}.`
131
138
  : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
132
- const reissue = prUrl
133
- ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
134
- : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
139
+ const reissue = idle
140
+ ? "To continue, reply in this thread to continue."
141
+ : prUrl
142
+ ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
143
+ : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
135
144
  const opening =
136
145
  cause === "container_replaced"
137
146
  ? "⚠️ The resident container running this pipeline's child was replaced (a deploy's image swap) and the child could not resume, so the pipeline stopped."
@@ -477,7 +486,7 @@ export type Brief =
477
486
  rebase: { branch: string; onto: string };
478
487
  /** A renewal's segment (decision 0046): the child continues the previous
479
488
  * segment's work from `from`, briefed with that run's write-up and handoff. */
480
- continue?: { segment: number; from?: string; previousRunId?: string };
489
+ continue?: { segment: number; from?: string; previousRunId?: string; texts?: string[] };
481
490
  }
482
491
  | {
483
492
  kind: "review";
@@ -636,6 +645,10 @@ export type PrCheck =
636
645
  /** The head's self-declared fix-up commit subjects (`fixup!`/`squash!`/`amend!`),
637
646
  * when the read asked for the facts — a head carrying one is not in the ready state. */
638
647
  fixupCommits?: string[];
648
+ /** The base branch takes changes only through a merge queue (issue 2011):
649
+ * read beside the checks on the ending's facts read, so a `merge_ready`
650
+ * report tells the person their merge is queued, never a direct one. */
651
+ baseHasMergeQueue?: boolean;
639
652
  }
640
653
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
641
654
 
@@ -852,6 +865,7 @@ export type UnitEnding =
852
865
  spent: ShipBudgetSpent;
853
866
  }
854
867
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
868
+ | { kind: "idle_expired"; reviewRounds: number }
855
869
  /** Round 0's coding child died on a provider transient — a model-gateway
856
870
  * 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
857
871
  * with nothing pushed, TWICE: the first such death re-ran the round once
@@ -896,7 +910,8 @@ export type UnitEnding =
896
910
  * the pull request, which no thread reply can produce — but a blocked hold
897
911
  * (issue 2086) waits for exactly the person's word the idle's wake carries,
898
912
  * so it alone idles; `idleEnding` draws that line by cause. */
899
- export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
913
+ export type IdleWhy =
914
+ Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
900
915
 
901
916
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
902
917
  "idle",
@@ -904,6 +919,7 @@ const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
904
919
  "already_landed",
905
920
  "merge_ready",
906
921
  "refused",
922
+ "idle_expired",
907
923
  ]);
908
924
 
909
925
  /** What a transition tells the driver beyond the next action: a round boundary
@@ -957,6 +973,12 @@ export interface LeaseSegmentProgress {
957
973
  /** The previous segment's coding run: its write-up and handoff brief the continuation. */
958
974
  previousRunId?: string;
959
975
  previousHandoff?: Handoff;
976
+ /** Words that woke an idle segment, already attributed in arrival order. */
977
+ texts?: string[];
978
+ /** A stopped segment reopens under the remainder of its cut lease. Each
979
+ * reopen gets a fresh attempt so its Workflow steps cannot replay the
980
+ * stopped attempt's cached answers. */
981
+ resume?: { leaseMs: number; attempt: number };
960
982
  }
961
983
 
962
984
  export interface UnitPipelineInput {
@@ -1162,7 +1184,8 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1162
1184
  return nextReview(resumed).state;
1163
1185
  }
1164
1186
 
1165
- const deadlineAt = (s: UnitPipelineState) => s.startedAt + s.input.caps.maxMinutes * MIN;
1187
+ const deadlineAt = (s: UnitPipelineState) =>
1188
+ s.startedAt + (s.input.session?.resume?.leaseMs ?? s.input.caps.maxMinutes * MIN);
1166
1189
  const remainingMs = (s: UnitPipelineState) => deadlineAt(s) - s.clock;
1167
1190
 
1168
1191
  /** The next slice of a wait: a chunk; the remainder when less is left before
@@ -1189,11 +1212,14 @@ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1189
1212
  );
1190
1213
  }
1191
1214
 
1192
- /** The prefix every step of this pipeline is named under: the unit id, and for
1193
- * a renewal's segment the segment too (`U10/s2/…`), so the Workflow's durable
1194
- * step cache never hands segment two the answers of segment one. */
1195
- export const stepPrefixOf = (unit: string, session: LeaseSegmentProgress | undefined): string =>
1196
- session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1215
+ /** The prefix every step of this pipeline is named under: the unit id, a
1216
+ * renewal's segment (`U10/s2/…`), and a stopped segment's resumed attempt
1217
+ * (`U10/s2/r1/…`). Both transitions need fresh Workflow identities so the
1218
+ * durable step cache cannot answer a new attempt with an older one's result. */
1219
+ export const stepPrefixOf = (unit: string, session: LeaseSegmentProgress | undefined): string => {
1220
+ const segment = session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1221
+ return session?.resume !== undefined ? `${segment}/r${session.resume.attempt}` : segment;
1222
+ };
1197
1223
  const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
1198
1224
  const roundStep = (s: UnitPipelineState, round: RoundRef) =>
1199
1225
  `${stepPrefix(s)}/${round.index}/${round.kind}${round.attempt !== undefined ? `/a${round.attempt}` : ""}`;
@@ -1212,6 +1238,7 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1212
1238
  segment: session.segment,
1213
1239
  ...(session.continueFrom !== undefined ? { from: session.continueFrom } : {}),
1214
1240
  ...(session.previousRunId !== undefined ? { previousRunId: session.previousRunId } : {}),
1241
+ ...(session.texts !== undefined ? { texts: session.texts } : {}),
1215
1242
  },
1216
1243
  }
1217
1244
  : {}),
@@ -1400,7 +1427,7 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1400
1427
  const round = "round" in old ? old.round : undefined;
1401
1428
  return {
1402
1429
  kind: "idle",
1403
- why: old.kind,
1430
+ why: old.kind === "aborted" && old.renewal !== undefined ? old.renewal.decision.why : old.kind,
1404
1431
  idled: old,
1405
1432
  renewalsLeft,
1406
1433
  ...(from !== undefined ? { from } : {}),
@@ -2612,6 +2639,10 @@ export interface MergeReadyFacts {
2612
2639
  * (git's autosquash prefixes `fixup!`, `squash!`, `amend!`): a head
2613
2640
  * carrying one is by its own words not in the ready state. */
2614
2641
  fixupCommits?: string[];
2642
+ /** The base branch takes changes only through a merge queue (issue 2011):
2643
+ * the merge:person path names "queued" in its line — the person's merge
2644
+ * enqueues the pull request and the queue merges it on its own. */
2645
+ baseHasMergeQueue?: boolean;
2615
2646
  }
2616
2647
 
2617
2648
  /** The check runs at one commit: how many, which still run, which failed. */
@@ -2733,7 +2764,12 @@ export function renderUnitReport(
2733
2764
  ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the pipeline merged nothing.`
2734
2765
  : facts?.autoMergeEnabled
2735
2766
  ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2736
- : "Remaining gate: a person's merge — the pipeline merges only when the plan's `merge` setting says so, and ship never approves.",
2767
+ : facts?.baseHasMergeQueue === true
2768
+ ? // The merge:person path names "queued" in its line (issue 2011):
2769
+ // the base takes changes only through a merge queue, so the
2770
+ // person's merge enqueues — never a direct merge.
2771
+ `Remaining gate: a person's merge, queued — \`${s.input.base}\` takes changes only through a merge queue, so the merge enqueues the pull request (\`gh pr merge --auto\`) and the queue merges it on its own; the pipeline merges only when the plan's \`merge\` setting says so, and ship never approves.`
2772
+ : "Remaining gate: a person's merge — the pipeline merges only when the plan's `merge` setting says so, and ship never approves.",
2737
2773
  ),
2738
2774
  ]
2739
2775
  .filter(Boolean)
@@ -2848,7 +2884,7 @@ export function renderUnitReport(
2848
2884
  e.postedReview
2849
2885
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
2850
2886
  : undefined,
2851
- reissue,
2887
+ s.input.idleDays && s.input.idleDays > 0 ? "Next step: reply in this thread to continue." : reissue,
2852
2888
  ]);
2853
2889
  case "aborted":
2854
2890
  if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
@@ -2870,7 +2906,9 @@ export function renderUnitReport(
2870
2906
  if (!shows(verbosity, "verbose")) return "";
2871
2907
  return join([
2872
2908
  writeUpPointer(s, e.round.kind, e.runId),
2873
- `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A fresh ${s.input.caps.maxMinutes}-minute budget opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""}, with the last run's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
2909
+ s.input.idleDays && s.input.idleDays > 0
2910
+ ? `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. Next step: reply in this thread to continue; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`
2911
+ : `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A fresh ${s.input.caps.maxMinutes}-minute budget opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""}, with the last run's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
2874
2912
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2875
2913
  ]);
2876
2914
  case "transient":
@@ -2892,7 +2930,9 @@ export function renderUnitReport(
2892
2930
  reissue,
2893
2931
  ]);
2894
2932
  case "interrupted":
2895
- return shipInterruptedNote(prUrl, e.cause);
2933
+ return shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0);
2934
+ case "idle_expired":
2935
+ return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
2896
2936
  case "refused":
2897
2937
  return join([
2898
2938
  `🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
@@ -82,7 +82,10 @@ export interface RenewalInput {
82
82
  /** Dollars spent under this request so far; null when a run's model had no
83
83
  * price, since a total that left a model's tokens out would understate it. */
84
84
  spendUsd: number | null;
85
- progress: Progress;
85
+ /** Progress normally comes from the segment's row. An idle wake sets it
86
+ * aside because the person's reply is the new work to run; the count, cap
87
+ * and fit still decide whether another lease may open. */
88
+ progress: Progress | "set_aside";
86
89
  /** The pipeline the next segment would run: its fit is re-asserted before every segment. */
87
90
  pipeline: Pipeline;
88
91
  }
@@ -101,7 +104,8 @@ const usd = (n: number): string => `$${n.toFixed(2)}`;
101
104
  * the progress. */
102
105
  export function renewalDecision(input: RenewalInput): RenewalDecision {
103
106
  const renewalsLeft = Math.max(0, input.grant.renewals - input.renewalsSpent);
104
- if (!input.progress.progressed) return { renew: false, why: "no_progress", detail: input.progress.why, renewalsLeft };
107
+ if (input.progress !== "set_aside" && !input.progress.progressed)
108
+ return { renew: false, why: "no_progress", detail: input.progress.why, renewalsLeft };
105
109
  if (renewalsLeft === 0) {
106
110
  const detail =
107
111
  input.grant.renewals === 0
@@ -139,7 +143,7 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
139
143
  return {
140
144
  renew: true,
141
145
  segment: input.renewalsSpent + 2,
142
- ...(input.progress.by === "push" ? { from: input.progress.sha } : {}),
146
+ ...(input.progress !== "set_aside" && input.progress.by === "push" ? { from: input.progress.sha } : {}),
143
147
  renewalsLeft: renewalsLeft - 1,
144
148
  };
145
149
  }
@@ -149,22 +153,31 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
149
153
  * names the clause and, when renewals remain, what actually spends one — a
150
154
  * budget that made progress — and the honest recourse (re-issue the request).
151
155
  * It teaches no keyword: the router has none (routing-and-config item 3). */
152
- export function renderRenewal(decision: RenewalDecision, grant: Grant): string {
156
+ export function renderRenewal(
157
+ decision: RenewalDecision,
158
+ grant: Grant,
159
+ options: { idle?: boolean; senders?: readonly string[] } = {},
160
+ ): string {
161
+ const recourse = options.idle ? "reply in this thread to continue" : "re-issue the request to try again";
162
+ const senders = options.senders?.length
163
+ ? `, with ${options.senders.length} message${options.senders.length === 1 ? "" : "s"} from ${options.senders.join(", ")}`
164
+ : "";
153
165
  if (decision.renew)
154
- return `budget renewed, ${decision.segment - 1} of ${grant.renewals}, continues ${decision.from !== undefined ? decision.from.slice(0, 7) : "the branch's head"}`;
166
+ return `budget renewed, ${decision.segment - 1} of ${grant.renewals}, continues ${decision.from !== undefined ? decision.from.slice(0, 7) : "the branch's head"}${senders}`;
155
167
  const holds =
156
168
  decision.renewalsLeft > 0
157
169
  ? `${decision.renewalsLeft} renewal${decision.renewalsLeft === 1 ? "" : "s"} left`
158
170
  : "no renewals left";
171
+ const idleRecourse = (line: string): string => (options.idle ? `${line}; ${recourse}` : line);
159
172
  switch (decision.why) {
160
173
  case "no_progress":
161
174
  return decision.renewalsLeft > 0
162
- ? `no progress on the last budget; ${holds} unspent — a renewal is spent only by a budget that pushed to the unit's branch or moved its write-up; re-issue the request to try again`
175
+ ? `no progress on the last budget; ${holds} unspent — a renewal is spent only by a budget that pushed to the unit's branch or moved its write-up; ${recourse}`
163
176
  : `no progress on the last budget; ${holds}`;
164
177
  case "cost_cap":
165
- return decision.renewalsLeft > 0 ? `${decision.detail}; ${holds} unspent` : decision.detail;
178
+ return idleRecourse(decision.renewalsLeft > 0 ? `${decision.detail}; ${holds} unspent` : decision.detail);
166
179
  case "grant_exhausted":
167
180
  case "unfit":
168
- return decision.detail;
181
+ return idleRecourse(decision.detail);
169
182
  }
170
183
  }
@@ -11,6 +11,11 @@ export interface ImageAttachment {
11
11
  /** base64 payload, no data: URI prefix */
12
12
  data: string;
13
13
  name?: string;
14
+ /** A channel-native source the coordinator may preserve so a later child
15
+ * stages the same accepted file through the artifact path. The current
16
+ * turn still reads `data` inline; only a folded durable event promotes this
17
+ * reference to `IncomingMessage.staged`. */
18
+ staged?: StagedFile;
14
19
  }
15
20
 
16
21
  /**
@@ -25,6 +30,8 @@ export interface DocumentAttachment {
25
30
  mediaType: string;
26
31
  data: string;
27
32
  name?: string;
33
+ /** The same optional by-reference source as an accepted image. */
34
+ staged?: StagedFile;
28
35
  }
29
36
 
30
37
  /** A file left on the platform by reference (record 0033): what the store's
@@ -38,6 +45,10 @@ export interface StagedFile {
38
45
  url: string;
39
46
  /** The platform's id of the message that carried the file — the per-message segment of its key. */
40
47
  messageId: string;
48
+ /** A channel file's message-local zero-based slot when accepted inline
49
+ * media is carried to a later child. Ordinary by-reference files omit it
50
+ * and take the run's one-based staging counter. */
51
+ workspaceIndex?: number;
41
52
  }
42
53
 
43
54
  export interface IncomingMessage {