@coreplane/switchboard 1.258.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 +17 -25
- 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 +8 -3
- 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 +12 -1
- 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 +8324 -7937
- 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
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
message
|
|
678
|
-
|
|
679
|
-
|
|
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)
|
|
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;
|
|
@@ -187,7 +192,7 @@ export interface VendorRef {
|
|
|
187
192
|
/** The one vendor parse (record 0052): `parseModelRef` is the only other parser of a
|
|
188
193
|
* ref. A block that declares a vendor name uses it; a block that declares
|
|
189
194
|
* `vendor: model`, or a model id that carries its own vendor prefix
|
|
190
|
-
* (`openrouter
|
|
195
|
+
* (`openrouter/<vendor>/<model>`), reads the vendor off the id's
|
|
191
196
|
* first segment; otherwise the block's own name is the vendor. `catalog`
|
|
192
197
|
* never enters: a block named unlike its catalog still serves the vendor the
|
|
193
198
|
* declaration or the id names. */
|
|
@@ -272,11 +277,11 @@ export function billerHarnessProvider(biller: string | undefined): BillerHarness
|
|
|
272
277
|
/** The env var Anthropic's own SDK reads when an `anthropic` provider block names none. */
|
|
273
278
|
export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
|
274
279
|
|
|
275
|
-
/** "
|
|
280
|
+
/** "<provider>/<model>" -> { provider, model }: the split at the ref's first slash. */
|
|
276
281
|
export function parseModelRef(ref: string): { provider: string; model: string } {
|
|
277
282
|
const i = ref.indexOf("/");
|
|
278
283
|
if (i === -1) {
|
|
279
|
-
throw new Error(`Model "${ref}" must be qualified as "<provider>/<model>"
|
|
284
|
+
throw new Error(`Model "${ref}" must be qualified as "<provider>/<model>"`);
|
|
280
285
|
}
|
|
281
286
|
return { provider: ref.slice(0, i), model: ref.slice(i + 1) };
|
|
282
287
|
}
|
|
@@ -1070,15 +1070,13 @@ export type RunEvent =
|
|
|
1070
1070
|
seq?: number;
|
|
1071
1071
|
at?: number;
|
|
1072
1072
|
}
|
|
1073
|
-
/** The operator's decision
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
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
|
-
/**
|
|
1092
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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 {
|
|
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 =
|
|
133
|
-
?
|
|
134
|
-
:
|
|
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 =
|
|
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) =>
|
|
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,
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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;
|
|
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 {
|
|
@@ -162,7 +173,7 @@ export type StatusActivity = { kind: "command"; tool: string; command: string }
|
|
|
162
173
|
|
|
163
174
|
/** A structured progress frame; adapters decide how to render it. */
|
|
164
175
|
export interface StatusUpdate {
|
|
165
|
-
/** one-line headline, e.g. "⚡ review on
|
|
176
|
+
/** one-line headline, e.g. "⚡ review on <provider>/<model> · 42s" */
|
|
166
177
|
title: string;
|
|
167
178
|
/** the agent's checklist and any lead lines, one per line */
|
|
168
179
|
detail?: string;
|