@nanobpm/nano-workforce 0.209.0 → 0.210.0

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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +11 -1
  3. package/app/behindBaseRemedy.test.ts +115 -0
  4. package/app/behindBaseRemedy.ts +95 -0
  5. package/app/convergeAdversarial.test.ts +152 -0
  6. package/app/feature.test.ts +31 -0
  7. package/app/feature.ts +12 -0
  8. package/app/github.test.ts +137 -2
  9. package/app/github.ts +137 -1
  10. package/app/mergeLoopBehaviour.test.ts +97 -0
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/service.test.ts +125 -1
  13. package/app/service.ts +70 -1
  14. package/build-info.json +4 -4
  15. package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
  16. package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
  17. package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
  18. package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
  19. package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
  20. package/docs/agent-guide.md +1 -0
  21. package/openapi.yaml +24 -0
  22. package/operations/listActivePrs.test.ts +17 -0
  23. package/operations/startAndMessage.test.ts +80 -0
  24. package/operations/startConvergenceLoop.ts +13 -1
  25. package/package.json +1 -1
  26. package/pages/home.page.json +7 -2
  27. package/resources/processes/convergence-loop.bpmn +8 -2
  28. package/resources/processes/feature.bpmn +1 -0
  29. package/resources/processes/merge-loop.bpmn +8 -3
  30. package/workers/converge-feature/worker.ts +10 -1
  31. package/workers/merge-stall-probe/worker.test.ts +119 -0
  32. package/workers/merge-stall-probe/worker.ts +69 -8
  33. package/workers/reconverge/worker.test.ts +50 -0
  34. package/workers/reconverge/worker.ts +7 -0
  35. package/workers/request-review/worker.test.ts +27 -0
  36. package/workers/request-review/worker.ts +42 -2
