@coreplane/switchboard 1.260.1 → 1.260.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.
- package/bin/switchboard.js +15 -11
- package/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +49 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnostics.ts +227 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsProtocol.ts +22 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsReporter.ts +88 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +14 -2
- package/dist/assets/deploy/cloudflare-memory/worker.ts +36 -16
- package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -18
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +8 -7
- package/dist/assets/src/core/budgets.ts +7 -0
- package/dist/assets/src/core/coordinator/contract.ts +48 -2
- package/dist/assets/src/core/coordinator/driver.ts +74 -6
- package/dist/assets/src/core/reviewVerdict.ts +15 -5
- package/dist/assets/src/core/reviewedHead.ts +16 -2
- package/dist/assets/src/core/ship/contract.ts +1 -1
- package/dist/assets/src/core/ship/coordinator.ts +303 -80
- package/dist/cli.js +912 -248
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.260.
|
|
3
|
+
"version": "1.260.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.260.
|
|
9
|
+
"version": "1.260.3",
|
|
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.
|
|
20448
|
+
"version": "1.260.3",
|
|
20449
20449
|
"license": "Apache-2.0",
|
|
20450
20450
|
"dependencies": {
|
|
20451
20451
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.260.
|
|
3
|
+
"version": "1.260.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",
|
package/dist/assets/source.json
CHANGED
|
@@ -191,7 +191,7 @@ const CONTRACT_HEADINGS_LIST = Object.values(CONTRACT_SECTION_HEADINGS)
|
|
|
191
191
|
// assembled by the child, which would choose what to leave out. Both coding
|
|
192
192
|
// prompts carry this verbatim so the resident and sandbox children read the
|
|
193
193
|
// same rule; the review prompts name the same block and the same severity.
|
|
194
|
-
const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent
|
|
194
|
+
const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent. Then implement the unit's section as written: every test scenario it lists is added as a test, every spec row it names is updated so its proof binding resolves, the agent rules are followed, and no guard it names is weakened. The review is handed the same block and checks the diff against it: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction. Never edit the plan record itself; where the unit is wrong or a criterion could not be proven, say so in the handoff and in your final message.`;
|
|
195
195
|
|
|
196
196
|
// The unit handoff (docs/reference/specs/agent-coding.md item 9; agent-ship.md
|
|
197
197
|
// item 14): the contract's return edge, as data. A child that ran for a plan
|
|
@@ -289,10 +289,11 @@ export const FAST_GATES_BEFORE_PUSH =
|
|
|
289
289
|
// (src/core/ship/contract.ts).
|
|
290
290
|
export const REBASE_BEFORE_PUSH =
|
|
291
291
|
"REBASE BEFORE EVERY PUSH — always, not configurable. Immediately before each push: fetch your base branch, " +
|
|
292
|
-
"rebase your branch onto it, resolve any conflict with the context you already have
|
|
293
|
-
"AGENTS.md says how a generated file is regenerated
|
|
294
|
-
"
|
|
295
|
-
"
|
|
292
|
+
"rebase your branch onto it, and resolve any conflict with the context you already have in one bounded model " +
|
|
293
|
+
"round — the thread's context and the repository's AGENTS.md (which says how a generated file is regenerated: " +
|
|
294
|
+
"regenerate it, never hand-merge it). If you cannot resolve it inside that bound, end the round; otherwise " +
|
|
295
|
+
"re-run THE FAST GATES on the rebased tree, and only then push. Every head that reaches review is then current " +
|
|
296
|
+
"with its base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
|
|
296
297
|
|
|
297
298
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
298
299
|
|
|
@@ -447,7 +448,7 @@ Your final message is posted to Slack — keep it readable, lead with the outcom
|
|
|
447
448
|
// whole reply — so the prose never repeats them and never pads around them.
|
|
448
449
|
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
450
|
|
|
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.`;
|
|
451
|
+
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
452
|
|
|
452
453
|
// The diff-gated spec review (docs/reference/specs/agent-review.md item 14) and
|
|
453
454
|
// the test guard under it (item 16; specs-coverage.md item 6), one text for
|
|
@@ -485,7 +486,7 @@ Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your c
|
|
|
485
486
|
|
|
486
487
|
1. GATHER, in 2-4 batched tool calls total:
|
|
487
488
|
- \`gh pr view <ref> --json title,body,url,baseRefName\` and \`gh pr diff <ref>\` (the complete diff) in one command
|
|
488
|
-
-
|
|
489
|
+
- 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
490
|
${REVIEW_WHOLE_CHANGE}
|
|
490
491
|
- 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
492
|
- 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
|
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
//
|
|
11
11
|
import { IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
|
|
12
12
|
import { isVerbosity, type Verbosity } from "../verbosity.js";
|
|
13
|
-
import {
|
|
13
|
+
import { isFindingShape } from "../reviewVerdict.js";
|
|
14
|
+
import {
|
|
15
|
+
isAddressSeverity,
|
|
16
|
+
type AddressSeverity,
|
|
17
|
+
type AddressSeveritySource,
|
|
18
|
+
type HumanGatePending,
|
|
19
|
+
} from "../ship/coordinator.js";
|
|
14
20
|
import { isHandoffShape, type Handoff } from "../ship/handoff.js";
|
|
15
21
|
|
|
16
22
|
// A coordinator is a Workflow instance in the shim Worker whose children are
|
|
@@ -421,6 +427,8 @@ export interface UnitIdle {
|
|
|
421
427
|
runId?: string;
|
|
422
428
|
spendUsd: number | null;
|
|
423
429
|
handoff?: Handoff;
|
|
430
|
+
/** The human-gated question this idle is waiting on. */
|
|
431
|
+
humanGate?: HumanGatePending;
|
|
424
432
|
wakes: number;
|
|
425
433
|
}
|
|
426
434
|
|
|
@@ -439,6 +447,7 @@ export type UnitWakeAnswer =
|
|
|
439
447
|
texts: string[];
|
|
440
448
|
senders: string[];
|
|
441
449
|
leaseMs?: number;
|
|
450
|
+
humanGate?: HumanGatePending;
|
|
442
451
|
}
|
|
443
452
|
| { kind: "answered"; reply: string }
|
|
444
453
|
| { kind: "stopped" }
|
|
@@ -579,6 +588,23 @@ const isRoundGate = (v: unknown): boolean =>
|
|
|
579
588
|
Array.isArray(v.findings) &&
|
|
580
589
|
v.findings.every((f) => typeof f === "string");
|
|
581
590
|
|
|
591
|
+
export const isHumanGatePending = (v: unknown): v is HumanGatePending =>
|
|
592
|
+
isObject(v) &&
|
|
593
|
+
isObject(v.pr) &&
|
|
594
|
+
typeof v.pr.number === "number" &&
|
|
595
|
+
Number.isInteger(v.pr.number) &&
|
|
596
|
+
v.pr.number > 0 &&
|
|
597
|
+
isText(v.pr.url) &&
|
|
598
|
+
typeof v.round === "number" &&
|
|
599
|
+
Number.isInteger(v.round) &&
|
|
600
|
+
v.round >= 1 &&
|
|
601
|
+
Array.isArray(v.findings) &&
|
|
602
|
+
v.findings.every((finding: unknown) => isFindingShape(finding)) &&
|
|
603
|
+
(v.verdict === "approve" || v.verdict === "request_changes") &&
|
|
604
|
+
(v.reviewRunId === undefined || isText(v.reviewRunId)) &&
|
|
605
|
+
(v.headSha === undefined || isText(v.headSha)) &&
|
|
606
|
+
(v.askedAt === undefined || isFinite(v.askedAt));
|
|
607
|
+
|
|
582
608
|
const isUnitIdle = (v: unknown): boolean =>
|
|
583
609
|
isObject(v) &&
|
|
584
610
|
isText(v.why, IDLE_WHY_MAX) &&
|
|
@@ -588,6 +614,7 @@ const isUnitIdle = (v: unknown): boolean =>
|
|
|
588
614
|
(v.runId === undefined || isText(v.runId)) &&
|
|
589
615
|
(v.spendUsd === null || isFinite(v.spendUsd)) &&
|
|
590
616
|
(v.handoff === undefined || isHandoffShape(v.handoff)) &&
|
|
617
|
+
(v.humanGate === undefined || isHumanGatePending(v.humanGate)) &&
|
|
591
618
|
isCount(v.wakes);
|
|
592
619
|
|
|
593
620
|
const isSegment = (v: unknown): boolean =>
|
|
@@ -616,7 +643,8 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
|
|
|
616
643
|
v.texts.every((text) => typeof text === "string") &&
|
|
617
644
|
Array.isArray(v.senders) &&
|
|
618
645
|
v.senders.every((sender) => isText(sender)) &&
|
|
619
|
-
(v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0))
|
|
646
|
+
(v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0)) &&
|
|
647
|
+
(v.humanGate === undefined || isHumanGatePending(v.humanGate))
|
|
620
648
|
);
|
|
621
649
|
};
|
|
622
650
|
|
|
@@ -693,6 +721,24 @@ export type RunFinishedSend =
|
|
|
693
721
|
| { kind: "no-binding"; instance: string }
|
|
694
722
|
| { kind: "failed"; instance: string; type: string; reason: string };
|
|
695
723
|
|
|
724
|
+
/** Wake one unit after durable outside input was appended. The payload is
|
|
725
|
+
* deliberately empty: the unit re-reads its own event list before acting. */
|
|
726
|
+
export async function sendUnitNudge(
|
|
727
|
+
workflow: WorkflowSender | undefined,
|
|
728
|
+
key: { instanceId: string; unit: string },
|
|
729
|
+
): Promise<RunFinishedSend> {
|
|
730
|
+
const instance = key.instanceId;
|
|
731
|
+
if (!workflow) return { kind: "no-binding", instance };
|
|
732
|
+
const type = unitNudgeEventType(key);
|
|
733
|
+
try {
|
|
734
|
+
const handle = await workflow.get(instance);
|
|
735
|
+
await handle.sendEvent({ type, payload: {} });
|
|
736
|
+
return { kind: "sent", instance, type };
|
|
737
|
+
} catch (err) {
|
|
738
|
+
return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
696
742
|
/** The one send per settled head (http-ingress.md item 12): best effort like
|
|
697
743
|
* `sendRunFinished` — a refusal is answered, never thrown, and the parent's
|
|
698
744
|
* bounded merge wait times out on its own. */
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
// the shim Worker imports this by relative path.
|
|
41
41
|
|
|
42
42
|
import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
|
|
43
|
+
import { isFindingShape, type Finding } from "../reviewVerdict.js";
|
|
43
44
|
import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
|
|
44
45
|
import {
|
|
45
46
|
applyReturn,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
type PlanCursor,
|
|
52
53
|
type UnitStatus,
|
|
53
54
|
renderUnitReport,
|
|
55
|
+
reenterApprovedRebase,
|
|
54
56
|
settleUnit,
|
|
55
57
|
startUnit,
|
|
56
58
|
type ChildFacts,
|
|
@@ -114,6 +116,7 @@ export type CoordinatorStepRoute =
|
|
|
114
116
|
| "unit-wake"
|
|
115
117
|
| "checks"
|
|
116
118
|
| "merge"
|
|
119
|
+
| "rebase"
|
|
117
120
|
| "finish";
|
|
118
121
|
|
|
119
122
|
/** What a step stores: the bot's reply as the wire carried it — its status and
|
|
@@ -341,6 +344,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
341
344
|
// they were written (the run record's validator), read here as they are.
|
|
342
345
|
const facts = run as unknown as Omit<Extract<ChildFacts, { finished: true }>, "finished" | "status">;
|
|
343
346
|
const {
|
|
347
|
+
finishedAt,
|
|
348
|
+
reviewAskedAt,
|
|
344
349
|
finalReply,
|
|
345
350
|
pr,
|
|
346
351
|
headSha,
|
|
@@ -365,6 +370,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
365
370
|
run: {
|
|
366
371
|
finished: true,
|
|
367
372
|
status: run.status as Extract<ChildFacts, { finished: true }>["status"],
|
|
373
|
+
...(typeof finishedAt === "number" ? { finishedAt } : {}),
|
|
374
|
+
...(typeof reviewAskedAt === "number" ? { reviewAskedAt } : {}),
|
|
368
375
|
...(finalReply !== undefined ? { finalReply } : {}),
|
|
369
376
|
...(pr !== undefined ? { pr } : {}),
|
|
370
377
|
...(headSha !== undefined ? { headSha } : {}),
|
|
@@ -401,6 +408,33 @@ function isCommitChecks(v: unknown): v is { total: number; pending: string[]; fa
|
|
|
401
408
|
return typeof c.total === "number" && names(c.pending) && names(c.failed);
|
|
402
409
|
}
|
|
403
410
|
|
|
411
|
+
function entryHumanGate(v: unknown):
|
|
412
|
+
| {
|
|
413
|
+
round: number;
|
|
414
|
+
findings: Finding[];
|
|
415
|
+
verdict: "approve" | "request_changes";
|
|
416
|
+
answer: string;
|
|
417
|
+
author: string;
|
|
418
|
+
commentId: string;
|
|
419
|
+
}
|
|
420
|
+
| undefined {
|
|
421
|
+
if (typeof v !== "object" || v === null) return undefined;
|
|
422
|
+
const row = v as Record<string, unknown>;
|
|
423
|
+
if (typeof row.round !== "number" || !Number.isInteger(row.round) || row.round < 1) return undefined;
|
|
424
|
+
if (!Array.isArray(row.findings) || !row.findings.every(isFindingShape)) return undefined;
|
|
425
|
+
if (row.verdict !== "approve" && row.verdict !== "request_changes") return undefined;
|
|
426
|
+
if (typeof row.answer !== "string" || typeof row.author !== "string" || typeof row.commentId !== "string")
|
|
427
|
+
return undefined;
|
|
428
|
+
return {
|
|
429
|
+
round: row.round,
|
|
430
|
+
findings: row.findings,
|
|
431
|
+
verdict: row.verdict,
|
|
432
|
+
answer: row.answer,
|
|
433
|
+
author: row.author,
|
|
434
|
+
commentId: row.commentId,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
404
438
|
function prCheckReturn(step: string, a: BotAnswer): StepReturn {
|
|
405
439
|
const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
|
|
406
440
|
if (ok === true && state === "none") {
|
|
@@ -446,6 +480,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
446
480
|
// The base's merge-queue rule beside the checks (issue 2011): the
|
|
447
481
|
// merge:person report says the person's merge is queued.
|
|
448
482
|
...(typeof a.body.baseHasMergeQueue === "boolean" ? { baseHasMergeQueue: a.body.baseHasMergeQueue } : {}),
|
|
483
|
+
...(entryHumanGate(a.body.humanGate) !== undefined ? { humanGate: entryHumanGate(a.body.humanGate)! } : {}),
|
|
449
484
|
},
|
|
450
485
|
at,
|
|
451
486
|
};
|
|
@@ -505,13 +540,26 @@ function mergeReturn(step: string, a: BotAnswer): StepReturn {
|
|
|
505
540
|
return { type: "merge", step, outcome: "merged", sha, at };
|
|
506
541
|
if (
|
|
507
542
|
ok === true &&
|
|
508
|
-
(outcome === "pending" ||
|
|
543
|
+
(outcome === "pending" ||
|
|
544
|
+
outcome === "refused" ||
|
|
545
|
+
outcome === "conflict" ||
|
|
546
|
+
outcome === "enqueued" ||
|
|
547
|
+
outcome === "removed") &&
|
|
509
548
|
typeof reason === "string"
|
|
510
549
|
)
|
|
511
550
|
return { type: "merge", step, outcome, reason, at };
|
|
512
551
|
throw new UnreadableAnswer("merge", a, "outcome");
|
|
513
552
|
}
|
|
514
553
|
|
|
554
|
+
function rebaseReturn(step: string, a: BotAnswer): StepReturn {
|
|
555
|
+
const { ok, outcome, headSha, reason, at } = a.body;
|
|
556
|
+
if (ok === true && (outcome === "carried" || outcome === "changed") && typeof headSha === "string")
|
|
557
|
+
return { type: "rebase", step, outcome, headSha, at };
|
|
558
|
+
if (ok === true && (outcome === "conflict" || outcome === "refused") && typeof reason === "string")
|
|
559
|
+
return { type: "rebase", step, outcome, reason, at };
|
|
560
|
+
throw new UnreadableAnswer("rebase", a, "outcome");
|
|
561
|
+
}
|
|
562
|
+
|
|
515
563
|
// ---- the steps ----------------------------------------------------------------------------------------
|
|
516
564
|
|
|
517
565
|
/** One bot call inside a step: a reply that is not the bot's answer and a
|
|
@@ -706,6 +754,16 @@ async function perform(
|
|
|
706
754
|
),
|
|
707
755
|
),
|
|
708
756
|
);
|
|
757
|
+
case "rebase":
|
|
758
|
+
return rebaseReturn(
|
|
759
|
+
action.step,
|
|
760
|
+
answerOf(
|
|
761
|
+
"rebase",
|
|
762
|
+
await step.do(action.step, STEP_CONFIG, () =>
|
|
763
|
+
call(bot, "rebase", { ...tag, prNumber: action.prNumber, headSha: action.headSha }),
|
|
764
|
+
),
|
|
765
|
+
),
|
|
766
|
+
);
|
|
709
767
|
}
|
|
710
768
|
}
|
|
711
769
|
|
|
@@ -838,7 +896,7 @@ async function runUnit(
|
|
|
838
896
|
let notes = 0;
|
|
839
897
|
let endedAt: number | undefined;
|
|
840
898
|
try {
|
|
841
|
-
for (;;) {
|
|
899
|
+
pipeline: for (;;) {
|
|
842
900
|
const action = nextAction(state);
|
|
843
901
|
if (action.type === "end") return { ...action.ending, ...(endedAt !== undefined ? { endedAt } : {}) };
|
|
844
902
|
// Keep the current round across its non-round phases (for example merge)
|
|
@@ -892,9 +950,9 @@ async function runUnit(
|
|
|
892
950
|
// The checks at the approved head (record 0055): the report's
|
|
893
951
|
// headline is a claim about them, never "merge-ready" over a red one.
|
|
894
952
|
...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
|
|
895
|
-
// The ready state beside them (agent-ship item 9): a
|
|
896
|
-
//
|
|
897
|
-
// approved-but-not-merge-ready
|
|
953
|
+
// The ready state beside them (agent-ship item 9): a conflict
|
|
954
|
+
// feeds the runner re-entry below, while an unsquashed fix-up
|
|
955
|
+
// commit remains an approved-but-not-merge-ready report.
|
|
898
956
|
...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
|
|
899
957
|
...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
|
|
900
958
|
// The base's merge-queue rule (issue 2011): the merge:person
|
|
@@ -907,6 +965,14 @@ async function runUnit(
|
|
|
907
965
|
// the report simply omits the fact
|
|
908
966
|
}
|
|
909
967
|
}
|
|
968
|
+
// A person-merge pipeline used to publish this dirty state as its
|
|
969
|
+
// ending and hand the pull request to the sweep. It remains the
|
|
970
|
+
// owner instead: rung one runs now, and only a conflict buys a coding
|
|
971
|
+
// child under this run's remaining lease.
|
|
972
|
+
if (note.ending.kind === "merge_ready" && endFacts?.mergeableState === "dirty") {
|
|
973
|
+
state = reenterApprovedRebase(state);
|
|
974
|
+
continue pipeline;
|
|
975
|
+
}
|
|
910
976
|
// The last coding child's run is named so the bot can put its handoff
|
|
911
977
|
// — the deviations it recorded — on the unit's board issue beside the
|
|
912
978
|
// ending (agent-ship item 14).
|
|
@@ -928,6 +994,7 @@ async function runUnit(
|
|
|
928
994
|
...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
|
|
929
995
|
spendUsd: note.ending.spendUsd,
|
|
930
996
|
...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
|
|
997
|
+
...(note.ending.humanGate !== undefined ? { humanGate: note.ending.humanGate } : {}),
|
|
931
998
|
}
|
|
932
999
|
: {}),
|
|
933
1000
|
},
|
|
@@ -1138,6 +1205,7 @@ async function waitOnIdle(
|
|
|
1138
1205
|
...(answer.runId !== undefined ? { previousRunId: answer.runId } : {}),
|
|
1139
1206
|
...(answer.handoff !== undefined ? { previousHandoff: answer.handoff } : {}),
|
|
1140
1207
|
...(answer.texts.length > 0 ? { texts: answer.texts } : {}),
|
|
1208
|
+
...(answer.humanGate !== undefined ? { humanGate: answer.humanGate } : {}),
|
|
1141
1209
|
...(answer.leaseMs !== undefined
|
|
1142
1210
|
? { resume: { leaseMs: answer.leaseMs, attempt: (currentSession?.resume?.attempt ?? 0) + 1 } }
|
|
1143
1211
|
: {}),
|
|
@@ -1207,7 +1275,7 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
|
|
|
1207
1275
|
next,
|
|
1208
1276
|
stepPrefixOf(next, session),
|
|
1209
1277
|
ending,
|
|
1210
|
-
plan.idleDays,
|
|
1278
|
+
ending.parkDays ?? plan.idleDays,
|
|
1211
1279
|
session,
|
|
1212
1280
|
);
|
|
1213
1281
|
if (parked.kind === "ending") {
|
|
@@ -328,8 +328,14 @@ function findingCounts(findings: readonly Finding[] | undefined): string {
|
|
|
328
328
|
return `${findings.length} finding${findings.length === 1 ? "" : "s"}: ${by}`;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
/** A table cell
|
|
332
|
-
|
|
331
|
+
/** A one-line Markdown table cell. Backslashes are escaped before pipes so
|
|
332
|
+
* input cannot consume the pipe escape and split the row. */
|
|
333
|
+
export const escapeMarkdownTableCell = (text: string): string =>
|
|
334
|
+
oneLine(text).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
335
|
+
|
|
336
|
+
/** Decode the two escapes emitted by `escapeMarkdownTableCell`. This also
|
|
337
|
+
* accepts the legacy form that escaped pipes without first escaping slashes. */
|
|
338
|
+
export const unescapeMarkdownTableCell = (text: string): string => text.replace(/\\([\\|])/g, "$1");
|
|
333
339
|
|
|
334
340
|
/** Whether a finding's `file` is a path a blob URL can point at: no
|
|
335
341
|
* whitespace, no backtick, not a URL. */
|
|
@@ -339,7 +345,7 @@ const isRepoPath = (file: string): boolean => /^[^\s`]+$/.test(file) && !/^[a-z]
|
|
|
339
345
|
* file is a path and a target is known. */
|
|
340
346
|
function whereCell(f: Finding, target: ReviewBodyTarget | undefined): string {
|
|
341
347
|
const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
|
|
342
|
-
const label = `\`${
|
|
348
|
+
const label = `\`${escapeMarkdownTableCell(location)}\``;
|
|
343
349
|
if (!target || !isRepoPath(f.file)) return label;
|
|
344
350
|
const path = encodeURI(f.file).replace(/#/g, "%23").replace(/\?/g, "%3F");
|
|
345
351
|
const url = `https://github.com/${target.repo}/blob/${target.head}/${path}${f.line !== undefined ? `#L${f.line}` : ""}`;
|
|
@@ -360,6 +366,7 @@ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTar
|
|
|
360
366
|
id: f.id,
|
|
361
367
|
severity: f.severity,
|
|
362
368
|
file: f.file,
|
|
369
|
+
title: f.title,
|
|
363
370
|
...(f.line !== undefined ? { line: f.line } : {}),
|
|
364
371
|
...(f.humanGated ? { humanGated: true } : {}),
|
|
365
372
|
})),
|
|
@@ -395,7 +402,10 @@ export function buildReviewPostBody(
|
|
|
395
402
|
[
|
|
396
403
|
"| Severity | Finding | Where |",
|
|
397
404
|
"| --- | --- | --- |",
|
|
398
|
-
...findings.map(
|
|
405
|
+
...findings.map(
|
|
406
|
+
(f) =>
|
|
407
|
+
`| ${f.severity} | **${escapeMarkdownTableCell(f.id)}** ${escapeMarkdownTableCell(f.title)} | ${whereCell(f, target)} |`,
|
|
408
|
+
),
|
|
399
409
|
].join("\n"),
|
|
400
410
|
);
|
|
401
411
|
}
|
|
@@ -517,7 +527,7 @@ const DISPOSITION_KINDS: readonly string[] = ["fixed", "declined"];
|
|
|
517
527
|
|
|
518
528
|
const isRecordLike = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
|
|
519
529
|
|
|
520
|
-
function isFindingShape(v: unknown): v is Finding {
|
|
530
|
+
export function isFindingShape(v: unknown): v is Finding {
|
|
521
531
|
if (!isRecordLike(v)) return false;
|
|
522
532
|
return (
|
|
523
533
|
typeof v.id === "string" &&
|
|
@@ -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:
|
|
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:
|
|
90
|
+
: { ok: false, reason: mismatchReason("model-reported", reported, expected) };
|
|
77
91
|
}
|
|
@@ -480,7 +480,7 @@ function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
|
|
|
480
480
|
const gates = `${FAST_GATES_POINTER} ${GATE_RECEIPTS}`;
|
|
481
481
|
return (
|
|
482
482
|
`Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
|
|
483
|
-
`the only writes are your own on that branch.
|
|
483
|
+
`the only writes are your own on that branch. ` +
|
|
484
484
|
`Push the branch as soon as the change exists and the fast gates pass — the project's full verification ` +
|
|
485
485
|
`is CI's gate, run there after the push with any fix as a further commit; an unpushed tree does not ` +
|
|
486
486
|
`survive the run's end. ${gates} ${REBASE_POINTER} At the wind-down note, ` +
|