@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
|
@@ -59,6 +59,7 @@ import { parsePlanUnit, planUnitIds } from "./contract.js";
|
|
|
59
59
|
import {
|
|
60
60
|
carve,
|
|
61
61
|
DEFAULT_GRANT,
|
|
62
|
+
IDLE_DAYS_MAX,
|
|
62
63
|
loopPosition,
|
|
63
64
|
MERGE_WAIT_ASK_MINUTES,
|
|
64
65
|
MINUTE_MS,
|
|
@@ -458,7 +459,7 @@ export function cursorFinished(cursor: PlanCursor): boolean {
|
|
|
458
459
|
* findings dispatched into the unit thread as `agent:coding`, so the coding
|
|
459
460
|
* session there continues with them (record 0034). Never a `fix` child briefed
|
|
460
461
|
* from the review. */
|
|
461
|
-
export type RoundKind = "coding" | "review" | "findings";
|
|
462
|
+
export type RoundKind = "coding" | "review" | "findings" | "rebase";
|
|
462
463
|
export interface RoundRef {
|
|
463
464
|
/** Round 0 is the coding round; review round n and its findings step share n. */
|
|
464
465
|
index: number;
|
|
@@ -494,8 +495,10 @@ export type Brief =
|
|
|
494
495
|
pr: number;
|
|
495
496
|
headSha?: string;
|
|
496
497
|
round: number;
|
|
497
|
-
/** The previous review round
|
|
498
|
-
|
|
498
|
+
/** The previous review round and the coding run that answered it. A
|
|
499
|
+
* recovered posted verdict carries findings by value when no run row
|
|
500
|
+
* belongs to this attempt. */
|
|
501
|
+
prior?: { reviewRunId?: string; codingRunId?: string; findings?: Finding[] };
|
|
499
502
|
/** The previous round's check findings (record 0055): they sit on no
|
|
500
503
|
* run's record, so the brief carries them beside `prior` by value. */
|
|
501
504
|
checks?: Finding[];
|
|
@@ -504,10 +507,22 @@ export type Brief =
|
|
|
504
507
|
kind: "findings";
|
|
505
508
|
unit: string;
|
|
506
509
|
pr: number;
|
|
507
|
-
|
|
510
|
+
/** The review run normally supplies the findings. An adopted attempt
|
|
511
|
+
* can recover them from the last posted verdict marker instead. */
|
|
512
|
+
reviewRunId?: string;
|
|
513
|
+
findings?: Finding[];
|
|
514
|
+
/** Human replies that answered a parked finding, attributed at intake. */
|
|
515
|
+
answers?: string[];
|
|
508
516
|
/** The round's check findings (record 0055), carried by value — the
|
|
509
517
|
* review run's record holds only the reviewer's own findings. */
|
|
510
518
|
checks?: Finding[];
|
|
519
|
+
}
|
|
520
|
+
| {
|
|
521
|
+
kind: "rebase";
|
|
522
|
+
unit: string;
|
|
523
|
+
pr: number;
|
|
524
|
+
headSha: string;
|
|
525
|
+
base: string;
|
|
511
526
|
};
|
|
512
527
|
|
|
513
528
|
export interface PrRef {
|
|
@@ -515,6 +530,20 @@ export interface PrRef {
|
|
|
515
530
|
url: string;
|
|
516
531
|
}
|
|
517
532
|
|
|
533
|
+
/** A human-gated review question parked on its live unit. The review's typed
|
|
534
|
+
* findings and the pull request survive the wait; `reviewRunId` is absent when
|
|
535
|
+
* an adopted attempt recovered the question from GitHub's verdict marker. */
|
|
536
|
+
export interface HumanGatePending {
|
|
537
|
+
pr: PrRef;
|
|
538
|
+
round: number;
|
|
539
|
+
findings: Finding[];
|
|
540
|
+
verdict: "approve" | "request_changes";
|
|
541
|
+
reviewRunId?: string;
|
|
542
|
+
headSha?: string;
|
|
543
|
+
/** When the verdict asked the question, for fencing older unit events. */
|
|
544
|
+
askedAt?: number;
|
|
545
|
+
}
|
|
546
|
+
|
|
518
547
|
/** What the coordinator asks for next. `step` is the Workflow step's name, and
|
|
519
548
|
* for a spawn the second half of the child's idempotency key. */
|
|
520
549
|
export type CoordinatorAction =
|
|
@@ -549,6 +578,9 @@ export type CoordinatorAction =
|
|
|
549
578
|
* the queue's outcome (merged, still queued, or removed with the reason)
|
|
550
579
|
* instead of attempting the squash. */
|
|
551
580
|
| { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
|
|
581
|
+
/** Rung one of an approved head's rebase: git first. A conflict alone buys
|
|
582
|
+
* the runner's coding round; a clean changed patch returns to review. */
|
|
583
|
+
| { type: "rebase"; step: string; prNumber: number; headSha: string }
|
|
552
584
|
/** Read the check runs at the reviewed head (record 0055, the round verdict):
|
|
553
585
|
* the merge door's own reading, folded into the round. `retry` names the
|
|
554
586
|
* failed checks whose one flake re-run the machine is spending; `refire`
|
|
@@ -567,6 +599,10 @@ export type ChildFacts =
|
|
|
567
599
|
| {
|
|
568
600
|
finished: true;
|
|
569
601
|
status: RunStatus;
|
|
602
|
+
/** The terminal record time; fallback fence for an older review record. */
|
|
603
|
+
finishedAt?: number;
|
|
604
|
+
/** When the posted verdict became the person's question. */
|
|
605
|
+
reviewAskedAt?: number;
|
|
570
606
|
finalReply?: string;
|
|
571
607
|
/** A coding child's `pr_opened`. */
|
|
572
608
|
pr?: { number: number; url: string; created: boolean };
|
|
@@ -649,6 +685,16 @@ export type PrCheck =
|
|
|
649
685
|
* read beside the checks on the ending's facts read, so a `merge_ready`
|
|
650
686
|
* report tells the person their merge is queued, never a direct one. */
|
|
651
687
|
baseHasMergeQueue?: boolean;
|
|
688
|
+
/** A person's newest pull-request comment answered the last posted
|
|
689
|
+
* human-gated verdict. Entry starts its fix round before any review. */
|
|
690
|
+
humanGate?: {
|
|
691
|
+
round: number;
|
|
692
|
+
findings: Finding[];
|
|
693
|
+
verdict: "approve" | "request_changes";
|
|
694
|
+
answer: string;
|
|
695
|
+
author: string;
|
|
696
|
+
commentId: string;
|
|
697
|
+
};
|
|
652
698
|
}
|
|
653
699
|
| { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
|
|
654
700
|
|
|
@@ -678,7 +724,15 @@ export type StepReturn =
|
|
|
678
724
|
| { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
|
|
679
725
|
/** The door enqueued the pull request (or found it still queued), or the
|
|
680
726
|
* queue removed it — the reason is the queue's own (issue 2011). */
|
|
681
|
-
| {
|
|
727
|
+
| {
|
|
728
|
+
type: "merge";
|
|
729
|
+
step: string;
|
|
730
|
+
outcome: "pending" | "refused" | "conflict" | "enqueued" | "removed";
|
|
731
|
+
reason: string;
|
|
732
|
+
at: number;
|
|
733
|
+
}
|
|
734
|
+
| { type: "rebase"; step: string; outcome: "carried" | "changed"; headSha: string; at: number }
|
|
735
|
+
| { type: "rebase"; step: string; outcome: "conflict" | "refused"; reason: string; at: number }
|
|
682
736
|
/** The checks read at the reviewed head; `checks` absent means GitHub could
|
|
683
737
|
* not be read, which the machine treats as pending (record 0055). Effect
|
|
684
738
|
* asks answer `retried` or `refired` instead; false still spends that one
|
|
@@ -775,12 +829,9 @@ export type UnitEnding =
|
|
|
775
829
|
* The unit is done and its dependents start on a base that carries it. */
|
|
776
830
|
| { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
|
|
777
831
|
| { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
|
|
778
|
-
/**
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
* nothing: the unit ends held for a person, the ending carrying the
|
|
782
|
-
* human-gated rows, and a re-issue with the pull request resumes at the
|
|
783
|
-
* review round once the receipt is posted (item 10's resume path). */
|
|
832
|
+
/** The report nested inside a parked human-gated question. The unit never
|
|
833
|
+
* publishes this as its terminal kind: `parkHumanGate` carries it under an
|
|
834
|
+
* idle ending until a person's answer opens the fix round. */
|
|
784
835
|
| {
|
|
785
836
|
kind: "held";
|
|
786
837
|
cause?: undefined;
|
|
@@ -920,6 +971,10 @@ export type UnitEnding =
|
|
|
920
971
|
handoff?: Handoff;
|
|
921
972
|
round?: RoundRef;
|
|
922
973
|
reviewRounds: number;
|
|
974
|
+
/** A parked human-gated review is always an idle, independent of the
|
|
975
|
+
* optional idle policy. Its question is the unit's pending state. */
|
|
976
|
+
humanGate?: HumanGatePending;
|
|
977
|
+
parkDays?: number;
|
|
923
978
|
};
|
|
924
979
|
|
|
925
980
|
/** The kinds that idle: every ending but the ended ones — the unit is
|
|
@@ -927,10 +982,9 @@ export type UnitEnding =
|
|
|
927
982
|
* a reply could continue it. `merged`, `already_landed`, `merge_ready` and
|
|
928
983
|
* `refused` never idle: the first two are done, merge-ready waits only for a
|
|
929
984
|
* person's merge, and a refused child would be refused again. A gated or
|
|
930
|
-
* draft `held` never idles either
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* so it alone idles; `idleEnding` draws that line by cause. */
|
|
985
|
+
* draft `held` never idles either. A blocked hold (issue 2086) follows the
|
|
986
|
+
* configured idle policy; a human-gated hold takes `parkHumanGate` instead,
|
|
987
|
+
* which always creates the pending idle question. */
|
|
934
988
|
export type IdleWhy =
|
|
935
989
|
Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
|
|
936
990
|
|
|
@@ -996,6 +1050,9 @@ export interface LeaseSegmentProgress {
|
|
|
996
1050
|
previousHandoff?: Handoff;
|
|
997
1051
|
/** Words that woke an idle segment, already attributed in arrival order. */
|
|
998
1052
|
texts?: string[];
|
|
1053
|
+
/** The human-gated question those words answer. It resumes as a findings
|
|
1054
|
+
* round before another review, without spending a renewal. */
|
|
1055
|
+
humanGate?: HumanGatePending;
|
|
999
1056
|
/** A stopped segment reopens under the remainder of its cut lease. Each
|
|
1000
1057
|
* reopen gets a fresh attempt so its Workflow steps cannot replay the
|
|
1001
1058
|
* stopped attempt's cached answers. */
|
|
@@ -1090,6 +1147,7 @@ type Phase =
|
|
|
1090
1147
|
* only through a merge queue (issue 2011) — so every later ask reads the
|
|
1091
1148
|
* queue's outcome instead of attempting the squash again. */
|
|
1092
1149
|
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
1150
|
+
| { at: "rebase"; pr: PrRef; headSha: string; n: number }
|
|
1093
1151
|
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
1094
1152
|
/** The round's checks step (record 0055): the check runs at the reviewed head
|
|
1095
1153
|
* are read after an approve settles, before merge_ready or the merge door.
|
|
@@ -1135,6 +1193,9 @@ export interface UnitPipelineState {
|
|
|
1135
1193
|
readonly phase: Phase;
|
|
1136
1194
|
/** Review rounds started so far. */
|
|
1137
1195
|
readonly reviewRounds: number;
|
|
1196
|
+
/** Approved-head rebases attempted in this run. Each fresh conflict may buy
|
|
1197
|
+
* one fix round; the run's remaining lease, not pull-request lifetime, is the bound. */
|
|
1198
|
+
readonly rebaseAttempts: number;
|
|
1138
1199
|
readonly pr?: PrRef;
|
|
1139
1200
|
readonly lastReviewHead?: string;
|
|
1140
1201
|
readonly lastVerdictSummary?: string;
|
|
@@ -1143,6 +1204,8 @@ export interface UnitPipelineState {
|
|
|
1143
1204
|
* ids are unique within one round only. A disposition naming an id the review
|
|
1144
1205
|
* never issued is dropped at the match (`matchDispositions`). */
|
|
1145
1206
|
readonly findingsByRound: Readonly<Record<number, Finding[]>>;
|
|
1207
|
+
/** Human answers folded into a recovered/parked findings brief. */
|
|
1208
|
+
readonly humanAnswersByRound: Readonly<Record<number, string[]>>;
|
|
1146
1209
|
readonly dispositionsByRound: Readonly<Record<number, FindingDisposition[]>>;
|
|
1147
1210
|
readonly reviewRunByRound: Readonly<Record<number, string>>;
|
|
1148
1211
|
/** The coding run each round's findings step dispatched. */
|
|
@@ -1186,19 +1249,29 @@ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
|
|
|
1186
1249
|
// ---- the unit pipeline: opening and the next action -----------------------------------------------
|
|
1187
1250
|
|
|
1188
1251
|
export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipelineState {
|
|
1252
|
+
const pending = input.session?.humanGate;
|
|
1189
1253
|
const base: UnitPipelineState = {
|
|
1190
1254
|
input,
|
|
1191
1255
|
startedAt: at,
|
|
1192
1256
|
clock: at,
|
|
1193
1257
|
phase: { at: "pre-check" },
|
|
1194
|
-
reviewRounds: 0,
|
|
1258
|
+
reviewRounds: pending?.round ?? 0,
|
|
1259
|
+
rebaseAttempts: 0,
|
|
1195
1260
|
spentMs: { coding: 0, review: 0, waiting: 0 },
|
|
1196
1261
|
spendUsd: input.session?.spendUsd ?? 0,
|
|
1197
|
-
findingsByRound: {},
|
|
1262
|
+
findingsByRound: pending ? { [pending.round]: pending.findings } : {},
|
|
1263
|
+
humanAnswersByRound: pending && input.session?.texts !== undefined ? { [pending.round]: input.session.texts } : {},
|
|
1198
1264
|
dispositionsByRound: {},
|
|
1199
|
-
reviewRunByRound: {},
|
|
1265
|
+
reviewRunByRound: pending?.reviewRunId !== undefined ? { [pending.round]: pending.reviewRunId } : {},
|
|
1200
1266
|
findingsRunByRound: {},
|
|
1267
|
+
...(pending !== undefined
|
|
1268
|
+
? {
|
|
1269
|
+
pr: pending.pr,
|
|
1270
|
+
...(pending.headSha !== undefined ? { lastReviewHead: pending.headSha } : {}),
|
|
1271
|
+
}
|
|
1272
|
+
: {}),
|
|
1201
1273
|
};
|
|
1274
|
+
if (pending !== undefined) return enterRound(base, { index: pending.round, kind: "findings" }).state;
|
|
1202
1275
|
if (!input.resume) return base;
|
|
1203
1276
|
const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
|
|
1204
1277
|
const resumed: UnitPipelineState = {
|
|
@@ -1230,11 +1303,8 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
|
|
|
1230
1303
|
* A review round `n` and the findings step that follows it share `n`; the
|
|
1231
1304
|
* module's positions are the loop's own. */
|
|
1232
1305
|
function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
{ kind: round.kind, index: loopPosition(loopOf(s), round.kind, round.index) },
|
|
1236
|
-
loopOf(s),
|
|
1237
|
-
);
|
|
1306
|
+
const kind = round.kind === "rebase" ? "findings" : round.kind;
|
|
1307
|
+
return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
|
|
1238
1308
|
}
|
|
1239
1309
|
|
|
1240
1310
|
/** The prefix every step of this pipeline is named under: the unit id, a
|
|
@@ -1270,27 +1340,44 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
|
1270
1340
|
};
|
|
1271
1341
|
}
|
|
1272
1342
|
const pr = s.pr!.number;
|
|
1343
|
+
if (round.kind === "rebase") {
|
|
1344
|
+
return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
|
|
1345
|
+
}
|
|
1273
1346
|
if (round.kind === "findings") {
|
|
1274
|
-
const
|
|
1347
|
+
const issued = s.findingsByRound[round.index] ?? [];
|
|
1348
|
+
const checks = checkFindingsOf(issued);
|
|
1349
|
+
const reviewRunId = s.reviewRunByRound[round.index];
|
|
1350
|
+
const answers = s.humanAnswersByRound[round.index];
|
|
1275
1351
|
return {
|
|
1276
1352
|
kind: "findings",
|
|
1277
1353
|
unit,
|
|
1278
1354
|
pr,
|
|
1279
|
-
reviewRunId:
|
|
1355
|
+
...(reviewRunId !== undefined ? { reviewRunId } : {}),
|
|
1356
|
+
...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
|
|
1357
|
+
...(answers !== undefined && answers.length > 0 ? { answers } : {}),
|
|
1280
1358
|
...(checks.length > 0 ? { checks } : {}),
|
|
1281
1359
|
};
|
|
1282
1360
|
}
|
|
1361
|
+
const priorFindings = s.findingsByRound[round.index - 1] ?? [];
|
|
1283
1362
|
const priorReview = s.reviewRunByRound[round.index - 1];
|
|
1284
1363
|
const priorCoding = s.findingsRunByRound[round.index - 1];
|
|
1285
|
-
const priorChecks = checkFindingsOf(
|
|
1364
|
+
const priorChecks = checkFindingsOf(priorFindings);
|
|
1286
1365
|
return {
|
|
1287
1366
|
kind: "review",
|
|
1288
1367
|
unit,
|
|
1289
1368
|
pr,
|
|
1290
1369
|
...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
|
|
1291
1370
|
round: round.index,
|
|
1292
|
-
...(priorReview !== undefined
|
|
1293
|
-
? {
|
|
1371
|
+
...(priorReview !== undefined || (priorCoding !== undefined && priorFindings.length > 0)
|
|
1372
|
+
? {
|
|
1373
|
+
prior: {
|
|
1374
|
+
...(priorReview !== undefined ? { reviewRunId: priorReview } : {}),
|
|
1375
|
+
...(priorReview === undefined || s.humanAnswersByRound[round.index - 1] !== undefined
|
|
1376
|
+
? { findings: priorFindings }
|
|
1377
|
+
: {}),
|
|
1378
|
+
...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}),
|
|
1379
|
+
},
|
|
1380
|
+
}
|
|
1294
1381
|
: {}),
|
|
1295
1382
|
...(priorChecks.length > 0 ? { checks: priorChecks } : {}),
|
|
1296
1383
|
};
|
|
@@ -1380,6 +1467,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1380
1467
|
headSha: p.headSha,
|
|
1381
1468
|
...(p.queued === true ? { queued: true as const } : {}),
|
|
1382
1469
|
};
|
|
1470
|
+
case "rebase":
|
|
1471
|
+
return {
|
|
1472
|
+
type: "rebase",
|
|
1473
|
+
step: `${unit}/rebase/${p.n}`,
|
|
1474
|
+
prNumber: p.pr.number,
|
|
1475
|
+
headSha: p.headSha,
|
|
1476
|
+
};
|
|
1383
1477
|
case "merge-wait":
|
|
1384
1478
|
return {
|
|
1385
1479
|
type: "wait-checks",
|
|
@@ -1424,9 +1518,9 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
|
|
|
1424
1518
|
function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
|
|
1425
1519
|
if ((s.input.idleDays ?? 0) <= 0) return undefined;
|
|
1426
1520
|
if (NEVER_IDLES.has(ending.kind)) return undefined;
|
|
1427
|
-
// A
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1521
|
+
// A draft hold is not continued by a reply. A blocked hold (issue 2086)
|
|
1522
|
+
// waits for the person's word. Human-gated questions bypass this mapping:
|
|
1523
|
+
// `parkHumanGate` always parks them with their typed pending state.
|
|
1430
1524
|
if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
|
|
1431
1525
|
const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
|
|
1432
1526
|
const grant = s.input.grant ?? DEFAULT_GRANT;
|
|
@@ -1482,7 +1576,7 @@ const heldEnding = (
|
|
|
1482
1576
|
round: RoundRef,
|
|
1483
1577
|
findings: Finding[],
|
|
1484
1578
|
verdict: "approve" | "request_changes",
|
|
1485
|
-
): UnitEnding => ({
|
|
1579
|
+
): Extract<UnitEnding, { kind: "held" }> => ({
|
|
1486
1580
|
kind: "held",
|
|
1487
1581
|
...(s.pr !== undefined ? { pr: s.pr } : {}),
|
|
1488
1582
|
round,
|
|
@@ -1491,6 +1585,48 @@ const heldEnding = (
|
|
|
1491
1585
|
reviewRounds: s.reviewRounds,
|
|
1492
1586
|
});
|
|
1493
1587
|
|
|
1588
|
+
/** A human-gated finding is a question, not a terminal hold. Park the live
|
|
1589
|
+
* unit in record 0051's idle state regardless of the optional idle policy, so
|
|
1590
|
+
* either a thread reply or a pull-request comment can become its fix brief. */
|
|
1591
|
+
function parkHumanGate(
|
|
1592
|
+
s: UnitPipelineState,
|
|
1593
|
+
round: RoundRef,
|
|
1594
|
+
findings: Finding[],
|
|
1595
|
+
verdict: "approve" | "request_changes",
|
|
1596
|
+
askedAt: number,
|
|
1597
|
+
notes: CoordinatorNote[],
|
|
1598
|
+
): Transition {
|
|
1599
|
+
const held = heldEnding(s, round, findings, verdict);
|
|
1600
|
+
const humanGate: HumanGatePending = {
|
|
1601
|
+
pr: s.pr!,
|
|
1602
|
+
round: round.index,
|
|
1603
|
+
findings,
|
|
1604
|
+
verdict,
|
|
1605
|
+
...(s.reviewRunByRound[round.index] !== undefined ? { reviewRunId: s.reviewRunByRound[round.index] } : {}),
|
|
1606
|
+
...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
|
|
1607
|
+
askedAt,
|
|
1608
|
+
};
|
|
1609
|
+
const grant = s.input.grant ?? DEFAULT_GRANT;
|
|
1610
|
+
const ending: Extract<UnitEnding, { kind: "idle" }> = {
|
|
1611
|
+
kind: "idle",
|
|
1612
|
+
why: "held",
|
|
1613
|
+
idled: held,
|
|
1614
|
+
renewalsLeft: Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0)),
|
|
1615
|
+
...(s.lastReviewHead !== undefined ? { from: s.lastReviewHead } : {}),
|
|
1616
|
+
...(s.lastCodingRunId !== undefined ? { runId: s.lastCodingRunId } : {}),
|
|
1617
|
+
spendUsd: s.spendUsd,
|
|
1618
|
+
...(s.lastCodingHandoff !== undefined ? { handoff: s.lastCodingHandoff } : {}),
|
|
1619
|
+
round,
|
|
1620
|
+
reviewRounds: s.reviewRounds,
|
|
1621
|
+
humanGate,
|
|
1622
|
+
parkDays: IDLE_DAYS_MAX,
|
|
1623
|
+
};
|
|
1624
|
+
return {
|
|
1625
|
+
state: { ...s, phase: ENDED, ending },
|
|
1626
|
+
notes: [...notes, { type: "ended", ending }],
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1494
1630
|
const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
|
|
1495
1631
|
type: "round",
|
|
1496
1632
|
index: round.index,
|
|
@@ -1534,6 +1670,14 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
|
|
|
1534
1670
|
};
|
|
1535
1671
|
}
|
|
1536
1672
|
|
|
1673
|
+
/** A rebase changed the approved patch, or a person's answer enabled a
|
|
1674
|
+
* human-gated fix, so review is mandatory even when the ordinary findings-
|
|
1675
|
+
* round count is spent. The run's remaining lease is the bound: `enterRound`
|
|
1676
|
+
* refuses when another review no longer fits. */
|
|
1677
|
+
function mandatoryReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
|
|
1678
|
+
return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1537
1681
|
/** The next review round, or the round cap. */
|
|
1538
1682
|
function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
|
|
1539
1683
|
if (s.reviewRounds >= s.input.caps.maxRounds)
|
|
@@ -1597,13 +1741,16 @@ function settleCoding(
|
|
|
1597
1741
|
[roundNote(round, "stopped")],
|
|
1598
1742
|
);
|
|
1599
1743
|
// A mechanical WIP push means the child did not declare the work ready for
|
|
1600
|
-
// review
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1744
|
+
// review, whatever terminal status its own answer produced. Keep the unit
|
|
1745
|
+
// branch as the resumption point and abort this attempt; an ordinary push
|
|
1746
|
+
// still takes the established recover-PR path.
|
|
1747
|
+
if (checkpoint !== undefined) {
|
|
1603
1748
|
const cause =
|
|
1604
1749
|
facts.failure?.kind === "provider_transient"
|
|
1605
1750
|
? "the model provider's transport retry budget was spent"
|
|
1606
|
-
:
|
|
1751
|
+
: facts.status === "failed"
|
|
1752
|
+
? "it failed before finishing"
|
|
1753
|
+
: "it ended before its work was ready for review";
|
|
1607
1754
|
return end(
|
|
1608
1755
|
next,
|
|
1609
1756
|
{
|
|
@@ -1729,9 +1876,10 @@ function settleReview(
|
|
|
1729
1876
|
},
|
|
1730
1877
|
notes,
|
|
1731
1878
|
);
|
|
1732
|
-
// Every gated finding is human-gated
|
|
1733
|
-
//
|
|
1734
|
-
if (allHumanGated(gated))
|
|
1879
|
+
// Every gated finding is human-gated: ask a person and park the live
|
|
1880
|
+
// unit; an answer later opens the fix round with the question attached.
|
|
1881
|
+
if (allHumanGated(gated))
|
|
1882
|
+
return parkHumanGate(next, round, gated, "approve", facts.reviewAskedAt ?? facts.finishedAt ?? s.clock, notes);
|
|
1735
1883
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1736
1884
|
return end(
|
|
1737
1885
|
next,
|
|
@@ -1760,11 +1908,18 @@ function settleReview(
|
|
|
1760
1908
|
},
|
|
1761
1909
|
notes,
|
|
1762
1910
|
);
|
|
1763
|
-
// Every finding of the round is human-gated
|
|
1764
|
-
//
|
|
1765
|
-
//
|
|
1911
|
+
// Every finding of the round is human-gated: no fix round can change
|
|
1912
|
+
// anything yet, so park the live unit for a person's answer before asking
|
|
1913
|
+
// the round cap.
|
|
1766
1914
|
if (allHumanGated(verdict.findings ?? []))
|
|
1767
|
-
return
|
|
1915
|
+
return parkHumanGate(
|
|
1916
|
+
next,
|
|
1917
|
+
round,
|
|
1918
|
+
verdict.findings ?? [],
|
|
1919
|
+
"request_changes",
|
|
1920
|
+
facts.reviewAskedAt ?? facts.finishedAt ?? s.clock,
|
|
1921
|
+
notes,
|
|
1922
|
+
);
|
|
1768
1923
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1769
1924
|
return end(
|
|
1770
1925
|
next,
|
|
@@ -1987,6 +2142,21 @@ function settleChecks(
|
|
|
1987
2142
|
}
|
|
1988
2143
|
}
|
|
1989
2144
|
|
|
2145
|
+
/** Re-enter the approved head's rebase step. The attempt count belongs to
|
|
2146
|
+
* this run, not to the pull request: every later base move is a fresh
|
|
2147
|
+
* conflict, while the one pipeline lease remains the hard bound. */
|
|
2148
|
+
export function reenterApprovedRebase(s: UnitPipelineState): UnitPipelineState {
|
|
2149
|
+
if (s.pr === undefined || s.lastReviewHead === undefined)
|
|
2150
|
+
throw new Error("an approved rebase needs the pull request and reviewed head");
|
|
2151
|
+
const { ending: _ending, ...open } = s;
|
|
2152
|
+
const n = s.rebaseAttempts + 1;
|
|
2153
|
+
return {
|
|
2154
|
+
...open,
|
|
2155
|
+
rebaseAttempts: n,
|
|
2156
|
+
phase: { at: "rebase", pr: s.pr, headSha: s.lastReviewHead, n },
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
|
|
1990
2160
|
/** An approve past the checks read: merge_ready for a person, the merge door
|
|
1991
2161
|
* under `merge: runner` — the tail the round verdict guards (record 0055). */
|
|
1992
2162
|
function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
|
|
@@ -2237,10 +2407,28 @@ function roundOnOpenPr(
|
|
|
2237
2407
|
const { round } = phase;
|
|
2238
2408
|
const head = pr.headSha ?? phase.childHead;
|
|
2239
2409
|
const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
|
|
2410
|
+
if (round.kind === "rebase") {
|
|
2411
|
+
const pushed = normalizeHead(head);
|
|
2412
|
+
const approved = normalizeHead(s.lastReviewHead);
|
|
2413
|
+
if (pushed === undefined || (approved !== undefined && sameCommit(pushed, approved)))
|
|
2414
|
+
return end(
|
|
2415
|
+
next,
|
|
2416
|
+
{
|
|
2417
|
+
kind: "aborted",
|
|
2418
|
+
reason: `⚠️ Rebase round ${round.attempt ?? 1} left the approved head unchanged, so the conflict still has no resolution to review.`,
|
|
2419
|
+
round,
|
|
2420
|
+
reviewRounds: next.reviewRounds,
|
|
2421
|
+
},
|
|
2422
|
+
[roundNote(round, "aborted")],
|
|
2423
|
+
);
|
|
2424
|
+
return mandatoryReview({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
|
|
2425
|
+
}
|
|
2426
|
+
const hasHumanAnswer = round.kind === "findings" && (s.humanAnswersByRound[round.index]?.length ?? 0) > 0;
|
|
2240
2427
|
if (round.kind === "findings") {
|
|
2241
2428
|
// Nothing repushed → nothing to re-review, unless every finding of the
|
|
2242
2429
|
// last review was declined on the record: that re-review verifies the
|
|
2243
|
-
// arguments and may concede.
|
|
2430
|
+
// arguments and may concede. A person's answer is itself new evidence for
|
|
2431
|
+
// the mandatory re-review, even when accepting it changes no code.
|
|
2244
2432
|
const codingHead = normalizeHead(head);
|
|
2245
2433
|
const reviewedAt = normalizeHead(s.lastReviewHead);
|
|
2246
2434
|
if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
|
|
@@ -2271,27 +2459,30 @@ function roundOnOpenPr(
|
|
|
2271
2459
|
},
|
|
2272
2460
|
[roundNote(round, "aborted")],
|
|
2273
2461
|
);
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2462
|
+
if (!hasHumanAnswer) {
|
|
2463
|
+
const findings = s.findingsByRound[round.index] ?? [];
|
|
2464
|
+
const dispositions = s.dispositionsByRound[round.index] ?? [];
|
|
2465
|
+
const allDeclined =
|
|
2466
|
+
findings.length > 0 &&
|
|
2467
|
+
findings.every((f) => dispositions.find((d) => d.findingId === f.id)?.disposition === "declined");
|
|
2468
|
+
if (!allDeclined)
|
|
2469
|
+
return end(
|
|
2470
|
+
next,
|
|
2471
|
+
{
|
|
2472
|
+
kind: "aborted",
|
|
2473
|
+
reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
|
|
2474
|
+
round,
|
|
2475
|
+
reviewRounds: next.reviewRounds,
|
|
2476
|
+
},
|
|
2477
|
+
[roundNote(round, "aborted")],
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2290
2480
|
}
|
|
2291
2481
|
}
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2482
|
+
const reviewed: UnitPipelineState = { ...next, ...(head !== undefined ? { lastReviewHead: head } : {}) };
|
|
2483
|
+
const notes = [roundNote(round, "pr_opened")];
|
|
2484
|
+
if (hasHumanAnswer) return mandatoryReview(reviewed, notes);
|
|
2485
|
+
return nextReview(reviewed, notes);
|
|
2295
2486
|
}
|
|
2296
2487
|
|
|
2297
2488
|
/** Move the clock and charge the elapsed time to the budget's bucket: a phase
|
|
@@ -2353,6 +2544,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2353
2544
|
pr: { number: r.pr.prNumber, url: r.pr.url },
|
|
2354
2545
|
lastReviewHead: entryHead,
|
|
2355
2546
|
};
|
|
2547
|
+
const answered = r.pr.humanGate;
|
|
2548
|
+
if (answered !== undefined) {
|
|
2549
|
+
const round = Math.max(1, answered.round);
|
|
2550
|
+
const withAnswer: UnitPipelineState = {
|
|
2551
|
+
...adopted,
|
|
2552
|
+
reviewRounds: round,
|
|
2553
|
+
findingsByRound: { ...adopted.findingsByRound, [round]: answered.findings },
|
|
2554
|
+
humanAnswersByRound: {
|
|
2555
|
+
...adopted.humanAnswersByRound,
|
|
2556
|
+
[round]: [`${answered.author}: ${answered.answer}`],
|
|
2557
|
+
},
|
|
2558
|
+
};
|
|
2559
|
+
return enterRound(withAnswer, { index: round, kind: "findings" });
|
|
2560
|
+
}
|
|
2356
2561
|
const checks = r.pr.checks;
|
|
2357
2562
|
const green =
|
|
2358
2563
|
checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
|
|
@@ -2577,6 +2782,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2577
2782
|
reviewRounds: s.reviewRounds,
|
|
2578
2783
|
})
|
|
2579
2784
|
: end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
|
|
2785
|
+
if (r.outcome === "conflict") return { state: reenterApprovedRebase(clocked), notes: [] };
|
|
2580
2786
|
if (r.outcome === "refused")
|
|
2581
2787
|
return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
|
|
2582
2788
|
if (r.outcome === "removed") {
|
|
@@ -2641,6 +2847,32 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2641
2847
|
: [],
|
|
2642
2848
|
};
|
|
2643
2849
|
}
|
|
2850
|
+
case "rebase": {
|
|
2851
|
+
const r = ret as Extract<StepReturn, { type: "rebase" }>;
|
|
2852
|
+
if (r.outcome === "refused")
|
|
2853
|
+
return end(clocked, {
|
|
2854
|
+
kind: "merge_refused",
|
|
2855
|
+
pr: p.pr,
|
|
2856
|
+
reason: r.reason,
|
|
2857
|
+
reviewRounds: s.reviewRounds,
|
|
2858
|
+
});
|
|
2859
|
+
if (r.outcome === "conflict")
|
|
2860
|
+
return enterRound(clocked, {
|
|
2861
|
+
index: s.reviewRounds,
|
|
2862
|
+
kind: "rebase",
|
|
2863
|
+
attempt: p.n,
|
|
2864
|
+
});
|
|
2865
|
+
if (!("headSha" in r))
|
|
2866
|
+
return end(clocked, {
|
|
2867
|
+
kind: "merge_refused",
|
|
2868
|
+
pr: p.pr,
|
|
2869
|
+
reason: "the rebase resolver returned no head",
|
|
2870
|
+
reviewRounds: s.reviewRounds,
|
|
2871
|
+
});
|
|
2872
|
+
const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
|
|
2873
|
+
if (r.outcome === "changed") return mandatoryReview(rebased);
|
|
2874
|
+
return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
|
|
2875
|
+
}
|
|
2644
2876
|
case "merge-wait":
|
|
2645
2877
|
return {
|
|
2646
2878
|
state: {
|
|
@@ -2764,7 +2996,7 @@ export interface CommitChecksFacts {
|
|
|
2764
2996
|
* the line is unchanged. */
|
|
2765
2997
|
function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
|
|
2766
2998
|
if (facts?.mergeableState === "dirty")
|
|
2767
|
-
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}
|
|
2999
|
+
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`; the pipeline runner owns the rebase (an unchanged patch carries the approval). The approved work stands.`;
|
|
2768
3000
|
const fixups = facts?.fixupCommits ?? [];
|
|
2769
3001
|
if (fixups.length > 0)
|
|
2770
3002
|
return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — ${fixups.length} unsquashed fix-up commit${fixups.length === 1 ? "" : "s"} on the head (${fixups.join("; ")}): squash into the unit's commit, push, and re-review.`;
|
|
@@ -2909,25 +3141,16 @@ export function renderUnitReport(
|
|
|
2909
3141
|
`No renewal was spent and no second coding child ran — a concluded round is not renewed (a renewal continues a budget that ran out mid-work). Next step: your word in this thread — answer what the child raised, and the unit continues from there. ${reissue}`,
|
|
2910
3142
|
]);
|
|
2911
3143
|
}
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
// resume path — once the receipt stands on the pull request. That path
|
|
2916
|
-
// fires only when the invocation carries NO new task text: re-issuing
|
|
2917
|
-
// with the task would ADOPT the pull request and run a coding round
|
|
2918
|
-
// first (item 10), the very round this ending exists to avoid — so the
|
|
2919
|
-
// held case renders its own re-issue line instead of the shared one.
|
|
3144
|
+
// A human-gated row is a question to a person. The enclosing idle keeps
|
|
3145
|
+
// this unit live; either answer surface wakes a fix round carrying both
|
|
3146
|
+
// the typed finding and the attributed answer.
|
|
2920
3147
|
const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
|
|
2924
|
-
const heldReissue = s.input.generated
|
|
2925
|
-
? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
|
|
2926
|
-
: reissue;
|
|
3148
|
+
if (!shows(verbosity, "verbose"))
|
|
3149
|
+
return `⏸️ Waiting for you: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
|
|
2927
3150
|
return join([
|
|
2928
|
-
`⏸️
|
|
3151
|
+
`⏸️ Waiting for a person after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. The unit stays live; no unanswered fix round was opened.`,
|
|
2929
3152
|
levelLine,
|
|
2930
|
-
`
|
|
3153
|
+
`Reply in this unit thread or comment on the pull request with the receipt. The answer and finding become the fix round's brief, then review runs again.`,
|
|
2931
3154
|
]);
|
|
2932
3155
|
}
|
|
2933
3156
|
case "merge_refused":
|