@nanobpm/nano-workforce 0.209.1 → 0.210.1

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +11 -1
  3. package/app/behindBaseRemedy.test.ts +1 -1
  4. package/app/convergeAdversarial.test.ts +152 -0
  5. package/app/failingChecksSupersede.test.ts +288 -1
  6. package/app/feature.test.ts +31 -0
  7. package/app/feature.ts +12 -0
  8. package/app/github.test.ts +200 -10
  9. package/app/github.ts +365 -59
  10. package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
  11. package/app/mergeLoopBehaviour.test.ts +48 -0
  12. package/app/pullRequestReadModel.test.ts +1 -1
  13. package/app/readiness.test.ts +90 -1
  14. package/app/readiness.ts +81 -3
  15. package/app/service.test.ts +125 -1
  16. package/app/service.ts +63 -1
  17. package/build-info.json +4 -4
  18. package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
  19. package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
  20. package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
  21. package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
  22. package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
  23. package/docs/agent-guide.md +1 -0
  24. package/openapi.yaml +24 -0
  25. package/operations/listActivePrs.test.ts +17 -0
  26. package/operations/startAndMessage.test.ts +80 -0
  27. package/operations/startConvergenceLoop.ts +13 -1
  28. package/package.json +1 -1
  29. package/pages/home.page.json +7 -2
  30. package/resources/processes/convergence-loop.bpmn +8 -2
  31. package/resources/processes/feature.bpmn +1 -0
  32. package/resources/processes/merge-loop.bpmn +6 -2
  33. package/workers/converge-feature/worker.ts +10 -1
  34. package/workers/landed-probe/worker.test.ts +1 -1
  35. package/workers/merge-stall-probe/worker.test.ts +2 -2
  36. package/workers/reconverge/worker.test.ts +50 -0
  37. package/workers/reconverge/worker.ts +7 -0
package/app/github.ts CHANGED
@@ -968,12 +968,16 @@ export interface PrState {
968
968
  * REST fallback can't enumerate checks). Lets `classifyMergeability` hold a merge when a
969
969
  * declared-required check has not yet concluded, without re-deriving conclusions by hand. */
970
970
  pendingCheckNames: string[];
971
- /** The newest concluded conclusion per head check (name → uppercase conclusion, e.g. `SUCCESS` /
972
- * `FAILURE` / `NEUTRAL` / `SKIPPED`), derived over `latestRunPerCheck` so a `CANCELLED` run
973
- * superseded by a newer green run on the same head reports the green result (#348). A still-pending
974
- * run maps to `""` (it has no conclusion yet — use `pendingCheckNames`). Empty in token mode. Lets
975
- * `classifyMergeability` honour a required check's `acceptedConclusions` precisely. */
976
- checkConclusions: Record<string, string>;
971
+ /** The terminal conclusion of EVERY retained gate of each head check (name → one uppercase
972
+ * conclusion per non-superseded gate, e.g. `["SUCCESS"]` or `["FAILURE","SUCCESS"]` when
973
+ * same-named runs from different events are different gates, #955), derived over
974
+ * `latestRunPerCheck` so a `CANCELLED` run superseded by a newer green run on the same head
975
+ * reports the green result (#348). A still-pending gate contributes `""` (it has no conclusion
976
+ * yet — use `pendingCheckNames`). Empty in token mode. Keeping each gate's conclusion (rather than
977
+ * one reduced value per name) lets `classifyMergeability` honour a required check's
978
+ * `acceptedConclusions` precisely: EVERY retained gate must be accepted, and a settled rejection
979
+ * routes to fix-ci even while a same-named sibling gate is still pending. */
980
+ checkGateConclusions: Record<string, string[]>;
977
981
  /** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
978
982
  isDraft: boolean;
979
983
  /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
@@ -1022,12 +1026,30 @@ interface RollupEntry {
1022
1026
  name?: string;
1023
1027
  context?: string;
1024
1028
  workflowName?: string;
1029
+ /** The GitHub event that triggered the run (e.g. `pull_request` / `push`), read from GraphQL
1030
+ * `CheckRun.checkSuite.workflowRun.event` (there is no `CheckRun.event` field; the `gh pr view
1031
+ * --json statusCheckRollup` exporter omits it, so `fetchPrState` enriches it — see
1032
+ * {@link enrichRollupEvents}). Two runs sharing a display name but triggered by different events
1033
+ * are DIFFERENT gates — a ruleset / branch-protection required context gates the `pull_request`
1034
+ * run, so a green `push` run must never collapse away a red `pull_request` run of the same name
1035
+ * (issue #955). Absent on legacy StatusContext entries and older payloads; runs without it keep
1036
+ * the name-only identity. */
1037
+ event?: string;
1025
1038
  /** CheckRun timestamps (GraphQL `statusCheckRollup`). A superseded run and the newer run that
1026
1039
  * replaced it carry the same check name but different times, so they order the runs of one check.
1027
1040
  * StatusContext carries `createdAt` instead. All are ISO-8601 or absent. */
1028
1041
  startedAt?: string;
1029
1042
  completedAt?: string;
1030
1043
  createdAt?: string;
1044
+ /** The CheckRun's details URL (`gh pr view --json statusCheckRollup` exports it, and GraphQL
1045
+ * `CheckRun.detailsUrl` returns it). It embeds the run/job id, so it is an IMMUTABLE identity
1046
+ * present in BOTH reads — the primary join key of {@link applyRollupEvents}. */
1047
+ detailsUrl?: string;
1048
+ /** The check run's IMMUTABLE GitHub identity (REST/GraphQL `databaseId`). Stamped by
1049
+ * {@link enrichRollupEvents} when it joins the event read onto the rollup, so the join is by
1050
+ * identity — never by array position, which can stamp one run's event onto another when the two
1051
+ * reads observe different runs (PR #957 review). Absent on legacy/un-enriched payloads. */
1052
+ databaseId?: number;
1031
1053
  }