package/app/github.ts CHANGED
@@ -716,7 +716,10 @@ export async function requestCopilotReview(
716
716
  const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
717
717
  if (await useGh()) {
718
718
  try {
719
- await runGh(["api", path, "-X", "POST", "-f", `reviewers[]=${COPILOT_REVIEWER}`]);
719
+ // `gh pr edit --add-reviewer @copilot` (issue #951): the REST POST with the bot login can add a
720
+ // `review_requested` event yet never start Copilot (`copilot_work_started` never appears); the
721
+ // gh-native @copilot reviewer path is the one that reliably triggers the review.
722
+ await runGh(["pr", "edit", String(number), "--repo", repo, "--add-reviewer", "@copilot"]);
720
723
  return "requested";
721
724
  } catch (err) {
722
725
  const msg = err instanceof Error ? err.message : String(err);
@@ -743,6 +746,73 @@ export async function requestCopilotReview(
743
746
  throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
744
747
  }
745
748
 
749
+ /** Whether Copilot has STARTED working on a review request since `sinceIso` — a
750
+ * `copilot_work_started` event on the PR's issue timeline (issue #951). A request that produced
751
+ * `review_requested` but never `copilot_work_started` was silently dropped. `null` when no transport
752
+ * is usable or the read failed (unknown — callers must not treat it as "dropped").
753
+ *
754
+ * `opts.timeoutMs` bounds the WHOLE read (issue #951 review): the verification caller has a fixed
755
+ * deadline, so a stalled `gh` or REST read must not run past it and starve the BPMN review-wait
756
+ * timer. The `gh` path passes it to `runGh`'s process `timeout`; the REST path creates ONE
757
+ * `AbortSignal.timeout` before pagination and shares it across every page, so the whole walk —
758
+ * not each page — is bounded by the budget. A timeout surfaces as `null` (unknown), never `false`. */
759
+ export async function hasCopilotWorkStarted(
760
+ repo: string,
761
+ number: number | string,
762
+ token: string,
763
+ sinceIso: string,
764
+ opts: { timeoutMs?: number } = {},
765
+ ): Promise<boolean | null> {
766
+ const path = `repos/${repo}/issues/${number}/timeline?per_page=100`;
767
+ try {
768
+ let events: { event?: string; created_at?: string }[];
769
+ if (await useGh()) {
770
+ const out = await runGh(
771
+ ["api", path, "--paginate", "--slurp", "-H", "Accept: application/vnd.github+json"],
772
+ opts.timeoutMs === undefined ? undefined : { timeoutMs: opts.timeoutMs },
773
+ );
774
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
775
+ events = (JSON.parse(out) as { event?: string; created_at?: string }[][]).flat();
776
+ } else {
777
+ if (!token) return null;
778
+ // Walk EVERY page (mirrors the `gh --paginate` path): a `copilot_work_started` beyond the first
779
+ // 100 events must not read as "never started". An incomplete read (non-OK page, or the page cap
780
+ // still advertising `rel="next"`) is UNKNOWN (`null`), never `false`.
781
+ const MAX_PAGES = 20;
782
+ const signal = opts.timeoutMs === undefined ? undefined : AbortSignal.timeout(opts.timeoutMs);
783
+ events = [];
784
+ for (let page = 1; page <= MAX_PAGES; page++) {
785
+ const r = await fetch(`https://api.github.com/${path}&page=${page}`, {
786
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
787
+ ...(signal === undefined ? {} : { signal }),
788
+ });
789
+ if (!r.ok) return null;
790
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
791
+ const batch = (await r.json()) as { event?: string; created_at?: string }[];
792
+ if (!Array.isArray(batch)) return null;
793
+ events.push(...batch);
794
+ const hasNext = /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "");
795
+ if (!hasNext) break;
796
+ if (page === MAX_PAGES) return copilotWorkStartedSince(events, sinceIso) ? true : null;
797
+ }
798
+ }
799
+ return copilotWorkStartedSince(events, sinceIso);
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+
805
+ /** Pure: does the timeline carry a `copilot_work_started` at/after `sinceIso`? GitHub timeline
806
+ * timestamps have WHOLE-SECOND precision, so the lower bound is floored to the second — otherwise a
807
+ * start in the same second as a millisecond-precise request time would never count. */
808
+ export function copilotWorkStartedSince(events: { event?: string; created_at?: string }[], sinceIso: string): boolean {
809
+ const parsed = Date.parse(sinceIso);
810
+ const since = Number.isNaN(parsed) ? parsed : Math.floor(parsed / 1000) * 1000;
811
+ return events.some(
812
+ (e) => e.event === "copilot_work_started" && (Number.isNaN(since) || Date.parse(e.created_at ?? "") >= since),
813
+ );
814
+ }
815
+
746
816
  /** Withdraw a pending Copilot review request (REST reviewers DELETE). Used by the merge loop's
747
817
  * head-current review request (issue #939) when a prior request was silently ignored: Copilot stays
748
818
  * listed as pending but never reviews, and a second POST is a no-op — removing and re-adding it is
@@ -1631,6 +1701,32 @@ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked"
1631
1701
  return "pass";
1632
1702
  }
1633
1703
 
1704
+ /** True for Mergify's merge-queue check run (e.g. "Mergify Merge Queue" / "Mergify — Merge Queue"). */
1705
+ export function isMergifyQueueCheck(name: string): boolean {
1706
+ return /mergify/i.test(name) && /queue/i.test(name);
1707
+ }
1708
+
1709
+ const SETTLED_OK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED"]);
1710
+
1711
+ /** Issue #951: is this BLOCKED PR merely waiting for the repo's declared `mergify-queue` land step?
1712
+ * True only when the protocol lands via `mergify-queue`, checks are enumerable and settled (none
1713
+ * 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`. */
1715
+ export function awaitingMergifyEnqueue(s: PrState, protocol?: MergeProtocol): boolean {
1716
+ if (protocol?.land.method !== "mergify-queue") return false;
1717
+ if (s.failingChecks !== 0 || s.pendingCheckNames.length > 0) return false;
1718
+ 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;
1724
+ }
1725
+ return false;
1726
+ }
1727
+ return sawMergifyNeutral;
1728
+ }
1729
+
1634
1730
  export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability {
1635
1731
  // A draft PR is NEVER landable — GitHub refuses the merge outright, whatever its checks say — so
1636
1732
  // draft outranks every other signal (issue #454). Surface it as a first-class verdict rather than
@@ -1723,6 +1819,11 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
1723
1819
  return "blocked";
1724
1820
  case "review-threads":
1725
1821
  return "review-threads";
1822
+ case "reviews":
1823
+ // Issue #951: on a `mergify-queue` repo a green, thread-free PR whose ONLY non-success
1824
+ // check is Mergify's own queue check at NEUTRAL ("not queued yet") is waiting for the
1825
+ // declared land step (`@mergifyio queue`), not a human review — land it.
1826
+ return awaitingMergifyEnqueue(s, protocol) ? "ready" : "waiting";
1726
1827
  default:
1727
1828
  return "waiting";
1728
1829
  }
@@ -2321,3 +2422,38 @@ export async function ensurePromotionPr(
2321
2422
  if (!created) return null;
2322
2423
  return { ...created, created: true };
2323
2424
  }
2425
+
2426
+ /** Bring a PR's head up to date with its base (GitHub's "Update branch" — `PUT
2427
+ * /pulls/{n}/update-branch`, the API behind `gh pr update-branch`). `expectedHeadSha` pins the head
2428
+ * the caller judged, so a concurrent push makes GitHub refuse (422) rather than merge base into a
2429
+ * head nobody inspected. Returns whether GitHub accepted the update; never throws (best-effort —
2430
+ * a refusal falls through to the caller's existing remediation). Issue #951. */
2431
+ export async function updatePrBranch(
2432
+ repo: string,
2433
+ number: number | string,
2434
+ token: string,
2435
+ expectedHeadSha: string | null,
2436
+ ): Promise<boolean> {
2437
+ const apiPath = `repos/${repo}/pulls/${number}/update-branch`;
2438
+ try {
2439
+ if (await useGh()) {
2440
+ const args = ["api", "-X", "PUT", apiPath, "-H", "Accept: application/vnd.github+json"];
2441
+ if (expectedHeadSha) args.push("-f", `expected_head_sha=${expectedHeadSha}`);
2442
+ await runGh(args);
2443
+ return true;
2444
+ }
2445
+ if (!token) return false;
2446
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
2447
+ method: "PUT",
2448
+ headers: {
2449
+ authorization: `Bearer ${token}`,
2450
+ accept: "application/vnd.github+json",
2451
+ "content-type": "application/json",
2452
+ },
2453
+ body: JSON.stringify(expectedHeadSha ? { expected_head_sha: expectedHeadSha } : {}),
2454
+ });
2455
+ return r.ok;
2456
+ } catch {
2457
+ return false;
2458
+ }
2459
+ }
@@ -523,6 +523,55 @@ test("the stall-round cap escalates to a human instead of looping forever (#636)
523
523
  assert(!completedElementIds(engine).has("mark-merged"), "an exhausted stall must not mark-merged");
524
524
  });
