@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +11 -1
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/convergeAdversarial.test.ts +152 -0
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/feature.test.ts +31 -0
- package/app/feature.ts +12 -0
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/mergeLoopBehaviour.test.ts +48 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/app/service.test.ts +125 -1
- package/app/service.ts +63 -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 +6 -2
- package/workers/converge-feature/worker.ts +10 -1
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
- package/workers/reconverge/worker.test.ts +50 -0
- package/workers/reconverge/worker.ts +7 -0
package/app/readiness.test.ts
CHANGED
|
@@ -554,6 +554,95 @@ test("#856 parsePrTarget agrees with the canonical parsePr on every PR shape (no
|
|
|
554
554
|
}
|
|
555
555
|
});
|
|
556
556
|
|
|
557
|
+
// ── PR #957 review round 3: the readiness `pr` path normalises events too ────────────────────────
|
|
558
|
+
//
|
|
559
|
+
// The `gh pr view` rollup omits `event`, so before this fix the readiness path grouped by name alone
|
|
560
|
+
// and a green `push` run hid a red required `pull_request` run of the same name — `checks-green`
|
|
561
|
+
// reported ready while the PR run was red. `probeOnce` now enriches the rollup through the injected
|
|
562
|
+
// executor (shared transport logic with the merge loop) before parsing.
|
|
563
|
+
|
|
564
|
+
test("#957 probeOnce pr checks-green: a green push run never hides a red pull_request run of the same name", async () => {
|
|
565
|
+
const head = "5fbd262a28987d647621da324bf1a7855a8da500";
|
|
566
|
+
// The `gh pr view` payload: two same-named runs, NO event on either (the CLI exporter omits it).
|
|
567
|
+
const prView = {
|
|
568
|
+
state: "OPEN",
|
|
569
|
+
mergedAt: null,
|
|
570
|
+
mergeStateStatus: "CLEAN",
|
|
571
|
+
isDraft: false,
|
|
572
|
+
headRefOid: head,
|
|
573
|
+
mergeCommit: null,
|
|
574
|
+
statusCheckRollup: [
|
|
575
|
+
{ name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
|
|
576
|
+
{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
// The GraphQL event read, keyed by the SAME head, joining by (name, startedAt).
|
|
580
|
+
const events = {
|
|
581
|
+
data: {
|
|
582
|
+
repository: {
|
|
583
|
+
pullRequest: {
|
|
584
|
+
commits: {
|
|
585
|
+
nodes: [
|
|
586
|
+
{
|
|
587
|
+
commit: {
|
|
588
|
+
oid: head,
|
|
589
|
+
statusCheckRollup: {
|
|
590
|
+
contexts: {
|
|
591
|
+
nodes: [
|
|
592
|
+
{ __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
|
|
593
|
+
{ __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
|
|
594
|
+
],
|
|
595
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
const exec: ProbeExec = {
|
|
607
|
+
async httpGet() {
|
|
608
|
+
return { status: 0, body: "" };
|
|
609
|
+
},
|
|
610
|
+
async run(command) {
|
|
611
|
+
// Route the two reads by shape: the GraphQL call carries `api graphql`, the rollup read `pr view`.
|
|
612
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 0, stdout: JSON.stringify(events), stderr: "" };
|
|
613
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
617
|
+
const res = await probeOnce(p, exec, {});
|
|
618
|
+
assertEquals(res.ready, false, "the red pull_request run survives event normalisation — checks-green is NOT ready");
|
|
619
|
+
assertStringIncludes(res.detail, "failing");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("#957 probeOnce pr checks-green: an event-enrichment failure degrades to name-only, never throws", async () => {
|
|
623
|
+
const prView = {
|
|
624
|
+
state: "OPEN",
|
|
625
|
+
mergedAt: null,
|
|
626
|
+
mergeStateStatus: "CLEAN",
|
|
627
|
+
isDraft: false,
|
|
628
|
+
headRefOid: "abc",
|
|
629
|
+
mergeCommit: null,
|
|
630
|
+
statusCheckRollup: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" }],
|
|
631
|
+
};
|
|
632
|
+
const exec: ProbeExec = {
|
|
633
|
+
async httpGet() {
|
|
634
|
+
return { status: 0, body: "" };
|
|
635
|
+
},
|
|
636
|
+
async run(command) {
|
|
637
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 1, stdout: "", stderr: "boom" }; // the event read fails
|
|
638
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
642
|
+
const res = await probeOnce(p, exec, {});
|
|
643
|
+
assertEquals(res.ready, true, "a failed event read leaves the rollup event-less — a single green run is still green");
|
|
644
|
+
});
|
|
645
|
+
|
|
557
646
|
// ── #857: an epic target is a CANONICAL planKey (`owner/repo#N`) — never a URL ────────────────────
|
|
558
647
|
// The epic probe keys off the durable lineage root (`owner/repo#N`, `parseIssue`'s `planKey` shape).
|
|
559
648
|
// Delegating its validation to `parsePr` (whose grammar includes GitHub PR URLs) let a URL pass the
|
|
@@ -780,7 +869,7 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
|
780
869
|
totalChecks: 0,
|
|
781
870
|
presentCheckNames: [],
|
|
782
871
|
pendingCheckNames: [],
|
|
783
|
-
|
|
872
|
+
checkGateConclusions: {},
|
|
784
873
|
isDraft: false,
|
|
785
874
|
headRefOid: "abc123",
|
|
786
875
|
mergedSha: null,
|
package/app/readiness.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
|
-
import { allCheckNames,
|
|
21
|
+
import { allCheckNames, applyRollupEvents, CHECK_EVENTS_QUERY, checkGateConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
22
22
|
import { parsePr } from "./prParse.ts";
|
|
23
23
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
24
24
|
|
|
@@ -764,7 +764,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
764
764
|
totalChecks: rollup.length,
|
|
765
765
|
presentCheckNames: allCheckNames(rollup),
|
|
766
766
|
pendingCheckNames: pending,
|
|
767
|
-
|
|
767
|
+
checkGateConclusions: checkGateConclusions(rollup),
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
@@ -857,6 +857,76 @@ export function prViewCommand(repo: string, number: string): string {
|
|
|
857
857
|
return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
|
|
858
858
|
}
|
|
859
859
|
|
|
860
|
+
/** The GraphQL page query backing {@link enrichPrViewEvents}, as a single-quoted shell argument.
|
|
861
|
+
* Mirrors the merge loop's enrichment (`enrichRollupEvents` in `app/github.ts`) but runs through the
|
|
862
|
+
* probe's injected {@link ProbeExec} so the readiness path stays host-agnostic and unit-testable. */
|
|
863
|
+
function prCheckEventsQuery(): string {
|
|
864
|
+
return CHECK_EVENTS_QUERY;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Enrich a `pr`-probe rollup with each run's triggering event, through the probe's injected
|
|
868
|
+
* executor. The `gh pr view --json statusCheckRollup` read omits `event`, so without this the
|
|
869
|
+
* readiness path groups by name alone and a green `push` run hides a red required `pull_request` run
|
|
870
|
+
* of the same name — `checks-green` then reports ready while the PR run is red (PR #957 review).
|
|
871
|
+
* Pages `statusCheckRollup.contexts` to exhaustion and pins the read to the rollup's head SHA
|
|
872
|
+
* (aborting the stamp when the head moved between the two reads), exactly like the merge loop's
|
|
873
|
+
* `enrichRollupEvents`. Never throws: any transport/parse failure leaves the rollup event-less. */
|
|
874
|
+
async function enrichPrViewEvents(
|
|
875
|
+
exec: ProbeExec,
|
|
876
|
+
env: Record<string, string | undefined>,
|
|
877
|
+
repo: string,
|
|
878
|
+
number: string,
|
|
879
|
+
payload: Record<string, unknown>,
|
|
880
|
+
): Promise<void> {
|
|
881
|
+
const j = payload;
|
|
882
|
+
if (!Array.isArray(j.statusCheckRollup) || j.statusCheckRollup.length === 0) return;
|
|
883
|
+
const rollup = j.statusCheckRollup;
|
|
884
|
+
const headSha = typeof j.headRefOid === "string" ? j.headRefOid : "";
|
|
885
|
+
const [owner, name] = repo.split("/");
|
|
886
|
+
const nodes: unknown[] = [];
|
|
887
|
+
let after: string | null = null;
|
|
888
|
+
for (;;) {
|
|
889
|
+
const args = [
|
|
890
|
+
"api",
|
|
891
|
+
"graphql",
|
|
892
|
+
"-f",
|
|
893
|
+
`query=${prCheckEventsQuery()}`,
|
|
894
|
+
"-f",
|
|
895
|
+
`o=${owner}`,
|
|
896
|
+
"-f",
|
|
897
|
+
`r=${name}`,
|
|
898
|
+
"-F",
|
|
899
|
+
`n=${number}`,
|
|
900
|
+
];
|
|
901
|
+
if (after !== null) args.push("-f", `after=${after}`);
|
|
902
|
+
const command = `gh ${args.map(shellQuote).join(" ")}`;
|
|
903
|
+
const out = await exec.run(command, env);
|
|
904
|
+
if (out.code !== 0) return; // a transport hiccup leaves the rollup event-less (name-only gates)
|
|
905
|
+
const page = parseJson(out.stdout);
|
|
906
|
+
if (!isRecord(page)) return;
|
|
907
|
+
const data = isRecord(page.data) ? page.data : undefined;
|
|
908
|
+
const repository = data && isRecord(data.repository) ? data.repository : undefined;
|
|
909
|
+
const pullRequest = repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
|
|
910
|
+
const commits = pullRequest && isRecord(pullRequest.commits) ? pullRequest.commits : undefined;
|
|
911
|
+
const firstCommit = commits && Array.isArray(commits.nodes) ? commits.nodes[0] : undefined;
|
|
912
|
+
const commit = isRecord(firstCommit) && isRecord(firstCommit.commit) ? firstCommit.commit : undefined;
|
|
913
|
+
if (!isRecord(commit)) return;
|
|
914
|
+
// Head-pin: the rollup came from the earlier `gh pr view`; if the head moved since, these
|
|
915
|
+
// contexts belong to a different head — stamping them would join events across heads.
|
|
916
|
+
const oid = typeof commit.oid === "string" ? commit.oid : "";
|
|
917
|
+
if (headSha !== "" && oid !== "" && oid !== headSha) return;
|
|
918
|
+
const rollupObj = isRecord(commit.statusCheckRollup) ? commit.statusCheckRollup : undefined;
|
|
919
|
+
const contexts = rollupObj && isRecord(rollupObj.contexts) ? rollupObj.contexts : undefined;
|
|
920
|
+
if (contexts === undefined) return;
|
|
921
|
+
if (Array.isArray(contexts.nodes)) nodes.push(...contexts.nodes);
|
|
922
|
+
const pageInfo = isRecord(contexts.pageInfo) ? contexts.pageInfo : undefined;
|
|
923
|
+
const endCursor = pageInfo && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : "";
|
|
924
|
+
if (pageInfo?.hasNextPage !== true || endCursor === "") break;
|
|
925
|
+
after = endCursor;
|
|
926
|
+
}
|
|
927
|
+
applyRollupEvents(rollup, nodes);
|
|
928
|
+
}
|
|
929
|
+
|
|
860
930
|
// ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ──────
|
|
861
931
|
|
|
862
932
|
/** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic
|
|
@@ -974,7 +1044,15 @@ export async function probeOnce(
|
|
|
974
1044
|
if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
|
|
975
1045
|
const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
|
|
976
1046
|
if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
|
|
977
|
-
|
|
1047
|
+
const payload = parseJson(out.stdout);
|
|
1048
|
+
if (!isRecord(payload)) return matchPr(probe.match, parsePrView(payload));
|
|
1049
|
+
// Normalise each run's triggering event BEFORE parsing (PR #957 review): the `gh pr view`
|
|
1050
|
+
// rollup omits `event`, so an un-enriched read groups by name alone and a green `push` run
|
|
1051
|
+
// hides a red required `pull_request` run of the same name — `checks-green` would report
|
|
1052
|
+
// ready while the PR run is red. Shared transport logic with the merge loop, over the
|
|
1053
|
+
// injected executor.
|
|
1054
|
+
await enrichPrViewEvents(exec, env, ref.repo, ref.number, payload);
|
|
1055
|
+
return matchPr(probe.match, parsePrView(payload));
|
|
978
1056
|
}
|
|
979
1057
|
case "epic": {
|
|
980
1058
|
// "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
|
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
|
@@ -443,6 +443,17 @@ export interface PullRequest {
|
|
|
443
443
|
// rows fall back to the #937 batched engine read. Run-scoped: reset by the atomic reopen.
|
|
444
444
|
merge_requested?: number | null;
|
|
445
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;
|
|
446
457
|
}
|
|
447
458
|
|
|
448
459
|
interface PrDependency {
|
|
@@ -669,9 +680,10 @@ export async function submitPr(
|
|
|
669
680
|
autoMerge?: boolean,
|
|
670
681
|
handoffFrom: string | null = null,
|
|
671
682
|
headShaOverride: string | null = null,
|
|
683
|
+
customInstructions: string | null = null,
|
|
672
684
|
) {
|
|
673
685
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
674
|
-
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),
|
|
675
687
|
);
|
|
676
688
|
}
|
|
677
689
|
|
|
@@ -688,6 +700,7 @@ async function submitPrCritical(
|
|
|
688
700
|
autoMerge?: boolean,
|
|
689
701
|
handoffFrom: string | null = null,
|
|
690
702
|
headShaOverride: string | null = null,
|
|
703
|
+
customInstructions: string | null = null,
|
|
691
704
|
) {
|
|
692
705
|
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
|
|
693
706
|
// the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
|
|
@@ -701,6 +714,13 @@ async function submitPrCritical(
|
|
|
701
714
|
// merge-loop — its own predicate, `AUTO_MERGE && merge intent on`. A run that won't merge
|
|
702
715
|
// (review-only, or the global switch off) must not park for a pointless approval.
|
|
703
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;
|
|
704
724
|
const table = prs(data);
|
|
705
725
|
const existing = await table.get(parsed.prKey);
|
|
706
726
|
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
@@ -875,6 +895,11 @@ async function submitPrCritical(
|
|
|
875
895
|
// Mirror the engine seed EXACTLY (PR #950 review): an ungated loop is seeded
|
|
876
896
|
// `humanApproval: false` (decided — no gate), a gated one `true` (outstanding).
|
|
877
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,
|
|
878
903
|
created_at: ts,
|
|
879
904
|
updated_at: ts,
|
|
880
905
|
});
|
|
@@ -943,6 +968,11 @@ async function submitPrCritical(
|
|
|
943
968
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
944
969
|
abandonUrl: abUrl,
|
|
945
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,
|
|
946
976
|
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
947
977
|
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
948
978
|
// unresolved head (`{}`) leaves the other vars untouched.
|
|
@@ -981,6 +1011,9 @@ async function submitPrCritical(
|
|
|
981
1011
|
// authoritative (`stale: false`) in `/status`.
|
|
982
1012
|
merge_requested: existing.merge_requested ?? null,
|
|
983
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,
|
|
984
1017
|
}
|
|
985
1018
|
: null;
|
|
986
1019
|
try {
|
|
@@ -1043,6 +1076,9 @@ async function submitPrCritical(
|
|
|
1043
1076
|
// the fresh run's intent wins, and the approval state mirrors the fresh loop's `humanApproval` seed.
|
|
1044
1077
|
merge_requested: mergeIntent ? 1 : 0,
|
|
1045
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,
|
|
1046
1082
|
// Publish the fresh run's identity in the SAME write as the status flip (may be null if the
|
|
1047
1083
|
// instance could not be created — a phantom row a retry re-enrolls, as on first submit).
|
|
1048
1084
|
process_key: processKey,
|
|
@@ -1114,6 +1150,14 @@ export async function startMerge(
|
|
|
1114
1150
|
// self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
|
|
1115
1151
|
// column.
|
|
1116
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;
|
|
1117
1161
|
const abUrl = abandonUrl(abandonToken);
|
|
1118
1162
|
// Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
|
|
1119
1163
|
// out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
|
|
@@ -1163,6 +1207,9 @@ export async function startMerge(
|
|
|
1163
1207
|
reviewRequest: null,
|
|
1164
1208
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
1165
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,
|
|
1166
1213
|
// Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
|
|
1167
1214
|
// the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
|
|
1168
1215
|
// the merge-loop process reads none of them.
|
|
@@ -1270,6 +1317,10 @@ export interface ActivePr {
|
|
|
1270
1317
|
/** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
|
|
1271
1318
|
* eligible for steward auto-resolution. Shown in `/status` per PR. */
|
|
1272
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;
|
|
1273
1324
|
/** The effective merge policy (issue #916): what the run intends (`requested`), what the
|
|
1274
1325
|
* deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
|
|
1275
1326
|
merge: MergePolicy;
|
|
@@ -1377,6 +1428,7 @@ export async function activePrs(
|
|
|
1377
1428
|
activeWorker: p.active_worker ?? null,
|
|
1378
1429
|
leaseUntil: p.lease_until ?? null,
|
|
1379
1430
|
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1431
|
+
hasCustomInstructions: p.custom_instructions === 1,
|
|
1380
1432
|
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1381
1433
|
incident,
|
|
1382
1434
|
terminationReason: p.termination_reason ?? null,
|
|
@@ -2326,6 +2378,10 @@ export async function handoffWaitingMergeToConvergence(
|
|
|
2326
2378
|
pr.auto_resolve_escalations === 1,
|
|
2327
2379
|
undefined,
|
|
2328
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,
|
|
2329
2385
|
);
|
|
2330
2386
|
const successor = "processKey" in res ? res.processKey : null;
|
|
2331
2387
|
if (successor == null) {
|
|
@@ -3870,6 +3926,12 @@ export async function pollFeatureDelivery(
|
|
|
3870
3926
|
// Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
|
|
3871
3927
|
// durable `auto_merge` flag (mirrors `converge-feature`).
|
|
3872
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,
|
|
3873
3935
|
);
|
|
3874
3936
|
}
|
|
3875
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.1",
|
|
3
|
+
"gitSha": "e2c4e966b566ce8b4bdc19e84bceb130909a981c",
|
|
4
|
+
"gitTag": "v0.210.1",
|
|
5
|
+
"builtAt": "2026-11-12T12:00:58.232Z"
|
|
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;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
|
|
2
|
+
-- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
|
|
3
|
+
-- re-emission of 133 with `custom_instructions` (134, issue #956) added; the derived columns are emitted
|
|
4
|
+
-- VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each
|
|
5
|
+
-- file). Numbered after 134.
|
|
6
|
+
|
|
7
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
8
|
+
|
|
9
|
+
CREATE VIEW pull_requests_read_model AS
|
|
10
|
+
SELECT
|
|
11
|
+
pr.pr_key AS pr_key,
|
|
12
|
+
pr.repo AS repo,
|
|
13
|
+
pr.number AS number,
|
|
14
|
+
pr.url AS url,
|
|
15
|
+
pr.title AS title,
|
|
16
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
17
|
+
pr.current_round AS current_round,
|
|
18
|
+
pr.process_key AS process_key,
|
|
19
|
+
pr.waiting_since AS waiting_since,
|
|
20
|
+
pr.last_review_id AS last_review_id,
|
|
21
|
+
pr.outcome AS outcome,
|
|
22
|
+
pr.created_at AS created_at,
|
|
23
|
+
pr.updated_at AS updated_at,
|
|
24
|
+
pr.converged_at AS converged_at,
|
|
25
|
+
pr.merged_at AS merged_at,
|
|
26
|
+
pr.active_worker AS active_worker,
|
|
27
|
+
pr.lease_until AS lease_until,
|
|
28
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
29
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
30
|
+
pr.abandon_token AS abandon_token,
|
|
31
|
+
pr.incident_key AS incident_key,
|
|
32
|
+
pr.incident_type AS incident_type,
|
|
33
|
+
pr.incident_since AS incident_since,
|
|
34
|
+
pr.incident_message AS incident_message,
|
|
35
|
+
pr.last_round_head AS last_round_head,
|
|
36
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
37
|
+
pr.last_progress_result AS last_progress_result,
|
|
38
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
39
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
40
|
+
pr.root_request_key AS root_request_key,
|
|
41
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
42
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
43
|
+
pr.auto_resolve_escalations AS auto_resolve_escalations,
|
|
44
|
+
pr.termination_reason AS termination_reason,
|
|
45
|
+
pr.termination_process_key AS termination_process_key,
|
|
46
|
+
pr.retire_pending_key AS retire_pending_key,
|
|
47
|
+
pr.merge_requested AS merge_requested,
|
|
48
|
+
pr.merge_human_approval AS merge_human_approval,
|
|
49
|
+
pr.custom_instructions AS custom_instructions,
|
|
50
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
51
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
52
|
+
FROM pull_requests__tracking pr;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Durable operator-steering TEXT for the convergence lifecycle (issue #956 review, Copilot threads
|
|
2
|
+
-- 4547353262 / 4547353302). Migration 134 added only the boolean `custom_instructions` PROJECTION
|
|
3
|
+
-- (1 ⇒ steered) so `/status` needs no engine read — but the TEXT itself lived solely on the
|
|
4
|
+
-- convergence instance's variables. Every path that RE-ENTERS `submitPr` without the text
|
|
5
|
+
-- (the merge→reconvergence `pr.reconverge` handoff, `handoffWaitingMergeToConvergence`, the
|
|
6
|
+
-- poller's feature-delivery re-enroll) therefore started a replacement loop that ran UNSTEERED and
|
|
7
|
+
-- reset the flag to 0. Persist the trimmed text on the row so any handoff can re-supply it, and so
|
|
8
|
+
-- `startMerge` can re-seed the merge-loop instance (whose `pr.reconverge` is one of those paths).
|
|
9
|
+
-- Run-scoped exactly like `custom_instructions`: the atomic reopen rewrites it to the fresh run's
|
|
10
|
+
-- steering. NULL on every pre-existing row; those read as "no instructions".
|
|
11
|
+
ALTER TABLE pull_requests ADD COLUMN custom_instructions_text TEXT;
|