@coreplane/switchboard 1.261.4 → 1.262.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.
@@ -22,7 +22,9 @@ import { NonRetryableError } from "cloudflare:workflows";
22
22
  import { COORDINATOR_IDENTITY, COORDINATOR_STEP_PATH_PREFIX } from "../../src/core/coordinator/contract.ts";
23
23
  import {
24
24
  readBotAnswer,
25
+ runOriginalUnitRecovery,
25
26
  runPlan,
27
+ type OriginalUnitRecoveryParams,
26
28
  type CoordinatorBot,
27
29
  type PlanRunSummary,
28
30
  type StepRunner,
@@ -38,7 +40,17 @@ export type CoordinatorEnv = Pick<Env, "SWITCHBOARD" | "SWITCHBOARD_INGRESS_TOKE
38
40
  /** What an instance is created with: nothing the driver reads — the instance
39
41
  * id names the plan, and the bot's rows are the input (the coordinator's
40
42
  * contract: ids only, never a task's text or a thread's contents). */
41
- export type ShipCoordinatorParams = Record<string, unknown>;
43
+ export type ShipCoordinatorParams = Record<string, unknown> | OriginalUnitRecoveryParams;
44
+
45
+ function originalUnitRecoveryParams(value: ShipCoordinatorParams): OriginalUnitRecoveryParams | undefined {
46
+ if (
47
+ value.kind !== "recover-original-unit" ||
48
+ typeof value.parentInstanceId !== "string" ||
49
+ typeof value.unit !== "string"
50
+ )
51
+ return undefined;
52
+ return { kind: value.kind, parentInstanceId: value.parentInstanceId, unit: value.unit };
53
+ }
42
54
 
43
55
  /** The bot behind the container binding: the reply as the wire carried it.
44
56
  * The transport's failures throw for the step's retry; the door's own refusal
@@ -81,6 +93,11 @@ function workflowSteps(step: WorkflowStep): StepRunner {
81
93
 
82
94
  export class ShipCoordinator extends WorkflowEntrypoint<CoordinatorEnv, ShipCoordinatorParams> {
83
95
  async run(event: Readonly<WorkflowEvent<ShipCoordinatorParams>>, step: WorkflowStep): Promise<PlanRunSummary> {
96
+ if (event.payload.kind === "recover-original-unit") {
97
+ const params = originalUnitRecoveryParams(event.payload);
98
+ if (params === undefined) throw new NonRetryableError("the original-unit recovery params are malformed");
99
+ return runOriginalUnitRecovery(workflowSteps(step), containerBot(this.env), event.instanceId, params);
100
+ }
84
101
  return runPlan(workflowSteps(step), containerBot(this.env), event.instanceId);
85
102
  }
86
103
  }
@@ -325,7 +325,10 @@ async function handleCoordinatorInstances(request: Request, env: Env): Promise<R
325
325
  const existing = await (await env.SHIP_COORDINATOR.get(parsed.id)).status();
326
326
  outcome = { kind: "duplicate", id: parsed.id, status: existing.status };
327
327
  } catch {
328
- outcome = { kind: "failed", id: parsed.id, reason };
328
+ // Creation may have committed before its response was lost. When the
329
+ // status read is also unavailable, do not call that a definite failure:
330
+ // the bot must retain its same-id claim for a later duplicate replay.
331
+ return json(503, { ok: false, error: "create_unanswered", message: reason });
329
332
  }
330
333
  }
331
334
  console.log(`[coordinator] ${auth.subject} → instance ${parsed.id}: ${outcome.kind}`);
@@ -117,6 +117,7 @@ import {
117
117
  sendRunFinished,
118
118
  STEP_NAME_PATTERN,
119
119
  UNIT_PATTERN,
120
+ unitOfIdempotencyKey,
120
121
  type CoordinatorInstance,
121
122
  type CoordinatorUnit,
122
123
  type RunFinishedSend,
@@ -2771,6 +2772,37 @@ export class RunHistoryDO extends DurableObject<Env> {
2771
2772
  .map((r) => JSON.parse(r.json) as CoordinatorUnit);
2772
2773
  }
2773
2774
 
2775
+ async listActiveRecoveries(): Promise<CoordinatorUnit[]> {
2776
+ return this.sql
2777
+ .exec<{ json: string }>(
2778
+ `SELECT json FROM coordinator_units
2779
+ WHERE json_valid(json) = 0 OR json_type(json, '$.recovery') IS NOT NULL
2780
+ ORDER BY rowid`,
2781
+ )
2782
+ .toArray()
2783
+ .map((r) => {
2784
+ const unit: unknown = JSON.parse(r.json);
2785
+ if (!isCoordinatorUnit(unit) || unit.recovery === undefined)
2786
+ throw new Error("active recovery index contains a malformed coordinator unit");
2787
+ return unit;
2788
+ });
2789
+ }
2790
+
2791
+ private recoveryTransport(parentInstanceId: string, idempotencyKey: string | undefined): string | undefined {
2792
+ const unit = idempotencyKey === undefined ? undefined : unitOfIdempotencyKey(idempotencyKey);
2793
+ if (unit === undefined) return undefined;
2794
+ const row = this.sql
2795
+ .exec<{ json: string }>(
2796
+ `SELECT json FROM coordinator_units WHERE instance_id = ? AND unit = ?`,
2797
+ parentInstanceId,
2798
+ unit,
2799
+ )
2800
+ .toArray()[0];
2801
+ if (row === undefined) return undefined;
2802
+ const parsed = JSON.parse(row.json) as CoordinatorUnit;
2803
+ return parsed.recovery?.workflowId;
2804
+ }
2805
+
2774
2806
  // ---- the thread events of a unit-owned thread (record 0051's reply-as-event rule) --------------
2775
2807
 
2776
2808
  /** The next sequence assigned in one transaction, the per-event cap applied
@@ -3025,9 +3057,11 @@ export class RunHistoryDO extends DurableObject<Env> {
3025
3057
  // tells the waiting parent the child resumed — best effort, beside the
3026
3058
  // bot's own announcement; a duplicate is consumed and re-armed, harmless.
3027
3059
  if (req.meta.restartOf !== undefined && req.meta.parentInstanceId !== undefined) {
3060
+ const recoveryTransport = this.recoveryTransport(req.meta.parentInstanceId, req.meta.idempotencyKey);
3028
3061
  const sent = await sendChildSignal(this.env.SHIP_COORDINATOR, {
3029
3062
  runId: req.runId,
3030
3063
  parentInstanceId: req.meta.parentInstanceId,
3064
+ ...(recoveryTransport !== undefined ? { transportWorkflowId: recoveryTransport } : {}),
3031
3065
  kind: "resumed",
3032
3066
  reason: `restarted from run ${req.meta.restartOf}`,
3033
3067
  at: now,
@@ -3318,7 +3352,14 @@ export class RunHistoryDO extends DurableObject<Env> {
3318
3352
  if ((await this.ctx.storage.getAlarm()) === null)
3319
3353
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
3320
3354
  await this.refreshSessionBytes(record.session?.key);
3321
- const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
3355
+ const transportWorkflowId =
3356
+ record.parentInstanceId === undefined
3357
+ ? undefined
3358
+ : this.recoveryTransport(record.parentInstanceId, record.idempotencyKey);
3359
+ const event = await sendRunFinished(this.env.SHIP_COORDINATOR, {
3360
+ ...record,
3361
+ ...(transportWorkflowId !== undefined ? { transportWorkflowId } : {}),
3362
+ });
3322
3363
  if (event.kind === "failed")
3323
3364
  console.warn(`[runs/finish] ${runId} → ${event.type} not delivered to ${event.instance}: ${event.reason}`);
3324
3365
  return { ...out, event: event.kind };
@@ -3654,7 +3695,11 @@ export class RunHistoryDO extends DurableObject<Env> {
3654
3695
  // `finishedAt` equals `startedAt`); the parent confirms by `read-record`
3655
3696
  // before it acts, so a duplicate send is harmless.
3656
3697
  if (result.stored && record.parentInstanceId !== undefined && record.finishedAt > record.startedAt) {
3657
- const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
3698
+ const transportWorkflowId = record.events.find((event) => event.type === "coordinator_tag")?.transportWorkflowId;
3699
+ const event = await sendRunFinished(this.env.SHIP_COORDINATOR, {
3700
+ ...record,
3701
+ ...(transportWorkflowId !== undefined ? { transportWorkflowId } : {}),
3702
+ });
3658
3703
  if (event.kind === "failed")
3659
3704
  console.warn(`[runs/put] ${record.id} → ${event.type} not delivered to ${event.instance}: ${event.reason}`);
3660
3705
  }
@@ -5220,6 +5265,7 @@ const LEDGER_ROUTES = new Set([
5220
5265
  "/runs/coordinator/stop",
5221
5266
  "/runs/coordinator/units/put",
5222
5267
  "/runs/coordinator/units/claim-legacy-continuation",
5268
+ "/runs/coordinator/units/list-active-recoveries",
5223
5269
  "/runs/coordinator/units/list",
5224
5270
  "/runs/coordinator/events/append",
5225
5271
  "/runs/coordinator/events/list",
@@ -5935,6 +5981,8 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
5935
5981
  return json({ error: "instanceId must be a Workflow instance id" }, 400);
5936
5982
  return json({ units: await stub.listUnits(b.instanceId) });
5937
5983
  }
5984
+ if (pathname === "/runs/coordinator/units/list-active-recoveries")
5985
+ return json({ units: await stub.listActiveRecoveries() });
5938
5986
  if (pathname === "/runs/coordinator/wake") {
5939
5987
  if (!isCoordinatorUnit(b.unit)) return json({ error: "unit must be a coordinator unit row" }, 400);
5940
5988
  if (typeof b.waitId !== "string" || !STEP_NAME_PATTERN.test(b.waitId))
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.261.4",
3
+ "version": "1.262.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.261.4",
9
+ "version": "1.262.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.261.4",
21063
+ "version": "1.262.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.261.4",
3
+ "version": "1.262.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.261.4",
3
- "commit": "9f4d0f4c9645ea67ecab3ede2455e658f38a7d4c",
4
- "builtAt": "2026-09-24T16:28:36.099Z"
2
+ "version": "1.262.0",
3
+ "commit": "90124c1dfe74e65a447d2ef33eebd01e998486e0",
4
+ "builtAt": "2026-09-24T21:21:11.789Z"
5
5
  }
@@ -313,6 +313,20 @@ export interface ExistingPrPublicationBinding {
313
313
  export interface CoordinatorTag {
314
314
  parentInstanceId: string;
315
315
  idempotencyKey: string;
316
+ /** Workflow transport for a recovered child. Identity and idempotency remain
317
+ * on `parentInstanceId`; only lifecycle wake-ups use this checkpoint id. */
318
+ transportWorkflowId?: string;
319
+ /** The immutable target and absolute lease of an original-unit recovery.
320
+ * Stored on the coordinator event so a resume or request-restart cannot turn
321
+ * the remaining lease into a fresh relative budget or adopt another head. */
322
+ recovery?: {
323
+ repo: string;
324
+ pr: number;
325
+ headRef: string;
326
+ baseRef: string;
327
+ expectedHeadSha: string;
328
+ deadlineAt: number;
329
+ };
316
330
  /** The branch the child's pull request targets: the plan's base
317
331
  * (`CoordinatorInstance.base`), set by the spawn when the instance knows it.
318
332
  * A coordinator's child is dispatched AT its unit branch so the resident
@@ -477,6 +491,45 @@ export interface RoundGate {
477
491
  findings: string[];
478
492
  }
479
493
 
494
+ /** One admitted continuation of an ended original unit. The claim replaces
495
+ * the terminal interpretation before a child starts; its step remains under
496
+ * the original instance/unit idempotency namespace. */
497
+ export interface OriginalUnitRecovery {
498
+ kind: "findings" | "review";
499
+ round: number;
500
+ expectedHeadSha: string;
501
+ remainingMs: number;
502
+ claimedAt: number;
503
+ step: string;
504
+ /** The original review record that authorizes this transition. */
505
+ reviewRunId: string;
506
+ /** A completed findings child that already advanced the original unit before
507
+ * recovery was claimed. Present only when recovery starts at re-review. */
508
+ findingsRunId?: string;
509
+ /** Exact durable key of that completed original findings child. */
510
+ findingsKey?: string;
511
+ /** The posted request-changes findings, retained so a Workflow restart can
512
+ * rebuild the exact findings state without trusting a later listing. */
513
+ findings?: import("../reviewVerdict.js").Finding[];
514
+ /** The exact terminal value replaced by the claim, for fail-closed rollback
515
+ * if the Workflow cannot be admitted. */
516
+ previousEnding: NonNullable<CoordinatorUnit["ending"]>;
517
+ /** The separate Workflow execution checkpoint. This is transport identity,
518
+ * not a replacement coordinator/unit identity. */
519
+ workflowId: string;
520
+ /** Absolute end of the original active lease. Delayed Workflow admission or
521
+ * replay cannot turn the claim's snapshot into fresh time. */
522
+ deadlineAt: number;
523
+ /** Exact durable child key of the review outcome that authorized recovery. */
524
+ reviewKey: string;
525
+ }
526
+
527
+ export interface OriginalUnitRecoveryReceipt {
528
+ reviewRunId: string;
529
+ workflowId: string;
530
+ at: number;
531
+ }
532
+
480
533
  /** One unit of the plan an instance runs (a task string is a generated plan of
481
534
  * one unit, `U1`): its branch, the units it waits on, and — as the runner
482
535
  * reaches it — its thread, its pull request, the round boundaries the card
@@ -543,6 +596,15 @@ export interface CoordinatorUnit {
543
596
  * item 9) — a mismatch to be seen, since the child's parser holds an approve
544
597
  * to the same level. */
545
598
  rounds: Array<{ index: number; agent: string; outcome: string; at: number; gate?: RoundGate }>;
599
+ /** An explicit recovery claim for this same durable unit. It is mutually
600
+ * exclusive with both `idle` and `ending`; legacy readers otherwise keep
601
+ * their existing decoding rules. */
602
+ recovery?: OriginalUnitRecovery;
603
+ /** The authorizing review already consumed by a completed recovery. */
604
+ recoveryReceipt?: OriginalUnitRecoveryReceipt;
605
+ /** A recovered review that reached a person-only question. Recovery settles
606
+ * truthfully instead of opening an idle renewal or replacement pipeline. */
607
+ recoveryHold?: { cause: "human"; gate: HumanGatePending } | { cause: "draft"; pr: { number: number; url: string } };
546
608
  /** How the unit ended: the ending's kind and the thread's report, when it
547
609
  * has. `cause` names the machine's reason behind a driver-posted kind;
548
610
  * `step` and `round` locate that reason without parsing the report. For a
@@ -727,6 +789,64 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
727
789
  )
728
790
  )
729
791
  return false;
792
+ if (
793
+ r.recovery !== undefined &&
794
+ !(
795
+ isObject(r.recovery) &&
796
+ (r.recovery.kind === "findings" || r.recovery.kind === "review") &&
797
+ typeof r.recovery.round === "number" &&
798
+ Number.isInteger(r.recovery.round) &&
799
+ r.recovery.round >= 1 &&
800
+ typeof r.recovery.expectedHeadSha === "string" &&
801
+ /^[0-9a-f]{40}$/i.test(r.recovery.expectedHeadSha) &&
802
+ isFinite(r.recovery.remainingMs) &&
803
+ r.recovery.remainingMs > 0 &&
804
+ isFinite(r.recovery.claimedAt) &&
805
+ typeof r.recovery.step === "string" &&
806
+ STEP_NAME_PATTERN.test(r.recovery.step) &&
807
+ isText(r.recovery.reviewRunId) &&
808
+ (r.recovery.findingsRunId === undefined || isText(r.recovery.findingsRunId)) &&
809
+ (r.recovery.findingsKey === undefined || isText(r.recovery.findingsKey)) &&
810
+ ((r.recovery.findingsRunId === undefined && r.recovery.findingsKey === undefined) ||
811
+ (r.recovery.kind === "review" &&
812
+ r.recovery.findingsRunId !== undefined &&
813
+ r.recovery.findingsKey !== undefined)) &&
814
+ (r.recovery.findings === undefined ||
815
+ (Array.isArray(r.recovery.findings) && r.recovery.findings.every(isFindingShape))) &&
816
+ isObject(r.recovery.previousEnding) &&
817
+ isText(r.recovery.previousEnding.kind) &&
818
+ typeof r.recovery.previousEnding.report === "string" &&
819
+ isFinite(r.recovery.previousEnding.at) &&
820
+ typeof r.recovery.workflowId === "string" &&
821
+ INSTANCE_ID_PATTERN.test(r.recovery.workflowId) &&
822
+ isFinite(r.recovery.deadlineAt) &&
823
+ isText(r.recovery.reviewKey)
824
+ )
825
+ )
826
+ return false;
827
+ if (
828
+ r.recoveryReceipt !== undefined &&
829
+ (!isObject(r.recoveryReceipt) ||
830
+ !isText(r.recoveryReceipt.reviewRunId) ||
831
+ typeof r.recoveryReceipt.workflowId !== "string" ||
832
+ !INSTANCE_ID_PATTERN.test(r.recoveryReceipt.workflowId) ||
833
+ !isFinite(r.recoveryReceipt.at))
834
+ )
835
+ return false;
836
+ if (
837
+ r.recoveryHold !== undefined &&
838
+ (!isObject(r.recoveryHold) ||
839
+ !(
840
+ (r.recoveryHold.cause === "human" && isHumanGatePending(r.recoveryHold.gate)) ||
841
+ (r.recoveryHold.cause === "draft" &&
842
+ isObject(r.recoveryHold.pr) &&
843
+ Number.isInteger(r.recoveryHold.pr.number) &&
844
+ (r.recoveryHold.pr.number as number) > 0 &&
845
+ isText(r.recoveryHold.pr.url))
846
+ ))
847
+ )
848
+ return false;
849
+ if (r.recovery !== undefined && (r.idle !== undefined || r.ending !== undefined)) return false;
730
850
  if (
731
851
  r.ending !== undefined &&
732
852
  !(
@@ -829,13 +949,20 @@ export async function sendPullMerged(
829
949
  * send costs the wait a chunk, never the round. */
830
950
  export async function sendChildSignal(
831
951
  workflow: WorkflowSender | undefined,
832
- signal: { runId: string; parentInstanceId: string; kind: "interrupted" | "resumed"; reason: string; at: number },
952
+ signal: {
953
+ runId: string;
954
+ parentInstanceId: string;
955
+ transportWorkflowId?: string;
956
+ kind: "interrupted" | "resumed";
957
+ reason: string;
958
+ at: number;
959
+ },
833
960
  ): Promise<RunFinishedSend> {
834
961
  const { runId, parentInstanceId: instance, kind, reason, at } = signal;
835
962
  if (!workflow) return { kind: "no-binding", instance };
836
963
  const type = kind === "interrupted" ? childInterruptedEventType(runId) : childResumedEventType(runId);
837
964
  try {
838
- const handle = await workflow.get(instance);
965
+ const handle = await workflow.get(signal.transportWorkflowId ?? instance);
839
966
  await handle.sendEvent({ type, payload: { runId, kind, reason, at, parentInstanceId: instance } });
840
967
  return { kind: "sent", instance, type };
841
968
  } catch (err) {
@@ -846,7 +973,13 @@ export async function sendChildSignal(
846
973
  /** The one send per committed terminal record (run-history item 47). */
847
974
  export async function sendRunFinished(
848
975
  workflow: WorkflowSender | undefined,
849
- record: { id: string; status: string; finishedAt: number; parentInstanceId?: string },
976
+ record: {
977
+ id: string;
978
+ status: string;
979
+ finishedAt: number;
980
+ parentInstanceId?: string;
981
+ transportWorkflowId?: string;
982
+ },
850
983
  ): Promise<RunFinishedSend> {
851
984
  const instance = record.parentInstanceId;
852
985
  if (instance === undefined) return { kind: "none" };
@@ -859,7 +992,7 @@ export async function sendRunFinished(
859
992
  parentInstanceId: instance,
860
993
  };
861
994
  try {
862
- const handle = await workflow.get(instance);
995
+ const handle = await workflow.get(record.transportWorkflowId ?? instance);
863
996
  await handle.sendEvent({ type, payload });
864
997
  return { kind: "sent", instance, type };
865
998
  } catch (err) {