525
525
 
526
+ test("an exhausted stall budget re-polls (not escalates) while a behind-base update is pending (#951)", async () => {
527
+ // The probe reports `waiting` + `updatePending: true` (an accepted update-branch still exposes the
528
+ // old head). The remedy's own bounded hold owns liveness, so `gw-merge-stall` must re-poll rather
529
+ // than escalate even with `mergeStallMax` spent.
530
+ const engine = await boot({
531
+ vars: { mergeStallMax: 0 },
532
+ responses: {
533
+ "pr.merge-stall-probe": { mergeState: "waiting", updatePending: true, failingChecks: 0, failingChecksList: "" },
534
+ },
535
+ });
536
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
537
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
538
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable-repoll");
539
+ assert(!completedElementIds(engine).has("merge-esc-conflict"), "a pending update must not escalate");
540
+ });
541
+
542
+ test("an exhausted stall budget still escalates an ordinary waiting verdict (#951)", async () => {
543
+ const engine = await boot({
544
+ vars: { mergeStallMax: 0 },
545
+ responses: {
546
+ "pr.merge-stall-probe": { mergeState: "waiting", updatePending: false, failingChecks: 0, failingChecksList: "" },
547
+ },
548
+ });
549
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
550
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
551
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
552
+ });
553
+
554
+ test("an exhausted stall budget whose probe re-reads a MERGED PR completes merged — never escalates (#951)", async () => {
555
+ // The probe re-reads PR state and surfaces `ready` for an already-merged PR (a human queued it while
556
+ // the loop waited). Even with the stall budget exhausted, `gw-merge-stall` must route a `ready`
557
+ // re-derivation to `attempt-merge` (whose already-merged short-circuit returns `merged`) rather
558
+ // than opening a "merge-blocked" escalation against a PR that has already landed.
559
+ const engine = await boot({
560
+ vars: { mergeStallMax: 0 },
561
+ responses: {
562
+ "pr.merge-stall-probe": { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
563
+ "pr.merge": { mergeStatus: "merged" },
564
+ },
565
+ });
566
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
567
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
568
+ assertThatInstance(engine, byProcessId("merge-loop"))
569
+ .hasCompleted()
570
+ .hasNoIncident()
571
+ .hasCompletedElements("merge-stall-probe", "attempt-merge", "mark-merged");
572
+ assert(!completedElementIds(engine).has("merge-esc-conflict"), "a merged PR must never escalate");
573
+ });
574
+
526
575
  // ---------------------------------------------------------------------------
527
576
  // Async-UNKNOWN "waiting" verdict → bounded re-poll, NOT a human (issue #774)
528
577
  // ---------------------------------------------------------------------------
@@ -1437,3 +1486,51 @@ test("an abandoned merge ends the whole instance via the root terminate, without
1437
1486
  const open = await engine.searchUserTasks({});
1438
1487
  assert(open.length === 0, "the root terminate must leave nothing running (no re-armed poller, no parked escalation)");
1439
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.
@@ -6,7 +6,7 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
- import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
9
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
10
10
  import { memDataFor } from "../test/worldDb.ts";
11
11
  import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  import { DurableResumeRegistry } from "./durableResume.ts";
@@ -1627,6 +1627,130 @@ test("startMerge stamps the merge-stage policy on the row (issue #947)", async (
1627
1627
  });
1628
1628
  });
