@coreplane/switchboard 1.260.6 → 1.261.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.
@@ -134,6 +134,8 @@ import {
134
134
  type IntakeWriteResult,
135
135
  type LivePhase,
136
136
  type LiveRunRow,
137
+ type LiveStateAssignRequest,
138
+ type LiveStateAssignResult,
137
139
  type ReclaimedRun,
138
140
  type RunState,
139
141
  type SessionHit,
@@ -154,6 +156,7 @@ import { isRunMetricsPoint, pointTurnsFinal, type RunMetricsPoint } from "../../
154
156
  import { AnalyticsEngineSink, NullSink, type RunMetricsSink } from "./runMetricsSink.ts";
155
157
  import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
156
158
  import { systemClock } from "../../src/core/trace/clock.ts";
159
+ import { assignRunLiveState } from "../../src/core/runLiveState.ts";
157
160
  import { createTracer } from "../../src/core/trace/tracer.ts";
158
161
  import { startAdoptedRoot, workerLogSink } from "../../src/core/trace/workerTrace.ts";
159
162
 
@@ -1539,6 +1542,9 @@ type LiveRow = {
1539
1542
  };
1540
1543
 
1541
1544
  function rowToLive(r: LiveRow): LiveRunRow {
1545
+ const state = JSON.parse(r.state_json) as RunState;
1546
+ const liveState = state.liveState as LiveRunRow["liveState"];
1547
+ const liveStateSeq = state.liveStateSeq;
1542
1548
  return {
1543
1549
  runId: r.run_id,
1544
1550
  threadKey: r.thread_key,
@@ -1551,7 +1557,9 @@ function rowToLive(r: LiveRow): LiveRunRow {
1551
1557
  card: r.card_json ? (JSON.parse(r.card_json) as LiveRunRow["card"]) : null,
1552
1558
  system: r.system_text,
1553
1559
  tools: JSON.parse(r.tools_json) as LiveRunRow["tools"],
1554
- state: JSON.parse(r.state_json) as RunState,
1560
+ state,
1561
+ ...(liveState !== undefined ? { liveState } : {}),
1562
+ ...(typeof liveStateSeq === "number" ? { liveStateSeq } : {}),
1555
1563
  };
1556
1564
  }
1557
1565
 
@@ -2955,7 +2963,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2955
2963
  req.card ? JSON.stringify(req.card) : null,
2956
2964
  req.system,
2957
2965
  JSON.stringify(req.tools),
2958
- JSON.stringify(req.state ?? {}),
2966
+ JSON.stringify({ ...existing!.state, ...(req.state ?? {}) }),
2959
2967
  req.runId,
2960
2968
  );
2961
2969
  this.registerSession(req);
@@ -3078,6 +3086,78 @@ export class RunHistoryDO extends DurableObject<Env> {
3078
3086
  return out;
3079
3087
  }
3080
3088
 
3089
+ async assignLiveState(
3090
+ runId: string,
3091
+ gen: string,
3092
+ assignment: LiveStateAssignRequest,
3093
+ ): Promise<LiveStateAssignResult> {
3094
+ let out: LiveStateAssignResult = { ok: false, reason: "unknown-run" };
3095
+ this.ctx.storage.transactionSync(() => {
3096
+ const row = this.liveRow(runId);
3097
+ const fence = checkFence(row, gen);
3098
+ if (!fence.ok) {
3099
+ out = fence;
3100
+ return;
3101
+ }
3102
+ if (!row) {
3103
+ out = { ok: false, reason: "unknown-run" };
3104
+ return;
3105
+ }
3106
+ const result = assignRunLiveState(
3107
+ assignment.restart ? undefined : row.liveState,
3108
+ row.liveStateSeq ?? 0,
3109
+ assignment,
3110
+ );
3111
+ if (!result.ok) {
3112
+ out = result;
3113
+ return;
3114
+ }
3115
+ let liveStateSeq = row.liveStateSeq ?? 0;
3116
+ const last = this.sql
3117
+ .exec<{ m: number | null }>(`SELECT MAX(seq) AS m FROM run_events WHERE run_id = ?`, runId)
3118
+ .one().m;
3119
+ let lastEventSeq = last ?? 0;
3120
+ for (const source of assignment.sourceEvents ?? []) {
3121
+ if (source.seq <= lastEventSeq) {
3122
+ out = { ok: false, reason: "stale-sequence" };
3123
+ return;
3124
+ }
3125
+ lastEventSeq = source.seq;
3126
+ }
3127
+ const boundarySeq = result.event ? (assignment.eventSeq ?? lastEventSeq + 1) : undefined;
3128
+ if (boundarySeq !== undefined && boundarySeq <= lastEventSeq) {
3129
+ out = { ok: false, reason: "stale-sequence" };
3130
+ return;
3131
+ }
3132
+ for (const source of assignment.sourceEvents ?? []) {
3133
+ this.sql.exec(
3134
+ `INSERT INTO run_events (run_id, seq, json) VALUES (?, ?, ?)`,
3135
+ runId,
3136
+ source.seq,
3137
+ JSON.stringify(source),
3138
+ );
3139
+ liveStateSeq = source.seq;
3140
+ }
3141
+ if (result.event && boundarySeq !== undefined) {
3142
+ liveStateSeq = boundarySeq;
3143
+ this.sql.exec(
3144
+ `INSERT INTO run_events (run_id, seq, json) VALUES (?, ?, ?)`,
3145
+ runId,
3146
+ liveStateSeq,
3147
+ JSON.stringify({ ...result.event, seq: liveStateSeq }),
3148
+ );
3149
+ }
3150
+ const state = { ...row.state, ...assignment.statePatch, liveState: result.liveState, liveStateSeq };
3151
+ this.sql.exec(`UPDATE live_runs SET state_json = ? WHERE run_id = ?`, JSON.stringify(state), runId);
3152
+ out = {
3153
+ ...result,
3154
+ ...(result.event ? { event: { ...result.event, seq: liveStateSeq } } : {}),
3155
+ liveStateSeq,
3156
+ };
3157
+ });
3158
+ return out;
3159
+ }
3160
+
3081
3161
  async setState(runId: string, gen: string, state: RunState): Promise<FenceResult> {
3082
3162
  let out: FenceResult = { ok: true };
3083
3163
  this.ctx.storage.transactionSync(() => {
@@ -5116,6 +5196,7 @@ const LEDGER_ROUTES = new Set([
5116
5196
  "/runs/claim",
5117
5197
  "/runs/heartbeat",
5118
5198
  "/runs/append",
5199
+ "/runs/live-state",
5119
5200
  "/runs/step",
5120
5201
  "/runs/state",
5121
5202
  "/runs/inbox",
@@ -5954,6 +6035,13 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
5954
6035
  console.log(`[runs/append] ${key.value} ${runId.value} <- ${events.length} event(s), ok=${r.ok}`);
5955
6036
  return fenced(r);
5956
6037
  }
6038
+ if (pathname === "/runs/live-state") {
6039
+ if (typeof b.assignment !== "object" || b.assignment === null)
6040
+ return json({ error: "assignment must be an object" }, 400);
6041
+ const assignment = b.assignment as unknown as LiveStateAssignRequest;
6042
+ const r = await stub.assignLiveState(runId.value, g.value, assignment);
6043
+ return r.ok ? json(r) : json(r, r.reason === "fenced" || r.reason === "unknown-run" ? 409 : 400);
6044
+ }
5957
6045
  if (pathname === "/runs/step") {
5958
6046
  const record = parseStep(b.record);
5959
6047
  if (!record.ok) return json({ error: record.error }, 400);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.6",
3
+ "version": "1.261.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.260.6",
9
+ "version": "1.261.1",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -21060,7 +21060,7 @@
21060
21060
  },
21061
21061
  "packages/switchboard": {
21062
21062
  "name": "@coreplane/switchboard",
21063
- "version": "1.260.6",
21063
+ "version": "1.261.1",
21064
21064
  "license": "Apache-2.0",
21065
21065
  "dependencies": {
21066
21066
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.6",
3
+ "version": "1.261.1",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.260.6",
3
- "commit": "903ae2855833637fbcc0c0a22d54e7268be75965",
4
- "builtAt": "2026-09-23T04:57:07.101Z"
2
+ "version": "1.261.1",
3
+ "commit": "3746a1c5fa2d30bebd66baec1fb3534a1f927e3a",
4
+ "builtAt": "2026-09-23T21:48:10.555Z"
5
5
  }
@@ -510,8 +510,9 @@ function prCheckReturn(step: string, a: BotAnswer): Extract<StepReturn, { type:
510
510
  },
511
511
  at,
512
512
  };
513
- // A merged pull request is read whole or not at all: the merge commit and
514
- // the time are what the unit's ending and its report carry.
513
+ // A merged pull request is read whole or not at all: its source head is
514
+ // retained for findings reconciliation, while the merge commit and time
515
+ // are what the unit's ending and report carry.
515
516
  if (
516
517
  ok === true &&
517
518
  state === "merged" &&
@@ -527,6 +528,7 @@ function prCheckReturn(step: string, a: BotAnswer): Extract<StepReturn, { type:
527
528
  state: "merged",
528
529
  prNumber,
529
530
  url,
531
+ ...(typeof headSha === "string" ? { headSha } : {}),
530
532
  sha,
531
533
  mergedAt,
532
534
  ...(typeof mergedBy === "string" ? { mergedBy } : {}),
@@ -923,9 +925,19 @@ async function tellStepThrew(
923
925
  : at.round.kind === "review" && /\/review\/(read|checks)\b/.test(at.step)
924
926
  ? `after round ${at.round.index}'s review verdict (\`${at.step}\`)`
925
927
  : `in round ${at.round.index} (\`${at.step}\`)`;
926
- const report =
927
- `⚠️ The runner failed ${where}: ${line}\n\n` +
928
- "The unit remains bound to this thread; the next reply continues it, and a pull request already approved with green checks resumes at the checks step, never at a fresh coding round.";
928
+ const findingsReconcile = at.round?.kind === "findings" && /\/findings(?:\/a\d+)?\/pr-check$/.test(at.step);
929
+ const report = findingsReconcile
930
+ ? [
931
+ `⚠️ Review did not restart because Switchboard could not read the pull request after the completed findings work: ${line}`,
932
+ pr !== undefined
933
+ ? `Saved-work fact: the existing pull request is ${pr.url}, but its current branch and exact head were not verified.`
934
+ : "Saved-work fact: no open pull request or exact remote head was verified.",
935
+ "No review was started. Next action: retry ship when the pull request is readable so Switchboard can verify the exact head first.",
936
+ ].join("\n\n")
937
+ : [
938
+ `⚠️ Switchboard failed ${where}: ${line}`,
939
+ "The unit remains resumable from its recorded branch and pull request facts. No continuation action was scheduled. Next action: start ship again after the failing operation is available.",
940
+ ].join("\n\n");
929
941
  const body = {
930
942
  ...tag,
931
943
  ending: {
@@ -12,6 +12,30 @@ import type { UnitEnding } from "./ship/coordinator.js";
12
12
  /** The stage vocabulary (record 0065): what every surface says a unit is in. */
13
13
  export type Stage = "coding" | "review" | "fix" | "approved" | "merge-ready" | "merged" | "idle" | "ended";
14
14
 
15
+ export const HOSTED_PIPELINE_STARTING_DETAIL = "pipeline starting";
16
+
17
+ /** Safe detail for a hosted parent's shared `working` state, total over Stage. */
18
+ export function hostedStageDetail(stage: Stage): string {
19
+ switch (stage) {
20
+ case "coding":
21
+ return "coding a unit";
22
+ case "review":
23
+ return "reviewing a unit";
24
+ case "fix":
25
+ return "fixing review findings";
26
+ case "approved":
27
+ return "waiting for checks and merge";
28
+ case "merge-ready":
29
+ return "ready for a person to merge";
30
+ case "merged":
31
+ return "a unit merged";
32
+ case "idle":
33
+ return "waiting for the next pipeline step";
34
+ case "ended":
35
+ return "finishing the pipeline";
36
+ }
37
+ }
38
+
15
39
  export const STAGES = [
16
40
  "coding",
17
41
  "review",
@@ -12,6 +12,31 @@
12
12
  import { PLANE, minutesToMs } from "../budgets.js";
13
13
  import type { ProviderFailureCause } from "../provider.js";
14
14
  import type { PlaneFinding } from "./findings.js";
15
+ import type { RunLiveStateName } from "../runLiveState.js";
16
+
17
+ /** The one user wording table for the server-owned live condition. */
18
+ export function liveStateWords(state: RunLiveStateName): string {
19
+ switch (state) {
20
+ case "admitted":
21
+ return "admitted";
22
+ case "waiting_deploy":
23
+ return "waiting for the current deploy";
24
+ case "waiting_repository":
25
+ return "waiting for the repository container";
26
+ case "falling_back":
27
+ return "switching to a fallback workspace";
28
+ case "preparing":
29
+ return "preparing the workspace";
30
+ case "working":
31
+ return "working";
32
+ case "waiting_provider":
33
+ return "waiting for the model provider";
34
+ case "wrapping_up":
35
+ return "wrapping up";
36
+ case "ended":
37
+ return "ended";
38
+ }
39
+ }
15
40
 
16
41
  // ---- endings and their causes (record 0064, "Endings and the watches") ------------------------
17
42
 
@@ -150,6 +150,10 @@ export interface ReviewVerdict {
150
150
  export const LGTM_TOKEN = "LGTM:";
151
151
  export const CHANGES_TOKEN = "Changes requested:";
152
152
  export const NO_VERDICT_LINE = "No verdict submitted — not approving.";
153
+ /** Conservative ceiling below GitHub's 65,536-character review-body limit.
154
+ * Counted as Unicode code points so clipping never splits a surrogate pair. */
155
+ export const MAX_REVIEW_POST_CODE_POINTS = 65_000;
156
+ const REVIEW_TRUNCATION_NOTE = "_(review truncated to fit GitHub's review size limit)_";
153
157
 
154
158
  /** Parse an arbitrary tool input into a verdict, or null when it is not one.
155
159
  * `addressSeverity` is the level in force for the run (default `minor`): an
@@ -383,7 +387,11 @@ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTar
383
387
  * `approve`), a GitHub alert callout with the verdict word, the pinned head
384
388
  * and the finding counts, the findings as a table (severity, id + title, the
385
389
  * file linked at the head), the model's text folded under `Full review`, and
386
- * the machine-readable marker last. The prose decides nothing above the fold.
390
+ * the machine-readable marker last. Only the prose may be clipped to fit the
391
+ * platform: every fixed verdict section and the marker remain byte-for-byte.
392
+ * If those sections leave no room for a visible bounded review, the oversized
393
+ * body is returned so the publication boundary can refuse it instead of
394
+ * silently dropping the write-up. The prose decides nothing above the fold.
387
395
  */
388
396
  export function buildReviewPostBody(
389
397
  answer: string,
@@ -395,10 +403,10 @@ export function buildReviewPostBody(
395
403
  ...(target ? [`head \`${target.head.slice(0, 7)}\``] : []),
396
404
  verdict ? findingCounts(verdict.findings) : "the run ended without a submit_verdict call",
397
405
  ];
398
- const parts: string[] = [verdictLine(verdict), `> [!${calloutKind(verdict)}]\n> ${facts.join(" · ")}`];
406
+ const fixedParts: string[] = [verdictLine(verdict), `> [!${calloutKind(verdict)}]\n> ${facts.join(" · ")}`];
399
407
  const findings = verdict?.findings ?? [];
400
408
  if (findings.length > 0) {
401
- parts.push(
409
+ fixedParts.push(
402
410
  [
403
411
  "| Severity | Finding | Where |",
404
412
  "| --- | --- | --- |",
@@ -409,10 +417,29 @@ export function buildReviewPostBody(
409
417
  ].join("\n"),
410
418
  );
411
419
  }
420
+
421
+ const marker = verdictMarker(verdict, target);
422
+ const fixedBody = [...fixedParts, marker].join("\n\n");
412
423
  const prose = answer.trim();
413
- if (prose) parts.push(`<details>\n<summary>Full review</summary>\n\n${prose}\n\n</details>`);
414
- parts.push(verdictMarker(verdict, target));
415
- return parts.join("\n\n");
424
+ if (!prose) return fixedBody;
425
+
426
+ const details = (text: string, truncated: boolean): string =>
427
+ `<details>\n<summary>Full review</summary>\n\n${text}${truncated ? `${text ? "\n\n" : ""}${REVIEW_TRUNCATION_NOTE}` : ""}\n\n</details>`;
428
+ const render = (text: string, truncated: boolean): string =>
429
+ [...fixedParts, details(text, truncated), marker].join("\n\n");
430
+ const full = render(prose, false);
431
+ if ([...full].length <= MAX_REVIEW_POST_CODE_POINTS) return full;
432
+
433
+ const clippedShell = render("", true);
434
+ const proseSeparatorCodePoints = 2; // the blank line before the note when clipped prose is present
435
+ const proseBudget = MAX_REVIEW_POST_CODE_POINTS - [...clippedShell].length - proseSeparatorCodePoints;
436
+ if (proseBudget > 0) return render([...prose].slice(0, proseBudget).join(""), true);
437
+ if ([...clippedShell].length <= MAX_REVIEW_POST_CODE_POINTS) return clippedShell;
438
+
439
+ // A successful post must retain a visible write-up. Return the full body
440
+ // oversized when even the bounded shell cannot fit, so postReviewComment
441
+ // rejects before fetch and the review remains on the Slack-only path.
442
+ return full;
416
443
  }
417
444
 
418
445
  /**
@@ -5,6 +5,7 @@ import type { DescriptionIssue, PrDescription, RecordedJson, RenderedPointer } f
5
5
  import type { HarnessScope } from "./harness/scope.js";
6
6
  import type { ModelCard } from "./modelCard.js";
7
7
  import type { ProviderFailureCause } from "./provider.js";
8
+ import type { RunStateEvent } from "./runLiveState.js";
8
9
 
9
10
  /** The `pr_description` review artifact minus the event envelope
10
11
  * (docs/reference/specs/reading-diff.md item 7). */
@@ -492,6 +493,7 @@ export function isHeadMaterial(event: RunEvent): boolean {
492
493
  case "context":
493
494
  case "reference":
494
495
  case "run_meta":
496
+ case "run_state":
495
497
  case "route":
496
498
  return true;
497
499
  case "run_note":
@@ -923,6 +925,9 @@ export type RunEvent =
923
925
  * start of the loop; a resumed run carries the original. Head material,
924
926
  * like `run_meta`. Additive: unknown → ignored. */
925
927
  | { type: "lease"; startedAt: number; endsAt: number; loopEndsAt: number; seq?: number; at?: number }
928
+ /** A server-owned live-condition boundary. Same-state refreshes update the
929
+ * durable projection beside their source event and do not emit this event. */
930
+ | RunStateEvent
926
931
  /** A head the run pushed (docs/reference/specs/run-history.md item 2; decision
927
932
  * 0046): the branch and the sha the coding post-step observed on the remote
928
933
  * (`by: "push"`), or the budget-end salvage pushed (`by: "salvage"`),
@@ -438,6 +438,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
438
438
  ev.type === "operator" ||
439
439
  ev.type === "refusal" ||
440
440
  ev.type === "reference" ||
441
+ ev.type === "run_state" ||
441
442
  ev.type === "lease" ||
442
443
  ev.type === "pushed_head"
443
444
  ) {
@@ -537,6 +538,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
537
538
  return;
538
539
  }
539
540
 
541
+ if (ev.type !== "run_note") return;
540
542
  switch (ev.kind) {
541
543
  case "wrap_up":
542
544
  // Its extent is the time from the warning to the end of the window (how
@@ -9,6 +9,7 @@ import type { ChannelVisibility } from "../authz/types.js";
9
9
  import type { RunProfile } from "../../config/profile.js";
10
10
  import type { RunEvent } from "../runEvents.js";
11
11
  import type { RunSeed, RunSession } from "../runRecord.js";
12
+ import type { AssignRunLiveStateInput, AssignRunLiveStateResult, RunLiveState } from "../runLiveState.js";
12
13
 
13
14
  /** How long a generation's claim on a run lasts without a heartbeat. */
14
15
  export const LEASE_MS = 30_000;
@@ -158,8 +159,28 @@ export interface LiveRunRow {
158
159
  /** The tool definitions the run was started with, verbatim. */
159
160
  tools: ToolDef[];
160
161
  state: RunState;
162
+ /** The restart-safe live-condition projection. Optional on legacy rows. */
163
+ liveState?: RunLiveState;
164
+ /** Stream sequence that most recently produced or refreshed `liveState`. */
165
+ liveStateSeq?: number;
161
166
  }
162
167
 
168
+ /** The atomic live-condition assignment request and answer shared by both ledger implementations. */
169
+ export type LiveStateAssignRequest = AssignRunLiveStateInput & {
170
+ /** Registry sequence reserved for a changed boundary before fan-out. */
171
+ eventSeq?: number;
172
+ /** Other durable row facts that must commit with the projection (for example hosting). */
173
+ statePatch?: RunState;
174
+ /** Existing activity facts committed with a same-state projection refresh. */
175
+ sourceEvents?: AppendableEvent[];
176
+ /** A new execution segment under the same run id starts after an ended predecessor. */
177
+ restart?: boolean;
178
+ };
179
+ export type LiveStateAssignResult =
180
+ | (Extract<AssignRunLiveStateResult, { ok: true }> & { liveStateSeq: number })
181
+ | Extract<AssignRunLiveStateResult, { ok: false }>
182
+ | { ok: false; reason: "fenced" | "unknown-run" };
183
+
163
184
  /** One tool call the step dispatched; `tool` decides how a resume settles it.
164
185
  * `boundMs` is the bound the call declared (a bash `timeout`), when it stated
165
186
  * one — what the plane judges a `long_call` steer against (record 0064). */
@@ -0,0 +1,205 @@
1
+ import type { PlaneEndingCause } from "./plane/decide.js";
2
+ import { oneLine, redactAndCap } from "./redact.js";
3
+
4
+ /** The server-owned condition of an admitted run. A plane queue row is not admitted and has none. */
5
+ export type RunLiveStateName =
6
+ | "admitted"
7
+ | "waiting_deploy"
8
+ | "waiting_repository"
9
+ | "falling_back"
10
+ | "preparing"
11
+ | "working"
12
+ | "waiting_provider"
13
+ | "wrapping_up"
14
+ | "ended";
15
+
16
+ export const RUN_LIVE_STATE_NAMES = [
17
+ "admitted",
18
+ "waiting_deploy",
19
+ "waiting_repository",
20
+ "falling_back",
21
+ "preparing",
22
+ "working",
23
+ "waiting_provider",
24
+ "wrapping_up",
25
+ "ended",
26
+ ] as const satisfies readonly RunLiveStateName[];
27
+
28
+ export const LIVE_STATE_DETAIL_MAX = 160;
29
+
30
+ export type ResidentLiveStateObservation =
31
+ | { state: "waiting_deploy"; bound: number; reason: "deploy"; attempt: number }
32
+ | { state: "waiting_repository"; bound: number; reason: "repository_container"; attempt: number };
33
+
34
+ export type ResidentLiveStateObserver = (observation: ResidentLiveStateObservation) => Promise<void>;
35
+
36
+ export interface RunLiveState {
37
+ state: RunLiveStateName;
38
+ /** Epoch ms at the most recent state boundary. Same-state refreshes preserve it. */
39
+ since: number;
40
+ /** Absolute epoch-ms deadline of the durable fact that created this live condition. */
41
+ bound?: number;
42
+ /** Capped, redacted user words. Never a provider body or resident refusal code. */
43
+ detail?: string;
44
+ }
45
+
46
+ export interface RunStateEvent extends RunLiveState {
47
+ type: "run_state";
48
+ /** Required only for the terminal boundary; it names the plane's existing closed cause. */
49
+ cause?: PlaneEndingCause;
50
+ seq?: number;
51
+ at?: number;
52
+ }
53
+
54
+ export interface RunLiveStateFoldEvent {
55
+ type: string;
56
+ seq?: number;
57
+ at?: number;
58
+ state?: unknown;
59
+ since?: number;
60
+ bound?: number;
61
+ detail?: string;
62
+ endsAt?: number;
63
+ boundMs?: number;
64
+ }
65
+
66
+ export interface RunLiveStateMaterialized {
67
+ liveState?: RunLiveState;
68
+ /** Authoritative event sequence that produced or refreshed `liveState`. */
69
+ liveStateSeq?: number;
70
+ }
71
+
72
+ const TRANSITIONS = {
73
+ admitted: ["waiting_deploy", "waiting_repository", "falling_back", "preparing", "working", "wrapping_up", "ended"],
74
+ waiting_deploy: ["waiting_repository", "falling_back", "preparing", "working", "wrapping_up", "ended"],
75
+ waiting_repository: ["waiting_deploy", "falling_back", "preparing", "working", "wrapping_up", "ended"],
76
+ falling_back: ["preparing", "working", "wrapping_up", "ended"],
77
+ preparing: ["waiting_deploy", "waiting_repository", "falling_back", "working", "wrapping_up", "ended"],
78
+ working: [
79
+ "waiting_deploy",
80
+ "waiting_repository",
81
+ "falling_back",
82
+ "preparing",
83
+ "waiting_provider",
84
+ "wrapping_up",
85
+ "ended",
86
+ ],
87
+ waiting_provider: ["working", "wrapping_up", "ended"],
88
+ wrapping_up: ["ended"],
89
+ ended: [],
90
+ } as const satisfies Record<RunLiveStateName, readonly RunLiveStateName[]>;
91
+
92
+ const UNSAFE_DETAIL =
93
+ /(?:\bimage-stale\b|\battach-failed\b|\bparked-provider\b|<\/?(?:html|body|head)\b|\bbad gateway\b)/i;
94
+
95
+ /** Admit only display-ready prose. Typed mappers create normal details; this is the final leak guard. */
96
+ export function safeLiveStateDetail(detail: string | undefined): string | undefined {
97
+ if (detail === undefined) return undefined;
98
+ const line = oneLine(detail);
99
+ if (line === "" || UNSAFE_DETAIL.test(line)) return undefined;
100
+ return redactAndCap(line, LIVE_STATE_DETAIL_MAX - 1);
101
+ }
102
+
103
+ export type AssignRunLiveStateInput = {
104
+ expectedSeq: number;
105
+ at: number;
106
+ state: RunLiveStateName;
107
+ bound?: number;
108
+ detail?: string;
109
+ cause?: PlaneEndingCause;
110
+ };
111
+
112
+ export type AssignRunLiveStateResult =
113
+ | { ok: true; liveState: RunLiveState; event?: RunStateEvent }
114
+ | {
115
+ ok: false;
116
+ reason:
117
+ "stale-sequence" | "terminal" | "invalid-transition" | "bound-required" | "invalid-bound" | "cause-required";
118
+ };
119
+
120
+ /** Pure assignment gate. Persistence chooses the next stream sequence and commits the result atomically. */
121
+ export function assignRunLiveState(
122
+ current: RunLiveState | undefined,
123
+ currentSeq: number,
124
+ input: AssignRunLiveStateInput,
125
+ ): AssignRunLiveStateResult {
126
+ if (input.expectedSeq !== currentSeq) return { ok: false, reason: "stale-sequence" };
127
+ if (current === undefined && input.state !== "admitted") return { ok: false, reason: "invalid-transition" };
128
+ if (current?.state === "ended") return { ok: false, reason: "terminal" };
129
+ if (input.state !== "ended") {
130
+ if (input.bound === undefined) return { ok: false, reason: "bound-required" };
131
+ if (!Number.isFinite(input.bound) || input.bound < input.at) return { ok: false, reason: "invalid-bound" };
132
+ } else if (input.cause === undefined) return { ok: false, reason: "cause-required" };
133
+ if (
134
+ current !== undefined &&
135
+ current.state !== input.state &&
136
+ !TRANSITIONS[current.state].includes(input.state as never)
137
+ )
138
+ return { ok: false, reason: "invalid-transition" };
139
+
140
+ const detail = safeLiveStateDetail(input.detail);
141
+ const liveState: RunLiveState = {
142
+ state: input.state,
143
+ since: current?.state === input.state ? current.since : input.at,
144
+ ...(input.state !== "ended" ? { bound: input.bound } : {}),
145
+ ...(detail !== undefined ? { detail } : {}),
146
+ };
147
+ if (current?.state === input.state) return { ok: true, liveState };
148
+ return {
149
+ ok: true,
150
+ liveState,
151
+ event: {
152
+ type: "run_state",
153
+ ...liveState,
154
+ ...(input.state === "ended" ? { cause: input.cause } : {}),
155
+ at: input.at,
156
+ },
157
+ };
158
+ }
159
+
160
+ /** Rebuild the projection from boundaries. A later event sequence always defeats stale materialized state. */
161
+ export function foldRunLiveState(
162
+ events: readonly RunLiveStateFoldEvent[],
163
+ materialized: RunLiveStateMaterialized = {},
164
+ ): RunLiveStateMaterialized {
165
+ let liveState = materialized.liveState;
166
+ let liveStateSeq = materialized.liveStateSeq ?? 0;
167
+ let leaseEndsAt: number | undefined;
168
+ for (const event of [...events].sort((left, right) => (left.seq ?? 0) - (right.seq ?? 0))) {
169
+ if (event.type === "lease" && event.endsAt !== undefined) leaseEndsAt = event.endsAt;
170
+ const seq = event.seq ?? 0;
171
+ if (seq <= liveStateSeq) continue;
172
+ if (
173
+ event.type === "run_state" &&
174
+ RUN_LIVE_STATE_NAMES.includes(event.state as RunLiveStateName) &&
175
+ event.since !== undefined
176
+ ) {
177
+ liveState = {
178
+ state: event.state as RunLiveStateName,
179
+ since: event.since,
180
+ ...(event.bound !== undefined ? { bound: event.bound } : {}),
181
+ ...(event.detail !== undefined ? { detail: event.detail } : {}),
182
+ };
183
+ liveStateSeq = seq;
184
+ continue;
185
+ }
186
+ if (
187
+ event.type === "tool_call" &&
188
+ liveState?.state === "working" &&
189
+ event.at !== undefined &&
190
+ event.boundMs !== undefined
191
+ ) {
192
+ liveState = { ...liveState, bound: event.at + event.boundMs, detail: "running a tool" };
193
+ liveStateSeq = seq;
194
+ continue;
195
+ }
196
+ if (event.type === "tool_result" && liveState?.state === "working" && leaseEndsAt !== undefined) {
197
+ liveState = { ...liveState, bound: leaseEndsAt, detail: "model turn" };
198
+ liveStateSeq = seq;
199
+ }
200
+ }
201
+ return {
202
+ ...(liveState !== undefined ? { liveState } : {}),
203
+ ...(liveStateSeq > 0 ? { liveStateSeq } : {}),
204
+ };
205
+ }
@@ -5,6 +5,7 @@ import type { ProviderFailureCause } from "./provider.js";
5
5
  import { isHeadMaterial, isSpanRecord } from "./runEvents.js";
6
6
  import { isRunUsage, type RunUsage } from "./runUsage.js";
7
7
  import type { PushedBranch } from "../execution/residentRebind.js";
8
+ import type { RunLiveState } from "./runLiveState.js";
8
9
  import { isHandoffShape, type Handoff } from "./ship/handoff.js";
9
10
  import {
10
11
  type FindingDisposition,
@@ -124,6 +125,9 @@ export interface RunRecord {
124
125
  stepCount?: number;
125
126
  schema?: number;
126
127
  status: RunStatus;
128
+ /** Final server-owned live condition. Optional on records from older writers. */
129
+ liveState?: RunLiveState;
130
+ liveStateSeq?: number;
127
131
  /** Present on a tombstone record still in its provisional window — the
128
132
  * start-of-run `interrupted` or the drain-deadline upgrade — before the
129
133
  * run's final write lands. Absent on every final (finished) record. A