@coreplane/switchboard 1.206.0 → 1.207.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.
@@ -64,7 +64,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
64
64
  export interface Env {
65
65
  SWITCHBOARD: DurableObjectNamespace<SwitchboardServer>;
66
66
  /** The ship coordinator (coordinator.ts): `POST /admin/coordinator/instances`
67
- * creates its instances; the state Worker's finish sends them `run finished`. */
67
+ * creates its instances; the state Worker's finish sends them `run-finished-<runId>`. */
68
68
  SHIP_COORDINATOR: Workflow<ShipCoordinatorParams>;
69
69
  // secrets (wrangler secret put ...)
70
70
  SLACK_BOT_TOKEN: string;
@@ -147,7 +147,7 @@ export interface Env {
147
147
  /** Delivery snapshots (delivery item 10): ONE DeliveryDO (named "delivery"), one snapshot per repository. */
148
148
  DELIVERY: DurableObjectNamespace<DeliveryDO>;
149
149
  /** The ship coordinator Workflow in the bot's shim Worker (run-history item
150
- * 47): where `RunHistoryDO.finish` sends `run finished:<runId>` for a record
150
+ * 47): where `RunHistoryDO.finish` sends `run-finished-<runId>` for a record
151
151
  * carrying `parentInstanceId`. Optional: this Worker deploys without it (the
152
152
  * binding is a cross-script one, and the class must exist on the bot before
153
153
  * the state Worker may name it), and a finish then commits with no event. */
@@ -1505,7 +1505,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1505
1505
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1506
1506
  const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
1507
1507
  if (event.kind === "failed")
1508
- console.warn(`[runs/finish] ${runId} → run finished not delivered to ${event.instance}: ${event.reason}`);
1508
+ console.warn(`[runs/finish] ${runId} → ${event.type} not delivered to ${event.instance}: ${event.reason}`);
1509
1509
  return { ...out, event: event.kind };
1510
1510
  }
1511
1511
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.206.0",
3
+ "version": "1.207.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.206.0",
9
+ "version": "1.207.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -18999,7 +18999,7 @@
18999
18999
  },
