@coreplane/switchboard 1.260.2 → 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/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +27 -3
- 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/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 +6 -5
- package/dist/assets/src/core/coordinator/contract.ts +48 -2
- package/dist/assets/src/core/coordinator/driver.ts +36 -1
- package/dist/assets/src/core/reviewVerdict.ts +15 -5
- package/dist/assets/src/core/ship/contract.ts +1 -1
- package/dist/assets/src/core/ship/coordinator.ts +206 -78
- package/dist/cli.js +372 -108
- package/package.json +1 -1
|
@@ -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" &&
|
|
@@ -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, ` +
|
|
@@ -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,
|
|
@@ -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,7 +507,12 @@ 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[];
|
|
@@ -522,6 +530,20 @@ export interface PrRef {
|
|
|
522
530
|
url: string;
|
|
523
531
|
}
|
|
524
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
|
+
|
|
525
547
|
/** What the coordinator asks for next. `step` is the Workflow step's name, and
|
|
526
548
|
* for a spawn the second half of the child's idempotency key. */
|
|
527
549
|
export type CoordinatorAction =
|
|
@@ -577,6 +599,10 @@ export type ChildFacts =
|
|
|
577
599
|
| {
|
|
578
600
|
finished: true;
|
|
579
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;
|
|
580
606
|
finalReply?: string;
|
|
581
607
|
/** A coding child's `pr_opened`. */
|
|
582
608
|
pr?: { number: number; url: string; created: boolean };
|
|
@@ -659,6 +685,16 @@ export type PrCheck =
|
|
|
659
685
|
* read beside the checks on the ending's facts read, so a `merge_ready`
|
|
660
686
|
* report tells the person their merge is queued, never a direct one. */
|
|
661
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
|
+
};
|
|
662
698
|
}
|
|
663
699
|
| { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
|
|
664
700
|
|
|
@@ -793,12 +829,9 @@ export type UnitEnding =
|
|
|
793
829
|
* The unit is done and its dependents start on a base that carries it. */
|
|
794
830
|
| { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
|
|
795
831
|
| { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
|
|
796
|
-
/**
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
* nothing: the unit ends held for a person, the ending carrying the
|
|
800
|
-
* human-gated rows, and a re-issue with the pull request resumes at the
|
|
801
|
-
* 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. */
|
|
802
835
|
| {
|
|
803
836
|
kind: "held";
|
|
804
837
|
cause?: undefined;
|
|
@@ -938,6 +971,10 @@ export type UnitEnding =
|
|
|
938
971
|
handoff?: Handoff;
|
|
939
972
|
round?: RoundRef;
|
|
940
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;
|
|
941
978
|
};
|
|
942
979
|
|
|
943
980
|
/** The kinds that idle: every ending but the ended ones — the unit is
|
|
@@ -945,10 +982,9 @@ export type UnitEnding =
|
|
|
945
982
|
* a reply could continue it. `merged`, `already_landed`, `merge_ready` and
|
|
946
983
|
* `refused` never idle: the first two are done, merge-ready waits only for a
|
|
947
984
|
* person's merge, and a refused child would be refused again. A gated or
|
|
948
|
-
* draft `held` never idles either
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
* 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. */
|
|
952
988
|
export type IdleWhy =
|
|
953
989
|
Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
|
|
954
990
|
|
|
@@ -1014,6 +1050,9 @@ export interface LeaseSegmentProgress {
|
|
|
1014
1050
|
previousHandoff?: Handoff;
|
|
1015
1051
|
/** Words that woke an idle segment, already attributed in arrival order. */
|
|
1016
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;
|
|
1017
1056
|
/** A stopped segment reopens under the remainder of its cut lease. Each
|
|
1018
1057
|
* reopen gets a fresh attempt so its Workflow steps cannot replay the
|
|
1019
1058
|
* stopped attempt's cached answers. */
|
|
@@ -1165,6 +1204,8 @@ export interface UnitPipelineState {
|
|
|
1165
1204
|
* ids are unique within one round only. A disposition naming an id the review
|
|
1166
1205
|
* never issued is dropped at the match (`matchDispositions`). */
|
|
1167
1206
|
readonly findingsByRound: Readonly<Record<number, Finding[]>>;
|
|
1207
|
+
/** Human answers folded into a recovered/parked findings brief. */
|
|
1208
|
+
readonly humanAnswersByRound: Readonly<Record<number, string[]>>;
|
|
1168
1209
|
readonly dispositionsByRound: Readonly<Record<number, FindingDisposition[]>>;
|
|
1169
1210
|
readonly reviewRunByRound: Readonly<Record<number, string>>;
|
|
1170
1211
|
/** The coding run each round's findings step dispatched. */
|
|
@@ -1208,20 +1249,29 @@ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
|
|
|
1208
1249
|
// ---- the unit pipeline: opening and the next action -----------------------------------------------
|
|
1209
1250
|
|
|
1210
1251
|
export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipelineState {
|
|
1252
|
+
const pending = input.session?.humanGate;
|
|
1211
1253
|
const base: UnitPipelineState = {
|
|
1212
1254
|
input,
|
|
1213
1255
|
startedAt: at,
|
|
1214
1256
|
clock: at,
|
|
1215
1257
|
phase: { at: "pre-check" },
|
|
1216
|
-
reviewRounds: 0,
|
|
1258
|
+
reviewRounds: pending?.round ?? 0,
|
|
1217
1259
|
rebaseAttempts: 0,
|
|
1218
1260
|
spentMs: { coding: 0, review: 0, waiting: 0 },
|
|
1219
1261
|
spendUsd: input.session?.spendUsd ?? 0,
|
|
1220
|
-
findingsByRound: {},
|
|
1262
|
+
findingsByRound: pending ? { [pending.round]: pending.findings } : {},
|
|
1263
|
+
humanAnswersByRound: pending && input.session?.texts !== undefined ? { [pending.round]: input.session.texts } : {},
|
|
1221
1264
|
dispositionsByRound: {},
|
|
1222
|
-
reviewRunByRound: {},
|
|
1265
|
+
reviewRunByRound: pending?.reviewRunId !== undefined ? { [pending.round]: pending.reviewRunId } : {},
|
|
1223
1266
|
findingsRunByRound: {},
|
|
1267
|
+
...(pending !== undefined
|
|
1268
|
+
? {
|
|
1269
|
+
pr: pending.pr,
|
|
1270
|
+
...(pending.headSha !== undefined ? { lastReviewHead: pending.headSha } : {}),
|
|
1271
|
+
}
|
|
1272
|
+
: {}),
|
|
1224
1273
|
};
|
|
1274
|
+
if (pending !== undefined) return enterRound(base, { index: pending.round, kind: "findings" }).state;
|
|
1225
1275
|
if (!input.resume) return base;
|
|
1226
1276
|
const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
|
|
1227
1277
|
const resumed: UnitPipelineState = {
|
|
@@ -1294,26 +1344,40 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
|
1294
1344
|
return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
|
|
1295
1345
|
}
|
|
1296
1346
|
if (round.kind === "findings") {
|
|
1297
|
-
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];
|
|
1298
1351
|
return {
|
|
1299
1352
|
kind: "findings",
|
|
1300
1353
|
unit,
|
|
1301
1354
|
pr,
|
|
1302
|
-
reviewRunId:
|
|
1355
|
+
...(reviewRunId !== undefined ? { reviewRunId } : {}),
|
|
1356
|
+
...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
|
|
1357
|
+
...(answers !== undefined && answers.length > 0 ? { answers } : {}),
|
|
1303
1358
|
...(checks.length > 0 ? { checks } : {}),
|
|
1304
1359
|
};
|
|
1305
1360
|
}
|
|
1361
|
+
const priorFindings = s.findingsByRound[round.index - 1] ?? [];
|
|
1306
1362
|
const priorReview = s.reviewRunByRound[round.index - 1];
|
|
1307
1363
|
const priorCoding = s.findingsRunByRound[round.index - 1];
|
|
1308
|
-
const priorChecks = checkFindingsOf(
|
|
1364
|
+
const priorChecks = checkFindingsOf(priorFindings);
|
|
1309
1365
|
return {
|
|
1310
1366
|
kind: "review",
|
|
1311
1367
|
unit,
|
|
1312
1368
|
pr,
|
|
1313
1369
|
...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
|
|
1314
1370
|
round: round.index,
|
|
1315
|
-
...(priorReview !== undefined
|
|
1316
|
-
? {
|
|
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
|
+
}
|
|
1317
1381
|
: {}),
|
|
1318
1382
|
...(priorChecks.length > 0 ? { checks: priorChecks } : {}),
|
|
1319
1383
|
};
|
|
@@ -1454,9 +1518,9 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
|
|
|
1454
1518
|
function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
|
|
1455
1519
|
if ((s.input.idleDays ?? 0) <= 0) return undefined;
|
|
1456
1520
|
if (NEVER_IDLES.has(ending.kind)) return undefined;
|
|
1457
|
-
// A
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
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.
|
|
1460
1524
|
if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
|
|
1461
1525
|
const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
|
|
1462
1526
|
const grant = s.input.grant ?? DEFAULT_GRANT;
|
|
@@ -1512,7 +1576,7 @@ const heldEnding = (
|
|
|
1512
1576
|
round: RoundRef,
|
|
1513
1577
|
findings: Finding[],
|
|
1514
1578
|
verdict: "approve" | "request_changes",
|
|
1515
|
-
): UnitEnding => ({
|
|
1579
|
+
): Extract<UnitEnding, { kind: "held" }> => ({
|
|
1516
1580
|
kind: "held",
|
|
1517
1581
|
...(s.pr !== undefined ? { pr: s.pr } : {}),
|
|
1518
1582
|
round,
|
|
@@ -1521,6 +1585,48 @@ const heldEnding = (
|
|
|
1521
1585
|
reviewRounds: s.reviewRounds,
|
|
1522
1586
|
});
|
|
1523
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
|
+
|
|
1524
1630
|
const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
|
|
1525
1631
|
type: "round",
|
|
1526
1632
|
index: round.index,
|
|
@@ -1564,10 +1670,11 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
|
|
|
1564
1670
|
};
|
|
1565
1671
|
}
|
|
1566
1672
|
|
|
1567
|
-
/** A rebase changed the approved patch,
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
|
|
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 {
|
|
1571
1678
|
return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
|
|
1572
1679
|
}
|
|
1573
1680
|
|
|
@@ -1634,13 +1741,16 @@ function settleCoding(
|
|
|
1634
1741
|
[roundNote(round, "stopped")],
|
|
1635
1742
|
);
|
|
1636
1743
|
// A mechanical WIP push means the child did not declare the work ready for
|
|
1637
|
-
// review
|
|
1638
|
-
//
|
|
1639
|
-
|
|
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) {
|
|
1640
1748
|
const cause =
|
|
1641
1749
|
facts.failure?.kind === "provider_transient"
|
|
1642
1750
|
? "the model provider's transport retry budget was spent"
|
|
1643
|
-
:
|
|
1751
|
+
: facts.status === "failed"
|
|
1752
|
+
? "it failed before finishing"
|
|
1753
|
+
: "it ended before its work was ready for review";
|
|
1644
1754
|
return end(
|
|
1645
1755
|
next,
|
|
1646
1756
|
{
|
|
@@ -1766,9 +1876,10 @@ function settleReview(
|
|
|
1766
1876
|
},
|
|
1767
1877
|
notes,
|
|
1768
1878
|
);
|
|
1769
|
-
// Every gated finding is human-gated
|
|
1770
|
-
//
|
|
1771
|
-
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);
|
|
1772
1883
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1773
1884
|
return end(
|
|
1774
1885
|
next,
|
|
@@ -1797,11 +1908,18 @@ function settleReview(
|
|
|
1797
1908
|
},
|
|
1798
1909
|
notes,
|
|
1799
1910
|
);
|
|
1800
|
-
// Every finding of the round is human-gated
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
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.
|
|
1803
1914
|
if (allHumanGated(verdict.findings ?? []))
|
|
1804
|
-
return
|
|
1915
|
+
return parkHumanGate(
|
|
1916
|
+
next,
|
|
1917
|
+
round,
|
|
1918
|
+
verdict.findings ?? [],
|
|
1919
|
+
"request_changes",
|
|
1920
|
+
facts.reviewAskedAt ?? facts.finishedAt ?? s.clock,
|
|
1921
|
+
notes,
|
|
1922
|
+
);
|
|
1805
1923
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1806
1924
|
return end(
|
|
1807
1925
|
next,
|
|
@@ -2303,12 +2421,14 @@ function roundOnOpenPr(
|
|
|
2303
2421
|
},
|
|
2304
2422
|
[roundNote(round, "aborted")],
|
|
2305
2423
|
);
|
|
2306
|
-
return
|
|
2424
|
+
return mandatoryReview({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
|
|
2307
2425
|
}
|
|
2426
|
+
const hasHumanAnswer = round.kind === "findings" && (s.humanAnswersByRound[round.index]?.length ?? 0) > 0;
|
|
2308
2427
|
if (round.kind === "findings") {
|
|
2309
2428
|
// Nothing repushed → nothing to re-review, unless every finding of the
|
|
2310
2429
|
// last review was declined on the record: that re-review verifies the
|
|
2311
|
-
// 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.
|
|
2312
2432
|
const codingHead = normalizeHead(head);
|
|
2313
2433
|
const reviewedAt = normalizeHead(s.lastReviewHead);
|
|
2314
2434
|
if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
|
|
@@ -2339,27 +2459,30 @@ function roundOnOpenPr(
|
|
|
2339
2459
|
},
|
|
2340
2460
|
[roundNote(round, "aborted")],
|
|
2341
2461
|
);
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
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
|
+
}
|
|
2358
2480
|
}
|
|
2359
2481
|
}
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
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);
|
|
2363
2486
|
}
|
|
2364
2487
|
|
|
2365
2488
|
/** Move the clock and charge the elapsed time to the budget's bucket: a phase
|
|
@@ -2421,6 +2544,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2421
2544
|
pr: { number: r.pr.prNumber, url: r.pr.url },
|
|
2422
2545
|
lastReviewHead: entryHead,
|
|
2423
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
|
+
}
|
|
2424
2561
|
const checks = r.pr.checks;
|
|
2425
2562
|
const green =
|
|
2426
2563
|
checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
|
|
@@ -2733,7 +2870,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2733
2870
|
reviewRounds: s.reviewRounds,
|
|
2734
2871
|
});
|
|
2735
2872
|
const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
|
|
2736
|
-
if (r.outcome === "changed") return
|
|
2873
|
+
if (r.outcome === "changed") return mandatoryReview(rebased);
|
|
2737
2874
|
return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
|
|
2738
2875
|
}
|
|
2739
2876
|
case "merge-wait":
|
|
@@ -3004,25 +3141,16 @@ export function renderUnitReport(
|
|
|
3004
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}`,
|
|
3005
3142
|
]);
|
|
3006
3143
|
}
|
|
3007
|
-
//
|
|
3008
|
-
//
|
|
3009
|
-
//
|
|
3010
|
-
// resume path — once the receipt stands on the pull request. That path
|
|
3011
|
-
// fires only when the invocation carries NO new task text: re-issuing
|
|
3012
|
-
// with the task would ADOPT the pull request and run a coding round
|
|
3013
|
-
// first (item 10), the very round this ending exists to avoid — so the
|
|
3014
|
-
// 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.
|
|
3015
3147
|
const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
|
|
3019
|
-
const heldReissue = s.input.generated
|
|
3020
|
-
? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
|
|
3021
|
-
: reissue;
|
|
3148
|
+
if (!shows(verbosity, "verbose"))
|
|
3149
|
+
return `⏸️ Waiting for you: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
|
|
3022
3150
|
return join([
|
|
3023
|
-
`⏸️
|
|
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.`,
|
|
3024
3152
|
levelLine,
|
|
3025
|
-
`
|
|
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.`,
|
|
3026
3154
|
]);
|
|
3027
3155
|
}
|
|
3028
3156
|
case "merge_refused":
|