@coreplane/switchboard 1.260.1 → 1.260.2

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,25 +1,29 @@
1
1
  #!/usr/bin/env node
2
- // The `switchboard` bin: this file hands the process to dist/cli.js, the bundle
3
- // the build writes (build.mts; docs/reference/specs/packaging.md item 1). A
4
- // committed entry rather than the bundle itself, because npm links a bin only
5
- // when its target exists at install time, and the gitignored dist/ does not
6
- // until the build runs: with dist/cli.js as the bin, `npx <the package>` inside
7
- // the checkout — where npx prefers the workspace over the registry — found no
8
- // link and died with `sh: switchboard: command not found`. The published package
9
- // always carries the bundle, so there this is one extra module load; a checkout
10
- // that has not built the package is told what to run.
2
+ // The published `switchboard` bin hands the process to the bundle beside it.
3
+ // In a repository checkout npm resolves the package name to this workspace too,
4
+ // but an ignored dist/ may be older than source. The checkout therefore never
5
+ // trusts dist: operators run the root source script, while a tarball runs only
6
+ // the bundle that its build packed.
11
7
  import { existsSync } from "node:fs";
12
8
  import { fileURLToPath } from "node:url";
13
9
 
14
10
  const bundle = new URL("../dist/cli.js", import.meta.url);
11
+ const checkoutRoot = new URL("../../../", import.meta.url);
12
+ const runsFromCheckout =
13
+ existsSync(new URL("project.json", checkoutRoot)) && existsSync(new URL("src/cli.ts", checkoutRoot));
15
14
 
