@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +11 -1
- package/app/behindBaseRemedy.test.ts +115 -0
- package/app/behindBaseRemedy.ts +95 -0
- package/app/convergeAdversarial.test.ts +152 -0
- package/app/feature.test.ts +31 -0
- package/app/feature.ts +12 -0
- package/app/github.test.ts +137 -2
- package/app/github.ts +137 -1
- package/app/mergeLoopBehaviour.test.ts +97 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +125 -1
- package/app/service.ts +70 -1
- package/build-info.json +4 -4
- package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
- package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
- package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
- package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
- package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
- package/docs/agent-guide.md +1 -0
- package/openapi.yaml +24 -0
- package/operations/listActivePrs.test.ts +17 -0
- package/operations/startAndMessage.test.ts +80 -0
- package/operations/startConvergenceLoop.ts +13 -1
- package/package.json +1 -1
- package/pages/home.page.json +7 -2
- package/resources/processes/convergence-loop.bpmn +8 -2
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/merge-loop.bpmn +8 -3
- package/workers/converge-feature/worker.ts +10 -1
- package/workers/merge-stall-probe/worker.test.ts +119 -0
- package/workers/merge-stall-probe/worker.ts +69 -8
- package/workers/reconverge/worker.test.ts +50 -0
- package/workers/reconverge/worker.ts +7 -0
- package/workers/request-review/worker.test.ts +27 -0
- 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
|
-
|
|
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 = "
|
|
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.
|
package/app/service.test.ts
CHANGED
|
@@ -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.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.
|
|
5
|
-
"builtAt": "2026-11-
|
|
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;
|