1629
1629
 
1630
+ // ── Issue #956 review: the operator-steering TEXT is durable and survives the merge handoff ──────
1631
+ // Copilot threads 4547353262 / 4547353302: the #956 projection carried only the boolean
1632
+ // `custom_instructions` flag — the TEXT lived solely on the convergence instance's variables, so
1633
+ // every re-enrollment path that re-enters `submitPr` without it (merge→reconvergence handoff,
1634
+ // `handoffWaitingMergeToConvergence`, the poller's feature-delivery re-enroll) silently dropped the
1635
+ // steering and reset the flag. The text is now persisted on the row (`custom_instructions_text`)
1636
+ // and re-seeded by `startMerge`, so a steered run stays steered across the whole lifecycle.
1637
+
1638
+ test("submitPr persists the instruction TEXT on the row (insert path) (#956 review)", async () => {
1639
+ await withGithubOff(async () => {
1640
+ const { data, engine, stores } = captureRoot();
1641
+ await submitPr(
1642
+ data,
1643
+ engine,
1644
+ { repo: "owner/repo", number: 31, url: "https://github.com/owner/repo/pull/31", prKey: "owner/repo#31" },
1645
+ [],
1646
+ 20,
1647
+ false,
1648
+ null,
1649
+ false,
1650
+ false,
1651
+ undefined,
1652
+ null,
1653
+ null,
1654
+ " prefer Deno; keep the diff small ",
1655
+ );
1656
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1657
+ assertEquals(pr.custom_instructions, 1, "the presence flag is stamped");
1658
+ assertEquals(pr.custom_instructions_text, "prefer Deno; keep the diff small", "the TRIMMED text is durable on the row");
1659
+ });
1660
+ });
1661
+
1662
+ test("submitPr's run-scoped reopen carries a re-supplied text (and a handoff re-supplying the row's text keeps it) (#956 review)", async () => {
1663
+ await withGithubOff(async () => {
1664
+ const { data, engine, stores, vars } = captureRoot();
1665
+ const parsed = { repo: "owner/repo", number: 32, url: "https://github.com/owner/repo/pull/32", prKey: "owner/repo#32" };
1666
+ await submitPr(data, engine, parsed, [], 20, false, null, false, false, undefined, null, null, "steer me");
1667
+ const firstVars = vars();
1668
+ assertEquals(firstVars.customInstructions, "steer me", "the first submission seeds the steering");
1669
+ // A merge→reconvergence handoff re-enters `submitPr` on the LIVE row, and by then the merge
1670
+ // loop OWNS it (`startMerge` re-points `process_key` at the merge instance). Simulate that
1671
+ // owner: without this, `handoffFrom: "PI-MERGE"` fails the live-owner gate and the call
1672
+ // short-circuits as `alreadyRunning` — never exercising the reopen this test exists for.
1673
+ const row = stores.pull_requests.rows[0] as Record<string, unknown>;
1674
+ assertEquals(row.process_key, "PI-1", "the first submission owns the row");
1675
+ row.process_key = "PI-MERGE";
1676
+ const handedBack = await submitPr(
1677
+ data,
1678
+ engine,
1679
+ parsed,
1680
+ [],
1681
+ 20,
1682
+ false,
1683
+ null,
1684
+ false,
1685
+ true,
1686
+ undefined,
1687
+ "PI-MERGE",
1688
+ null,
1689
+ row.custom_instructions_text as string,
1690
+ );
1691
+ assertEquals(handedBack, { prKey: "owner/repo#32", processKey: "PI-1" }, "the owning merge loop's hand-back mints a SUCCESSOR, not alreadyRunning");
1692
+ const after = stores.pull_requests.rows[0] as Record<string, unknown>;
1693
+ assertEquals(after.custom_instructions, 1, "the flag survives the handoff reopen");
1694
+ assertEquals(after.custom_instructions_text, "steer me", "the text survives the handoff reopen");
1695
+ const successorVars = vars();
1696
+ assertEquals(successorVars.customInstructions, "steer me", "the fresh convergence instance is re-seeded with the steering");
1697
+ assert(successorVars !== firstVars, "a successor instance was actually created (not the first submission's captured variables)");
1698
+ assertEquals(after.process_key, "PI-1", "the successor instance now owns the row (the fake engine mints PI-1 per capture)");
1699
+ });
1700
+ });
1701
+
1702
+ test("startMerge re-seeds the merge-loop instance with the row's custom instructions (#956 review)", async () => {
1703
+ await withGithubOff(async () => {
1704
+ const { data, engine, stores, vars } = captureRoot();
1705
+ (stores.pull_requests.rows as unknown[]).push({
1706
+ pr_key: "owner/repo#33",
1707
+ repo: "owner/repo",
1708
+ number: 33,
1709
+ url: "https://github.com/owner/repo/pull/33",
1710
+ status: "converged",
1711
+ current_round: 2,
1712
+ process_key: "PI-CONV",
1713
+ merge_requested: 1,
1714
+ merge_human_approval: 1,
1715
+ custom_instructions: 1,
1716
+ custom_instructions_text: "commit as josh.wulf@camunda.com",
1717
+ });
1718
+ await startMerge(data, engine, {
1719
+ repo: "owner/repo",
1720
+ number: 33,
1721
+ url: "https://github.com/owner/repo/pull/33",
1722
+ prKey: "owner/repo#33",
1723
+ round: 2,
1724
+ });
1725
+ assertEquals(vars()?.customInstructions, "commit as josh.wulf@camunda.com", "the merge stage carries the steering (and a reconverge can re-read it)");
1726
+ });
1727
+ });
1728
+
1729
+ test("startMerge seeds customInstructions null on an unsteered row (prompts stay byte-identical)", async () => {
1730
+ await withGithubOff(async () => {
1731
+ const { data, engine, stores, vars } = captureRoot();
1732
+ (stores.pull_requests.rows as unknown[]).push({
1733
+ pr_key: "owner/repo#34",
1734
+ repo: "owner/repo",
1735
+ number: 34,
1736
+ url: "https://github.com/owner/repo/pull/34",
1737
+ status: "converged",
1738
+ current_round: 1,
1739
+ process_key: "PI-CONV",
1740
+ custom_instructions: 0,
1741
+ custom_instructions_text: null,
1742
+ });
1743
+ await startMerge(data, engine, {
1744
+ repo: "owner/repo",
1745
+ number: 34,
1746
+ url: "https://github.com/owner/repo/pull/34",
1747
+ prKey: "owner/repo#34",
1748
+ round: 1,
1749
+ });
1750
+ assertEquals(vars()?.customInstructions ?? null, null, "no steering → null, never a blank string");
1751
+ });
1752
+ });
1753
+
1630
1754
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1631
1755
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1632
1756
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
package/app/service.ts CHANGED
@@ -13,6 +13,7 @@ import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudi
13
13
  import { AGENT_SLA_JOB_TYPES, recordAgentJobObservation } from "./agentActivationLedger.ts";