1032
1054
 
1033
1055
  /** The canonical identity of a check across its reruns: its name (CheckRun) or context
@@ -1038,6 +1060,32 @@ function checkKey(c: RollupEntry): string {
1038
1060
  return c.name || c.context || c.workflowName || "check";
1039
1061
  }
1040
1062
 
1063
+ /** The GATE identity a run is collapsed under (issue #955): the display identity ({@link checkKey})
1064
+ * plus, when known, the triggering event. Same name is NOT the same gate when the runs come from
1065
+ * different workflows or different triggering events — one workflow file with both `pull_request`
1066
+ * and `push` jobs named `build` puts two runs named `build` on the same head SHA, and the ruleset /
1067
+ * branch-protection required context gates the `pull_request` one. Grouping by name alone kept the
1068
+ * NEWER run, so a green `push` `build` collapsed away a red required `pull_request` `build` and the
1069
+ * merge loop never saw the failed required check (camunda/console-api-js#17). Runs whose event is
1070
+ * unknown (legacy StatusContext, older payloads) share one name-only gate, exactly as pre-#955, so
1071
+ * the #348 cancelled-rerun collapse is unchanged for them; a known event never merges with the
1072
+ * unknown-event bucket, so an eventful red run is never shadowed by an event-less green one.
1073
+ *
1074
+ * The key is an UNAMBIGUOUS tuple, not a bare concatenation: `${name.length}:${name}${event}`, with
1075
+ * the event LOWER-CASE-normalised and the empty event encoded as the SAME tuple (never a bare
1076
+ * name). Concatenating `name + event` lets two DIFFERENT identities collide — `name:"build",
1077
+ * event:"push"` and an event-less `context:"buildpush"` both produce `"buildpush"`. Encoding only
1078
+ * the known-event bucket leaves the same hole across buckets: `name:"build", event:"push"` yields
1079
+ * `5:buildpush` and an event-less `context:"5:buildpush"` ALSO yields `5:buildpush`, so a newer
1080
+ * green entry could still discard an unrelated red check (PR #957 review). Encoding EVERY entry —
1081
+ * event-less ones included — makes the name/event boundary explicit for all, so no `(name, event)`
1082
+ * pair can alias a different pair. */
1083
+ function gateKey(c: RollupEntry): string {
1084
+ const event = (c.event || "").trim().toLowerCase();
1085
+ const name = checkKey(c);
1086
+ return `${name.length}:${name}${event}`;
1087
+ }
1088
+
1041
1089
  /** A run's ordering timestamp (newest wins): its completion, else its start, else its creation.
1042
1090
  * `0` when none is present (the shape carries no time) so a timed run always outranks an untimed
1043
1091
  * one. */
@@ -1055,17 +1103,22 @@ function isCancelled(c: RollupEntry): boolean {
1055
1103
  return (c.conclusion || c.state || "").toUpperCase() === "CANCELLED";
1056
1104
  }
1057
1105
 
1058
- /** Collapse a head's rollup to the **newest run per check**. GitHub's CI-concurrency cancellation
1106
+ /** Collapse a head's rollup to the **newest run per gate**. GitHub's CI-concurrency cancellation
1059
1107
  * (issue #348) leaves BOTH a superseded run (stamped `CANCELLED`) and the newer run that replaced
1060
1108
  * it on the *same head SHA*, under the same check name. Counting the stale `CANCELLED` as a failure
1061
1109
  * escalates a self-healing PR whose head is actually green. The rollup is already scoped to the head
1062
- * commit, so grouping by check name and keeping the newest run per name yields one ground-truth
1063
- * conclusion per `(headSha, checkName)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED`
1064
- * run, so a superseded cancellation never shadows the real result even when GitHub omits times. */
1110
+ * commit, so grouping by gate and keeping the newest run per gate yields one ground-truth
1111
+ * conclusion per `(headSha, gate)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED`
1112
+ * run, so a superseded cancellation never shadows the real result even when GitHub omits times.
1113
+ *
1114
+ * The gate is the check's display identity PLUS its triggering event when known (issue #955): two
1115
+ * runs named `build` from `pull_request` and `push` are different gates, so a newer green push run
1116
+ * no longer collapses away a red required PR run. Within one `(name, event)` gate the #348
1117
+ * cancelled-rerun collapse is preserved verbatim. */
1065
1118
  export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
1066
1119
  const newest = new Map<string, RollupEntry>();
