@coreplane/switchboard 1.206.1 → 1.207.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.206.1",
3
+ "version": "1.207.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.206.1",
9
+ "version": "1.207.1",
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.1",
19002
+ "version": "1.207.1",
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.1",
3
+ "version": "1.207.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.206.1",
3
- "commit": "262ef07cb31181e496954cd5f4f189a6670491bc",
4
- "builtAt": "2026-09-13T23:14:45.369Z"
2
+ "version": "1.207.1",
3
+ "commit": "a5e85d6a364f558fdf079ec32e85eb6deadf5033",
4
+ "builtAt": "2026-09-14T01:09:34.713Z"
5
5
  }
@@ -24,7 +24,10 @@
24
24
  // dependencies are done. `done` is merged: a plan branch's pull request is the
25
25
  // runner's to squash (the `merge` step, under `plan:merge`) once the review
26
26
  // approved at its head and the checks are green, so its dependents start on a
27
- // base that carries it; a unit that ended any other way — a refused merge, a
27
+ // base that carries it — or was found merged already by the machine's first
28
+ // `pr-check` (a re-issued plan whose unit a person, or an earlier attempt,
29
+ // merged), which ends the unit with no branch and no child; a unit that ended
30
+ // any other way — a refused merge, a
28
31
  // cap, a stop — blocks its dependents, each told so as its own ending, and the
29
32
  // plan finishes `failed` so the summary says which units are left for the
30
33
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
@@ -252,7 +255,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
252
255
  }
253
256
 
254
257
  function prCheckReturn(step: string, a: BotAnswer): StepReturn {
255
- const { ok, state, prNumber, url, headSha, at } = a.body;
258
+ const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
256
259
  if (ok === true && state === "none") return { type: "pr-check", step, pr: { state: "none" }, at };
257
260
  if (ok === true && state === "open" && typeof prNumber === "number" && typeof url === "string")
258
261
  return {
@@ -261,6 +264,17 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
261
264
  pr: { state: "open", prNumber, url, ...(typeof headSha === "string" ? { headSha } : {}) },
262
265
  at,
263
266
  };
267
+ // A merged pull request is read whole or not at all: the merge commit and
268
+ // the time are what the unit's ending and its report carry.
269
+ if (
270
+ ok === true &&
271
+ state === "merged" &&
272
+ typeof prNumber === "number" &&
273
+ typeof url === "string" &&
274
+ typeof sha === "string" &&
275
+ typeof mergedAt === "string"
276
+ )
277
+ return { type: "pr-check", step, pr: { state: "merged", prNumber, url, sha, mergedAt }, at };
264
278
  throw new UnreadableAnswer("pr-check", a, "state");
265
279
  }
266
280
 
@@ -16,7 +16,12 @@
16
16
  // approve and the merge: one thread and one head branch per unit
17
17
  // (`plan/<plan-id>/<unit-slug>`), every child a `dispatch()` run the bot
18
18
  // starts as the requesting user, every step retry-safe under the key
19
- // `<instance>:<unit>/<round>/<kind>`. Nothing here reads a clock: the bot
19
+ // `<instance>:<unit>/<round>/<kind>`. Its first step asks what already heads
20
+ // the branch: a pull request merged before the attempt reached the unit — a
21
+ // person's merge, or an earlier attempt's — ends the unit `merged` with no
22
+ // branch and no child, so a re-issued plan walks past its done units instead
23
+ // of aborting them; the same answer after a round ends it the same way, since
24
+ // a merge can land while a child runs. Nothing here reads a clock: the bot
20
25
  // answers every step with its own `at`, and that is the machine's time. Nothing
21
26
  // here carries a task's text or a thread's contents: a spawn's brief names the
22
27
  // unit and the runs whose records the bot reads to compose the child's turn.
@@ -348,7 +353,12 @@ export type ChildFacts =
348
353
  handoff?: boolean;
349
354
  };
350
355
 
