@coreplane/switchboard 1.261.2 → 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.2",
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.2",
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.2",
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.2",
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.2",
3
- "commit": "652eb70199d24be067ee91e1c9e14f48770a594c",
4
- "builtAt": "2026-09-24T02:09:02.047Z"
2
+ "version": "1.261.3",
3
+ "commit": "b727e58725e3d234e0d34b43e04ca0dc9af17075",
4
+ "builtAt": "2026-09-24T14:03:08.080Z"
5
5
  }
@@ -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
package/dist/cli.js CHANGED
@@ -34687,6 +34687,9 @@ var init_instanceStore = __esm({
34687
34687
  async putUnits(_units) {
34688
34688
  return { ok: false, reason: "unavailable" };
34689
34689
  }
34690
+ async claimLegacyContinuation(_expected, _recovered) {
34691
+ return { ok: false, reason: "unavailable" };
34692
+ }
34690
34693
  async listUnits(_instanceId) {
34691
34694
  return [];
34692
34695
  }
@@ -34761,6 +34764,15 @@ var init_instanceStore = __esm({
34761
34764
  if (d.ok === true) return { ok: true };
34762
34765
  throw new Error(`coordinator store /runs/coordinator/units/put: unexpected answer (HTTP ${r.status})`);
34763
34766
  }
34767
+ async claimLegacyContinuation(expected, recovered) {
34768
+ const r = await this.post("/runs/coordinator/units/claim-legacy-continuation", { expected, recovered });
34769
+ const d = r.data;
34770
+ if (r.status === 409 && d.reason === "stale") return { ok: false, reason: "stale" };
34771
+ if (d.ok === true) return { ok: true };
34772
+ throw new Error(
34773
+ `coordinator store /runs/coordinator/units/claim-legacy-continuation: unexpected answer (HTTP ${r.status})`
34774
+ );
34775
+ }
34764
34776
  async listUnits(instanceId) {
34765
34777
  const r = await this.post("/runs/coordinator/units/list", { instanceId });
34766
34778
  const d = r.data;
@@ -54774,75 +54786,103 @@ async function handOffToCoordinatorUnchecked(deps, input) {
54774
54786
  }
54775
54787
  async function start(deps, input, instance, units, where, write) {
54776
54788
  const log = deps.log ?? console.log;
54777
- const put = write === "replace" ? await deps.instances.replace(instance) : await deps.instances.put(instance);
54778
- if (!put.ok) {
54779
- if (put.reason === "unavailable")
54789
+ const reservation = await input.beforeStart?.();
54790
+ if (reservation?.ok === false)
54791
+ return { status: "aborted", reply: reservation.refusal.text, refusal: reservation.refusal };
54792
+ let started = false;
54793
+ try {
54794
+ const put = write === "replace" ? await deps.instances.replace(instance) : await deps.instances.put(instance);
54795
+ if (!put.ok) {
54796
+ if (put.reason === "unavailable")
54797
+ return refused5(
54798
+ "plan_history_unavailable",
54799
+ "\u26A0\uFE0F The plan runner needs run history on the state Worker (`runHistory.worker`): the instance record could not be written, so nothing ran."
54800
+ );
54780
54801
  return refused5(
54781
- "plan_history_unavailable",
54782
- "\u26A0\uFE0F The plan runner needs run history on the state Worker (`runHistory.worker`): the instance record could not be written, so nothing ran."
54802
+ "plan_runner_conflict",
54803
+ `\u{1F6AB} A runner for \`${instance.id}\` was just recorded by another request, so this request started nothing; the recorded runner owns the pipeline.`
54783
54804
  );
54784
- return refused5(
54785
- "plan_runner_conflict",
54786
- `\u{1F6AB} A runner for \`${instance.id}\` was just recorded by another request, so this request started nothing; the recorded runner owns the pipeline.`
54787
- );
54788
- }
54789
- const rows = await deps.instances.putUnits(units);
54790
- if (!rows.ok)
54791
- return refused5(
54792
- "plan_history_unavailable",
54793
- "\u26A0\uFE0F The plan runner needs run history on the state Worker: the unit rows could not be written, so nothing ran."
54794
- );
54795
- const accepted = [...input.msg.images ?? [], ...input.msg.documents ?? []];
54796
- if (instance.plan?.path === void 0 && accepted.length > 0) {
54797
- const unit = units[0];
54798
- const seeded = await deps.instances.appendEvent(
54799
- { instanceId: instance.id, unit: unit.unit },
54800
- {
54801
- id: `${instance.id}:${unit.unit}:ship-request`,
54802
- sender: input.msg.userId,
54803
- ...input.msg.userName !== void 0 ? { senderName: input.msg.userName } : {},
54804
- text: "Attachments from the ship request.",
54805
- attachments: accepted,
54806
- mode: "steer",
54807
- at: input.now
54808
- }
54809
- );
54810
- if (!seeded.ok)
54805
+ }
54806
+ const rows = await deps.instances.putUnits(units);
54807
+ if (!rows.ok)
54811
54808
  return refused5(
54812
54809
  "plan_history_unavailable",
54813
- "\u26A0\uFE0F The plan runner needs run history on the state Worker: the ship request's attachments could not be written, so nothing ran."
54810
+ "\u26A0\uFE0F The plan runner needs run history on the state Worker: the unit rows could not be written, so nothing ran."
54814
54811
  );
54815
- }
54816
- let answer;
54817
- try {
54818
- answer = await deps.create(instance.id);
54819
- } catch (err2) {
54820
- answer = { kind: "unanswered", reason: describe7(err2) };
54821
- }
54822
- switch (answer.kind) {
54823
- case "created": {
54824
- log(`[ship] ${input.msg.threadKey}: handed to the plan runner ${instance.id} (${units.length} unit(s))`);
54825
- const replaced = write === "replace" ? ["the records of an earlier attempt that never started were replaced"] : [];
54826
- return {
54827
- status: "completed",
54828
- reply: handedOff([...where, ...replaced]),
54829
- instanceId: instance.id
54830
- };
54831
- }
54832
- case "duplicate": {
54833
- const status3 = answer.status !== void 0 ? `, status: ${answer.status}` : "";
54834
- return refused5(
54835
- "plan_instance_orphaned",
54836
- `\u{1F6AB} A Workflow instance \`${instance.id}\` already exists on the platform${status3} but the state Worker knew nothing of it \u2014 a person decides; re-issuing will not resume it.`
54812
+ const accepted = [...input.msg.images ?? [], ...input.msg.documents ?? []];
54813
+ if (instance.plan?.path === void 0 && accepted.length > 0) {
54814
+ const unit = units[0];
54815
+ const seeded = await deps.instances.appendEvent(
54816
+ { instanceId: instance.id, unit: unit.unit },
54817
+ {
54818
+ id: `${instance.id}:${unit.unit}:ship-request`,
54819
+ sender: input.msg.userId,
54820
+ ...input.msg.userName !== void 0 ? { senderName: input.msg.userName } : {},
54821
+ text: "Attachments from the ship request.",
54822
+ attachments: accepted,
54823
+ mode: "steer",
54824
+ at: input.now
54825
+ }
54837
54826
  );
54827
+ if (!seeded.ok)
54828
+ return refused5(
54829
+ "plan_history_unavailable",
54830
+ "\u26A0\uFE0F The plan runner needs run history on the state Worker: the ship request's attachments could not be written, so nothing ran."
54831
+ );
54838
54832
  }
54839
- case "failed":
54840
- case "unanswered":
54841
- log(`[ship] ${input.msg.threadKey}: the plan runner ${instance.id} could not be started \u2014 ${answer.reason}`);
54842
- return refused5(
54843
- "plan_start_failed",
54844
- `\u26A0\uFE0F This is a bug: the plan runner could not be started (${answer.reason}), nothing ran, and no automatic start retry was scheduled.`
54845
- );
54833
+ if (reservation?.ok === true) {
54834
+ const unit = units[0];
54835
+ if (unit === void 0)
54836
+ return refused5(
54837
+ "setup_failed",
54838
+ "\u26A0\uFE0F The plan runner had no durable unit to own at its final start gate, so nothing ran."
54839
+ );
54840
+ try {
54841
+ await reservation.commit({ instanceId: instance.id, unit: unit.unit });
54842
+ } catch (err2) {
54843
+ return refused5(
54844
+ "setup_failed",
54845
+ `\u26A0\uFE0F The plan runner could not transfer existing-pull-request ownership to its new attempt (${describe7(err2)}), so nothing ran.`
54846
+ );
54847
+ }
54848
+ }
54849
+ let answer;
54850
+ try {
54851
+ answer = await deps.create(instance.id);
54852
+ } catch (err2) {
54853
+ answer = { kind: "unanswered", reason: describe7(err2) };
54854
+ }
54855
+ switch (answer.kind) {
54856
+ case "created": {
54857
+ if (reservation?.ok === true) reservation.complete();
54858
+ started = true;
54859
+ log(`[ship] ${input.msg.threadKey}: handed to the plan runner ${instance.id} (${units.length} unit(s))`);
54860
+ const replaced = write === "replace" ? ["the records of an earlier attempt that never started were replaced"] : [];
54861
+ return {
54862
+ status: "completed",
54863
+ reply: handedOff([...where, ...replaced]),
54864
+ instanceId: instance.id
54865
+ };
54866
+ }
54867
+ case "duplicate": {
54868
+ const status3 = answer.status !== void 0 ? `, status: ${answer.status}` : "";
54869
+ return refused5(
54870
+ "plan_instance_orphaned",
54871
+ `\u{1F6AB} A Workflow instance \`${instance.id}\` already exists on the platform${status3} but the state Worker knew nothing of it \u2014 a person decides; re-issuing will not resume it.`
54872
+ );
54873
+ }
54874
+ case "failed":
54875
+ case "unanswered":
54876
+ log(`[ship] ${input.msg.threadKey}: the plan runner ${instance.id} could not be started \u2014 ${answer.reason}`);
54877
+ return refused5(
54878
+ "plan_start_failed",
54879
+ `\u26A0\uFE0F This is a bug: the plan runner could not be started (${answer.reason}), nothing ran, and no automatic start retry was scheduled.`
54880
+ );
54881
+ }
54882
+ const unreachable3 = answer;
54883
+ return unreachable3;
54884
+ } finally {
54885
+ if (!started && reservation?.ok === true) await reservation.abort();
54846
54886
  }
54847
54887
  }
54848
54888
  function handedOff(facts2) {
@@ -55296,6 +55336,7 @@ async function runShipBranch(deps, msg, io, ctx) {
55296
55336
  entry,
55297
55337
  requestText: directives.text,
55298
55338
  ...ctx.reissuePlanId !== void 0 ? { reissuePlanId: ctx.reissuePlanId } : {},
55339
+ ...ctx.beforeCoordinatorStart !== void 0 ? { beforeStart: ctx.beforeCoordinatorStart } : {},
55299
55340
  msg,
55300
55341
  agentSource: ctx.agentSource,
55301
55342
  runId: run2.id,
@@ -56089,6 +56130,29 @@ function continuationBudgetOf(instance, unit) {
56089
56130
  if (remainingRounds <= 0) return { kind: "exhausted", budget: "review-round" };
56090
56131
  return { kind: "remaining", caps: { maxRounds: remainingRounds, maxMinutes: remainingMinutes } };
56091
56132
  }
56133
+ async function legacyReviewedHeadOf(runs, instance, unit, prNumber) {
56134
+ const listed = await runs.listUnitRuns(unitKeyOf({ instanceId: instance.id, unit: unit.unit }), { kind: "all" }).catch(() => void 0);
56135
+ if (listed === void 0 || !listed.ok || listed.value.unit !== unitKeyOf({ instanceId: instance.id, unit: unit.unit }) || listed.value.instanceId !== instance.id || listed.value.id !== unit.unit || listed.value.instance.id !== instance.id || listed.value.instance.repo.toLowerCase() !== instance.repo.toLowerCase())
56136
+ return void 0;
56137
+ const prefix = `${instance.id}:${unit.unit}/`;
56138
+ const owned = listed.value.runs.filter(
56139
+ (run2) => run2.finished && run2.status === "completed" && run2.parentInstanceId === instance.id && run2.idempotencyKey?.startsWith(prefix) === true && run2.userId === instance.userId && run2.repo?.toLowerCase() === instance.repo.toLowerCase()
56140
+ );
56141
+ const coding = new Set(
56142
+ owned.flatMap(
56143
+ (run2) => run2.agent === "coding" && run2.pr?.number === prNumber && typeof run2.headSha === "string" && FULL_SHA2.test(run2.headSha) ? [run2.headSha.toLowerCase()] : []
56144
+ )
56145
+ );
56146
+ const approved = new Set(
56147
+ owned.flatMap((run2) => {
56148
+ const head = run2.reviewHead;
56149
+ const post = run2.reviewPost;
56150
+ return run2.agent === "review" && typeof head === "string" && FULL_SHA2.test(head) && run2.verdict?.verdict === "approve" && run2.verdict.head?.toLowerCase() === head.toLowerCase() && post?.posted === true && post.target.repo.toLowerCase() === instance.repo.toLowerCase() && post.target.number === prNumber && post.head.toLowerCase() === head.toLowerCase() && post.verdict === "approve" ? [head.toLowerCase()] : [];
56151
+ })
56152
+ );
56153
+ const exact = [...approved].filter((head) => coding.has(head));
56154
+ return exact.length === 1 ? exact[0] : void 0;
56155
+ }
56092
56156
  async function dispatch(deps, msg, io, opts = {}) {
56093
56157
  const ended = { status: "completed" };
56094
56158
  const resume = opts.resume;
@@ -56173,6 +56237,7 @@ async function dispatch(deps, msg, io, opts = {}) {
56173
56237
  let stoppedWhileAttaching;
56174
56238
  let resumeRowClosed = false;
56175
56239
  let restartRequest;
56240
+ let releaseLegacyOwnership;
56176
56241
  const reservationHooks = {
56177
56242
  onStop: (mode) => void registered?.control.requestStop(mode),
56178
56243
  onFenced: () => {
@@ -56191,6 +56256,8 @@ async function dispatch(deps, msg, io, opts = {}) {
56191
56256
  let operatorRepo;
56192
56257
  let reissuePlanId;
56193
56258
  let reissueCaps;
56259
+ let beforeCoordinatorStart;
56260
+ let reserveLegacyOwnership;
56194
56261
  let operatorThread;
56195
56262
  let pageOwner;
56196
56263
  if (operatorMode !== "off") {
@@ -56408,7 +56475,19 @@ async function dispatch(deps, msg, io, opts = {}) {
56408
56475
  await recordPendingOperator();
56409
56476
  return ended;
56410
56477
  }
56411
- const expectedHead = owner.unit.lastPush;
56478
+ let expectedHead = owner.unit.lastPush;
56479
+ const recoverLegacyBinding = owner.unit.ending?.kind === "merge_ready" && (expectedHead === void 0 || owner.unit.publication === void 0);
56480
+ if (recoverLegacyBinding) {
56481
+ const recovered = await legacyReviewedHeadOf(runsService, instance, owner.unit, recordedPr.number);
56482
+ if (recovered === void 0 || expectedHead !== void 0 && expectedHead.toLowerCase() !== recovered) {
56483
+ await io.reply(
56484
+ `Unit ${owner.unit.unit} has no single exact reviewed head in its durable child records, so continuation did not start. Nothing else ran.`
56485
+ );
56486
+ await recordPendingOperator();
56487
+ return ended;
56488
+ }
56489
+ expectedHead = recovered;
56490
+ }
56412
56491
  if (expectedHead === void 0) {
56413
56492
  await io.reply(
56414
56493
  `Unit ${owner.unit.unit} does not retain the pull request's durable expected head, so continuation did not start. Nothing else ran.`
@@ -56447,6 +56526,140 @@ async function dispatch(deps, msg, io, opts = {}) {
56447
56526
  await recordPendingOperator();
56448
56527
  return ended;
56449
56528
  }
56529
+ if (recoverLegacyBinding) {
56530
+ const baseRef = instance.base;
56531
+ if (baseRef === void 0 || facts2.baseRef !== baseRef) {
56532
+ await io.reply(
56533
+ `${instance.repo}#${recordedPr.number} no longer has the pipeline's verifiable base ref, so continuation did not start. Nothing else ran.`
56534
+ );
56535
+ await recordPendingOperator();
56536
+ return ended;
56537
+ }
56538
+ const existingBinding = owner.unit.publication;
56539
+ if (existingBinding !== void 0 && (existingBinding.repo.toLowerCase() !== instance.repo.toLowerCase() || existingBinding.pr !== recordedPr.number || existingBinding.headRef !== owner.unit.branch || existingBinding.baseRef !== baseRef || existingBinding.expectedHeadSha.toLowerCase() !== expectedHead || existingBinding.publicationRef !== owner.unit.branch || existingBinding.owner.instanceId !== owner.instanceId || existingBinding.owner.unit !== owner.unit.unit)) {
56540
+ await io.reply(
56541
+ `Unit ${owner.unit.unit}'s durable publication binding no longer matches this pipeline, so continuation did not start. Nothing else ran.`
56542
+ );
56543
+ await recordPendingOperator();
56544
+ return ended;
56545
+ }
56546
+ let currentOwner;
56547
+ const runnerOwnership = deps.runnerOwnership;
56548
+ try {
56549
+ if (runnerOwnership === void 0) throw new Error("runner ownership is unavailable");
56550
+ currentOwner = runnerOwnership.owner(instance.repo, recordedPr.number);
56551
+ } catch {
56552
+ await io.reply(
56553
+ `Runner ownership for ${instance.repo}#${recordedPr.number} could not be verified, so continuation did not start. Nothing else ran.`
56554
+ );
56555
+ await recordPendingOperator();
56556
+ return ended;
56557
+ }
56558
+ if (currentOwner !== void 0 && (currentOwner.instanceId !== owner.instanceId || currentOwner.unit !== owner.unit.unit)) {
56559
+ await io.reply(
56560
+ `${instance.repo}#${recordedPr.number} is owned by another runner, so continuation of ${owner.instanceId}:${owner.unit.unit} did not start. Nothing else ran.`
56561
+ );
56562
+ await recordPendingOperator();
56563
+ return ended;
56564
+ }
56565
+ const legacyUnit = owner.unit;
56566
+ const coordinatorInstances = deps.coordinatorInstances;
56567
+ const reservationOwner = { instanceId: owner.instanceId, unit: owner.unit.unit };
56568
+ const recoveredUnit = {
56569
+ ...legacyUnit,
56570
+ lastPush: expectedHead,
56571
+ publication: {
56572
+ repo: instance.repo,
56573
+ pr: recordedPr.number,
56574
+ headRef: legacyUnit.branch,
56575
+ baseRef,
56576
+ expectedHeadSha: expectedHead,
56577
+ publicationRef: legacyUnit.branch,
56578
+ owner: reservationOwner
56579
+ }
56580
+ };
56581
+ let token2;
56582
+ let transferredOwner;
56583
+ releaseLegacyOwnership = () => {
56584
+ if (token2 !== void 0) {
56585
+ const released = runnerOwnership.releaseReservation(instance.repo, recordedPr.number, token2);
56586
+ if (released) token2 = void 0;
56587
+ return released;
56588
+ }
56589
+ if (transferredOwner !== void 0) {
56590
+ const released = runnerOwnership.release(instance.repo, recordedPr.number, transferredOwner);
56591
+ if (released) transferredOwner = void 0;
56592
+ return released;
56593
+ }
56594
+ return true;
56595
+ };
56596
+ reserveLegacyOwnership = () => {
56597
+ token2 = runnerOwnership.reserve(instance.repo, recordedPr.number, reservationOwner);
56598
+ if (token2 !== void 0) return void 0;
56599
+ const competing = runnerOwnership.owner(instance.repo, recordedPr.number);
56600
+ const sameContinuation = competing?.instanceId === reservationOwner.instanceId && competing.unit === reservationOwner.unit;
56601
+ return refusalOf(
56602
+ "setup_failed",
56603
+ sameContinuation ? `Unit ${legacyUnit.unit} changed while its legacy continuation was being reserved, so continuation did not start. Nothing else ran.` : `${instance.repo}#${recordedPr.number} is owned by another runner, so continuation of ${owner.instanceId}:${legacyUnit.unit} did not start. Nothing else ran.`
56604
+ );
56605
+ };
56606
+ beforeCoordinatorStart = async () => {
56607
+ const activeToken = token2;
56608
+ if (activeToken === void 0)
56609
+ return {
56610
+ ok: false,
56611
+ refusal: refusalOf(
56612
+ "setup_failed",
56613
+ `Unit ${legacyUnit.unit}'s ownership reservation was lost, so continuation did not start. Nothing else ran.`
56614
+ )
56615
+ };
56616
+ const persisted = await coordinatorInstances.claimLegacyContinuation(legacyUnit, recoveredUnit).catch(() => void 0);
56617
+ if (persisted?.ok !== true) {
56618
+ const released = releaseLegacyOwnership?.() ?? true;
56619
+ const reason2 = persisted?.reason === "stale" ? `Unit ${legacyUnit.unit} changed while its legacy continuation was being reserved, so continuation did not start.` : `Unit ${legacyUnit.unit}'s recovered continuation binding could not be stored, so continuation did not start.`;
56620
+ const text = released ? `${reason2} Nothing else ran.` : `${reason2} Its ownership reservation could not be released. Nothing else ran.`;
56621
+ return { ok: false, refusal: refusalOf("setup_failed", text) };
56622
+ }
56623
+ const reservedOwner = runnerOwnership.owner(instance.repo, recordedPr.number);
56624
+ if (reservedOwner?.instanceId !== reservationOwner.instanceId || reservedOwner.unit !== reservationOwner.unit) {
56625
+ const restored = await coordinatorInstances.claimLegacyContinuation(recoveredUnit, legacyUnit).catch(() => void 0);
56626
+ const released = releaseLegacyOwnership?.() ?? true;
56627
+ const rollbackFailure = restored?.ok === true ? void 0 : restored?.reason ?? "unavailable";
56628
+ const cleanupFailure = released ? "" : " and its ownership reservation could not be released";
56629
+ const text = rollbackFailure === void 0 ? `${instance.repo}#${recordedPr.number} changed runner ownership while its legacy continuation was being reserved${cleanupFailure}, so continuation did not start. Nothing else ran.` : `${instance.repo}#${recordedPr.number} changed runner ownership while its legacy continuation was being reserved, and the exact legacy row could not be restored (${rollbackFailure})${cleanupFailure}; continuation did not start. Nothing else ran.`;
56630
+ return { ok: false, refusal: refusalOf("setup_failed", text) };
56631
+ }
56632
+ owner.unit = recoveredUnit;
56633
+ return {
56634
+ ok: true,
56635
+ commit: async (nextOwner) => {
56636
+ if (!runnerOwnership.transferReservation(
56637
+ instance.repo,
56638
+ recordedPr.number,
56639
+ activeToken,
56640
+ reservationOwner,
56641
+ nextOwner
56642
+ ))
56643
+ throw new Error("legacy continuation ownership reservation or current owner changed");
56644
+ token2 = void 0;
56645
+ transferredOwner = nextOwner;
56646
+ },
56647
+ complete: () => {
56648
+ transferredOwner = void 0;
56649
+ },
56650
+ abort: async () => {
56651
+ const restored = await coordinatorInstances.claimLegacyContinuation(recoveredUnit, legacyUnit).catch(() => void 0);
56652
+ const released = releaseLegacyOwnership?.() ?? true;
56653
+ owner.unit = legacyUnit;
56654
+ const failures = [
56655
+ ...restored?.ok === true ? [] : [`legacy continuation rollback failed (${restored?.reason ?? "unavailable"})`],
56656
+ ...released ? [] : ["legacy continuation ownership cleanup failed"]
56657
+ ];
56658
+ if (failures.length > 0) throw new Error(failures.join("; "));
56659
+ }
56660
+ };
56661
+ };
56662
+ }
56450
56663
  reissuePlanId = instance.plan.id;
56451
56664
  reissueCaps = continuationBudget.caps;
56452
56665
  const durableRequest = parseDirectives(original);
@@ -56499,6 +56712,12 @@ async function dispatch(deps, msg, io, opts = {}) {
56499
56712
  });
56500
56713
  if (profileGate.kind === "refused") return ended;
56501
56714
  const { profile } = profileGate;
56715
+ const legacyOwnershipRefusal = reserveLegacyOwnership?.();
56716
+ if (legacyOwnershipRefusal !== void 0) {
56717
+ await io.reply(legacyOwnershipRefusal.text);
56718
+ await recordPendingOperator();
56719
+ return ended;
56720
+ }
56502
56721
  const earlyRepoTarget = agent.name === "review" ? resolveRepoTarget(deps, {
56503
56722
  msg,
56504
56723
  history,
@@ -56677,6 +56896,7 @@ async function dispatch(deps, msg, io, opts = {}) {
56677
56896
  card,
56678
56897
  directives,
56679
56898
  ...reissuePlanId !== void 0 ? { reissuePlanId } : {},
56899
+ ...beforeCoordinatorStart !== void 0 ? { beforeCoordinatorStart } : {},
56680
56900
  ...reissueCaps !== void 0 ? { reissueCaps: { ...reissueCaps, maxMinutes: Math.min(reissueCaps.maxMinutes, profile.minutes) } } : {},
56681
56901
  sticky,
56682
56902
  history,
@@ -57389,6 +57609,7 @@ async function dispatch(deps, msg, io, opts = {}) {
57389
57609
  });
57390
57610
  } finally {
57391
57611
  clearInterval(setupHeartbeat);
57612
+ releaseLegacyOwnership?.();
57392
57613
  ending.drain(void 0);
57393
57614
  if (registered) deps.runBearers?.revoke(registered.id);
57394
57615
  if (resume && ledgerRun && !runLoopStarted && !resumeRowClosed) {
@@ -57576,7 +57797,7 @@ async function dispatchClick(deps, click) {
57576
57797
  activeRuns--;
57577
57798
  }
57578
57799
  }
57579
- var activeRuns;
57800
+ var activeRuns, FULL_SHA2;
57580
57801
  var init_dispatcher = __esm({
57581
57802
  "../../src/core/dispatcher.ts"() {
57582
57803
  "use strict";
@@ -57642,6 +57863,7 @@ var init_dispatcher = __esm({
57642
57863
  init_types5();
57643
57864
  init_decisionRecordReservation();
57644
57865
  activeRuns = 0;
57866
+ FULL_SHA2 = /^[0-9a-f]{40}$/i;
57645
57867
  }
57646
57868
  });
57647
57869
 
@@ -71522,22 +71744,73 @@ var init_runnerOwnership = __esm({
71522
71744
  recovered = /* @__PURE__ */ new Set();
71523
71745
  claimedOwners = /* @__PURE__ */ new Map();
71524
71746
  recoveredOwners = /* @__PURE__ */ new Map();
71747
+ reservations = /* @__PURE__ */ new Map();
71525
71748
  localRecoveredInstances = /* @__PURE__ */ new Set();
71526
71749
  recoveryComplete;
71527
71750
  constructor(requiresRecovery) {
71528
71751
  this.recoveryComplete = !requiresRecovery;
71529
71752
  }
71753
+ /** Claim without displacing a different live runner. The read and write are
71754
+ * synchronous, so a caller owns the process-local fence before its next
71755
+ * awaited durable transition. Repeating the same owner's claim is idempotent. */
71530
71756
  claim(repo, prNumber, owner) {
71531
71757
  const key = runnerOwnedPullKey(repo, prNumber);
71758
+ const current = this.claimedOwners.get(key) ?? this.recoveredOwners.get(key);
71759
+ const alreadyOwned = this.claimed.has(key) || this.recovered.has(key);
71760
+ if (this.reservations.has(key)) return false;
71761
+ if (alreadyOwned && (owner === void 0 || current === void 0 || current.instanceId !== owner.instanceId || current.unit !== owner.unit))
71762
+ return false;
71532
71763
  this.claimed.add(key);
71533
71764
  if (owner !== void 0) this.claimedOwners.set(key, owner);
71765
+ return true;
71534
71766
  }
71535
- release(repo, prNumber) {
71767
+ /** Exclusively reserve an unowned pull request across an awaited durable
71768
+ * transition. Even the same runner identity cannot reserve it twice: the
71769
+ * returned token, not the owner fields, identifies the one caller. */
71770
+ reserve(repo, prNumber, owner) {
71536
71771
  const key = runnerOwnedPullKey(repo, prNumber);
71772
+ if (this.claimed.has(key) || this.recovered.has(key) || this.reservations.has(key)) return void 0;
71773
+ const token2 = Symbol(key);
71774
+ this.reservations.set(key, token2);
71775
+ this.claimed.add(key);
71776
+ this.claimedOwners.set(key, owner);
71777
+ return token2;
71778
+ }
71779
+ /** Move an exclusive recovery reservation to the durable attempt that is
71780
+ * about to start. Both the unforgeable token and the currently recorded
71781
+ * owner must still match; a stale caller can neither displace nor adopt a
71782
+ * successor. The transfer consumes the reservation but retains ownership. */
71783
+ transferReservation(repo, prNumber, token2, currentOwner, nextOwner) {
71784
+ const key = runnerOwnedPullKey(repo, prNumber);
71785
+ const current = this.claimedOwners.get(key);
71786
+ if (this.reservations.get(key) !== token2 || !this.claimed.has(key) || current?.instanceId !== currentOwner.instanceId || current.unit !== currentOwner.unit)
71787
+ return false;
71788
+ this.reservations.delete(key);
71789
+ this.claimedOwners.set(key, nextOwner);
71790
+ return true;
71791
+ }
71792
+ releaseReservation(repo, prNumber, token2) {
71793
+ const key = runnerOwnedPullKey(repo, prNumber);
71794
+ if (this.reservations.get(key) !== token2) return false;
71795
+ this.reservations.delete(key);
71796
+ this.claimed.delete(key);
71797
+ this.claimedOwners.delete(key);
71798
+ return true;
71799
+ }
71800
+ /** Release unconditionally for the runner's ordinary end, or conditionally
71801
+ * for a provisional claim whose cleanup must not erase a successor. */
71802
+ release(repo, prNumber, owner) {
71803
+ const key = runnerOwnedPullKey(repo, prNumber);
71804
+ if (owner !== void 0) {
71805
+ const current = this.claimedOwners.get(key) ?? this.recoveredOwners.get(key);
71806
+ if (current?.instanceId !== owner.instanceId || current.unit !== owner.unit) return false;
71807
+ }
71808
+ this.reservations.delete(key);
71537
71809
  this.claimed.delete(key);
71538
71810
  this.recovered.delete(key);
71539
71811
  this.claimedOwners.delete(key);
71540
71812
  this.recoveredOwners.delete(key);
71813
+ return true;
71541
71814
  }
71542
71815
  owns(repo, prNumber) {
71543
71816
  if (!this.recoveryComplete) throw new Error("runner ownership recovery is still in progress");
@@ -75544,6 +75817,7 @@ async function runBot() {
75544
75817
  ) : new NullMetricsService();
75545
75818
  const sweepState = { roundSpent: /* @__PURE__ */ new Set() };
75546
75819
  const runnerOwnership = new RunnerOwnershipFence(capabilities.runLedger);
75820
+ deps.runnerOwnership = runnerOwnership;
75547
75821
  const pullsWiring = /* @__PURE__ */ (() => {
75548
75822
  const state = sweepState;
75549
75823
  const chains = /* @__PURE__ */ new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.261.2",
3
+ "version": "1.261.3",
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",