1067
1120
  for (const c of rollup) {
1068
- const key = checkKey(c);
1121
+ const key = gateKey(c);
1069
1122
  const prev = newest.get(key);
1070
1123
  if (prev === undefined) {
1071
1124
  newest.set(key, c);
@@ -1086,14 +1139,22 @@ export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
1086
1139
  * both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext
1087
1140
  * shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which
1088
1141
  * gates to make green; `failingChecks` (the count) is derived from this list. Derivation is over the
1089
- * **newest run per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green
1090
- * run on the identical head SHA is not counted as a failing gate (issue #348). */
1142
+ * **newest run per gate** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green
1143
+ * run on the identical head SHA is not counted as a failing gate (issue #348). A name is failed
1144
+ * when ANY non-superseded gate of that name is failed (issue #955): a green `push` run must not
1145
+ * satisfy a red `pull_request` run of the same name, so the names are deduped to the display name —
1146
+ * the required context (e.g. `build`) the fix-ci agent has to make green. */
1091
1147
  export function failingCheckNames(rollup: RollupEntry[]): string[] {
1092
1148
  const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
1093
1149
  const names: string[] = [];
1150
+ const seen = new Set<string>();
1094
1151
  for (const c of latestRunPerCheck(rollup)) {
1095
1152
  const v = (c.conclusion || c.state || "").toUpperCase();
1096
- if (bad.has(v)) names.push(checkKey(c));
1153
+ const name = checkKey(c);
1154
+ if (bad.has(v) && !seen.has(name)) {
1155
+ seen.add(name);
1156
+ names.push(name);
1157
+ }
1097
1158
  }
1098
1159
  return names;
1099
1160
  }
@@ -1101,20 +1162,26 @@ export function failingCheckNames(rollup: RollupEntry[]): string[] {
1101
1162
  /** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a
1102
1163
  * hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but
1103
1164
  * COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest
1104
- * run per check (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
1105
- * linger as pending. A `checks-green` gate MUST count these so it never reports green while a run has
1106
- * not yet concluded (a pending run has no failing conclusion, so it would otherwise slip through). */
1165
+ * run per gate (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
1166
+ * linger as pending; deduped to the display name so same-named runs from different events (issue
1167
+ * #955) list the gate once. A `checks-green` gate MUST count these so it never reports green while a
1168
+ * run has not yet concluded (a pending run has no failing conclusion, so it would otherwise slip
1169
+ * through). */
1107
1170
  export function pendingCheckNames(rollup: RollupEntry[]): string[] {
1108
1171
  const names: string[] = [];
1172
+ const seen = new Set<string>();
1109
1173
  for (const c of latestRunPerCheck(rollup)) {
1110
1174
  const status = (c.status || "").toUpperCase();
1111
- if (status !== "") {
1112
- // CheckRun: anything other than COMPLETED is still running/queued.
1113
- if (status !== "COMPLETED") names.push(checkKey(c));
1114
- } else {
1115
- // Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
1116
- const state = (c.state || "").toUpperCase();
1117
- if (state === "PENDING" || state === "EXPECTED") names.push(checkKey(c));
1175
+ const name = checkKey(c);
1176
+ const pending =
1177
+ status !== ""
1178
+ ? // CheckRun: anything other than COMPLETED is still running/queued.
1179
+ status !== "COMPLETED"
1180
+ : // Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
1181
+ (c.state || "").toUpperCase() === "PENDING" || (c.state || "").toUpperCase() === "EXPECTED";
1182
+ if (pending && !seen.has(name)) {
1183
+ seen.add(name);
1184
+ names.push(name);
1118
1185
  }
1119
1186
  }
1120
1187
  return names;
@@ -1124,35 +1191,84 @@ export function pendingCheckNames(rollup: RollupEntry[]): string[] {
1124
1191
  * (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
1125
1192
  * repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
1126
1193
  * Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened.
1127
- * Deduped to the newest run per check so a superseded rerun doesn't list a check name twice. */
1194
+ * Deduped to the newest run per gate so a superseded rerun doesn't list a check name twice, and
1195
+ * deduped to the display name so same-named runs from different events (issue #955) report the
1196
+ * required context once — presence is a per-name question. */
1128
1197
  export function allCheckNames(rollup: RollupEntry[]): string[] {
1129
1198
  const names: string[] = [];
1199
+ const seen = new Set<string>();
1130
1200
  for (const c of latestRunPerCheck(rollup)) {
1131
1201
  const name = c.name || c.context || c.workflowName;
1132
- if (name) names.push(name);
1202
+ if (name && !seen.has(name)) {
1203
+ seen.add(name);
1204
+ names.push(name);
1205
+ }
1133
1206
  }
1134
1207
  return names;
1135
1208
  }
1136
1209
 
1137
- /** The ground-truth conclusion of each head check, keyed by check name, derived over the **newest run
1138
- * per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green run on the
1139
- * identical head SHA reports the green result, not the stale cancellation (issue #348). The value is
1140
- * the run's `conclusion` (CheckRun) or `state` (legacy StatusContext), upper-cased; a still-in-flight
1141
- * run that has not concluded maps to `""` (it has no conclusion — `pendingCheckNames` tracks those).
1142
- * In-flight is normalised to `""` for BOTH shapes: a CheckRun whose `status` is not `COMPLETED`, and a
1143
- * legacy StatusContext whose `state` is `PENDING`/`EXPECTED`, so a caller never mistakes a pending
1144
- * status-context's `PENDING`/`EXPECTED` `state` for a terminal conclusion.
1145
- * Lets `classifyMergeability` intersect a repo's declared `requiredChecks` against actual head
1146
- * conclusions and honour each check's `acceptedConclusions` without re-deriving per-run state. */
1147
- export function checkConclusions(rollup: RollupEntry[]): Record<string, string> {
1148
- const out: Record<string, string> = {};
1210
+ /** The ground-truth terminal conclusion of EVERY retained gate of a check, keyed by check name,
1211
+ * derived over the **newest run per gate** (`latestRunPerCheck`) so a `CANCELLED` run superseded by
1212
+ * a newer green run on the identical head SHA reports the green result, not the stale cancellation
1213
+ * (issue #348). Each value is that gate run's `conclusion` (CheckRun) or `state` (legacy
1214
+ * StatusContext), upper-cased; a still-in-flight gate maps to `""` (it has no conclusion —
1215
+ * `pendingCheckNames` tracks those). In-flight is normalised to `""` for BOTH shapes: a CheckRun
1216
+ * whose `status` is not `COMPLETED`, and a legacy StatusContext whose `state` is
1217
+ * `PENDING`/`EXPECTED`, so a caller never mistakes a pending status-context's `PENDING`/`EXPECTED`
1218
+ * `state` for a terminal conclusion.
1219
+ *
1220
+ * Every retained gate KEEPS its own conclusion (issue #955): when same-named runs come from
1221
+ * DIFFERENT triggering events they are different gates, so the name maps to ONE ENTRY PER GATE —
1222
+ * e.g. a red `pull_request` `build` and a green `push` `build` yield `build → ["FAILURE",
1223
+ * "SUCCESS"]`. No cross-gate reduction happens here: preserving each gate's terminal conclusion is
1224
+ * what lets `requiredChecksVerdict` (a) route a settled FAILURE to fix-ci even while a same-named
1225
+ * sibling gate is still pending, and (b) require EVERY retained gate of a required name to satisfy
1226
+ * `acceptedConclusions` — a `SUCCESS` gate must not let a same-named `NEUTRAL`/`SKIPPED` gate pass a
1227
+ * success-only protocol, and a pending gate must not delay a settled rejection's remediation. */
1228
+ export function checkGateConclusions(rollup: RollupEntry[]): Record<string, string[]> {
1229
+ // A PROTOTYPE-FREE dictionary: a check can legitimately be named `constructor`, `toString`, or
1230
+ // `__proto__` (GitHub imposes no restriction), and on a plain `{}` those resolve to an INHERITED
1231
+ // property — `out[name] ?? []` would return a function/object, so `gates.push` throws and aborts
1232
+ // `fetchPrState` / readiness parsing for that PR (PR #957 review). `Object.create(null)` makes
1233
+ // every check name a safe own-key.
1234
+ const out: Record<string, string[]> = Object.create(null);
1149
1235
  for (const c of latestRunPerCheck(rollup)) {
1150
1236
  const status = (c.status || "").toUpperCase();
1151
1237
  const state = (c.state || "").toUpperCase();
1152
1238
  // A still-in-flight run has no terminal conclusion — normalise both shapes to "" (mirrors
1153
1239
  // `pendingCheckNames`): CheckRun status != COMPLETED, or legacy StatusContext state PENDING/EXPECTED.
1154
1240
  const inFlight = status !== "" ? status !== "COMPLETED" : state === "PENDING" || state === "EXPECTED";
1155
- out[checkKey(c)] = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase();
1241
+ const conclusion = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase();
1242
+ const name = checkKey(c);
1243
+ const gates = out[name] ?? [];
1244
+ gates.push(conclusion);
1245
+ out[name] = gates;
1246
+ }
1247
+ return out;
1248
+ }
1249
+
1250
+ /** The ground-truth conclusion of each head check, keyed by check name — a SINGLE value per name,
1251
+ * reduced from {@link checkGateConclusions} failure-first: a hard failure (`FAILURE`, `TIMED_OUT`,
1252
+ * `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `ERROR`) on ANY non-superseded gate wins over
1253
+ * another gate's green (a green `push` run must not satisfy a red required `pull_request` run of
1254
+ * the same name, issue #955), then a concluded result wins over a still-in-flight `""`, so a pending
1255
+ * gate never masks a settled one. This is the lossy single-conclusion projection kept for callers
1256
+ * that want one answer per name; the mergeability gate uses {@link checkGateConclusions} instead so
1257
+ * no retained gate's terminal conclusion is collapsed away before `acceptedConclusions` is honoured. */
1258
+ export function checkConclusions(rollup: RollupEntry[]): Record<string, string> {
1259
+ const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
1260
+ const out: Record<string, string> = {};
1261
+ for (const [name, gates] of Object.entries(checkGateConclusions(rollup))) {
1262
+ let best = "";
1263
+ for (const g of gates) {
1264
+ // A hard failure wins across gates; otherwise a settled conclusion beats an in-flight "".
1265
+ if (bad.has(g)) {
1266
+ best = g;
1267
+ break;
1268
+ }
1269
+ if (best === "" && g !== "") best = g;
1270
+ }
1271
+ out[name] = best;
1156
1272
  }
1157
1273
  return out;
1158
1274
  }
@@ -1256,6 +1372,167 @@ export async function fetchMergeQueueMembership(
1256
1372
  }
1257
1373
  }
1258
1374
 
1375
+ /** GraphQL response for the per-check triggering-event enrichment. Each `CheckRun` carries its
1376
+ * immutable `databaseId` plus its `checkSuite.workflowRun.event` (e.g. `pull_request` / `push`); a
1377
+ * `StatusContext` (or a CheckRun whose suite/run is unreadable) contributes no event. `pageInfo`
1378
+ * drives the pagination loop (`contexts(first:100)` is per-page, so >100 contexts need the cursor). */
1379
+ interface CheckEventsResponse {
1380
+ data?: {
1381
+ repository?: {
1382
+ pullRequest?: {
1383
+ commits?: {
1384
+ nodes?: {
1385
+ commit?: {
1386
+ oid?: string;
1387
+ statusCheckRollup?: {
1388
+ contexts?: { nodes?: unknown[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null } | null;
1389
+ } | null;
1390
+ };
1391
+ }[];
1392
+ };
1393
+ } | null;
1394
+ } | null;
1395
+ } | null;
1396
+ }
1397
+
1398
+ /** Stamp each rollup entry's `event` from the GraphQL `statusCheckRollup.contexts` nodes, joining by
1399
+ * the run's IMMUTABLE identity — never by array position (PR #957 review): the `gh pr view` read and
1400
+ * this GraphQL read are two SEPARATE GitHub calls that can observe different runs or heads, so index
1401
+ * `i` in one is not reliably run `i` in the other, and a positional join can stamp one run's event
1402
+ * onto another (the name guard cannot catch it for duplicate `build` names, re-creating the very
1403
+ * cross-event collapse #955 fixed). The join key is the CheckRun `databaseId` — but the CLI's
1404
+ * `statusCheckRollup` exporter omits the id, so the join is two-step: (1) bucket BOTH sides by
1405
+ * `(name, startedAt)` — a run's start instant plus its display name identifies one run within a
1406
+ * head — then (2) stamp the node's `event` (and `databaseId`, so a later reader can re-join) onto
1407
+ * every rollup entry in the matching bucket. A bucket whose sizes disagree between the two reads is
1408
+ * left event-less (the reads observed different run sets — stamping anything would risk a
1409
+ * cross-run stamp), as is any entry with no matching bucket. Mutates `rollup` in place. Exported for
1410
+ * the transport regression test. */
1411
+ export function applyRollupEvents(rollup: RollupEntry[], nodes: unknown[]): void {
1412
+ /** The within-a-head identity of one run: its display name + start instant. Two reruns of one
1413
+ * check start at different instants; two different checks have different names — so the pair
1414
+ * singles out one run unless the two reads genuinely disagree (handled by the size check). */
1415
+ const bucketKey = (name: string, startedAt: string): string => `${name.length}:${name}${startedAt}`;
1416
+ type Stamp = { event: string; databaseId?: number };
1417
+ const nodeBuckets = new Map<string, Stamp[]>();
1418
+ const byUrl = new Map<string, Stamp | null>(); // null = duplicate URL → ambiguous, never joined
1419
+ for (const node of nodes) {
1420
+ if (typeof node !== "object" || node === null) continue;
1421
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1422
+ const n = node as {
1423
+ __typename?: string;
1424
+ databaseId?: number | null;
1425
+ name?: string;
1426
+ startedAt?: string | null;
1427
+ detailsUrl?: string | null;
1428
+ checkSuite?: { workflowRun?: { event?: string | null } | null } | null;
1429
+ };
1430
+ if (n.__typename !== "CheckRun") continue; // a StatusContext carries no triggering event
1431
+ const event = n.checkSuite?.workflowRun?.event;
1432
+ if (typeof event !== "string" || event.trim() === "") continue; // unreadable run — nothing to stamp
1433
+ const stamp: Stamp = { event, ...(typeof n.databaseId === "number" ? { databaseId: n.databaseId } : {}) };
1434
+ if (typeof n.detailsUrl === "string" && n.detailsUrl !== "") {
1435
+ byUrl.set(n.detailsUrl, byUrl.has(n.detailsUrl) ? null : stamp);
1436
+ }
1437
+ const name = n.name ?? "";
1438
+ const startedAt = typeof n.startedAt === "string" ? n.startedAt : "";
1439
+ const bucket = nodeBuckets.get(bucketKey(name, startedAt)) ?? [];
1440
+ bucket.push(stamp);
1441
+ nodeBuckets.set(bucketKey(name, startedAt), bucket);
1442
+ }
1443
+ const stampEntry = (entry: RollupEntry, stamp: Stamp): void => {
1444
+ entry.event = stamp.event;
1445
+ if (stamp.databaseId !== undefined) entry.databaseId = stamp.databaseId;
1446
+ };
1447
+ const rollupBuckets = new Map<string, RollupEntry[]>();
1448
+ for (const entry of rollup) {
1449
+ // (1) Immutable identity: the details URL embeds the run/job id and is present in BOTH reads.
1450
+ const url = entry.detailsUrl;
1451
+ if (typeof url === "string" && url !== "" && byUrl.has(url)) {
1452
+ const stamp = byUrl.get(url);
1453
+ if (stamp) stampEntry(entry, stamp);
1454
+ continue; // joined (or ambiguous) by identity — never fall back to the heuristic bucket
1455
+ }
1456
+ const name = entry.name || entry.workflowName || "";
1457
+ const startedAt = entry.startedAt ?? "";
1458
+ const bucket = rollupBuckets.get(bucketKey(name, startedAt)) ?? [];
1459
+ bucket.push(entry);
1460
+ rollupBuckets.set(bucketKey(name, startedAt), bucket);
1461
+ }
1462
+ // (2) Fallback for entries without a joinable URL: a `(name, startedAt)` bucket. NEVER assign by
1463
+ // position within a multi-run bucket (PR #957 review) — stamp only when the bucket sizes agree AND
1464
+ // every node in it carries the SAME event, so the stamp is order-independent. A mixed-event
1465
+ // multi-run bucket stays event-less rather than risk swapping events across runs.
1466
+ for (const [key, entries] of rollupBuckets) {
1467
+ const nodeBucket = nodeBuckets.get(key);
1468
+ if (nodeBucket === undefined || nodeBucket.length !== entries.length) continue;
1469
+ if (nodeBucket.length === 1) {
1470
+ stampEntry(entries[0], nodeBucket[0]);
1471
+ continue;
1472
+ }
1473
+ const event = nodeBucket[0].event;
1474
+ if (!nodeBucket.every((st) => st.event === event)) continue;
1475
+ for (const entry of entries) entry.event = event; // databaseId is ambiguous here — not stamped
1476
+ }
1477
+ }
1478
+
1479
+ /** The ONE GraphQL page query reading each check-run's triggering event (PR #957). Shared by the
1480
+ * merge loop ({@link enrichRollupEvents}) and the readiness probe so the two can never drift; its
1481
+ * brace balance is pinned by a unit test (a malformed query is rejected by GitHub and silently
1482
+ * leaves the rollup event-less). */
1483
+ export const CHECK_EVENTS_QUERY =
1484
+ "query($o:String!,$r:String!,$n:Int!,$after:String){repository(owner:$o,name:$r){pullRequest(number:$n){" +
1485
+ "commits(last:1){nodes{commit{oid statusCheckRollup{contexts(first:100,after:$after){nodes{" +
1486
+ "__typename ... on CheckRun{databaseId name startedAt detailsUrl checkSuite{workflowRun{event}}}}" +
1487
+ "pageInfo{hasNextPage endCursor}}}}}}}}}";
1488
+
1489
+ /** Populate each rollup entry's `event` from GraphQL, because the `gh pr view --json
1490
+ * statusCheckRollup` exporter omits it (cli/cli `api/export_pr.go`) — without it every run takes the
1491
+ * name-only gate fallback and a green `push` run collapses away a red required `pull_request` run of
1492
+ * the same name, leaving #955 unfixed on the real transport (PR #957 review). The event lives at
1493
+ * `CheckRun.checkSuite.workflowRun.event` (there is no `CheckRun.event` field). Pages the contexts
1494
+ * connection to exhaustion (`pageInfo.endCursor`) so >100 checks are all enriched, and verifies the
1495
+ * read observed the SAME head SHA as the rollup (`headSha`) before stamping — a head that moved
1496
+ * between the two reads aborts the stamp rather than joining across heads. Never throws and never
1497
+ * blocks the read: any transport/parse failure leaves the rollup event-less (pre-#955 behaviour)
1498
+ * rather than wedging the merge loop. Mutates `rollup` in place. */
1499
+ export async function enrichRollupEvents(
1500
+ repo: string,
1501
+ number: number | string,
1502
+ rollup: RollupEntry[],
1503
+ headSha?: string,
1504
+ ): Promise<void> {
1505
+ if (rollup.length === 0) return;
1506
+ const [owner, name] = repo.split("/");
1507
+ const nodes: unknown[] = [];
1508
+ let after: string | null = null;
1509
+ try {
1510
+ // Page the contexts connection to exhaustion: `first:100` returns one page, and a PR with >100
1511
+ // contexts would leave every later page event-less (cross-event failures hidden there) and split
1512
+ // a same-event cancelled/rerun pair across the boundary into two gates (PR #957 review).
1513
+ for (;;) {
1514
+ const query = CHECK_EVENTS_QUERY;
1515
+ const args = ["api", "graphql", "-f", `query=${query}`, "-f", `o=${owner}`, "-f", `r=${name}`, "-F", `n=${number}`];
1516
+ if (after !== null) args.push("-f", `after=${after}`);
1517
+ const out = await runGh(args);
1518
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1519
+ const payload = JSON.parse(out) as CheckEventsResponse;
1520
+ const commit = payload.data?.repository?.pullRequest?.commits?.nodes?.[0]?.commit;
1521
+ // Head-pin: the rollup came from an earlier `gh pr view` read; if the head moved since, these
1522
+ // contexts belong to a DIFFERENT head — stamping them would join events across heads.
1523
+ if (typeof headSha === "string" && headSha !== "" && commit?.oid !== undefined && commit.oid !== headSha) return;
1524
+ const contexts = commit?.statusCheckRollup?.contexts;
1525
+ nodes.push(...(contexts?.nodes ?? []));
1526
+ const pageInfo = contexts?.pageInfo;
1527
+ if (pageInfo?.hasNextPage !== true || typeof pageInfo.endCursor !== "string" || pageInfo.endCursor === "") break;
1528
+ after = pageInfo.endCursor;
1529
+ }
1530
+ } catch {
1531
+ return; // a transport/parse hiccup leaves the rollup event-less (conservative name-only gates)
1532
+ }
1533
+ applyRollupEvents(rollup, nodes);
1534
+ }
1535
+
1259
1536
  export async function fetchPrState(
1260
1537
  repo: string,
1261
1538
  number: number | string,
@@ -1283,6 +1560,12 @@ export async function fetchPrState(
1283
1560
  baseRefName?: string | null;
1284
1561
  };
1285
1562
  const rollup = j.statusCheckRollup ?? [];
1563
+ // The `gh pr view --json statusCheckRollup` exporter does NOT emit a run's triggering `event`
1564
+ // (cli/cli `api/export_pr.go` projects only name/status/conclusion/timestamps/workflowName), so a
1565
+ // raw rollup would take the name-only fallback and a green `push` run would still hide a red
1566
+ // `pull_request` run of the same name — leaving #955 unfixed on the real transport. Enrich each
1567
+ // entry with its event (GraphQL `CheckRun.checkSuite.workflowRun.event`) before grouping.
1568
+ await enrichRollupEvents(repo, number, rollup, j.headRefOid ?? undefined);
1286
1569
  const names = failingCheckNames(rollup);
1287
1570
  const merged = j.state === "MERGED" || !!j.mergedAt;
1288
1571
  // Probe native-queue membership only for the queued-PR reconciliation (block 4) — it is an extra
@@ -1300,7 +1583,7 @@ export async function fetchPrState(
1300
1583
  totalChecks: rollup.length,
1301
1584
  presentCheckNames: allCheckNames(rollup),
1302
1585
  pendingCheckNames: pendingCheckNames(rollup),
1303
- checkConclusions: checkConclusions(rollup),
1586
+ checkGateConclusions: checkGateConclusions(rollup),
1304
1587
  isDraft: !!j.isDraft,
1305
1588
  headRefOid: j.headRefOid ?? null,
1306
1589
  mergeQueueEntry,
@@ -1339,7 +1622,7 @@ export async function fetchPrState(
1339
1622
  totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
1340
1623
  presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
1341
1624
  pendingCheckNames: [], // …no per-check pending signal either → classifier degrades to today's switch
1342
- checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode
1625
+ checkGateConclusions: {}, // …no per-gate conclusions → protocol-aware gate falls through in token mode
1343
1626
  isDraft: !!j.draft,
1344
1627
  headRefOid: j.head?.sha ?? null,
1345
1628
  mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
@@ -1679,21 +1962,37 @@ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked"
1679
1962
  let anyBlocked = false;
1680
1963
  let anyPending = false;
1681
1964
  for (const rc of required) {
1682
- // Absent from the head, or still in flight → not-yet-run → wait (never treat absence as pass).
1683
- if (!present.has(rc.name) || pending.has(rc.name)) {
1684
- anyPending = true;
1685
- continue;
1686
- }
1687
- const conclusion = (s.checkConclusions[rc.name] ?? "").toUpperCase();
1688
- if (conclusion === "") {
1689
- // Present but no terminal conclusion yet (and not flagged pending) — treat conservatively as
1690
- // not-yet-concluded rather than as a pass.
1965
+ // Absent from the head entirely → not-yet-run → wait (never treat absence as pass).
1966
+ if (!present.has(rc.name)) {
1691
1967
  anyPending = true;
1692
1968
  continue;
1693
1969
  }
1694
1970
  const accepted = rc.acceptedConclusions.map((a) => a.toUpperCase());
1695
- if (accepted.includes(conclusion)) continue; // satisfied
1696
- anyBlocked = true; // present, concluded, NOT accepted → a red required check
1971
+ // Evaluate EVERY retained gate of this required name against `acceptedConclusions`, and a
1972
+ // SETTLED rejection BEFORE any pending sibling (issue #955). Same-named runs from different
1973
+ // events are different gates, so the name can carry several terminal conclusions. Two distinct
1974
+ // failure modes are guarded here:
1975
+ // • a settled gate whose conclusion is NOT accepted (a hard failure like `FAILURE`, or any
1976
+ // other unaccepted terminal conclusion — e.g. a `NEUTRAL`/`SKIPPED` gate under a
1977
+ // success-only protocol) is a red required check → `blocked`, routed to fix-ci NOW. A
1978
+ // same-named gate still pending must NOT read as `waiting` first and delay that remediation
1979
+ // (a red PR `build` plus an in-progress push `build` is BLOCKED, not "wait").
1980
+ // • a same-named gate that IS accepted (e.g. a `SUCCESS` run) must not satisfy the required
1981
+ // check while a sibling gate settled unaccepted — every retained gate must be accepted.
1982
+ const gates = s.checkGateConclusions[rc.name] ?? [];
1983
+ let namePending = pending.has(rc.name);
1984
+ let nameBlocked = false;
1985
+ for (const g of gates) {
1986
+ const conclusion = g.toUpperCase();
1987
+ if (conclusion === "") {
1988
+ // This gate has not concluded — treat conservatively as not-yet-concluded, not a pass.
1989
+ namePending = true;
1990
+ continue;
1991
+ }
1992
+ if (!accepted.includes(conclusion)) nameBlocked = true; // settled, NOT accepted → red
1993
+ }
1994
+ if (nameBlocked) anyBlocked = true;
1995
+ else if (namePending) anyPending = true;
1697
1996
  }
1698
1997
  // A failing required check outranks a pending one: it needs fix-ci now, not more waiting.
1699
1998
  if (anyBlocked) return "blocked";
@@ -1711,18 +2010,25 @@ const SETTLED_OK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED"]);
1711
2010
  /** Issue #951: is this BLOCKED PR merely waiting for the repo's declared `mergify-queue` land step?
1712
2011
  * True only when the protocol lands via `mergify-queue`, checks are enumerable and settled (none
1713
2012
  * failing, none pending), and every non-SUCCESS/SKIPPED check is a Mergify queue check at NEUTRAL —
1714
- * with at least one such check present. Anything else keeps the conservative `waiting`. */
2013
+ * with at least one such check present. Anything else keeps the conservative `waiting`. With
2014
+ * per-gate conclusions (#955) EVERY retained gate of a name must qualify: a same-named gate that
2015
+ * settled unaccepted (not SUCCESS/SKIPPED, not a Mergify-queue NEUTRAL) disqualifies the name even
2016
+ * when a sibling gate is green. */
1715
2017
  export function awaitingMergifyEnqueue(s: PrState, protocol?: MergeProtocol): boolean {
1716
2018
  if (protocol?.land.method !== "mergify-queue") return false;
1717
2019
  if (s.failingChecks !== 0 || s.pendingCheckNames.length > 0) return false;
1718
2020
  let sawMergifyNeutral = false;
1719
- for (const [name, conclusion] of Object.entries(s.checkConclusions)) {
1720
- if (SETTLED_OK_CONCLUSIONS.has(conclusion)) continue;
1721
- if (conclusion === "NEUTRAL" && isMergifyQueueCheck(name)) {
1722
- sawMergifyNeutral = true;
1723
- continue;
2021
+ for (const [name, gates] of Object.entries(s.checkGateConclusions)) {
2022
+ let nameHasMergifyNeutral = false;
2023
+ for (const conclusion of gates) {
2024
+ if (SETTLED_OK_CONCLUSIONS.has(conclusion)) continue;
2025
+ if (conclusion === "NEUTRAL" && isMergifyQueueCheck(name)) {
2026
+ nameHasMergifyNeutral = true;
2027
+ continue;
2028
+ }
2029
+ return false;
1724
2030
  }
1725
- return false;
2031
+ if (nameHasMergifyNeutral) sawMergifyNeutral = true;
1726
2032
  }
1727
2033
  return sawMergifyNeutral;
1728
2034
  }
@@ -39,7 +39,7 @@ function draftState(overrides: Partial<PrState> = {}): PrState {
39
39
  totalChecks: 0, // no head run yet → frugal-CI stuck state
40
40
  presentCheckNames: [],
41
41
  pendingCheckNames: [],
42
- checkConclusions: {},
42
+ checkGateConclusions: {},
43
43
  isDraft: true,
44
44
  headRefOid: "h1",
45
45
  ...overrides,
@@ -1486,3 +1486,51 @@ test("an abandoned merge ends the whole instance via the root terminate, without
1486
1486
  const open = await engine.searchUserTasks({});
1487
1487
  assert(open.length === 0, "the root terminate must leave nothing running (no re-armed poller, no parked escalation)");
1488
1488
  });
1489
+
1490
+ // Operator steering (issue #956 review): the merge-stage agents must carry the run's custom
1491
+ // instructions in their `appendPrompt` (e.g. a required commit email), and an agent echo must not
1492
+ // clear them for later re-arms.
1493
+ const STEER = "Commit as bot@example.test.";
1494
+ const STEER_BLOCK = `## Operator custom instructions\n\nThe operator supplied these instructions for this run — follow them:\n\n${STEER}`;
1495
+
1496
+ for (const [jobType, mergeState, failingChecks, failingChecksList, status] of [
1497
+ ["senior:fix-ci", "blocked", 1, "build", "fixed"],
1498
+ ["senior:rebase", "conflict", 0, "", "rebased"],
1499
+ ] as const) {
1500
+ test(`${jobType} appendPrompt carries operator custom instructions; an echo cannot clear them (#956)`, async () => {
1501
+ const prompts: string[] = [];
1502
+ const engine = await boot({
1503
+ vars: { customInstructions: STEER },
1504
+ responses: {
1505
+ "pr.merge-stall-probe": { mergeState, failingChecks, failingChecksList },
1506
+ [jobType]: (job) => {
1507
+ prompts.push(String(job.variables.appendPrompt ?? ""));
1508
+ return { status, customInstructions: null };
1509
+ },
1510
+ },
1511
+ });
1512
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
1513
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
1514
+ assert(prompts.length >= 1, `${jobType} was never dispatched`);
1515
+ assertStringIncludes(prompts[0], STEER_BLOCK);
1516
+ assertStringIncludes(prompts[0], "Abort if cancelled");
1517
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasVariable("customInstructions", STEER);
1518
+ });
1519
+
1520
+ test(`${jobType} appendPrompt omits the operator block when no instructions were seeded (#956)`, async () => {
1521
+ const prompts: string[] = [];
1522
+ const engine = await boot({
1523
+ responses: {
1524
+ "pr.merge-stall-probe": { mergeState, failingChecks, failingChecksList },
1525
+ [jobType]: (job) => {
1526
+ prompts.push(String(job.variables.appendPrompt ?? ""));
1527
+ return { status };
1528
+ },
1529
+ },
1530
+ });
1531
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
1532
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
1533
+ assert(prompts.length >= 1, `${jobType} was never dispatched`);
1534
+ assert(!prompts[0].includes("Operator custom instructions"), "absent instructions must not append the block");
1535
+ });
1536
+ }
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "133_pull_requests_read_model_merge_policy.sql";
36
+ const READ_MODEL_MIGRATION = "138_pull_requests_read_model_custom_instructions_text.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.