19000
19000
  "packages/switchboard": {
19001
19001
  "name": "@coreplane/switchboard",
19002
- "version": "1.206.0",
19002
+ "version": "1.207.0",
19003
19003
  "license": "Apache-2.0",
19004
19004
  "dependencies": {
19005
19005
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.206.0",
3
+ "version": "1.207.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.206.0",
3
- "commit": "c46066984259fae88e1f242c39cc38c8affe0776",
4
- "builtAt": "2026-09-12T15:07:26.252Z"
2
+ "version": "1.207.0",
3
+ "commit": "123a4b1aba2acf34fc922a24f3493fa6c05df2d9",
4
+ "builtAt": "2026-09-14T00:08:39.382Z"
5
5
  }
@@ -38,8 +38,11 @@ export function idempotencyKeyFor(parentInstanceId: string, step: string): strin
38
38
 
39
39
  /** The event a child's terminal record sends its parent: the type carries the
40
40
  * run id, so each `waitForEvent` matches its own child and a duplicate is
41
- * buffered harmlessly. */
42
- export const RUN_FINISHED_EVENT_PREFIX = "run finished:";
41
+ * buffered harmlessly. An event type is the platform's alphabet — letters,
42
+ * digits, `-` and `_` (`^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`); a space, a colon or a
43
+ * dot is refused as `workflow.invalid_event_type`, and the parent never hears
44
+ * the child end. A run id is a UUID, already inside it. */
45
+ export const RUN_FINISHED_EVENT_PREFIX = "run-finished-";
43
46
  export function runFinishedEventType(runId: string): string {
44
47
  return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
45
48
  }
@@ -54,14 +57,26 @@ export interface RunFinishedPayload {
54
57
  }
55
58
 
56
59
  /** What a coordinator's spawn stamps on the child's every row: the instance
57
- * the child belongs to and the key the spawn carried. */
60
+ * the child belongs to and the key the spawn carried — and, for the child's
61
+ * own post-step, the base its pull request targets. */
58
62
  export interface CoordinatorTag {
59
63
  parentInstanceId: string;
60
64
  idempotencyKey: string;
65
+ /** The branch the child's pull request targets: the plan's base
66
+ * (`CoordinatorInstance.base`), set by the spawn when the instance knows it.
67
+ * A coordinator's child is dispatched AT its unit branch so the resident
68
+ * attaches there, which makes the binding ref the branch itself — no base
69
+ * the post-step could resolve from the thread — so the spawn says it. An
70
+ * instruction to the run like `DispatchOptions.contract`, not a fact about
71
+ * it: the fact the record keeps is the `pr_opened` it leads to, and
72
+ * `coordinatorFields` leaves it off the rows. */
73
+ base?: string;
61
74
  }
62
75
 
63
76
  /** The tag as the two flat record fields, or nothing — so a row, a summary
64
- * and a record spread the same thing and a run with no coordinator carries no key. */
77
+ * and a record spread the same thing and a run with no coordinator carries no
78
+ * key. The base never rides here: rows and records keep the shape written
79
+ * before it existed. */
65
80
  export function coordinatorFields(tag: CoordinatorTag | undefined): {
66
81
  parentInstanceId?: string;
67
82
  idempotencyKey?: string;
@@ -207,7 +222,7 @@ export interface WorkflowSender {
207
222
  /** How a send ended. `none`: the record names no instance. `no-binding`: the
208
223
  * Worker has no coordinator binding. `failed`: the engine refused (the
209
224
  * instance ended, or is unknown) — swallowed, never thrown: the commit stands,
210
- * and the parent's `waitForEvent` timeout falls back to `read-record`. */
225
+ * and the parent's wait falls back to `read-record` at its next chunk. */
211
226
  export type RunFinishedSend =
212
227
  | { kind: "sent"; instance: string; type: string }
213
228
  | { kind: "none" }
@@ -13,9 +13,12 @@
13
13
  // status and the text of a JSON object the bot stamped with its clock (`at`) —
14
14
  // so the mapping onto the machine is pure and runs identically on replay; an
15
15
  // answer the runner cannot read fails the instance at once rather than twelve
16
- // times over. Every wait is a `waitForEvent` typed `run finished:<runId>` for
17
- // the child's clipped budget plus the margin, and every wait — event or
18
- // timeout alike — is confirmed by a `read-record` before the machine acts on it.
16
+ // times over. Every wait is a `waitForEvent` typed `run-finished-<runId>` for
17
+ // one chunk of the child's budget (the machine slices the budget plus the
18
+ // margin, `WAIT_CHUNK_MS`, and asks `read-record` between the slices), and
19
+ // every wait — event or timeout alike — is confirmed by a `read-record` before
20
+ // the machine acts on it, so an event the engine never delivered costs a
21
+ // chunk, not the budget.
19
22
  //
20
23
  // Units run one at a time in the plan's order, each once its in-play
21
24
  // dependencies are done. `done` is merged: a plan branch's pull request is the
@@ -104,7 +104,13 @@ export type RunNoteKind =
104
104
  * item 24). The same text the card carries; published by the dispatcher
105
105
  * after the attach, before the first turn, so the run page explains a
106
106
  * sandbox run that shows resident steps. */
107
- | "cold_sandbox";
107
+ | "cold_sandbox"
108
+ /** A coding run submitted a PR description but the post-step opened no
109
+ * pull request because the branch it observed IS the base the pull
110
+ * request would target (docs/reference/specs/pr-description.md item 5) —
111
+ * the summary names the branch. Published by the post-step, so a unit
112
+ * that ends without a pull request says why on the record and the card. */
113
+ | "pr_not_opened";
108
114
 
109
115
  /** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
110
116
  * this; adding a kind to the union without adding it here is a type error). */
@@ -123,6 +129,7 @@ export const RUN_NOTE_KINDS = [
123
129
  "resumed",
124
130
  "description_turn",
125
131
  "cold_sandbox",
132
+ "pr_not_opened",
126
133
  ] as const satisfies readonly RunNoteKind[];
127
134
  type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
128
135
  const _everyKindListed: _EveryKindListed = true;
@@ -129,7 +129,7 @@ export interface RunRecord {
129
129
  * on every run a person or a schedule started. */
130
130
  parentRunId?: string;
131
131
  /** The coordinator instance this run is a child of (item 48): the state
132
- * Worker's finish sends that instance `run finished:<id>` for a record
132
+ * Worker's finish sends that instance `run-finished-<id>` for a record
133
133
  * carrying it, and `read-record` answers only for a matching instance.
134
134
  * Absent on every run no coordinator spawned. */
135
135
  parentInstanceId?: string;
@@ -414,8 +414,9 @@ type Phase =
414
414
  | { at: "branch" }
415
415
  | { at: "spawn"; round: RoundRef; busy: number }
416
416
  | { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
417
- | { at: "wait"; round: RoundRef; runId: string; n: number; budgetMinutes: number }
418
- | { at: "read"; round: RoundRef; runId: string; n: number; budgetMinutes: number }
417
+ /** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */
418
+ | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
419
+ | { at: "read"; round: RoundRef; runId: string; n: number; until: number }
419
420
  | { at: "pr-check"; round: RoundRef; runId: string; childHead?: string; finalReply?: string }
420
421
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
421
422
  | { at: "merge-sleep"; pr: PrRef; headSha: string; n: number; since: number }
@@ -445,6 +446,14 @@ export interface UnitPipelineState {
445
446
 
446
447
  /** Past a child's budget, the parent asks the bot instead of waiting on. */
447
448
  export const WAIT_MARGIN_MS = 5 * MIN;
449
+ /** One slice of a wait on a child. The child's budget plus the margin is
450
+ * walked in chunks with a `read-record` between them, so an event the engine
451
+ * never delivered — refused, lost, sent to an instance that had ended — costs
452
+ * one chunk of the runner's time, not the child's whole budget. Five minutes
453
+ * is the machine's one cadence for asking the bot what it cannot be told (the
454
+ * margin and the merge poll are the same number), and it keeps a round to a
455
+ * few steps: a coding child's 45 minutes are ten waits and ten reads. */
456
+ export const WAIT_CHUNK_MS = 5 * MIN;
448
457
  /** How often the runner asks for the merge while the guards are still pending, and for how long at most. */
449
458
  export const MERGE_POLL_MS = 5 * MIN;
450
459
  export const MERGE_WAIT_MAX_MS = 60 * MIN;
@@ -478,6 +487,15 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
478
487
  const deadlineAt = (s: UnitPipelineState) => s.startedAt + s.input.caps.maxMinutes * MIN;
479
488
  const remainingMs = (s: UnitPipelineState) => deadlineAt(s) - s.clock;
480
489
 
490
+ /** The next slice of a wait: a chunk; the remainder when less is left before
491
+ * `until`, so the slices sum to exactly the budget plus the margin; and a
492
+ * chunk again once `until` has passed — an overdue child is its own budget's
493
+ * to end, and the runner asks about it every chunk, never in a zero-length wait. */
494
+ function waitSliceMs(clock: number, until: number): number {
495
+ const remaining = until - clock;
496
+ return remaining > 0 ? Math.min(WAIT_CHUNK_MS, remaining) : WAIT_CHUNK_MS;
497
+ }
498
+
481
499
  /** The child's budget: its preset's own, clipped to the pipeline's remaining
482
500
  * wall clock (the in-process loop's `clip`), never under the two minutes a
483
501
  * spawn accepts. */
@@ -529,14 +547,14 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
529
547
  case "busy-wait": {
530
548
  const step = `${roundStep(s, p.round)}/busy/${p.n}`;
531
549
  if (p.runId === undefined) return { type: "sleep", step, ms: BUSY_RETRY_MS };
532
- return { type: "wait", step, runId: p.runId, timeoutMs: Math.max(0, remainingMs(s)) + WAIT_MARGIN_MS };
550
+ return { type: "wait", step, runId: p.runId, timeoutMs: waitSliceMs(s.clock, deadlineAt(s) + WAIT_MARGIN_MS) };
533
551
  }
534
552
  case "wait":
535
553
  return {
536
554
  type: "wait",
537
555
  step: `${roundStep(s, p.round)}/wait/${p.n}`,
538
556
  runId: p.runId,
539
- timeoutMs: p.budgetMinutes * MIN + WAIT_MARGIN_MS,
557
+ timeoutMs: waitSliceMs(s.clock, p.until),
540
558
  };
541
559
  case "read":
542
560
  return { type: "read-record", step: `${roundStep(s, p.round)}/read/${p.n}`, runId: p.runId };
@@ -813,7 +831,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
813
831
  switch (r.outcome) {
814
832
  case "spawned":
815
833
  case "alreadySpawned": {
816
- const budgetMinutes = budgetMinutesFor(s, presetOf(p.round.kind));
834
+ // The child's budget runs from the spawn's answer; the wait walks it, plus the margin, in chunks.
835
+ const until = clocked.clock + budgetMinutesFor(s, presetOf(p.round.kind)) * MIN + WAIT_MARGIN_MS;
817
836
  const runs =
818
837
  p.round.kind === "review"
819
838
  ? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
@@ -821,7 +840,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
821
840
  ? { fixRunByRound: { ...s.fixRunByRound, [p.round.index]: r.runId }, lastCodingRunId: r.runId }
822
841
  : { lastCodingRunId: r.runId };
823
842
  return {
824
- state: { ...clocked, ...runs, phase: { at: "wait", round: p.round, runId: r.runId, n: 1, budgetMinutes } },
843
+ state: { ...clocked, ...runs, phase: { at: "wait", round: p.round, runId: r.runId, n: 1, until } },
825
844
  notes: [roundNote(p.round, "started")],
826
845
  };
827
846
  }
@@ -869,7 +888,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
869
888
  return { state: { ...s, phase: { at: "spawn", round: p.round, busy: p.n } }, notes: [] };
870
889
  case "wait":
871
890
  return {
872
- state: { ...s, phase: { at: "read", round: p.round, runId: p.runId, n: p.n, budgetMinutes: p.budgetMinutes } },
891
+ state: { ...s, phase: { at: "read", round: p.round, runId: p.runId, n: p.n, until: p.until } },
873
892
  notes: [],
874
893
  };
875
894
  case "read": {
@@ -878,7 +897,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
878
897
  return {
879
898
  state: {
880
899
  ...clocked,
881
- phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, budgetMinutes: p.budgetMinutes },
900
+ phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until },
882
901
  },
883
902
  notes: [],
884
903
  };
package/dist/cli.js CHANGED
@@ -7544,7 +7544,8 @@ var init_runEvents = __esm({
7544
7544
  "follow_up",
7545
7545
  "resumed",
7546
7546
  "description_turn",
7547
- "cold_sandbox"
7547
+ "cold_sandbox",
7548
+ "pr_not_opened"
7548
7549
  ];
7549
7550
  ACTOR_ID_CHARS = "A-Za-z0-9:@._-";
7550
7551
  ACTOR_ID_MAX = 128;
@@ -19525,6 +19526,24 @@ function createRunsService(deps) {
19525
19526
  if (!store || !RUN_ID_PATTERN.test(id)) return null;
19526
19527
  return store.getSummary(id);
19527
19528
  };
19529
+ const storedArtifacts = async (id) => {
19530
+ let row;
19531
+ try {
19532
+ row = await storeSummary(id);
19533
+ } catch (err2) {
19534
+ warn(
19535
+ `[runs] history store read failed for ${id} \u2014 serving the registry row without its record: ${describe9(err2)}`
19536
+ );
19537
+ return {};
19538
+ }
19539
+ if (!row) return {};
19540
+ return {
19541
+ ...row.verdict !== void 0 ? { verdict: row.verdict } : {},
19542
+ ...row.reviewHead !== void 0 ? { reviewHead: row.reviewHead } : {},
19543
+ ...row.dispositions !== void 0 ? { dispositions: row.dispositions } : {},
19544
+ ...row.handoff !== void 0 ? { handoff: row.handoff } : {}
19545
+ };
19546
+ };
19528
19547
  return {
19529
19548
  async listRuns(opts) {
19530
19549
  const limit = clampListLimit(opts.limit);
@@ -19596,6 +19615,7 @@ function createRunsService(deps) {
19596
19615
  if (summary && snap) {
19597
19616
  const view2 = liveView(summary);
19598
19617
  if (opts.include === "messages") view2.events = snap.events;
19618
+ if (summary.finished) Object.assign(view2, await storedArtifacts(id));
19599
19619
  return { ok: true, value: view2 };
19600
19620
  }
19601
19621
  const far = await ledgerRow(id);
@@ -28281,8 +28301,16 @@ async function runCodingPrPostStep(input) {
28281
28301
  const base = prDescription ? await resolveBaseRefLazy(candidates, repo, input.fetchRepoInfo) : resolveBaseRef(candidates, void 0);
28282
28302
  const pushedBranch = branch2 !== void 0 && branch2 !== base && pushed;
28283
28303
  if (prDescription && branch2 !== void 0 && branch2 === base) {
28284
- console.log(`[pr-post] ${logKey} skipped: workspace on the base branch ${base} (repo ${repo}) \u2014 nothing pushed`);
28285
- return void 0;
28304
+ console.log(
28305
+ `[pr-post] ${logKey} skipped: the branch ${branchLog} is the base branch ${base} (repo ${repo}) \u2014 no PR opened`
28306
+ );
28307
+ input.publish({
28308
+ type: "run_note",
28309
+ kind: "pr_not_opened",
28310
+ summary: `no PR opened: the branch ${branch2} is the base branch the pull request would target`,
28311
+ at: systemClock()
28312
+ });
28313
+ return `\u26A0\uFE0F A PR description was submitted but the branch \`${branch2}\`${branchNote} is the base branch the pull request would target, so no PR was opened \u2014 a pull request needs a head branch other than its base.`;
28286
28314
  }
28287
28315
  if (prDescription && branch2 !== void 0 && headSha !== void 0 && !pushed) {
28288
28316
  const why = remoteHead === void 0 ? "was not found on the remote" : `has unpushed commits (the remote branch is at ${remoteHead.slice(0, 7)}, the workspace at ${headSha.slice(0, 7)})`;
@@ -28787,7 +28815,7 @@ async function runLoop2(deps, ctx) {
28787
28815
  };
28788
28816
  const prTarget = {
28789
28817
  repo: repoCtx.repo,
28790
- baseRef: repoCtx.baseRef,
28818
+ baseRef: repoCtx.baseRef ?? coordinator?.base,
28791
28819
  bindingRef: binding?.ref,
28792
28820
  resolvedRef: repoCtx.ref
28793
28821
  };
@@ -29210,7 +29238,7 @@ function openPlanCursor(graph, selected) {
29210
29238
  for (const id of order) visit(id, []);
29211
29239
  return cursor;
29212
29240
  }
29213
- var MIN, SHIP_ROUND_RESERVE_MS, PLAN_ID_PATTERN, PLAN_BRANCH, UNIT_REF, SLUG_MAX, INSTANCE_ID_MAX, WAIT_MARGIN_MS, MERGE_POLL_MS, MERGE_WAIT_MAX_MS, BUSY_RETRY_MS;
29241
+ var MIN, SHIP_ROUND_RESERVE_MS, PLAN_ID_PATTERN, PLAN_BRANCH, UNIT_REF, SLUG_MAX, INSTANCE_ID_MAX, WAIT_MARGIN_MS, WAIT_CHUNK_MS, MERGE_POLL_MS, MERGE_WAIT_MAX_MS, BUSY_RETRY_MS;
29214
29242
  var init_coordinator = __esm({
29215
29243
  "../../src/core/ship/coordinator.ts"() {
29216
29244
  "use strict";
@@ -29225,6 +29253,7 @@ var init_coordinator = __esm({
29225
29253
  SLUG_MAX = 24;
29226
29254
  INSTANCE_ID_MAX = 100;
29227
29255
  WAIT_MARGIN_MS = 5 * MIN;
29256
+ WAIT_CHUNK_MS = 5 * MIN;
29228
29257
  MERGE_POLL_MS = 5 * MIN;
29229
29258
  MERGE_WAIT_MAX_MS = 60 * MIN;
29230
29259
  BUSY_RETRY_MS = 2 * MIN;
@@ -37774,7 +37803,11 @@ async function spawn6(body, deps) {
37774
37803
  lastReply = text;
37775
37804
  }
37776
37805
  });
37777
- const tag = { parentInstanceId: instance.id, idempotencyKey: key };
37806
+ const tag = {
37807
+ parentInstanceId: instance.id,
37808
+ idempotencyKey: key,
37809
+ ...instance.base !== void 0 ? { base: instance.base } : {}
37810
+ };
37778
37811
  const settled = deps.dispatch(msg, child, {
37779
37812
  coordinator: tag,
37780
37813
  ...turn.contract !== void 0 ? { contract: turn.contract } : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.206.0",
3
+ "version": "1.207.0",
4
4
  "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.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://openswitchboard.dev",