@coreplane/switchboard 1.260.5 → 1.261.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 (40) hide show
  1. package/dist/assets/deploy/cloudflare-memory/worker.ts +106 -4
  2. package/dist/assets/package-lock.json +3 -3
  3. package/dist/assets/package.json +1 -1
  4. package/dist/assets/source.json +3 -3
  5. package/dist/assets/src/core/budgets.ts +5 -0
  6. package/dist/assets/src/core/coordinator/driver.ts +43 -6
  7. package/dist/assets/src/core/pipelineStanding.ts +24 -0
  8. package/dist/assets/src/core/plane/decide.ts +25 -0
  9. package/dist/assets/src/core/refusal.ts +1 -1
  10. package/dist/assets/src/core/runEvents.ts +5 -0
  11. package/dist/assets/src/core/runFriction.ts +2 -0
  12. package/dist/assets/src/core/runLedger/decisions.ts +10 -1
  13. package/dist/assets/src/core/runLedger/types.ts +24 -0
  14. package/dist/assets/src/core/runLiveState.ts +205 -0
  15. package/dist/assets/src/core/runRecord.ts +4 -0
  16. package/dist/assets/src/core/ship/coordinator.ts +29 -6
  17. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  18. package/dist/assets/web/dist/assets/{DeliveryPage-Cf1joUyA.js → DeliveryPage-BUJckSuW.js} +1 -1
  19. package/dist/assets/web/dist/assets/{HomePage-DZEAnCCv.js → HomePage-CBQpcnaW.js} +1 -1
  20. package/dist/assets/web/dist/assets/{PendingTurnRow-4B8JwETU.js → PendingTurnRow-DIC86uWA.js} +1 -1
  21. package/dist/assets/web/dist/assets/{PlanePage-CHU2szQs.js → PlanePage-CfrbMh8t.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ResidentDetailPage-L5SRcN_O.js → ResidentDetailPage-C-SgOUTl.js} +1 -1
  23. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BIFf8qyE.js → ResidentsIndexPage-CiDhvXok.js} +1 -1
  24. package/dist/assets/web/dist/assets/{RunFoldRow-DzPIshJx.js → RunFoldRow-DZ8huwUU.js} +1 -1
  25. package/dist/assets/web/dist/assets/{RunRoutePage-Cru5IVux.js → RunRoutePage-Cp6wicYc.js} +3 -3
  26. package/dist/assets/web/dist/assets/{RunsIndexPage-CBkdPAdj.js → RunsIndexPage-BPXtfwwx.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ScheduledPage-D_R55BDk.js → ScheduledPage-DbvP48HI.js} +1 -1
  28. package/dist/assets/web/dist/assets/{SettingsPage-DlmZvAvf.js → SettingsPage-B87lZaiN.js} +1 -1
  29. package/dist/assets/web/dist/assets/{SilentTurn-PWMDMbMG.js → SilentTurn-CPED_XCV.js} +1 -1
  30. package/dist/assets/web/dist/assets/{StatusDot-Bq0mYMFo.js → StatusDot-CFLn-DZ2.js} +1 -1
  31. package/dist/assets/web/dist/assets/{Tooltip-Bs6zd7yE.js → Tooltip-Dx9YBVEx.js} +1 -1
  32. package/dist/assets/web/dist/assets/{UnitRoutePage-BMGEZCvn.js → UnitRoutePage-d4A2X_o7.js} +1 -1
  33. package/dist/assets/web/dist/assets/budgets-BQgcCyvb.js +1 -0
  34. package/dist/assets/web/dist/assets/{dist-EkKVbIM-.js → dist-Cn51vWpw.js} +1 -1
  35. package/dist/assets/web/dist/assets/{indexRow-5s3hNzZ9.js → indexRow-BsWcHHVh.js} +1 -1
  36. package/dist/assets/web/dist/assets/{main-Disot0n_.js → main-DYwRHh0F.js} +2 -2
  37. package/dist/assets/web/dist/assets/{sseReplay-9gcMpylx.js → sseReplay-DGRDRLua.js} +1 -1
  38. package/dist/cli.js +1673 -722
  39. package/package.json +1 -1
  40. package/dist/assets/web/dist/assets/budgets-0szUBIMv.js +0 -1
@@ -76,6 +76,7 @@ import {
76
76
  phaseTransition,
77
77
  reclaimPhase,
78
78
  selectReclaim,
79
+ unreadInbox,
79
80
  } from "../../src/core/runLedger/decisions.ts";
80
81
  import { INTAKE_DELIVERY_CLAIM_MS, intakeReceiptRetentionMs, minutesToMs, PLANE } from "../../src/core/budgets.ts";