351
- export type PrCheck = { state: "none" } | { state: "open"; prNumber: number; url: string; headSha?: string };
356
+ /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
357
+ * with no open one — a merged one, `sha` the merge commit on the base. */
358
+ export type PrCheck =
359
+ | { state: "none" }
360
+ | { state: "open"; prNumber: number; url: string; headSha?: string }
361
+ | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
352
362
 
353
363
  /** What a step answered. Every bot answer carries `at`, the bot's clock — the machine's time. */
354
364
  export type StepReturn =
@@ -366,11 +376,14 @@ export type StepReturn =
366
376
  | { type: "sleep"; step: string };
367
377
 
368
378
  /** How one unit's pipeline ended — the truthful vocabulary the in-process loop
369
- * has, plus the merge's own: `merged` by the runner, `merge_ready` for a
370
- * person, `merge_refused` by the guards; `interrupted` a child the ledger
371
- * closed; `refused` a child the authorize stage never started. */
379
+ * has, plus the merge's own: `merged` by the runner, or found merged (`by:
380
+ * other` — a person's merge, or an earlier attempt's that died after it, so
381
+ * the runner merged nothing), `merge_ready` for a person, `merge_refused` by
382
+ * the guards; `interrupted` a child the ledger closed; `refused` a child the
383
+ * authorize stage never started. */
372
384
  export type UnitEnding =
373
- | { kind: "merged"; pr: PrRef; sha: string; reviewRounds: number }
385
+ | { kind: "merged"; by: "runner"; pr: PrRef; sha: string; reviewRounds: number }
386
+ | { kind: "merged"; by: "other"; pr: PrRef; sha: string; mergedAt: string; reviewRounds: number }
374
387
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
375
388
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
376
389
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
@@ -411,6 +424,8 @@ export interface UnitPipelineInput {
411
424
  }
412
425
 
413
426
  type Phase =
427
+ /** Before anything is created: what already heads the branch — a merged pull request ends the unit here. */
428
+ | { at: "pre-check" }
414
429
  | { at: "branch" }
415
430
  | { at: "spawn"; round: RoundRef; busy: number }
416
431
  | { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
@@ -467,7 +482,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
467
482
  input,
468
483
  startedAt: at,
469
484
  clock: at,
470
- phase: { at: "branch" },
485
+ phase: { at: "pre-check" },
471
486
  reviewRounds: 0,
472
487
  findingsByRound: {},
473
488
  dispositionsByRound: {},
@@ -531,6 +546,8 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
531
546
  const unit = s.input.unit.id;
532
547
  const p = s.phase;
533
548
  switch (p.at) {
549
+ case "pre-check":
550
+ return { type: "pr-check", step: `${unit}/pr-check` };
534
551
  case "branch":
535
552
  return { type: "branch", step: `${unit}/branch`, branch: s.input.unit.branch, from: s.input.base };
536
553
  case "spawn": {
@@ -753,9 +770,30 @@ function settleReview(
753
770
  return enterRound(next, { index: round.index, kind: "fix" }, notes);
754
771
  }
755
772
 
773
+ /** The unit's ending when its pull request is found merged — by a person, or
774
+ * by an earlier attempt of the plan that died after its merge: the unit is
775
+ * done and its dependents run on a base that carries it, and the runner
776
+ * merged nothing, so the ending says so (`by: other`). */
777
+ function foundMerged(
778
+ s: UnitPipelineState,
779
+ pr: Extract<PrCheck, { state: "merged" }>,
780
+ notes: CoordinatorNote[] = [],
781
+ ): Transition {
782
+ const ref: PrRef = { number: pr.prNumber, url: pr.url };
783
+ return end(
784
+ { ...s, pr: ref },
785
+ { kind: "merged", by: "other", pr: ref, sha: pr.sha, mergedAt: pr.mergedAt, reviewRounds: s.reviewRounds },
786
+ notes,
787
+ );
788
+ }
789
+
756
790
  /** The pull request heading the branch after a coding or fix round. */
757
791
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
758
792
  const { round } = phase;
793
+ // The merge landed during the round: the child found nothing left to ship
794
+ // (or shipped into a pull request a person merged under it). The round
795
+ // completed without a pull request of its own, and the unit is done.
796
+ if (pr.state === "merged") return foundMerged(s, pr, [roundNote(round, "completed")]);
759
797
  if (pr.state === "none") {
760
798
  const reason =
761
799
  round.index === 0
@@ -817,6 +855,16 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
817
855
  const clocked: UnitPipelineState = "at" in ret ? { ...s, clock: ret.at } : s;
818
856
  const p = s.phase;
819
857
  switch (p.at) {
858
+ case "pre-check": {
859
+ // A pull request merged before this attempt reached the unit — a
860
+ // person's merge, or an earlier attempt's that died after it — makes the
861
+ // unit done before a branch or a child: nothing to run. Anything else is
862
+ // round 0's to work on: an open pull request is rebased and re-described
863
+ // by the coding child and adopted at the round's own pr-check.
864
+ const r = ret as Extract<StepReturn, { type: "pr-check" }>;
865
+ if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
866
+ return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
867
+ }
820
868
  case "branch": {
821
869
  const r = ret as Extract<StepReturn, { type: "branch" }>;
822
870
  if (r.ok) return enterRound(clocked, { index: 0, kind: "coding" });
@@ -914,7 +962,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
914
962
  case "merge": {
915
963
  const r = ret as Extract<StepReturn, { type: "merge" }>;
916
964
  if (r.outcome === "merged")
917
- return end(clocked, { kind: "merged", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
965
+ return end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
918
966
  if (r.outcome === "refused")
919
967
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
920
968
  const waited = r.at - p.since;
@@ -1000,6 +1048,8 @@ export function renderUnitReport(s: UnitPipelineState): string {
1000
1048
  const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
1001
1049
  switch (e.kind) {
1002
1050
  case "merged":
1051
+ if (e.by === "other")
1052
+ return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this attempt reached it, by a person or by an earlier attempt of this plan; the runner merged nothing. The unit is done and its dependents start on a base that carries it.`;
1003
1053
  return [
1004
1054
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
1005
1055
  verdictLine,
package/dist/cli.js CHANGED
@@ -19526,6 +19526,24 @@ function createRunsService(deps) {
19526
19526
  if (!store || !RUN_ID_PATTERN.test(id)) return null;
19527
19527
  return store.getSummary(id);
19528
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
+ };
19529
19547
  return {
19530
19548
  async listRuns(opts) {
19531
19549
  const limit = clampListLimit(opts.limit);
@@ -19597,6 +19615,7 @@ function createRunsService(deps) {
19597
19615
  if (summary && snap) {
19598
19616
  const view2 = liveView(summary);
19599
19617
  if (opts.include === "messages") view2.events = snap.events;
19618
+ if (summary.finished) Object.assign(view2, await storedArtifacts(id));
19600
19619
  return { ok: true, value: view2 };
19601
19620
  }
19602
19621
  const far = await ledgerRow(id);
@@ -21111,6 +21130,25 @@ async function findOpenPrByHead(repo, branch2) {
21111
21130
  ...first.head?.sha ? { headSha: first.head.sha } : {}
21112
21131
  };
21113
21132
  }
21133
+ async function findMergedPrByHead(repo, branch2) {
21134
+ const token = await requireToken();
21135
+ const owner = repo.split("/")[0];
21136
+ const head = encodeURIComponent(`${owner}:${branch2}`);
21137
+ const res = await fetch(
21138
+ `https://api.github.com/repos/${repo}/pulls?state=closed&head=${head}&sort=updated&direction=desc`,
21139
+ { headers: apiHeaders(token), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS5) }
21140
+ );
21141
+ if (!res.ok) {
21142
+ const text = await res.text().catch(() => "");
21143
+ throw new Error(`PR lookup failed: HTTP ${res.status} ${redactAndCap(text, 300)}`);
21144
+ }
21145
+ const rows = await res.json();
21146
+ if (!Array.isArray(rows)) return null;
21147
+ const merged = rows.flatMap(
21148
+ (r) => typeof r.merged_at === "string" && typeof r.merge_commit_sha === "string" && /^[0-9a-f]{40}$/.test(r.merge_commit_sha) ? [{ number: r.number, htmlUrl: r.html_url, sha: r.merge_commit_sha, mergedAt: r.merged_at }] : []
21149
+ ).sort((a, b) => a.mergedAt < b.mergedAt ? 1 : a.mergedAt > b.mergedAt ? -1 : 0);
21150
+ return merged[0] ?? null;
21151
+ }
21114
21152
  async function openPullRequest(target) {
21115
21153
  const existing = await findOpenPrByHead(target.repo, target.headBranch);
21116
21154
  if (existing) {
@@ -37911,17 +37949,33 @@ async function prCheck(body, deps) {
37911
37949
  const unit = await unitRowOf(deps, instance, body.unit);
37912
37950
  if (!unit.ok) return unit.response;
37913
37951
  const branch2 = unit.row?.branch ?? instance.branch;
37952
+ const remember = async (pr) => {
37953
+ if (unit.row && (unit.row.pr?.number !== pr.number || unit.row.pr.url !== pr.url))
37954
+ await deps.instances.putUnits([{ ...unit.row, pr }]);
37955
+ };
37914
37956
  try {
37915
- const pr = await deps.findOpenPrByHead(instance.repo, branch2);
37916
- if (!pr) return json(200, { ok: true, state: "none", at });
37917
- if (unit.row && (unit.row.pr?.number !== pr.number || unit.row.pr.url !== pr.htmlUrl))
37918
- await deps.instances.putUnits([{ ...unit.row, pr: { number: pr.number, url: pr.htmlUrl } }]);
37957
+ const open = await deps.findOpenPrByHead(instance.repo, branch2);
37958
+ if (open) {
37959
+ await remember({ number: open.number, url: open.htmlUrl });
37960
+ return json(200, {
37961
+ ok: true,
37962
+ state: "open",
37963
+ prNumber: open.number,
37964
+ url: open.htmlUrl,
37965
+ ...open.headSha !== void 0 ? { headSha: open.headSha } : {},
37966
+ at
37967
+ });
37968
+ }
37969
+ const merged = await deps.findMergedPrByHead(instance.repo, branch2);
37970
+ if (!merged) return json(200, { ok: true, state: "none", at });
37971
+ await remember({ number: merged.number, url: merged.htmlUrl });
37919
37972
  return json(200, {
37920
37973
  ok: true,
37921
- state: "open",
37922
- prNumber: pr.number,
37923
- url: pr.htmlUrl,
37924
- ...pr.headSha !== void 0 ? { headSha: pr.headSha } : {},
37974
+ state: "merged",
37975
+ prNumber: merged.number,
37976
+ url: merged.htmlUrl,
37977
+ sha: merged.sha,
37978
+ mergedAt: merged.mergedAt,
37925
37979
  at
37926
37980
  });
37927
37981
  } catch (err2) {
@@ -38904,6 +38958,7 @@ async function runBot() {
38904
38958
  dispatch: (msg, io, opts) => dispatch(deps, msg, io, opts),
38905
38959
  ioFor: (thread) => threadIoFor(thread),
38906
38960
  findOpenPrByHead,
38961
+ findMergedPrByHead,
38907
38962
  // The App's GitHub reads for the plan, the specs, the rules and a unit's
38908
38963
  // board issue; the branch create; the reviews and the identity the merge
38909
38964
  // gate's "the verdict stands" question is answered from.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.206.1",
3
+ "version": "1.207.1",
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",