@coreplane/switchboard 1.261.1 → 1.261.3

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.
@@ -2732,6 +2732,38 @@ export class RunHistoryDO extends DurableObject<Env> {
2732
2732
  return { ok: true };
2733
2733
  }
2734
2734
 
2735
+ /** One compare-and-replace transaction reserves a legacy continuation for
2736
+ * exactly one caller. The whole expected JSON is the fence: any intervening
2737
+ * unit write, including another recovery, makes this caller stale. */
2738
+ async claimLegacyContinuation(
2739
+ expected: CoordinatorUnit,
2740
+ recovered: CoordinatorUnit,
2741
+ now: number,
2742
+ ): Promise<{ ok: true } | { ok: false; reason: "stale" }> {
2743
+ let out: { ok: true } | { ok: false; reason: "stale" } = { ok: true };
2744
+ this.ctx.storage.transactionSync(() => {
2745
+ const row = this.sql
2746
+ .exec<{ json: string }>(
2747
+ `SELECT json FROM coordinator_units WHERE instance_id = ? AND unit = ?`,
2748
+ expected.instanceId,
2749
+ expected.unit,
2750
+ )
2751
+ .toArray()[0];
2752
+ if (row?.json !== JSON.stringify(expected)) {
2753
+ out = { ok: false, reason: "stale" };
2754
+ return;
2755
+ }
2756
+ this.sql.exec(
2757
+ `UPDATE coordinator_units SET json = ?, updated_at = ? WHERE instance_id = ? AND unit = ?`,
2758
+ JSON.stringify(recovered),
2759
+ now,
2760
+ expected.instanceId,
2761
+ expected.unit,
2762
+ );
2763
+ });
2764
+ return out;
2765
+ }
2766
+
2735
2767
  async listUnits(instanceId: string): Promise<CoordinatorUnit[]> {
2736
2768
  return this.sql
2737
2769
  .exec<{ json: string }>(`SELECT json FROM coordinator_units WHERE instance_id = ? ORDER BY rowid`, instanceId)
@@ -5187,6 +5219,7 @@ const LEDGER_ROUTES = new Set([
5187
5219
  "/runs/coordinator/get",
5188
5220
  "/runs/coordinator/stop",
5189
5221
  "/runs/coordinator/units/put",
5222
+ "/runs/coordinator/units/claim-legacy-continuation",
5190
5223
  "/runs/coordinator/units/list",
5191
5224
  "/runs/coordinator/events/append",
5192
5225
  "/runs/coordinator/events/list",
@@ -5886,6 +5919,17 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
5886
5919
  console.log(`[runs/coordinator/units/put] ${key.value} ${units[0]!.instanceId} ${units.length} row(s)`);
5887
5920
  return json(r);
5888
5921
  }
5922
+ if (pathname === "/runs/coordinator/units/claim-legacy-continuation") {
5923
+ if (!isCoordinatorUnit(b.expected) || !isCoordinatorUnit(b.recovered))
5924
+ return json({ error: "expected and recovered must be coordinator unit rows" }, 400);
5925
+ if (b.expected.instanceId !== b.recovered.instanceId || b.expected.unit !== b.recovered.unit)
5926
+ return json({ error: "expected and recovered must name the same unit" }, 400);
5927
+ const r = await stub.claimLegacyContinuation(b.expected, b.recovered, now);
5928
+ console.log(
5929
+ `[runs/coordinator/units/claim-legacy-continuation] ${key.value} ${b.expected.instanceId}:${b.expected.unit} → ${r.ok ? "claimed" : r.reason}`,
5930
+ );
5931
+ return r.ok ? json(r) : json(r, 409);
5932
+ }
5889
5933
  if (pathname === "/runs/coordinator/units/list") {
5890
5934
  if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
5891
5935
  return json({ error: "instanceId must be a Workflow instance id" }, 400);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.261.1",
3
+ "version": "1.261.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.261.1",
9
+ "version": "1.261.3",
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.1",
21063
+ "version": "1.261.3",
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.1",
3
+ "version": "1.261.3",
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.1",
3
- "commit": "3746a1c5fa2d30bebd66baec1fb3534a1f927e3a",
4
- "builtAt": "2026-09-23T21:48:10.555Z"
2
+ "version": "1.261.3",
3
+ "commit": "b727e58725e3d234e0d34b43e04ca0dc9af17075",
4
+ "builtAt": "2026-09-24T14:03:08.080Z"
5
5
  }
@@ -293,6 +293,20 @@ export interface RunFinishedPayload {
293
293
  parentInstanceId: string;
294
294
  }
295
295
 
296
+ /** Durable authority for publishing another commit to an existing pull
297
+ * request. Every identity field is repeated deliberately: publication is
298
+ * allowed only when the unit, checkout and a fresh remote read all agree with
299
+ * this exact record. */
300
+ export interface ExistingPrPublicationBinding {
301
+ repo: string;
302
+ pr: number;
303
+ headRef: string;
304
+ baseRef: string;
305
+ expectedHeadSha: string;
306
+ publicationRef: string;
307
+ owner: { instanceId: string; unit: string };
308
+ }
309
+
296
310
  /** What a coordinator's spawn stamps on the child's every row: the instance
297
311
  * the child belongs to and the key the spawn carried — and, for the child's
298
312
  * own post-step, the base its pull request targets. */
@@ -310,6 +324,10 @@ export interface CoordinatorTag {
310
324
  * back off its ledger events; `coordinatorFields` still leaves it off the
311
325
  * rows, so rows and records keep the shape written before it existed. */
312
326
  base?: string;
327
+ /** The existing-PR publication authority this child received from its
328
+ * durable unit row and current Workflow round. Published on the run's event
329
+ * stream so a rehost retains the same fence. */
330
+ publication?: ExistingPrPublicationBinding;
313
331
  }
314
332
 
315
333
  /** The tag as the two flat record fields, or nothing — so a row, a summary
@@ -489,6 +507,9 @@ export interface CoordinatorUnit {
489
507
  * — no pre-check, no branch, no round 0. A task string's row only; written by
490
508
  * the hand-off, read by the driver into the machine's input. */
491
509
  resume?: { pr: number; headSha?: string; url?: string };
510
+ /** Exact existing-PR publication authority. Absent for a fresh unit branch;
511
+ * an adopted or resumed PR may publish only through this binding. */
512
+ publication?: ExistingPrPublicationBinding;
492
513
  /** The decision-record number reserved at admission for this unit. A unit
493
514
  * that writes a record carries it through every attempt and briefs its child
494
515
  * as `record: NNNN`; the child never scans the directory for a number. */
@@ -549,6 +570,23 @@ const isResume = (v: unknown): boolean =>
549
570
  isFinite(v.pr) &&
550
571
  (v.headSha === undefined || isText(v.headSha)) &&
551
572
  (v.url === undefined || isText(v.url, 2048));
573
+ const isFullSha = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{40}$/i.test(v);
574
+ const isPublication = (v: unknown): v is ExistingPrPublicationBinding =>
575
+ isObject(v) &&
576
+ typeof v.repo === "string" &&
577
+ REPO_SLUG.test(v.repo) &&
578
+ typeof v.pr === "number" &&
579
+ Number.isInteger(v.pr) &&
580
+ v.pr > 0 &&
581
+ isText(v.headRef) &&
582
+ isText(v.baseRef) &&
583
+ isFullSha(v.expectedHeadSha) &&
584
+ isText(v.publicationRef) &&
585
+ isObject(v.owner) &&
586
+ typeof v.owner.instanceId === "string" &&
587
+ INSTANCE_ID_PATTERN.test(v.owner.instanceId) &&
588
+ typeof v.owner.unit === "string" &&
589
+ UNIT_PATTERN.test(v.owner.unit);
552
590
  const isThread = (v: unknown): boolean => isObject(v) && isText(v.threadKey) && isOptionalText(v.sourceUrl);
553
591
 
554
592
  /** Structural check on a record from outside the process (a Worker response, an HTTP body). */
@@ -663,6 +701,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
663
701
  if (r.issue !== undefined && !isFinite(r.issue)) return false;
664
702
  if (r.pr !== undefined && !isPr(r.pr)) return false;
665
703
  if (r.resume !== undefined && !isResume(r.resume)) return false;
704
+ if (r.publication !== undefined && !isPublication(r.publication)) return false;
666
705
  if (r.record !== undefined && (typeof r.record !== "string" || !/^\d{4}$/.test(r.record))) return false;
667
706
  if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
668
707
  if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
@@ -1137,14 +1137,16 @@ async function runUnit(
1137
1137
  : {}),
1138
1138
  },
1139
1139
  ...(state.pr !== undefined ? { pr: state.pr } : {}),
1140
- // A review_pending ending names the child's own last push so the next
1141
- // attempt's pre-check can start at the review round (the row's lastPush)
1142
- // — an idled one the same, off the idle's `from` (record 0051).
1143
- ...(ending.kind === "review_pending" && ending.headSha !== undefined
1144
- ? { headSha: ending.headSha }
1145
- : ending.kind === "idle" && ending.why === "review_pending" && ending.from !== undefined
1146
- ? { headSha: ending.from }
1147
- : {}),
1140
+ // A merge_ready ending retains the exact head its final approval
1141
+ // reviewed. Review-pending endings retain the coding child's push;
1142
+ // both become the row's durable lastPush for a later attempt.
1143
+ ...(ending.kind === "merge_ready" && state.lastReviewHead !== undefined
1144
+ ? { headSha: state.lastReviewHead }
1145
+ : ending.kind === "review_pending" && ending.headSha !== undefined
1146
+ ? { headSha: ending.headSha }
1147
+ : ending.kind === "idle" && ending.why === "review_pending" && ending.from !== undefined
1148
+ ? { headSha: ending.from }
1149
+ : {}),
1148
1150
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
1149
1151
  // A continued ending is a segment's end, not the unit's: the bot
1150
1152
  // writes the renewal as a row keyed by the next segment's index
@@ -208,6 +208,11 @@ export type RunNoteKind =
208
208
  * after the model's last turn — the release itself runs after the record
209
209
  * is sealed — and set on the card's label too, so the loss is never silent. */
210
210
  | "work_left_behind"
211
+ /** An existing pull request's exact publication fence denied the push while
212
+ * the workspace held a local checkpoint. The workspace stays attached
213
+ * instead of being discarded or redirected; the summary names the head,
214
+ * counts, denial and that retention beyond the run remains unverified. */
215
+ | "publication_blocked"
211
216
  /** The run's ending may have left a command running in its workspace — a
212
217
  * call cut by the ending's abort or interrupt, or open when the run failed —
213
218
  * so the release tears the workspace down rather than pair it for the
@@ -356,6 +361,7 @@ export const RUN_NOTE_KINDS = [
356
361
  "ledger_untracked",
357
362
  "rebind_refused",
358
363
  "work_left_behind",
364
+ "publication_blocked",
359
365
  "workspace_torn_down",
360
366
  "pr_not_opened",
361
367
  "pr_head_unverified",
@@ -953,7 +959,15 @@ export type RunEvent =
953
959
  * instead of letting the binding ref — the unit branch itself — stand in.
954
960
  * `base` is absent when the spawn knew none; the post-step then falls to
955
961
  * the coordinator store's `instance.base`. Additive: unknown → ignored. */
956
- | { type: "coordinator_tag"; parentInstanceId: string; unit?: string; base?: string; seq?: number; at?: number }
962
+ | {
963
+ type: "coordinator_tag";
964
+ parentInstanceId: string;
965
+ unit?: string;
966
+ base?: string;
967
+ publication?: import("./coordinator/contract.js").ExistingPrPublicationBinding;
968
+ seq?: number;
969
+ at?: number;
970
+ }
957
971
  /** The plan runner instance a ship run's hand-off created (record 0051 R2;
958
972
  * docs/reference/specs/run-history.md item 2): published by the ship branch
959
973
  * after `handOffToCoordinator` succeeds, straight to the registry like
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { ChatMessage } from "../chatMessage.js";
7
7
  import { PROVIDER_FAILURE_CAUSES, type ProviderFailureCause, type ToolDef } from "../provider.js";
8
+ import type { Verbosity } from "../verbosity.js";
8
9
  import type { ChannelVisibility } from "../authz/types.js";
9
10
  import type { RunProfile } from "../../config/profile.js";
10
11
  import type { RunEvent } from "../runEvents.js";
@@ -60,6 +61,9 @@ export interface LiveRunMeta {
60
61
  /** The app that relayed the request for the person (authorization.md item 14): a resume or restart keeps app ∩ person at the gates. */
61
62
  postedBy?: string;
62
63
  effort?: string;
64
+ /** The request's resolved display level. Hosted parents have no model loop
65
+ * to rebuild it from, and their input event deliberately omits directives. */
66
+ verbosity?: Verbosity;
63
67
  /** Decision-record reservation assigned before the attach, so a restart of
64
68
  * the admitted task keeps the same brief and process environment. */
65
69
  record?: string;
@@ -502,6 +502,8 @@ export type Brief =
502
502
  kind: "findings";
503
503
  unit: string;
504
504
  pr: number;
505
+ /** The exact reviewed head the fix round may replace. */
506
+ headSha?: string;
505
507
  /** The review run normally supplies the findings. An adopted attempt
506
508
  * can recover them from the last posted verdict marker instead. */
507
509
  reviewRunId?: string;
@@ -1466,6 +1468,7 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1466
1468
  kind: "findings",
1467
1469
  unit,
1468
1470
  pr,
1471
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1469
1472
  ...(reviewRunId !== undefined ? { reviewRunId } : {}),
1470
1473
  ...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
1471
1474
  ...(answers !== undefined && answers.length > 0 ? { answers } : {}),
@@ -1,3 +1,5 @@
1
+ import type { Verbosity } from "./verbosity.js";
2
+
1
3
  // Channel abstraction. A channel (Slack, CLI, Discord, HTTP, ...) is only a
2
4
  // transport: it receives text from a user somewhere, hands it to the core
3
5
  // dispatcher as an IncomingMessage, and provides a ChannelIO for the core to
@@ -306,8 +308,10 @@ export interface ChannelIO {
306
308
  * the row lives in the config object until the click or the expiry.
307
309
  */
308
310
  offer?(offer: ConfirmationOffer): Promise<void>;
309
- /** Create a progress indicator. Adapters may return a no-op handle. */
310
- status(initial: StatusUpdate): Promise<StatusHandle>;
311
+ /** Create a progress indicator. Adapters may return a no-op handle. The
312
+ * resolved verbosity reaches the transport so native lifecycle chrome can
313
+ * follow the same display contract as replies and cards. */
314
+ status(initial: StatusUpdate, display?: { verbosity?: Verbosity }): Promise<StatusHandle>;
311
315
  /**
312
316
  * Prior turns of this conversation, oldest first, excluding the triggering
313
317
  * message and any bot status noise. Adapters without history return [].