14
14
  import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
15
15
  import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
16
+ import { maybeUpdateBehindBranch } from "./behindBaseRemedy.ts";
16
17
  import {
17
18
  CAPS_RESOLVED_MESSAGE,
18
19
  type CapabilityNeed,
@@ -442,6 +443,17 @@ export interface PullRequest {
442
443
  // rows fall back to the #937 batched engine read. Run-scoped: reset by the atomic reopen.
443
444
  merge_requested?: number | null;
444
445
  merge_human_approval?: number | null;
446
+ // Operator-steering projection (134_pull_requests_custom_instructions.sql, issue #956): 1 ⇒ this
447
+ // run's converge agents carry `customInstructions` (stamped by `submitPr` at submit/reopen, run-
448
+ // scoped like the merge policy), so `/status` shows the loop is steered with no engine read. NULL
449
+ // marks a row written before the projection existed; it reads as false.
450
+ custom_instructions?: number | null;
451
+ // The steering TEXT itself (136_pull_requests_custom_instructions_text.sql, issue #956 review):
452
+ // the boolean above can't re-seed a loop, so the trimmed instructions are durable on the row and
453
+ // every re-enrollment path (merge→reconvergence `pr.reconverge`, `handoffWaitingMergeToConvergence`,
454
+ // the poller's feature-delivery re-enroll) re-supplies them, and `startMerge` re-seeds the merge
455
+ // instance. Run-scoped with the flag; NULL when the run carries no steering.
456
+ custom_instructions_text?: string | null;
445
457
  }
446
458
 
447
459
  interface PrDependency {
@@ -668,9 +680,10 @@ export async function submitPr(
668
680
  autoMerge?: boolean,
669
681
  handoffFrom: string | null = null,
670
682
  headShaOverride: string | null = null,
683
+ customInstructions: string | null = null,
671
684
  ) {
672
685
  return withPrSubmitLock(parsed.prKey, () =>
673
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
686
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride, customInstructions),
674
687
  );
675
688
  }
676
689
 
@@ -687,6 +700,7 @@ async function submitPrCritical(
687
700
  autoMerge?: boolean,
688
701
  handoffFrom: string | null = null,
689
702
  headShaOverride: string | null = null,
703
+ customInstructions: string | null = null,
690
704
  ) {
691
705
  // Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
692
706
  // the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
@@ -700,6 +714,13 @@ async function submitPrCritical(
700
714
  // merge-loop — its own predicate, `AUTO_MERGE && merge intent on`. A run that won't merge
701
715
  // (review-only, or the global switch off) must not park for a pointless approval.
702
716
  const gated = humanApproval && mergeIntent && AUTO_MERGE;
717
+ // Operator free-text steering for the converge agents (issue #956), mirrored from the feature path
718
+ // (feature.ts): blank/absent → null so the agent tasks' `appendPrompt` FEEL skips the "## Operator
719
+ // custom instructions" block rather than appending an empty heading — an omitted/blank value leaves
720
+ // every prompt byte-identical to no-instructions.
721
+ const instructions = typeof customInstructions === "string" && customInstructions.trim() !== ""
722
+ ? customInstructions.trim()
723
+ : null;
703
724
  const table = prs(data);
704
725
  const existing = await table.get(parsed.prKey);
705
726
  // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
@@ -874,6 +895,11 @@ async function submitPrCritical(
874
895
  // Mirror the engine seed EXACTLY (PR #950 review): an ungated loop is seeded
875
896
  // `humanApproval: false` (decided — no gate), a gated one `true` (outstanding).
876
897
  merge_human_approval: gated ? 1 : 0,
898
+ // Operator-steering projection (issue #956): 1 ⇒ this run's converge agents carry custom
899
+ // instructions, so `/status` can show the loop is steered with no engine read. The TEXT is
900
+ // durable alongside (issue #956 review) so a handoff/re-enrollment can re-supply it.
901
+ custom_instructions: instructions != null ? 1 : 0,
902
+ custom_instructions_text: instructions,
877
903
  created_at: ts,
878
904
  updated_at: ts,
879
905
  });
@@ -942,6 +968,11 @@ async function submitPrCritical(
942
968
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
943
969
  abandonUrl: abUrl,
944
970
  abandonBrief: renderAbandonBrief(abUrl),
971
+ // Optional operator steering (issue #956): appended to EVERY converge round's agent prompt
972
+ // (review-round + adversarial-review) via their `appendPrompt` FEEL, under the same
973
+ // "## Operator custom instructions" block the implement cell uses. Null when none was supplied;
974
+ // persists on the instance, so reconverge re-dispatches and later rounds keep carrying it.
975
+ customInstructions: instructions,
945
976
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
946
977
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
947
978
  // unresolved head (`{}`) leaves the other vars untouched.
@@ -980,6 +1011,9 @@ async function submitPrCritical(
980
1011
  // authoritative (`stale: false`) in `/status`.
981
1012
  merge_requested: existing.merge_requested ?? null,
982
1013
  merge_human_approval: existing.merge_human_approval ?? null,
1014
+ // The operator-steering projection (issue #956) rolls back with the same ownership.
1015
+ custom_instructions: existing.custom_instructions ?? null,
1016
+ custom_instructions_text: existing.custom_instructions_text ?? null,
983
1017
  }
984
1018
  : null;
985
1019
  try {
@@ -1042,6 +1076,9 @@ async function submitPrCritical(
1042
1076
  // the fresh run's intent wins, and the approval state mirrors the fresh loop's `humanApproval` seed.
1043
1077
  merge_requested: mergeIntent ? 1 : 0,
1044
1078
  merge_human_approval: gated ? 1 : 0,
1079
+ // Operator-steering projection (issue #956): run-scoped too — the fresh run's steering wins.
1080
+ custom_instructions: instructions != null ? 1 : 0,
1081
+ custom_instructions_text: instructions,
1045
1082
  // Publish the fresh run's identity in the SAME write as the status flip (may be null if the
1046
1083
  // instance could not be created — a phantom row a retry re-enrolls, as on first submit).
1047
1084
  process_key: processKey,
@@ -1113,6 +1150,14 @@ export async function startMerge(
1113
1150
  // self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
1114
1151
  // column.
1115
1152
  const rootRequestKey = existing?.root_request_key ?? null;
1153
+ // Operator steering (issue #956 review): re-seed the merge-loop instance with the run's durable
1154
+ // custom instructions. The convergence instance's own copy dies with it at the merge handoff, and
1155
+ // the merge stage's `pr.reconverge` re-entry is one of the paths that must not drop the steering —
1156
+ // carrying it here keeps the merge-stage agents (and any reconverge fallback) steered.
1157
+ const customInstructions =
1158
+ typeof existing?.custom_instructions_text === "string" && existing.custom_instructions_text.trim() !== ""
1159
+ ? existing.custom_instructions_text.trim()
1160
+ : null;
1116
1161
  const abUrl = abandonUrl(abandonToken);
1117
1162
  // Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
1118
1163
  // out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
@@ -1162,6 +1207,9 @@ export async function startMerge(
1162
1207
  reviewRequest: null,
1163
1208
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1164
1209
  rootRequestKey,
1210
+ // Operator steering (issue #956 review): the run's durable custom instructions, so the merge
1211
+ // stage's agents and a `pr.reconverge` re-entry keep the steering the convergence loop ran with.
1212
+ customInstructions,
1165
1213
  // Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
1166
1214
  // the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
1167
1215
  // the merge-loop process reads none of them.
@@ -1269,6 +1317,10 @@ export interface ActivePr {
1269
1317
  /** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
1270
1318
  * eligible for steward auto-resolution. Shown in `/status` per PR. */
1271
1319
  autoResolveEscalations: boolean;
1320
+ /** Whether this run's converge agents carry operator custom instructions (issue #956), served
1321
+ * from the durable `custom_instructions` row projection — the operator can see a loop is being
1322
+ * steered with no engine read. */
1323
+ hasCustomInstructions: boolean;
1272
1324
  /** The effective merge policy (issue #916): what the run intends (`requested`), what the
1273
1325
  * deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
1274
1326
  merge: MergePolicy;
@@ -1376,6 +1428,7 @@ export async function activePrs(
1376
1428
  activeWorker: p.active_worker ?? null,
1377
1429
  leaseUntil: p.lease_until ?? null,
1378
1430
  autoResolveEscalations: p.auto_resolve_escalations === 1,
1431
+ hasCustomInstructions: p.custom_instructions === 1,
1379
1432
  merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1380
1433
  incident,
1381
1434
  terminationReason: p.termination_reason ?? null,
@@ -2325,6 +2378,10 @@ export async function handoffWaitingMergeToConvergence(
2325
2378
  pr.auto_resolve_escalations === 1,
2326
2379
  undefined,
2327
2380
  mergeKey,
2381
+ // Operator steering (issue #956 review): re-supply the run's durable instruction text so the
2382
+ // merge→convergence handoff never starts an unsteered replacement loop.
2383
+ null,
2384
+ typeof pr.custom_instructions_text === "string" ? pr.custom_instructions_text : null,
2328
2385
  );
2329
2386
  const successor = "processKey" in res ? res.processKey : null;
2330
2387
  if (successor == null) {
@@ -2646,6 +2703,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
2646
2703
  );
2647
2704
  continue;
2648
2705
  }
2706
+ if (verdict === "blocked" && (await maybeUpdateBehindBranch(repo, number, token, st, protocol))) {
2707
+ // Issue #951: the checks failed on a head BEHIND its base (e.g. a CI-infra fix since landed on
2708
+ // base) — update the branch (and reopen on a reopen-CI repo) and re-poll the fresh head
2709
+ // instead of dispatching fix-ci / escalating "failing required checks". Once per head.
2710
+ continue;
2711
+ }
2649
2712
  if (verdict === "ready") {
2650
2713
  const lane = await mergeLaneDecisionForPr(data, prKey);
2651
2714
  if (lane?.isHeld) {
@@ -3863,6 +3926,12 @@ export async function pollFeatureDelivery(
3863
3926
  // Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
3864
3927
  // durable `auto_merge` flag (mirrors `converge-feature`).
3865
3928
  run.auto_merge === 1,
3929
+ // Operator steering (issue #956 review): the run's durable instruction text, so a
3930
+ // poller-driven (re-)enrollment — which has no job variables — recovers the same
3931
+ // steering the `converge-feature` worker would have forwarded.
3932
+ null,
3933
+ null,
3934
+ typeof run.custom_instructions === "string" ? run.custom_instructions : null,
3866
3935
  );
3867
3936
  }
3868
3937
  }
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.209.0",
3
- "gitSha": "21622b4529383c575de7ffcf77548a704353c4d4",
4
- "gitTag": "v0.209.0",
5
- "builtAt": "2026-11-12T05:08:52.210Z"
2
+ "version": "0.210.0",
3
+ "gitSha": "c1a72ce90744ccd23aa8160d20f0add2779db837",
4
+ "gitTag": "v0.210.0",
5
+ "builtAt": "2026-11-12T10:53:21.705Z"
6
6
  }
@@ -0,0 +1,8 @@
1
+ -- Durable operator-steering projection for /status (issue #956). `startConvergenceLoop` accepts an
2
+ -- optional `customInstructions` string that is appended to EVERY converge round's agent prompt
3
+ -- (review-round + adversarial-review) via their `appendPrompt` FEEL. So the operator can SEE that a
4
+ -- loop is being steered without an engine read, the writer that already knows — `submitPr`
5
+ -- (submit/reopen) — stamps `custom_instructions` (1|0) on the row, and `/status` serves it as
6
+ -- `hasCustomInstructions`. Run-scoped: the atomic reopen resets it to the fresh run's steering.
7
+ -- `custom_instructions` is NULL on every pre-#956 row; those legacy rows read as false.
8
+ ALTER TABLE pull_requests ADD COLUMN custom_instructions INTEGER;