81
82
  import { PROVIDER_FAILURE_CAUSES, type ProviderFailureCause } from "../../src/core/provider.ts";
@@ -133,6 +134,8 @@ import {
133
134
  type IntakeWriteResult,
134
135
  type LivePhase,
135
136
  type LiveRunRow,
137
+ type LiveStateAssignRequest,
138
+ type LiveStateAssignResult,
136
139
  type ReclaimedRun,
137
140
  type RunState,
138
141
  type SessionHit,
@@ -153,6 +156,7 @@ import { isRunMetricsPoint, pointTurnsFinal, type RunMetricsPoint } from "../../
153
156
  import { AnalyticsEngineSink, NullSink, type RunMetricsSink } from "./runMetricsSink.ts";
154
157
  import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
155
158
  import { systemClock } from "../../src/core/trace/clock.ts";
159
+ import { assignRunLiveState } from "../../src/core/runLiveState.ts";
156
160
  import { createTracer } from "../../src/core/trace/tracer.ts";
157
161
  import { startAdoptedRoot, workerLogSink } from "../../src/core/trace/workerTrace.ts";
158
162
 
@@ -1538,6 +1542,9 @@ type LiveRow = {
1538
1542
  };
1539
1543
 
1540
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;
1541
1548
  return {
1542
1549
  runId: r.run_id,
1543
1550
  threadKey: r.thread_key,
@@ -1550,7 +1557,9 @@ function rowToLive(r: LiveRow): LiveRunRow {
1550
1557
  card: r.card_json ? (JSON.parse(r.card_json) as LiveRunRow["card"]) : null,
1551
1558
  system: r.system_text,
1552
1559
  tools: JSON.parse(r.tools_json) as LiveRunRow["tools"],
1553
- state: JSON.parse(r.state_json) as RunState,
1560
+ state,
1561
+ ...(liveState !== undefined ? { liveState } : {}),
1562
+ ...(typeof liveStateSeq === "number" ? { liveStateSeq } : {}),
1554
1563
  };
1555
1564
  }
1556
1565
 
@@ -3077,6 +3086,78 @@ export class RunHistoryDO extends DurableObject<Env> {
3077
3086
  return out;
3078
3087
  }
3079
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
+
3080
3161
  async setState(runId: string, gen: string, state: RunState): Promise<FenceResult> {
3081
3162
  let out: FenceResult = { ok: true };
3082
3163
  this.ctx.storage.transactionSync(() => {
@@ -3256,15 +3337,22 @@ export class RunHistoryDO extends DurableObject<Env> {
3256
3337
  .exec<{ json: string }>(`SELECT json FROM run_steps WHERE run_id = ? ORDER BY step DESC LIMIT 1`, row.runId)
3257
3338
  .toArray()[0];
3258
3339
  const lastStep = stepRow ? (JSON.parse(stepRow.json) as StepRecord) : null;
3259
- const consumed = lastStep?.inboxConsumedSeq ?? 0;
3340
+ // Past the cursor, plus the deferred rows at or below it: the lowest
3341
+ // deferred seq bounds the read, and the record's predicate filters.
3342
+ const unread = unreadInbox(lastStep);
3343
+ const from = Math.min(
3344
+ lastStep?.inboxConsumedSeq ?? 0,
3345
+ ...(lastStep?.inboxDeferredSeqs ?? []).map((s) => s - 1),
3346
+ );
3260
3347
  const inbox = this.sql
3261
3348
  .exec<{ seq: number; json: string }>(
3262
3349
  `SELECT seq, json FROM run_inbox WHERE run_id = ? AND seq > ? ORDER BY seq ASC`,
3263
3350
  row.runId,
3264
- consumed,
3351
+ from,
3265
3352
  )
3266
3353
  .toArray()
3267
- .map((r) => ({ seq: r.seq, message: JSON.parse(r.json) as Record<string, unknown> }));
3354
+ .map((r) => ({ seq: r.seq, message: JSON.parse(r.json) as Record<string, unknown> }))
3355
+ .filter(unread);
3268
3356
  const jobs = this.sql
3269
3357
  .exec<{ kind: string; json: string }>(`SELECT kind, json FROM run_jobs WHERE run_id = ?`, row.runId)
3270
3358
  .toArray()
@@ -5108,6 +5196,7 @@ const LEDGER_ROUTES = new Set([
5108
5196
  "/runs/claim",
5109
5197
  "/runs/heartbeat",
5110
5198
  "/runs/append",
5199
+ "/runs/live-state",
5111
5200
  "/runs/step",
5112
5201
  "/runs/state",
5113
5202
  "/runs/inbox",
@@ -5484,6 +5573,12 @@ function parseStep(v: unknown): Validated<StepRecord> {
5484
5573
  return invalid(`record.${k} must be a non-negative integer`);
5485
5574
  }
5486
5575
  }
5576
+ if (
5577
+ s.inboxDeferredSeqs !== undefined &&
5578
+ (!Array.isArray(s.inboxDeferredSeqs) ||
5579
+ !s.inboxDeferredSeqs.every((seq) => typeof seq === "number" && Number.isInteger(seq) && seq > 0))
5580
+ )
5581
+ return invalid("record.inboxDeferredSeqs must be an array of positive integers");
5487
5582
  if (!Array.isArray(s.inFlight)) return invalid("record.inFlight must be an array");
5488
5583
  for (const c of s.inFlight) {
5489
5584
  const call = c as Record<string, unknown>;
@@ -5940,6 +6035,13 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
5940
6035
  console.log(`[runs/append] ${key.value} ${runId.value} <- ${events.length} event(s), ok=${r.ok}`);
5941
6036
  return fenced(r);
5942
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
+ }
5943
6045
  if (pathname === "/runs/step") {
5944
6046
  const record = parseStep(b.record);
5945
6047
  if (!record.ok) return json({ error: record.error }, 400);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.5",
3
+ "version": "1.261.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.260.5",
9
+ "version": "1.261.0",
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.5",
21063
+ "version": "1.261.0",
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.5",
3
+ "version": "1.261.0",
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.5",
3
- "commit": "3b665eb6f1bb239a78981014dae4bc88a50264a7",
4
- "builtAt": "2026-09-22T23:49:01.800Z"
2
+ "version": "1.261.0",
3
+ "commit": "c60b26943f774d0ba0e71af0df8db7d1d90613c5",
4
+ "builtAt": "2026-09-23T08:28:35.014Z"
5
5
  }
@@ -254,6 +254,11 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
254
254
  * and the pause before a busy spawn is asked again. */
255
255
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
256
256
 
257
+ /** A finish event and the run record's durable write are separate deliveries.
258
+ * After the runner receives its own event, it gives the record this short
259
+ * visibility bound before treating an opaque absence as permanent. */
260
+ export const SHIP_RECORD_VISIBILITY = { retries: 3, retryMs: secondsToMs(1) } as const;
261
+
257
262
  /** How long a restarting close gives its replacement dispatch to claim the
258
263
  * same run before a read with no successor treats the close as final
259
264
  * (run-history item 47a). One whole coordinator wait chunk lets an ordinary
@@ -39,7 +39,15 @@
39
39
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
40
40
  // the shim Worker imports this by relative path.
41
41
 
42
- import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
42
+ import {
43
+ DAY_MS,
44
+ DEFAULT_GRANT,
45
+ GRANT_RENEWALS_MAX,
46
+ IDLE_DAYS_MAX,
47
+ SHIP_RECORD_VISIBILITY,
48
+ type Grant,
49
+ type GrantSource,
50
+ } from "../budgets.js";
43
51
  import { isFindingShape, type Finding } from "../reviewVerdict.js";
44
52
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
45
53
  import {
@@ -640,15 +648,47 @@ async function call(
640
648
  bot: CoordinatorBot,
641
649
  route: CoordinatorStepRoute,
642
650
  body: Record<string, unknown>,
651
+ acceptOpaqueNotFound = false,
643
652
  ): Promise<BotReply> {
644
653
  const reply = await bot.step(route, body);
645
654
  const read = readBotAnswer(reply.status, reply.text);
646
- if (!read.ok) throw new Error(`the bot did not answer ${route}: ${read.reason}`);
655
+ if (!read.ok) {
656
+ if (acceptOpaqueNotFound && isOpaqueNotFound(reply)) return reply;
657
+ throw new Error(`the bot did not answer ${route}: ${read.reason}`);
658
+ }
647
659
  const transient = transientRefusal(read.answer);
648
660
  if (transient !== undefined) throw new Error(transient);
649
661
  return reply;
650
662
  }
651
663
 
664
+ /** The read door's authorization mask: absent and denied are deliberately the
665
+ * same reply. The runner may pace that reply only from its own addressed
666
+ * finish event; parsing it here never changes what another caller sees. */
667
+ function isOpaqueNotFound(reply: BotReply): boolean {
668
+ if (reply.status !== 404) return false;
669
+ try {
670
+ const body = JSON.parse(reply.text) as { ok?: unknown; error?: unknown };
671
+ return body?.ok === false && body.error === "not_found";
672
+ } catch {
673
+ return false;
674
+ }
675
+ }
676
+
677
+ async function readRecordStep(
678
+ step: StepRunner,
679
+ bot: CoordinatorBot,
680
+ action: Extract<CoordinatorAction, { type: "read-record" }>,
681
+ body: Record<string, unknown>,
682
+ ): Promise<BotReply> {
683
+ for (let retry = 0; ; retry++) {
684
+ const name = retry === 0 ? action.step : `${action.step}/record-read/${retry}`;
685
+ const reply = await step.do(name, STEP_CONFIG, () => call(bot, "read-record", body, true));
686
+ if (!isOpaqueNotFound(reply) || action.finishedObserved !== true || retry >= SHIP_RECORD_VISIBILITY.retries)
687
+ return reply;
688
+ await step.sleep(`${action.step}/record-visible/${retry + 1}`, SHIP_RECORD_VISIBILITY.retryMs);
689
+ }
690
+ }
691
+
652
692
  /** The answer a stored reply carries — the reply passed `call` once, so it reads the same on replay. */
653
693
  function answerOf(route: CoordinatorStepRoute, reply: BotReply): BotAnswer {
654
694
  const read = readBotAnswer(reply.status, reply.text);
@@ -745,10 +785,7 @@ async function perform(
745
785
  case "read-record":
746
786
  return readRecordReturn(
747
787
  action.step,
748
- answerOf(
749
- "read-record",
750
- await step.do(action.step, STEP_CONFIG, () => call(bot, "read-record", { ...tag, runId: action.runId })),
751
- ),
788
+ answerOf("read-record", await readRecordStep(step, bot, action, { ...tag, runId: action.runId })),
752
789
  );
753
790
  case "steer":
754
791
  return steerReturn(
@@ -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
 
@@ -47,7 +47,7 @@ const CAUSE_OF = {
47
47
  repo_not_onboarded: "request",
48
48
  repo_access: "policy",
49
49
  pr_head_unknown: "system",
50
- branch_moved: "system",
50
+ workspace_head_mismatch: "system",
51
51
  coordinator_thread_live: "system",
52
52
  live_agent_allowlist: "policy",
53
53
  follow_up_refused: "request",
@@ -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
@@ -2,7 +2,7 @@
2
2
  // Durable Object applies them inside one transaction; the in-memory ledger
3
3
  // applies them in tests; both agree because this is the only copy.
4
4
 
5
- import type { ClaimResult, FenceResult, IntakeReceipt, IntakeWriteResult, LivePhase } from "./types.js";
5
+ import type { ClaimResult, FenceResult, IntakeReceipt, IntakeWriteResult, LivePhase, StepRecord } from "./types.js";
6
6
 
7
7
  /** One live run per thread. The existing row, if any, is what `live_runs` holds
8
8
  * for the thread; the same run re-claimed by its owner is idempotent (a retry
@@ -80,6 +80,15 @@ export function selectReclaim<T extends { leaseUntil: number; phase: LivePhase;
80
80
  return rows.filter((r) => r.ownerGen !== gen && (r.phase === "handoff" || r.leaseUntil <= now));
81
81
  }
82
82
 
83
+ /** Which inbox rows a reclaim hands the next generation (run-history item 40):
84
+ * every row past the last record's cursor, and the rows at or below it the
85
+ * record names deferred — handed to the run, never read by its model. */
86
+ export function unreadInbox(lastStep: Pick<StepRecord, "inboxConsumedSeq" | "inboxDeferredSeqs"> | null) {
87
+ const consumed = lastStep?.inboxConsumedSeq ?? 0;
88
+ const deferred = new Set(lastStep?.inboxDeferredSeqs ?? []);
89
+ return (item: { seq: number }): boolean => item.seq > consumed || deferred.has(item.seq);
90
+ }
91
+
83
92
  /** The compare-and-swap table for a run's phase. `attaching → live` (the
84
93
  * prompt landed) or `→ finishing` (the dispatch failed before it — never
85
94
  * handoff: an attaching run has nothing to resume from, so the drain waits
@@ -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). */
@@ -179,6 +200,9 @@ export interface StepRecord {
179
200
  inFlight: InFlightCall[];
180
201
  /** The inbox `seq` the run has consumed up to (0 = none). */
181
202
  inboxConsumedSeq: number;
203
+ /** Seqs at or below `inboxConsumedSeq` the run has not consumed yet: a
204
+ * reclaim offers them again with every row past the cursor. */
205
+ inboxDeferredSeqs?: number[];
182
206
  remainingMs: number;
183
207
  turn: number;
184
208
  iteration: number;