16
- if (!existsSync(bundle)) {
15
+ if (runsFromCheckout) {
17
16
  console.error(
18
- `switchboard: ${fileURLToPath(bundle)} is missing — the package is not built. Inside the checkout, npx runs this workspace, not the published package: run \`npm run build -w packages/switchboard\` first, or use the checkout's CLI, \`npm run cli -- <group> <verb> …\`.`,
17
+ "switchboard: npx resolved the package name to this checkout; its ignored dist/ is not authoritative. Run `npm run --silent cli -- <group> <verb> …` from the checkout root instead.",
19
18
  );
20
19
  process.exit(1);
21
20
  }
22
21
 
22
+ if (!existsSync(bundle)) {
23
+ console.error(`switchboard: ${fileURLToPath(bundle)} is missing — the published package is incomplete.`);
24
+ process.exit(1);
25
+ }
26
+
23
27
  // From here the bundle is the script: its entry claim (src/invokedAsScript.ts)
24
28
  // and its usage spelling (`programName`, src/cli.ts) read argv[1].
25
29
  process.argv[1] = fileURLToPath(bundle);
@@ -0,0 +1,25 @@
1
+ type WaitUntilContext = Pick<DurableObjectState, "waitUntil">;
2
+
3
+ const TASKS = Symbol.for("switchboard.memory-worker.pending-background-tasks");
4
+ type PendingTasks = Map<Promise<unknown>, string>;
5
+
6
+ const pendingTasks = (): PendingTasks => {
7
+ const root = globalThis as typeof globalThis & { [TASKS]?: PendingTasks };
8
+ return (root[TASKS] ??= new Map());
9
+ };
10
+
11
+ /** Keep deliberately detached Worker work in the runtime's request lifetime.
12
+ * The process-wide label set is also the test seam: a case cannot finish while
13
+ * work it started is still able to contend with the next case. */
14
+ export function holdBackgroundTask(context: WaitUntilContext, label: string, task: Promise<unknown>): void {
15
+ const pending = pendingTasks();
16
+ const held = task.finally(() => pending.delete(held));
17
+ pending.set(held, label);
18
+ context.waitUntil(held);
19
+ }
20
+
21
+ export function assertNoPendingBackgroundTasks(): void {
22
+ const labels = [...pendingTasks().values()];
23
+ if (labels.length === 0) return;
24
+ throw new Error(`test ended with ${labels.length} pending background task(s): ${labels.join(", ")}`);
25
+ }
@@ -78,6 +78,7 @@ import {
78
78
  selectReclaim,
79
79
  } from "../../src/core/runLedger/decisions.ts";
80
80
  import { intakeReceiptRetentionMs, minutesToMs, PLANE } from "../../src/core/budgets.ts";
81
+ import { holdBackgroundTask } from "./backgroundTasks.ts";
81
82
  import {
82
83
  causeOfClose,
83
84
  causeOfReclaim,
@@ -2058,7 +2059,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2058
2059
  /** The admission ask (`POST /plane/admit`, record 0064 "The queue"): one
2059
2060
  * transaction decides and writes — `admitted` reserves the thread,
2060
2061
  * `queued` stores the request under the minted id. */
2061
- planeAdmit(
2062
+ async planeAdmit(
2062
2063
  post: {
2063
2064
  runId: string;
2064
2065
  requester: string;
@@ -2070,7 +2071,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2070
2071
  reaskMs?: number;
2071
2072
  },
2072
2073
  now: number,
2073
- ): PlaneAskAnswer {
2074
+ ): Promise<PlaneAskAnswer> {
2074
2075
  let answer!: PlaneAskAnswer;
2075
2076
  this.ctx.storage.transactionSync(() => {
2076
2077
  if (post.reaskMs !== undefined)
@@ -2089,7 +2090,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2089
2090
  this.applyPlaneWrites(decision.writes);
2090
2091
  answer = planeAskAnswerOf(decision, post.runId);
2091
2092
  });
2092
- if (answer.kind === "queued") void this.ensurePlaneReaskAlarm(now);
2093
+ if (answer.kind === "queued") await this.settlePlaneReaskAlarmAfterCommit(now);
2093
2094
  console.log(
2094
2095
  `[plane/admit] ${post.threadKey} → ${answer.kind}${answer.kind === "queued" ? ` position ${answer.position}` : ""} (run ${post.runId})`,
2095
2096
  );
@@ -2152,7 +2153,10 @@ export class RunHistoryDO extends DurableObject<Env> {
2152
2153
 
2153
2154
  /** A refusal-by-name the bot met at attach or exec (`POST /plane/observe`,
2154
2155
  * record 0064): an admitted run re-enters the queue at its old position. */
2155
- planeObserve(post: { runId: string; resident: string; refusal: string }, now: number): { reentered: boolean } {
2156
+ async planeObserve(
2157
+ post: { runId: string; resident: string; refusal: string },
2158
+ now: number,
2159
+ ): Promise<{ reentered: boolean }> {
2156
2160
  let reentered = false;
2157
2161
  this.ctx.storage.transactionSync(() => {
2158
2162
  const decision = decide(this.planeState(), {
@@ -2165,7 +2169,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2165
2169
  this.applyPlaneWrites(decision.writes);
2166
2170
  reentered = decision.writes.length > 0;
2167
2171
  });
2168
- if (reentered) void this.ensurePlaneReaskAlarm(now);
2172
+ if (reentered) await this.settlePlaneReaskAlarmAfterCommit(now);
2169
2173
  console.log(
2170
2174
  `[plane/observe] run ${post.runId} on ${post.resident}: ${post.refusal.slice(0, 60)} — ${reentered ? "re-entered" : "no-op"}`,
2171
2175
  );
@@ -2194,6 +2198,18 @@ export class RunHistoryDO extends DurableObject<Env> {
2194
2198
  if (set === null || set > due) await this.ctx.storage.setAlarm(due);
2195
2199
  }
2196
2200
 
2201
+ /** Settle alarm I/O without contradicting the plane transaction that already
2202
+ * committed. A scheduling failure may delay a re-ask until another wake,
2203
+ * but returning an error would make the caller proceed or retry while the
2204
+ * durable queued row remains eligible for admission. */
2205
+ private async settlePlaneReaskAlarmAfterCommit(now: number): Promise<void> {
2206
+ try {
2207
+ await this.ensurePlaneReaskAlarm(now);
2208
+ } catch (err) {
2209
+ console.error("[plane/alarm] re-ask scheduling failed after the plane state committed", err);
2210
+ }
2211
+ }
2212
+
2197
2213
  /** The earliest instant the plane must wake at (record 0064): the
2198
2214
  * earliest hosting deadline, lease end or re-ask across its rows. A due
2199
2215
  * already past re-offers at the re-ask cadence, never in a hot loop, and
@@ -2372,21 +2388,24 @@ export class RunHistoryDO extends DurableObject<Env> {
2372
2388
 
2373
2389
  /** The transport's push (record 0064, "Where it lives"): committed effects
2374
2390
  * are pushed to the bot Worker over the service binding, which forwards to
2375
- * the container. Fire and forget — a push that fails is not retried by a
2376
- * timer; the effect rides the next heartbeat or reclaim-sweep answer. */
2391
+ * the container. The response does not wait for this best-effort push, but
2392
+ * the actor does: waitUntil keeps its I/O inside this request's lifetime so
2393
+ * it cannot contend with an unrelated request after the caller moves on. */
2377
2394
  private pushPlaneEffects(effects: PlaneEffect[]): void {
2378
2395
  if (effects.length === 0) return;
2379
2396
  const bot = this.env.BOT;
2380
2397
  if (!bot) return;
2381
- void bot
2382
- .fetch("https://bot/plane/effects", {
2383
- method: "POST",
2384
- headers: {
2385
- "content-type": "application/json",
2386
- authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`,
2387
- },
2388
- body: JSON.stringify({ effects }),
2389
- })
2398
+ const delivery = Promise.resolve()
2399
+ .then(() =>
2400
+ bot.fetch("https://bot/plane/effects", {
2401
+ method: "POST",
2402
+ headers: {
2403
+ "content-type": "application/json",
2404
+ authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`,
2405
+ },
2406
+ body: JSON.stringify({ effects }),
2407
+ }),
2408
+ )
2390
2409
  .then((r) => {
2391
2410
  if (!r.ok)
2392
2411
  console.warn(
@@ -2398,6 +2417,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2398
2417
  `[plane/push] ${effects.length} effect(s) not delivered: ${err instanceof Error ? err.message : String(err)} — they ride the next heartbeat`,
2399
2418
  );
2400
2419
  });
2420
+ holdBackgroundTask(this.ctx, `plane effect push (${effects.map((effect) => effect.id).join(", ")})`, delivery);
2401
2421
  }
2402
2422
 
2403
2423
  /** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER`
@@ -5027,11 +5027,32 @@ export class ResidentDO extends Sandbox<Env> {
5027
5027
  * deploy's alone. Answers `restarted` when a stop was issued, else why
5028
5028
  * not. */
5029
5029
  private async reconcileImage(where: "attach" | "refresh" | "deploy", force = false): Promise<ImageReconcileResult> {
5030
- if (!(await this.isRuntimeActive().catch(() => false))) {
5031
- // Inactivity proves only that the old process is gone, not that its
5032
- // replacement started successfully. Keep the marker: the next refresh's
5033
- // hydration starts the deployed image and reports only after it reaches
5034
- // `warm` (issue 2101).
5030
+ const active = await this.isRuntimeActive().catch((err) => {
5031
+ console.log(`image-reconcile (${where}): activity probe failed — preserving the pending report: ${errMsg(err)}`);
5032
+ return null;
5033
+ });
5034
+ // A failed probe proves neither state. Preserve the marker and its drain
5035
+ // hold until a later reconcile confirms inactivity or hydrates a fresh
5036
+ // active container. An attach with that marker must fail closed even after
5037
+ // the drain backstop reopens: admitting it could wake a pre-deploy image.
5038
+ if (active === null) {
5039
+ if (
5040
+ where === "attach" &&
5041
+ (await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY)) !== undefined
5042
+ ) {
5043
+ console.log(
5044
+ "image-stale (attach): activity could not be verified while a new-image report is pending — the attach is refused",
5045
+ );
5046
+ return "stale";
5047
+ }
5048
+ return "deferred";
5049
+ }
5050
+ if (!active) {
5051
+ // An inactive container's next start is on the deployed image by
5052
+ // construction. Report on every reconcile: the deploy may have marked
5053
+ // it pending after an earlier idle cycle, and the drain itself refuses
5054
+ // the attach that would otherwise wake it and provide another reporter.
5055
+ await this.reportPendingImageCurrent(where);
5035
5056
  return "inactive";
5036
5057
  }
5037
5058
  const last = THREAD_USERS[THREAD_USERS.length - 1];
@@ -5091,12 +5112,13 @@ export class ResidentDO extends Sandbox<Env> {
5091
5112
  /** The report a held drain waits for (issue 1931): when the deploy's
5092
5113
  * reconcile could not verify this resident's fresh container on the new
5093
5114
  * image, a marker stays in storage and the registry holds the drain. The
5094
- * fact the report stands on is the replacement's own completed hydration:
5095
- * the fresh container has restored its snapshot and reached `warm`. A stop
5096
- * or inactive runtime does not report; each keeps the marker for that next
5097
- * start (issue 2101). The marker is deleted only after a successful report:
5098
- * a transient failure keeps it for the next reconcile, and the drain's
5099
- * `until` is the backstop for a report that never lands. */
5115
+ * fact the report stands on is either an inactive runtime, whose next start
5116
+ * uses the deployed image by construction, or the active replacement's own
5117
+ * completed hydration: the fresh container has restored its snapshot and
5118
+ * reached `warm`. A stop alone does not report (issue 2101). The marker is
5119
+ * deleted only after a successful report: a transient failure keeps it for
5120
+ * the next reconcile, and the drain's `until` is the backstop for a report
5121
+ * that never lands. */
5100
5122
  private async reportPendingImageCurrent(where: string): Promise<boolean> {
5101
5123
  const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5102
5124
  if (pending === undefined) return true;
@@ -5125,9 +5147,9 @@ export class ResidentDO extends Sandbox<Env> {
5125
5147
  * an active, quiet container is always cycled, but that stop is not a report.
5126
5148
  * The marker survives until the replacement hydrates and reaches `warm` on
5127
5149
  * the deploy's image (issue 2101: the old probe raced that restore and lost).
5128
- * `verified` true is the fact the reopen may stand on; a cycle or inactive
5129
- * result leaves a hold on the drain, cleared by this resident's later
5130
- * `reportPendingImageCurrent`, never by a timer.
5150
+ * `verified` true is the fact the reopen may stand on: inactivity reports
5151
+ * at once, while an active container's cycle leaves a hold until its fresh
5152
+ * hydration calls `reportPendingImageCurrent`, never until a timer.
5131
5153
  *
5132
5154
  * The hold lands BEFORE the marker: the marker is what lets any concurrent
5133
5155
  * attach/refresh reconcile report, and a report that reaches the registry
@@ -5139,10 +5161,9 @@ export class ResidentDO extends Sandbox<Env> {
5139
5161
  await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
5140
5162
  const result = await this.reconcileImage("deploy", true);
5141
5163
  if (result === "deferred") return { result, verified: false };
5142
- // Neither inactivity nor a successful stop is verification: only the
5143
- // replacement's completed hydration clears the marker. A concurrent fresh
5144
- // start may have done so while this reconcile yielded, hence the storage
5145
- // read rather than an unconditional false.
5164
+ // Inactivity reports synchronously; a successful stop does not. A
5165
+ // concurrent fresh start may also have hydrated while this reconcile
5166
+ // yielded, hence the storage read rather than a verdict from `result`.
5146
5167
  const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
5147
5168
  return { result, verified };
5148
5169
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.1",
3
+ "version": "1.260.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.260.1",
9
+ "version": "1.260.2",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.260.1",
20448
+ "version": "1.260.2",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.1",
3
+ "version": "1.260.2",
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.260.1",
3
- "commit": "c57cb7b614e8916b3fd0ab86c848a8398d8dc781",
4
- "builtAt": "2026-09-21T18:18:08.788Z"
2
+ "version": "1.260.2",
3
+ "commit": "95c0153dc141f2e586238634e66970fea994bf7b",
4
+ "builtAt": "2026-09-21T20:25:49.397Z"
5
5
  }
@@ -447,7 +447,7 @@ Your final message is posted to Slack — keep it readable, lead with the outcom
447
447
  // whole reply — so the prose never repeats them and never pads around them.
448
448
  const REVIEW_FINAL_MESSAGE = `YOUR FINAL MESSAGE IS THE REVIEW'S TEXT, NOTHING ELSE. Switchboard renders the verdict line and the findings list from your submit_verdict call — on GitHub as the head of the comment, in Slack as the whole reply — and folds your final message under them on GitHub as the full review. So write only what the list cannot carry: one short paragraph per finding, keyed by its id (what is wrong, the concrete failure, the fix). Do not restate the verdict or the findings, do not summarize what you read, do not list what you verified clean, do not describe your method — what you checked belongs in your notes, which the run page shows. A change with no findings needs one sentence, not a tour.`;
449
449
 
450
- const REVIEW_VERDICT_INSTRUCTION = `VERDICT: before your final message, call the submit_verdict tool exactly once with \`approve\` (no finding at or above the severity to address remains — the level in force for this run, \`minor\` by default: a major or a minor finding means \`request_changes\`; nits alone never block) or \`request_changes\`, a one-line summary, \`head\` = the output of \`git rev-parse HEAD\` in the checkout you reviewed, and \`findings\` — every issue you report as a structured entry with a stable id you assign in order (F1, F2, …), a severity of exactly blocking|major|minor|nit, the file (plus line when it points at one), and a one-line title. The findings array is the index of your review: the full explanation of each finding stays in your prose, keyed by the same ids. Switchboard writes the verdict as the first line of the GitHub comment itself, lists the findings under it and folds your text below them as the full review; a review with no submitted verdict is posted as not approving, so never skip it. An \`approve\` carrying a finding at or above the severity to address is downgraded to \`request_changes\` and the tool's ack says so — approve only when every finding sits below the level. Do not write "LGTM" in your own text — the verdict line carries it.`;
450
+ const REVIEW_VERDICT_INSTRUCTION = `VERDICT: before your final message, call the submit_verdict tool exactly once with \`approve\` (no finding at or above the severity to address remains — the level in force for this run, \`minor\` by default: a major or a minor finding means \`request_changes\`; nits alone never block) or \`request_changes\`, a one-line summary, \`head\` = the output of \`git rev-parse HEAD\` in the checkout you reviewed, and \`findings\` — every issue you report as a structured entry with a stable id you assign in order (F1, F2, …), a severity of exactly blocking|major|minor|nit, the file (plus line when it points at one), and a one-line title. The findings array is the index of your review: the full explanation of each finding stays in your prose, keyed by the same ids. Switchboard writes the verdict as the first line of the GitHub comment itself, lists the findings under it and folds your text below them as the full review; a review with no submitted verdict is posted as not approving, so never skip it. The only exception is a preflight infrastructure failure from the REVIEW TARGET block's first-command HEAD check: stop, submit no finding or verdict, and let Switchboard report it without a GitHub post. An \`approve\` carrying a finding at or above the severity to address is downgraded to \`request_changes\` and the tool's ack says so — approve only when every finding sits below the level. Do not write "LGTM" in your own text — the verdict line carries it.`;
451
451
 
452
452
  // The diff-gated spec review (docs/reference/specs/agent-review.md item 14) and
453
453
  // the test guard under it (item 16; specs-coverage.md item 6), one text for
@@ -485,7 +485,7 @@ Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your c
485
485
 
486
486
  1. GATHER, in 2-4 batched tool calls total:
487
487
  - \`gh pr view <ref> --json title,body,url,baseRefName\` and \`gh pr diff <ref>\` (the complete diff) in one command
488
- - clone the repo and check out the PR branch, then call the \`diff_digest\` tool to orient: per-file churn, totals, and risky-file flags (migrations/schema, auth/permission, whole-file deletions, lockfiles, very large files) so you know where to look hardest before you read a line
488
+ - for a PR review, use the checkout Switchboard provisioned at the head named in the REVIEW TARGET block — do not clone or check out another ref — then call the \`diff_digest\` tool to orient: per-file churn, totals, and risky-file flags (migrations/schema, auth/permission, whole-file deletions, lockfiles, very large files) so you know where to look hardest before you read a line
489
489
  ${REVIEW_WHOLE_CHANGE}
490
490
  - in ONE command, print the full current contents of every changed source file, e.g.: \`gh pr diff <ref> --name-only | grep -v -E "lock|generated|snap" | while read f; do echo "=== $f ==="; cat "$f"; done\`
491
491
  - if the PR is enormous (>~6k changed lines), print the riskiest files in full (state mutation, auth, concurrency, data deletion, public APIs) and only the diff hunks for the rest — and say which files you skimmed
@@ -231,6 +231,13 @@ export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
231
231
  * budget. */
232
232
  export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
233
233
 
234
+ /** How often the model proxy writes an SSE comment while the provider is
235
+ * reasoning silently. The public container hop has cut an otherwise healthy
236
+ * stream at about thirty seconds of silence; a comment inside half that
237
+ * window keeps the transport alive without becoming a model event. The
238
+ * model call itself remains bounded by the run's lease. */
239
+ export const MODEL_STREAM_HEARTBEAT_MS = 15_000;
240
+
234
241
  /** A hosted ship parent's deadline margin past the pipeline's wall clock
235
242
  * (record 0060): the row's `state.hosting.until` is the hand-off time plus
236
243
  * the instance's `caps.maxMinutes` plus this hour, absorbing the runner's own
@@ -51,6 +51,7 @@ import {
51
51
  type PlanCursor,
52
52
  type UnitStatus,
53
53
  renderUnitReport,
54
+ reenterApprovedRebase,
54
55
  settleUnit,
55
56
  startUnit,
56
57
  type ChildFacts,
@@ -114,6 +115,7 @@ export type CoordinatorStepRoute =
114
115
  | "unit-wake"
115
116
  | "checks"
116
117
  | "merge"
118
+ | "rebase"
117
119
  | "finish";
118
120
 
119
121
  /** What a step stores: the bot's reply as the wire carried it — its status and
@@ -505,13 +507,26 @@ function mergeReturn(step: string, a: BotAnswer): StepReturn {
505
507
  return { type: "merge", step, outcome: "merged", sha, at };
506
508
  if (
507
509
  ok === true &&
508
- (outcome === "pending" || outcome === "refused" || outcome === "enqueued" || outcome === "removed") &&
510
+ (outcome === "pending" ||
511
+ outcome === "refused" ||
512
+ outcome === "conflict" ||
513
+ outcome === "enqueued" ||
514
+ outcome === "removed") &&
509
515
  typeof reason === "string"
510
516
  )
511
517
  return { type: "merge", step, outcome, reason, at };
512
518
  throw new UnreadableAnswer("merge", a, "outcome");
513
519
  }
514
520
 
521
+ function rebaseReturn(step: string, a: BotAnswer): StepReturn {
522
+ const { ok, outcome, headSha, reason, at } = a.body;
523
+ if (ok === true && (outcome === "carried" || outcome === "changed") && typeof headSha === "string")
524
+ return { type: "rebase", step, outcome, headSha, at };
525
+ if (ok === true && (outcome === "conflict" || outcome === "refused") && typeof reason === "string")
526
+ return { type: "rebase", step, outcome, reason, at };
527
+ throw new UnreadableAnswer("rebase", a, "outcome");
528
+ }
529
+
515
530
  // ---- the steps ----------------------------------------------------------------------------------------
516
531
 
517
532
  /** One bot call inside a step: a reply that is not the bot's answer and a
@@ -706,6 +721,16 @@ async function perform(
706
721
  ),
707
722
  ),
708
723
  );
724
+ case "rebase":
725
+ return rebaseReturn(
726
+ action.step,
727
+ answerOf(
728
+ "rebase",
729
+ await step.do(action.step, STEP_CONFIG, () =>
730
+ call(bot, "rebase", { ...tag, prNumber: action.prNumber, headSha: action.headSha }),
731
+ ),
732
+ ),
733
+ );
709
734
  }
710
735
  }
711
736
 
@@ -838,7 +863,7 @@ async function runUnit(
838
863
  let notes = 0;
839
864
  let endedAt: number | undefined;
840
865
  try {
841
- for (;;) {
866
+ pipeline: for (;;) {
842
867
  const action = nextAction(state);
843
868
  if (action.type === "end") return { ...action.ending, ...(endedAt !== undefined ? { endedAt } : {}) };
844
869
  // Keep the current round across its non-round phases (for example merge)
@@ -892,9 +917,9 @@ async function runUnit(
892
917
  // The checks at the approved head (record 0055): the report's
893
918
  // headline is a claim about them, never "merge-ready" over a red one.
894
919
  ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
895
- // The ready state beside them (agent-ship item 9): a conflicting
896
- // head, or one carrying an unsquashed fix-up commit, is reported
897
- // approved-but-not-merge-ready, never "merge-ready".
920
+ // The ready state beside them (agent-ship item 9): a conflict
921
+ // feeds the runner re-entry below, while an unsquashed fix-up
922
+ // commit remains an approved-but-not-merge-ready report.
898
923
  ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
899
924
  ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
900
925
  // The base's merge-queue rule (issue 2011): the merge:person
@@ -907,6 +932,14 @@ async function runUnit(
907
932
  // the report simply omits the fact
908
933
  }
909
934
  }
935
+ // A person-merge pipeline used to publish this dirty state as its
936
+ // ending and hand the pull request to the sweep. It remains the
937
+ // owner instead: rung one runs now, and only a conflict buys a coding
938
+ // child under this run's remaining lease.
939
+ if (note.ending.kind === "merge_ready" && endFacts?.mergeableState === "dirty") {
940
+ state = reenterApprovedRebase(state);
941
+ continue pipeline;
942
+ }
910
943
  // The last coding child's run is named so the bot can put its handoff
911
944
  // — the deviations it recorded — on the unit's board issue beside the
912
945
  // ending (agent-ship item 14).
@@ -51,6 +51,20 @@ export function sameCommit(a: string, b: string): boolean {
51
51
  return n >= 7 && a.slice(0, n) === b.slice(0, n);
52
52
  }
53
53
 
54
+ /** A mismatch sentence that cannot print two identical accepted-looking
55
+ * prefixes: it names the authoritative source, both values and their first
56
+ * differing hex position. */
57
+ function mismatchReason(source: "workspace-observed" | "model-reported", reviewed: string, expected: string): string {
58
+ const compared = Math.min(reviewed.length, expected.length);
59
+ let at = 0;
60
+ while (at < compared && reviewed[at] === expected[at]) at += 1;
61
+ const divergence =
62
+ at < compared
63
+ ? `they first differ at hex ${at + 1} (${reviewed[at]} ≠ ${expected[at]})`
64
+ : `the ${reviewed.length}-hex reviewed value ends before the ${expected.length}-hex PR head`;
65
+ return `${source} reviewed head ${reviewed} is not the PR head ${expected}; ${divergence}`;
66
+ }
67
+
54
68
  /**
55
69
  * Decide whether the reviewed head is the PR head. `expected` is the PR head
56
70
  * from repo resolution; `observed` the dispatcher's own `git rev-parse HEAD`
@@ -63,7 +77,7 @@ export function checkReviewedHead(input: { expected?: string; observed?: string;
63
77
  if (observed) {
64
78
  return sameCommit(observed, expected)
65
79
  ? { ok: true, head: observed, source: "observed" }
66
- : { ok: false, reason: `reviewed head ${observed.slice(0, 7)} is not the PR head ${expected.slice(0, 7)}` };
80
+ : { ok: false, reason: mismatchReason("workspace-observed", observed, expected) };
67
81
  }
68
82
  const reported = normalizeHead(input.reported);
69
83
  if (!reported)
@@ -73,5 +87,5 @@ export function checkReviewedHead(input: { expected?: string; observed?: string;
73
87
  };
74
88
  return sameCommit(reported, expected)
75
89
  ? { ok: true, head: reported, source: "reported" }
76
- : { ok: false, reason: `reviewed head ${reported.slice(0, 7)} is not the PR head ${expected.slice(0, 7)}` };
90
+ : { ok: false, reason: mismatchReason("model-reported", reported, expected) };
77
91
  }