@nanobpm/nano-workforce 0.204.1 → 0.205.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 +10 -7
- package/app/agentOutputEnvelope.test.ts +50 -0
- package/app/feature.ts +3 -3
- package/app/featureDelivery.test.ts +2 -0
- package/app/github.test.ts +285 -1
- package/app/github.ts +160 -8
- package/app/mergeApproval.test.ts +20 -1
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +129 -0
- package/app/mergeStatus.test.ts +169 -0
- package/app/readiness.ts +1 -0
- package/app/service.test.ts +25 -0
- package/app/service.ts +257 -10
- package/app/userTasks.ts +1 -0
- package/docs/agent-guide.md +33 -0
- package/openapi.yaml +34 -0
- package/operations/getAgentInstructions.test.ts +16 -1
- package/operations/listActivePrs.ts +1 -1
- package/operations/startAndMessage.test.ts +13 -3
- package/operations/startConvergenceLoop.ts +10 -4
- package/operations/startFeature.readiness.integration.test.ts +15 -0
- package/operations/startFeature.ts +6 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +8 -0
- package/resources/processes/merge-loop.bpmn +55 -9
- package/skills/nano-workforce/SKILL.md +7 -0
- package/workers/converge-feature/worker.ts +8 -6
- package/workers/delivery-connector/worker.ts +1 -1
- package/workers/finalize/worker.test.ts +35 -0
- package/workers/finalize/worker.ts +9 -4
- package/workers/merge-stall-probe/worker.ts +17 -3
package/app/service.ts
CHANGED
|
@@ -627,9 +627,10 @@ export async function submitPr(
|
|
|
627
627
|
rootRequestKey: string | null = null,
|
|
628
628
|
humanApproval = false,
|
|
629
629
|
autoResolveEscalations = false,
|
|
630
|
+
autoMerge?: boolean,
|
|
630
631
|
) {
|
|
631
632
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
632
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations),
|
|
633
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
|
|
633
634
|
);
|
|
634
635
|
}
|
|
635
636
|
|
|
@@ -643,11 +644,20 @@ async function submitPrCritical(
|
|
|
643
644
|
rootRequestKey: string | null = null,
|
|
644
645
|
humanApproval = false,
|
|
645
646
|
autoResolveEscalations = false,
|
|
647
|
+
autoMerge?: boolean,
|
|
646
648
|
) {
|
|
649
|
+
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
|
|
650
|
+
// the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
|
|
651
|
+
// positional remains as a fallback: an explicit `autoMerge` wins; otherwise intent derives from
|
|
652
|
+
// `convergeOnly`. The instance carries BOTH (below): `autoMerge` so the intent is searchable under
|
|
653
|
+
// its positive name, and the `convergeOnly` mirror so in-flight consumers (and the guard-restore
|
|
654
|
+
// ioMappings on the loop's agent tasks) keep working unchanged.
|
|
655
|
+
const mergeIntent = autoMerge ?? !convergeOnly;
|
|
656
|
+
const effectiveConvergeOnly = !mergeIntent;
|
|
647
657
|
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
648
|
-
// merge-loop — its own predicate, `AUTO_MERGE &&
|
|
658
|
+
// merge-loop — its own predicate, `AUTO_MERGE && merge intent on`. A run that won't merge
|
|
649
659
|
// (review-only, or the global switch off) must not park for a pointless approval.
|
|
650
|
-
const gated = humanApproval &&
|
|
660
|
+
const gated = humanApproval && mergeIntent && AUTO_MERGE;
|
|
651
661
|
const table = prs(data);
|
|
652
662
|
const existing = await table.get(parsed.prKey);
|
|
653
663
|
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
@@ -818,10 +828,14 @@ async function submitPrCritical(
|
|
|
818
828
|
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
819
829
|
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
|
820
830
|
rootRequestKey: effectiveRoot,
|
|
821
|
-
// Per-request
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
convergeOnly
|
|
831
|
+
// Per-request merge intent (issue #916): `autoMerge` is the CANONICAL positive variable — the
|
|
832
|
+
// same name the doors accept — so a live loop's merge intent is searchable under `autoMerge`.
|
|
833
|
+
// `convergeOnly` is its inverse mirror, kept for in-flight compat: `pr.finalize` reads
|
|
834
|
+
// `autoMerge` first and falls back to `convergeOnly` when it is absent (a loop seeded before
|
|
835
|
+
// this variable existed). Merge intent only ever narrows (never forces merge on when the
|
|
836
|
+
// global NANO_PR_AUTO_MERGE switch is off).
|
|
837
|
+
autoMerge: mergeIntent,
|
|
838
|
+
convergeOnly: effectiveConvergeOnly,
|
|
825
839
|
// Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
|
|
826
840
|
// task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
|
|
827
841
|
humanApproval: gated,
|
|
@@ -1024,6 +1038,24 @@ export async function startMerge(
|
|
|
1024
1038
|
return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
|
|
1025
1039
|
}
|
|
1026
1040
|
|
|
1041
|
+
/** The effective merge policy for a PR run, surfaced on every `/status` row (issue #916). */
|
|
1042
|
+
export interface MergePolicy {
|
|
1043
|
+
/** The run's merge intent: the canonical positive `autoMerge` process variable when present,
|
|
1044
|
+
* else the inverse of the legacy `convergeOnly` variable (in-flight compat), else the historical
|
|
1045
|
+
* default `true` when the instance key is unreadable or carries neither. */
|
|
1046
|
+
requested: boolean;
|
|
1047
|
+
/** The deployment's global `NANO_PR_AUTO_MERGE` switch. */
|
|
1048
|
+
enabled: boolean;
|
|
1049
|
+
/** `requested && enabled && !humanApproval` — the run merges with no further gate. */
|
|
1050
|
+
effective: boolean;
|
|
1051
|
+
/** WHY a requested merge will not proceed: the global env switch is off, or a human merge
|
|
1052
|
+
* approval is outstanding. Null when the merge is effective or none was requested. The
|
|
1053
|
+
* `merge-approval` task clears the loop's `humanApproval` variable on an explicit "approve"
|
|
1054
|
+
* (convergence-loop.bpmn), so the blocker lifts as soon as approval is complete — even while
|
|
1055
|
+
* the `pr.finalize` handoff is still queued or incidented. */
|
|
1056
|
+
blockedBy: "env" | "human-approval" | null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1027
1059
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
1028
1060
|
export interface ActivePr {
|
|
1029
1061
|
prKey: string;
|
|
@@ -1045,6 +1077,9 @@ export interface ActivePr {
|
|
|
1045
1077
|
/** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
|
|
1046
1078
|
* eligible for steward auto-resolution. Shown in `/status` per PR. */
|
|
1047
1079
|
autoResolveEscalations: boolean;
|
|
1080
|
+
/** The effective merge policy (issue #916): what the run intends (`requested`), what the
|
|
1081
|
+
* deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
|
|
1082
|
+
merge: MergePolicy;
|
|
1048
1083
|
}
|
|
1049
1084
|
|
|
1050
1085
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1058,7 +1093,10 @@ export interface ActivePr {
|
|
|
1058
1093
|
* Both loops park on a native user task answered through the one canonical `completeUserTask` door
|
|
1059
1094
|
* (#256), so deriving from the read model surfaces BOTH loops' escalations uniformly. Once the task is
|
|
1060
1095
|
* completed its row is removed, so `openEscalation` derives back to null. */
|
|
1061
|
-
export async function activePrs(
|
|
1096
|
+
export async function activePrs(
|
|
1097
|
+
data: DataLayer,
|
|
1098
|
+
engine?: Pick<EngineClient, "searchVariables">,
|
|
1099
|
+
): Promise<ActivePr[]> {
|
|
1062
1100
|
const all = await prsTracking(data).all();
|
|
1063
1101
|
const active = all
|
|
1064
1102
|
// ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
|
|
@@ -1086,6 +1124,16 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
1086
1124
|
if (openEscByPr.has(t.subject_key)) continue;
|
|
1087
1125
|
openEscByPr.set(t.subject_key, toOpenEscalation(t));
|
|
1088
1126
|
}
|
|
1127
|
+
// Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
|
|
1128
|
+
// variables per PR so the effective merge policy is visible without opening the engine. One
|
|
1129
|
+
// best-effort read per PR; a failure (engine unreachable, instance gone) degrades to the
|
|
1130
|
+
// historical default (merge requested) rather than failing the whole status read.
|
|
1131
|
+
const mergeByPr = new Map<string, MergePolicy>();
|
|
1132
|
+
await Promise.all(
|
|
1133
|
+
active.map(async (p) => {
|
|
1134
|
+
mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
|
|
1135
|
+
}),
|
|
1136
|
+
);
|
|
1089
1137
|
return active.map((p) => ({
|
|
1090
1138
|
prKey: p.pr_key,
|
|
1091
1139
|
repo: p.repo,
|
|
@@ -1101,9 +1149,67 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
1101
1149
|
activeWorker: p.active_worker ?? null,
|
|
1102
1150
|
leaseUntil: p.lease_until ?? null,
|
|
1103
1151
|
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1152
|
+
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1104
1153
|
}));
|
|
1105
1154
|
}
|
|
1106
1155
|
|
|
1156
|
+
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
1157
|
+
* unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
|
|
1158
|
+
* default, so the projection must NOT read such a PR as review-only. */
|
|
1159
|
+
function mergePolicyDefault(): MergePolicy {
|
|
1160
|
+
return mergePolicyFrom(true, false);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Combine a merge intent + human-approval gate with the global switch into the effective policy.
|
|
1164
|
+
* `humanApproval` is the OUTSTANDING-approval signal: the loop seeds it when approval is required
|
|
1165
|
+
* and the `merge-approval` user task clears it on an explicit "approve", so an approved PR whose
|
|
1166
|
+
* finalization is still queued/incidented reads `effective` with no blocker rather than a stale
|
|
1167
|
+
* `blockedBy: "human-approval"`. */
|
|
1168
|
+
function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolicy {
|
|
1169
|
+
const enabled = AUTO_MERGE;
|
|
1170
|
+
const effective = requested && enabled && !humanApproval;
|
|
1171
|
+
const blockedBy = !requested ? null : !enabled ? "env" : humanApproval ? "human-approval" : null;
|
|
1172
|
+
return { requested, enabled, effective, blockedBy };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Read one boolean process variable off a live instance; undefined when absent/unreadable. */
|
|
1176
|
+
async function readBoolVar(
|
|
1177
|
+
engine: Pick<EngineClient, "searchVariables">,
|
|
1178
|
+
processKey: string,
|
|
1179
|
+
name: string,
|
|
1180
|
+
): Promise<boolean | undefined> {
|
|
1181
|
+
const [row] = await engine.searchVariables({ processInstanceKey: processKey, name });
|
|
1182
|
+
if (!row) return undefined;
|
|
1183
|
+
try {
|
|
1184
|
+
const v: unknown = JSON.parse(row.value);
|
|
1185
|
+
return typeof v === "boolean" ? v : undefined;
|
|
1186
|
+
} catch {
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Derive a PR's effective merge policy from its loop's process variables (issue #916). The
|
|
1192
|
+
* canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
|
|
1193
|
+
* in-flight loops seeded before `autoMerge` existed. Best-effort: any read failure degrades to the
|
|
1194
|
+
* historical default (merge requested) rather than failing the status read. */
|
|
1195
|
+
async function mergePolicyFor(
|
|
1196
|
+
processKey: string | null,
|
|
1197
|
+
engine: Pick<EngineClient, "searchVariables"> | undefined,
|
|
1198
|
+
): Promise<MergePolicy> {
|
|
1199
|
+
if (processKey == null || !engine) return mergePolicyDefault();
|
|
1200
|
+
try {
|
|
1201
|
+
const [autoMerge, convergeOnly, humanApproval] = await Promise.all([
|
|
1202
|
+
readBoolVar(engine, processKey, "autoMerge"),
|
|
1203
|
+
readBoolVar(engine, processKey, "convergeOnly"),
|
|
1204
|
+
readBoolVar(engine, processKey, "humanApproval"),
|
|
1205
|
+
]);
|
|
1206
|
+
const requested = autoMerge ?? (convergeOnly === undefined ? true : !convergeOnly);
|
|
1207
|
+
return mergePolicyFrom(requested, humanApproval === true);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return mergePolicyDefault();
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1107
1213
|
/** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
|
|
1108
1214
|
* "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
|
|
1109
1215
|
export interface ActiveDelivery {
|
|
@@ -1592,6 +1698,77 @@ export async function maybeEnsureFreshHeadRun(
|
|
|
1592
1698
|
* EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
|
|
1593
1699
|
* terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
|
|
1594
1700
|
* is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
|
|
1701
|
+
/** Durably hand a `waiting_merge` PR back into the canonical convergence loop (issue #921, PR #922
|
|
1702
|
+
* review): flip the row to `waiting_review` FIRST (so a review landing in the start window is still
|
|
1703
|
+
* collected by `pollReviews` — it scans on `waiting_review`, not on instance liveness), RETIRE the
|
|
1704
|
+
* live merge instance (while the row's `process_key` names a non-terminal merge instance, `submitPr`'s
|
|
1705
|
+
* idempotency gate classifies the PR `alreadyRunning` and starts NOTHING — the cancel flips the derived
|
|
1706
|
+
* tracking status terminal so the gate falls through), then `submitPr` re-opens the PR into a fresh
|
|
1707
|
+
* convergence instance (round 1). The review-round agent's backlog IS the open threads: it fixes or
|
|
1708
|
+
* pushes back, replies, and resolves them; on `converged` the converge-gate re-verifies zero
|
|
1709
|
+
* unresolved threads and `pr.finalize` re-enters the merge stage — merging resumes automatically.
|
|
1710
|
+
*
|
|
1711
|
+
* The escalation-steward opt-in is carried through the resubmission (PR #922 review): `submitPr`
|
|
1712
|
+
* defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
|
|
1713
|
+
* would silently lose automated escalation resolution merely because late review threads appeared.
|
|
1714
|
+
*
|
|
1715
|
+
* RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
|
|
1716
|
+
* row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
|
|
1717
|
+
* already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
|
|
1718
|
+
* no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
|
|
1719
|
+
* cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
|
|
1720
|
+
* unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
|
|
1721
|
+
* the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
|
|
1722
|
+
* cancel or restore only logs, leaving the row for the next pass. */
|
|
1723
|
+
async function handoffWaitingMergeToConvergence(
|
|
1724
|
+
data: DataLayer,
|
|
1725
|
+
engine: EngineClient,
|
|
1726
|
+
pr: PullRequest,
|
|
1727
|
+
logContext: string,
|
|
1728
|
+
) {
|
|
1729
|
+
const prKey = pr.pr_key;
|
|
1730
|
+
try {
|
|
1731
|
+
// `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
|
|
1732
|
+
// the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
|
|
1733
|
+
// run's wait.
|
|
1734
|
+
const handoffTs = now();
|
|
1735
|
+
await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
|
|
1736
|
+
const mergeKey = pr.process_key;
|
|
1737
|
+
if (mergeKey != null) {
|
|
1738
|
+
try {
|
|
1739
|
+
await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
|
|
1740
|
+
} catch (cancelErr) {
|
|
1741
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
await submitPr(
|
|
1745
|
+
data,
|
|
1746
|
+
engine,
|
|
1747
|
+
{ repo: pr.repo, number: pr.number, url: pr.url, prKey },
|
|
1748
|
+
[],
|
|
1749
|
+
MAX_ROUNDS,
|
|
1750
|
+
false,
|
|
1751
|
+
null,
|
|
1752
|
+
false,
|
|
1753
|
+
pr.auto_resolve_escalations === 1,
|
|
1754
|
+
);
|
|
1755
|
+
console.log(`[poller] ${logContext} -> handed back into convergence -> ${prKey}`);
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
|
|
1758
|
+
// Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
|
|
1759
|
+
// `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
|
|
1760
|
+
// NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
|
|
1761
|
+
// `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
|
|
1762
|
+
// parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
|
|
1763
|
+
// resurrected). Best-effort: a failed restore still leaves the original error logged.
|
|
1764
|
+
try {
|
|
1765
|
+
await prs(data).update(prKey, { status: "waiting_merge", process_key: null, updated_at: now() });
|
|
1766
|
+
} catch (restoreErr) {
|
|
1767
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1595
1772
|
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
1596
1773
|
// 1) Dependencies merged?
|
|
1597
1774
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
@@ -1625,7 +1802,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1625
1802
|
for (const pr of await prs(data).find({ status: "waiting_merge" })) {
|
|
1626
1803
|
const { repo, number, pr_key: prKey } = pr;
|
|
1627
1804
|
try {
|
|
1628
|
-
|
|
1805
|
+
// Opt into the review-threads probe so a `BLOCKED` merge state is classified by its real
|
|
1806
|
+
// cause (issue #921): unresolved review threads (branch protection
|
|
1807
|
+
// `required_conversation_resolution`) surface as the `review-threads` verdict below instead
|
|
1808
|
+
// of polling as "waiting" until the mergeable-wait timer escalates the misleading
|
|
1809
|
+
// "mergeability never settled". One extra GraphQL read per pass per waiting PR.
|
|
1810
|
+
const st = await fetchPrState(repo, number, token, { withReviewThreads: true });
|
|
1629
1811
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1630
1812
|
// Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
|
|
1631
1813
|
// routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
|
|
@@ -1633,6 +1815,39 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1633
1815
|
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1634
1816
|
// path the whole class (#368) now shares.
|
|
1635
1817
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1818
|
+
// DEAD-OWNER guard (PR #922 review, round 5): a `waiting_merge` row with `process_key === null`
|
|
1819
|
+
// has NO live owner — the unfinished-handoff shape {@link handoffWaitingMergeToConvergence}
|
|
1820
|
+
// leaves when its `submitPr` failed after the merge instance was retired (the restore nulls the
|
|
1821
|
+
// key to mark exactly this). The verdict switch below must NOT run against it: every arm ends in
|
|
1822
|
+
// `flipToMergingThenPublish`, which would publish `merge-ready`/`merge-evicted`/remediation
|
|
1823
|
+
// correlated to a merge instance that no longer exists — the message is dropped and the row wedges
|
|
1824
|
+
// at the transient `merging` with no live owner (a fresh `ready`/`conflict`/`blocked` verdict after
|
|
1825
|
+
// the threads cleared is exactly the case the retry-restore leaves behind). Persist the handoff
|
|
1826
|
+
// INDEPENDENTLY of the current GitHub verdict: re-establish a live convergence owner FIRST,
|
|
1827
|
+
// publishing nothing. Keying on the NULL key — not on the terminal `derived_status` — is what
|
|
1828
|
+
// keeps a HUMAN cancel of a parked merge wait (which leaves `process_key` naming the terminated
|
|
1829
|
+
// instance) OUT of this path: a cancelled run must stay cancelled, never auto-resurrected. A
|
|
1830
|
+
// normal merge wait always carries a live `process_key`, so this fires only on the
|
|
1831
|
+
// ownerless-handoff shape. Placed after the out-of-band terminal pre-check so a merged/closed PR
|
|
1832
|
+
// still takes the proven terminal path first.
|
|
1833
|
+
if (pr.process_key == null) {
|
|
1834
|
+
await handoffWaitingMergeToConvergence(data, engine, pr, "dead merge owner (unfinished handoff)");
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
// HUMAN-CANCEL guard (PR #922 review, round 5): a `waiting_merge` row whose `process_key` names a
|
|
1838
|
+
// TERMINAL merge instance (`derived_status` folded to `abandoned`) was cancelled out-of-band. The
|
|
1839
|
+
// verdict switch must NOT run against it either — publishing `merge-ready`/`merge-evicted`/
|
|
1840
|
+
// remediation to the dead instance is dropped and wedges the row at `merging` (the reviewer's "a
|
|
1841
|
+
// new conflict or check failure likewise sends remediation to the dead owner"). Unlike the
|
|
1842
|
+
// unfinished-handoff shape above, a cancelled run must NOT be resurrected into convergence — the
|
|
1843
|
+
// abandon protocol exists to stop exactly that orphaned side effect — so it is simply SKIPPED
|
|
1844
|
+
// (left cancelled) with nothing published. A LIVE parked merge wait reads a non-terminal
|
|
1845
|
+
// `derived_status` (`waiting_merge` ∈ activeStatuses), so this fires only on the cancelled shape.
|
|
1846
|
+
const trackedMerge = await prsTracking(data).get(prKey);
|
|
1847
|
+
if (trackedMerge && TERMINAL_STATUSES.includes(trackedMerge.derived_status)) {
|
|
1848
|
+
console.log(`[poller] merge owner terminal (cancelled) — leaving ${prKey} abandoned, no merge message published`);
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1636
1851
|
// Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
|
|
1637
1852
|
// protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
|
|
1638
1853
|
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
@@ -1682,6 +1897,25 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1682
1897
|
}
|
|
1683
1898
|
continue; // GitHub still computing / checks pending
|
|
1684
1899
|
}
|
|
1900
|
+
if (verdict === "review-threads") {
|
|
1901
|
+
// BLOCKED by unresolved review threads with every check green (issue #921): the threads are
|
|
1902
|
+
// findings from the reviewer this loop converges with, so route them back into convergence
|
|
1903
|
+
// rather than escalating to a human. Requesting another Copilot review alone does NOT
|
|
1904
|
+
// remediate them: `pollReviews` only scans `waiting_review` rows, so a review landing while
|
|
1905
|
+
// the PR sits in `waiting_merge` is never picked up and no review-fix agent ever activates
|
|
1906
|
+
// (PR #922 review). The handoff is a DURABLE re-entry into the canonical convergence loop
|
|
1907
|
+
// (see {@link handoffWaitingMergeToConvergence}). Once the fresh convergence instance exists,
|
|
1908
|
+
// `pr.progress-check` parks the row back in `waiting_merge` after the round, where this
|
|
1909
|
+
// block's verdict has by then flipped off `review-threads` (the gate guarantees it) — so this
|
|
1910
|
+
// fires at most once per thread-blocked episode.
|
|
1911
|
+
await handoffWaitingMergeToConvergence(
|
|
1912
|
+
data,
|
|
1913
|
+
engine,
|
|
1914
|
+
pr,
|
|
1915
|
+
`blocked by ${st.unresolvedThreads} unresolved review thread(s)`,
|
|
1916
|
+
);
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1685
1919
|
if (verdict === "ready") {
|
|
1686
1920
|
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
1687
1921
|
if (lane?.isHeld) {
|
|
@@ -1701,6 +1935,16 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1701
1935
|
// fix-ci task appends it to the agent prompt so the agent knows which gates to green.
|
|
1702
1936
|
failingChecks: st.failingChecks,
|
|
1703
1937
|
failingChecksList: st.failingCheckNames.join("\n"),
|
|
1938
|
+
// The probed unresolved-thread count (issue #921) — carried so a later stall-budget
|
|
1939
|
+
// escalation can name the real BLOCKED cause (and so the `review-threads` arm's question
|
|
1940
|
+
// can quote it). `-1` when the probe was unreadable.
|
|
1941
|
+
reviewThreads: st.unresolvedThreads,
|
|
1942
|
+
// The pending-check count (PR #922 review): zero failing checks does not mean checks are
|
|
1943
|
+
// green — a queued/running check leaves the BLOCKED cause undetermined, so the
|
|
1944
|
+
// stall-escalation question gates its "required review approval" wording on this being 0
|
|
1945
|
+
// too. 0 in token mode (checks unenumerable — the conservative `waiting` verdict already
|
|
1946
|
+
// keeps the approval wording from firing there).
|
|
1947
|
+
pendingChecks: st.pendingCheckNames.length,
|
|
1704
1948
|
},
|
|
1705
1949
|
});
|
|
1706
1950
|
console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
|
|
@@ -2784,13 +3028,16 @@ export async function pollFeatureDelivery(
|
|
|
2784
3028
|
parsed,
|
|
2785
3029
|
[],
|
|
2786
3030
|
MAX_ROUNDS,
|
|
2787
|
-
|
|
3031
|
+
false,
|
|
2788
3032
|
run.feature_key,
|
|
2789
3033
|
run.human_approval === 1,
|
|
2790
3034
|
// Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
|
|
2791
3035
|
// inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
|
|
2792
3036
|
// the PR row's previously persisted opt-in with the `false` default.
|
|
2793
3037
|
run.auto_resolve_escalations === 1,
|
|
3038
|
+
// Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
|
|
3039
|
+
// durable `auto_merge` flag (mirrors `converge-feature`).
|
|
3040
|
+
run.auto_merge === 1,
|
|
2794
3041
|
);
|
|
2795
3042
|
}
|
|
2796
3043
|
}
|
package/app/userTasks.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const PR_ESCALATION_PRODUCER_ELEMENTS: readonly string[] = [
|
|
|
90
90
|
"merge-esc-conflict",
|
|
91
91
|
"merge-esc-attempt",
|
|
92
92
|
"merge-esc-landed",
|
|
93
|
+
"merge-esc-threads",
|
|
93
94
|
];
|
|
94
95
|
|
|
95
96
|
/** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
|
package/docs/agent-guide.md
CHANGED
|
@@ -31,6 +31,24 @@ Each `/status` entry carries `prKey` (`owner/repo#123`), `status`, `round`, `pro
|
|
|
31
31
|
`activeWorker`/`leaseUntil` (an agent holds the round, or the job is just queued), and
|
|
32
32
|
`updatedAt`.
|
|
33
33
|
|
|
34
|
+
It also carries the run's **merge intent** as a structured `merge` block (so "will this PR
|
|
35
|
+
merge?" is a read, not a guess):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
"merge": { "requested": true, "enabled": true, "effective": false, "blockedBy": "human-approval" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| field | meaning |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `requested` | the run's merge intent — the `autoMerge` process variable on its convergence loop (a loop seeded before that variable existed derives it from the inverse of the legacy flag). |
|
|
44
|
+
| `enabled` | the deployment's global `NANO_PR_AUTO_MERGE` switch. |
|
|
45
|
+
| `effective` | `requested && enabled && !humanApproval` — the run merges with no further gate. |
|
|
46
|
+
| `blockedBy` | why a *requested* merge will not proceed: `"env"` (the global switch is off) or `"human-approval"` (parked at the merge-approval task). `null` when the merge is effective — and when the run is converge-only (`requested: false`), which is a *choice*, not a block. |
|
|
47
|
+
|
|
48
|
+
The same intent is on the engine variables of the loop itself: query the instance
|
|
49
|
+
(`processKey`) for `autoMerge` — it is always seeded, so a missing `autoMerge` means a loop
|
|
50
|
+
started before this version (read `convergeOnly` and invert it there).
|
|
51
|
+
|
|
34
52
|
---
|
|
35
53
|
|
|
36
54
|
## 1. Submit a PR for review convergence
|
|
@@ -57,6 +75,16 @@ curl -sS -X POST __BASE__/actions/start/convergence-loop \
|
|
|
57
75
|
Idempotent on the PR key: re-POSTing refreshes the record instead of starting a second
|
|
58
76
|
loop. The 202 response echoes `prKey` and `processKey`.
|
|
59
77
|
|
|
78
|
+
`autoMerge` must be a JSON **boolean** — a string (`"true"`) is a `400` at every start door
|
|
79
|
+
(`startConvergenceLoop`, `startFeature`), never a silent review-only run. To *read* the intent
|
|
80
|
+
back, use the `merge` block in `/status` (§0). There is **no supported way to flip merge
|
|
81
|
+
intent on a running loop**. Direct variable writes onto a live instance (e.g. `autoMerge=true`)
|
|
82
|
+
are **unsupported and timing-dependent, not reliable no-ops**: the agent tasks guard-restore the
|
|
83
|
+
seeded value, so a write is usually overwritten — but a write that lands while the loop awaits
|
|
84
|
+
merge approval is never restored and reaches `pr.finalize`, where it can change whether merging
|
|
85
|
+
starts. To change the intent, cancel the run (`cancelInstance`, §7) and re-submit with the
|
|
86
|
+
intent you want.
|
|
87
|
+
|
|
60
88
|
---
|
|
61
89
|
|
|
62
90
|
## 2. Submit an epic (plan → implement → converge)
|
|
@@ -174,6 +202,11 @@ PR `status` values: `converging` (round live), `waiting_review`, `escalated`, `c
|
|
|
174
202
|
`waiting_deps` / `waiting_merge` / `queued` / `merging`, `merged`, `abandoned`. An
|
|
175
203
|
**incident** (§5) can overlay any live status when the engine parks the token on a fault.
|
|
176
204
|
|
|
205
|
+
The merge-stage statuses only ever appear when the run's merge policy is effective — read the
|
|
206
|
+
`merge` block in `/status` (§0) to distinguish "merge requested but disabled by env"
|
|
207
|
+
(`blockedBy: "env"`) and "waiting for human approval" (`blockedBy: "human-approval"`) from
|
|
208
|
+
"converge-only" (`requested: false`).
|
|
209
|
+
|
|
177
210
|
---
|
|
178
211
|
|
|
179
212
|
## 5. Debug: find engine instances and relate them to PRs
|
package/openapi.yaml
CHANGED
|
@@ -92,6 +92,7 @@ components:
|
|
|
92
92
|
- activeWorker
|
|
93
93
|
- leaseUntil
|
|
94
94
|
- autoResolveEscalations
|
|
95
|
+
- merge
|
|
95
96
|
properties:
|
|
96
97
|
prKey:
|
|
97
98
|
type: string
|
|
@@ -155,6 +156,39 @@ components:
|
|
|
155
156
|
Whether this PR's run opted into automatic escalation resolution by the escalation
|
|
156
157
|
steward (issue #889). Derived from the subject row's durable `auto_resolve_escalations`
|
|
157
158
|
flag, never a clobbered process variable.
|
|
159
|
+
merge:
|
|
160
|
+
type: object
|
|
161
|
+
additionalProperties: false
|
|
162
|
+
description: >-
|
|
163
|
+
The effective merge policy for this PR's run (issue #916). `requested` is the run's merge
|
|
164
|
+
intent — the canonical positive `autoMerge` process variable on its convergence loop
|
|
165
|
+
(falling back to the inverse of the legacy `convergeOnly` variable for a loop seeded
|
|
166
|
+
before `autoMerge` existed, and to the historical default `true` when the instance is
|
|
167
|
+
unreadable). `enabled` is the deployment's global `NANO_PR_AUTO_MERGE` switch.
|
|
168
|
+
`effective` is `requested && enabled && !humanApproval` — the run merges with no further
|
|
169
|
+
gate. `blockedBy` names WHY a requested merge will not proceed: `env` (the global switch
|
|
170
|
+
is off) or `human-approval` (parked at the merge-approval task); null when the merge is
|
|
171
|
+
effective or none was requested (converge-only).
|
|
172
|
+
required:
|
|
173
|
+
- requested
|
|
174
|
+
- enabled
|
|
175
|
+
- effective
|
|
176
|
+
- blockedBy
|
|
177
|
+
properties:
|
|
178
|
+
requested:
|
|
179
|
+
type: boolean
|
|
180
|
+
description: The run's merge intent (the loop's `autoMerge` variable, or the inverse of its legacy `convergeOnly`).
|
|
181
|
+
enabled:
|
|
182
|
+
type: boolean
|
|
183
|
+
description: The deployment's global NANO_PR_AUTO_MERGE switch.
|
|
184
|
+
effective:
|
|
185
|
+
type: boolean
|
|
186
|
+
description: requested && enabled && !humanApproval — the run merges with no further gate.
|
|
187
|
+
blockedBy:
|
|
188
|
+
type: string
|
|
189
|
+
nullable: true
|
|
190
|
+
enum: ["env", "human-approval", null]
|
|
191
|
+
description: Why a requested merge will not proceed; null when effective or converge-only.
|
|
158
192
|
ActivePrList:
|
|
159
193
|
type: object
|
|
160
194
|
required:
|
|
@@ -41,7 +41,22 @@ test("the guide covers every capability the endpoint promises", async () => {
|
|
|
41
41
|
// Submit a PR (converge vs. merge), submit an epic, answer escalations…
|
|
42
42
|
assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
|
|
43
43
|
assert(md.includes("autoMerge"), "documents the per-request merge control");
|
|
44
|
-
|
|
44
|
+
// Issue #916: the guide must document where to READ merge intent (the `/status` merge block) and
|
|
45
|
+
// that writing variables onto a running loop is not the way to change it. The legacy inverted
|
|
46
|
+
// `convergeOnly` name appears ONLY inside that sanctioned fallback explanation (a pre-`autoMerge`
|
|
47
|
+
// loop exposes its intent solely as `convergeOnly`) — never as a control the agent can set.
|
|
48
|
+
assert(md.includes('"merge": { "requested"'), "documents the /status merge block");
|
|
49
|
+
assert(md.includes("blockedBy"), "documents the env / human-approval distinction");
|
|
50
|
+
assert(
|
|
51
|
+
md.includes("no supported way to flip merge"),
|
|
52
|
+
"warns that writing variables onto a running loop is a no-op",
|
|
53
|
+
);
|
|
54
|
+
const legacyMentions = md.split("\n").filter((l) => l.includes("convergeOnly"));
|
|
55
|
+
assert(legacyMentions.length <= 1, `legacy convergeOnly appears only in the #916 fallback note; got: ${legacyMentions.join(" | ")}`);
|
|
56
|
+
assert(
|
|
57
|
+
legacyMentions.length === 1 && legacyMentions[0].includes("invert"),
|
|
58
|
+
"the one convergeOnly mention is the read-side fallback note (read it and invert it)",
|
|
59
|
+
);
|
|
45
60
|
assert(md.includes("start/plan-fanout"), "covers submitting an epic");
|
|
46
61
|
assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
|
|
47
62
|
// …debug the system.
|
|
@@ -20,6 +20,6 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
|
|
|
20
20
|
app.log.warn("listActivePrs rejected: missing/invalid shared secret");
|
|
21
21
|
return { status: 401, body: { error: "unauthorized" } };
|
|
22
22
|
}
|
|
23
|
-
const [prs, deliveries] = await Promise.all([activePrs(app.data), activeDeliveries(app.data)]);
|
|
23
|
+
const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
|
|
24
24
|
return { status: 200, body: { count: prs.length, prs, deliveries } };
|
|
25
25
|
});
|
|
@@ -190,10 +190,20 @@ test("startConvergenceLoop defaults convergeOnly to false and does not truthy-co
|
|
|
190
190
|
const stringy = captureApp();
|
|
191
191
|
await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
|
|
192
192
|
assertEquals(stringy.get(), false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
193
195
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
// Issue #916: `autoMerge: "true"` (a STRING) was silently FALSE at the door — the delegate read
|
|
197
|
+
// `body.autoMerge === true`, so a mistyped merge request ran review-only with no signal. The door
|
|
198
|
+
// now REJECTS a present-but-non-boolean `autoMerge` with a 400 at every start door.
|
|
199
|
+
test("startConvergenceLoop → 400 on a non-boolean autoMerge (issue #916)", async () => {
|
|
200
|
+
await withGithubOff(async () => {
|
|
201
|
+
const { app: capApp, get } = captureApp();
|
|
202
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), capApp);
|
|
203
|
+
const r = res as any;
|
|
204
|
+
assertEquals(r.status, 400);
|
|
205
|
+
assertEquals(typeof r.body.error, "string");
|
|
206
|
+
assertEquals(get(), undefined, "a rejected door never reaches the engine");
|
|
197
207
|
});
|
|
198
208
|
});
|
|
199
209
|
|
|
@@ -32,19 +32,25 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
32
32
|
const dependsOn = body.dependsOn ?? [];
|
|
33
33
|
const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
|
|
34
34
|
// Prefer the positive autoMerge form so an unchecked UI box is explicitly review-only. Legacy
|
|
35
|
-
// callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
|
|
35
|
+
// callers that omit autoMerge retain the existing convergeOnly/global-default behavior. A
|
|
36
|
+
// PRESENT-but-non-boolean `autoMerge` (e.g. the string "true") is a 400 (issue #916): it used to
|
|
37
|
+
// read as silently false (`=== true`), so a mistyped merge request ran review-only with no signal.
|
|
38
|
+
if ("autoMerge" in body && typeof body.autoMerge !== "boolean") {
|
|
39
|
+
app.log.warn("start-convergence rejected: autoMerge must be a boolean", { autoMerge: body.autoMerge });
|
|
40
|
+
return { status: 400, body: { error: "autoMerge must be a boolean (true or false)" } };
|
|
41
|
+
}
|
|
36
42
|
const hasAutoMerge = typeof body.autoMerge === "boolean";
|
|
37
|
-
const
|
|
43
|
+
const autoMerge = hasAutoMerge ? body.autoMerge : body.convergeOnly === true ? false : undefined;
|
|
38
44
|
// Opt-in escalation steward (issue #889): only when explicitly requested do this loop's
|
|
39
45
|
// escalations become eligible for steward auto-resolution. Default off.
|
|
40
46
|
const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
|
|
41
|
-
const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds,
|
|
47
|
+
const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, false, null, false, autoResolveEscalations, autoMerge);
|
|
42
48
|
app.log.info("convergence loop started", {
|
|
43
49
|
prKey: parsed.prKey,
|
|
44
50
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
45
51
|
dependsOn: dependsOn.length,
|
|
46
52
|
maxRounds,
|
|
47
|
-
convergeOnly,
|
|
53
|
+
autoMerge: autoMerge ?? body.convergeOnly !== true,
|
|
48
54
|
autoResolveEscalations,
|
|
49
55
|
});
|
|
50
56
|
return { status: 202, body: result };
|
|
@@ -207,3 +207,18 @@ test("humanApproval reaches the run only together with autoMerge", async () => {
|
|
|
207
207
|
}
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
// ── issue #916: a present-but-non-boolean `autoMerge` is a 400 at the door, never a silent false ──
|
|
212
|
+
|
|
213
|
+
test("non-boolean autoMerge → 400 at the edge (never silently review-only)", async () => {
|
|
214
|
+
await withGithub(REPO, async () => {
|
|
215
|
+
const { app, started } = makeApp();
|
|
216
|
+
const res = (await startFeature(
|
|
217
|
+
input({ issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge: "true" }),
|
|
218
|
+
app,
|
|
219
|
+
)) as any;
|
|
220
|
+
assertEquals(res.status, 400);
|
|
221
|
+
assertEquals(typeof res.body.error, "string");
|
|
222
|
+
assertEquals(started.length, 0, "a rejected door never starts a run");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -113,6 +113,12 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
113
113
|
throw err;
|
|
114
114
|
}
|
|
115
115
|
const converge = "converge" in body && body.converge === true;
|
|
116
|
+
// A PRESENT-but-non-boolean `autoMerge` (e.g. the string "true") is a 400 (issue #916): it used to
|
|
117
|
+
// read as silently false (`=== true`), so a mistyped merge request ran review-only with no signal.
|
|
118
|
+
if ("autoMerge" in body && typeof body.autoMerge !== "boolean") {
|
|
119
|
+
app.log.warn("start-feature rejected: autoMerge must be a boolean", { autoMerge: body.autoMerge });
|
|
120
|
+
return { status: 400, body: { error: "autoMerge must be a boolean (true or false)" } };
|
|
121
|
+
}
|
|
116
122
|
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
117
123
|
// the persisted row and the process variable can't disagree.
|
|
118
124
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|