@nanobpm/nano-workforce 0.193.2 → 0.195.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/README.md +6 -0
- package/SPEC.md +19 -1
- package/app/agentCompletion.ts +5 -1
- package/app/agentic/cockpit/agent-history-render.test.ts +24 -0
- package/app/agentic/cockpit/agent-history-render.ts +18 -4
- package/app/agentic/cockpit/agent-history-view.test.ts +16 -0
- package/app/agentic/cockpit/agent-history-view.ts +17 -0
- package/app/agentic/cockpit/mount.test.ts +12 -0
- package/app/convergeGate.test.ts +9 -2
- package/app/convergenceStaleRoundReset.test.ts +1 -1
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +8 -0
- package/app/featureDelivery.test.ts +21 -0
- package/app/fineGrainedCells.test.ts +1 -1
- package/app/mcpToolSurface.ts +6 -1
- package/app/mergeApproval.test.ts +126 -0
- package/app/service.test.ts +105 -0
- package/app/service.ts +43 -4
- package/app/userTasks.ts +7 -0
- package/db/migrations/116_feature_runs_human_approval.sql +51 -0
- package/e2e/feature-run.e2e.ts +18 -0
- package/openapi.yaml +15 -0
- package/operations/startFeature.readiness.integration.test.ts +14 -0
- package/operations/startFeature.ts +4 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +17 -1
- package/pages/cockpit/mount.js +27 -4
- package/pages/feature.page.json +1 -0
- package/resources/forms/merge-approval.form +36 -0
- package/resources/processes/convergence-loop.bpmn +216 -132
- package/resources/processes/feature.bpmn +2 -0
- package/resources/prompts/review-round.md +4 -3
- package/test/derivation-parity/README.md +4 -3
- package/test/derivation-parity/derivation-parity.test.ts +7 -7
- package/test/derivation-parity/flows.ts +6 -5
- package/workers/converge-feature/worker.ts +3 -1
package/app/service.test.ts
CHANGED
|
@@ -876,6 +876,111 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
|
|
|
876
876
|
});
|
|
877
877
|
});
|
|
878
878
|
|
|
879
|
+
test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
|
|
880
|
+
await withGithubOff(async () => {
|
|
881
|
+
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
|
882
|
+
const merging = captureVars();
|
|
883
|
+
await submitPr(merging.data, merging.engine, pr(11), [], 20, false, null, true);
|
|
884
|
+
assertEquals(merging.get()?.humanApproval, true);
|
|
885
|
+
const convergeOnly = captureVars();
|
|
886
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr(12), [], 20, true, null, true);
|
|
887
|
+
assertEquals(convergeOnly.get()?.humanApproval, false);
|
|
888
|
+
const unset = captureVars();
|
|
889
|
+
await submitPr(unset.data, unset.engine, pr(13));
|
|
890
|
+
assertEquals(unset.get()?.humanApproval, false);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Adoption (issue #826, Copilot review): `submitPr` returns `alreadyRunning` for a PR whose loop is
|
|
895
|
+
// live, so a gated caller adopting an UNGATED loop would inherit its auto-merge. `humanApproval` is
|
|
896
|
+
// monotonic: the gated caller narrows the live loop (idempotent, so a retry against its own gated loop
|
|
897
|
+
// is a no-op). A PR already finalized (`converged_at` set) is past its approval point: `startMerge`
|
|
898
|
+
// re-pointed its `process_key` at the LIVE merge-loop, which must never be written to.
|
|
899
|
+
function adoptLoop(status: string, loopState: string, convergedAt: string | null = null) {
|
|
900
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
901
|
+
pull_requests: {
|
|
902
|
+
rows: [
|
|
903
|
+
{
|
|
904
|
+
pr_key: "owner/repo#14",
|
|
905
|
+
repo: "owner/repo",
|
|
906
|
+
number: 14,
|
|
907
|
+
url: "https://github.com/owner/repo/pull/14",
|
|
908
|
+
title: "t",
|
|
909
|
+
status,
|
|
910
|
+
current_round: 1,
|
|
911
|
+
process_key: "PI-LOOP",
|
|
912
|
+
converged_at: convergedAt,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
key: "pr_key",
|
|
916
|
+
},
|
|
917
|
+
escalations: { rows: [], key: "id" },
|
|
918
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
919
|
+
};
|
|
920
|
+
const data = {
|
|
921
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
922
|
+
open: () => memOpen(stores),
|
|
923
|
+
} as any;
|
|
924
|
+
const narrowed: unknown[] = [];
|
|
925
|
+
const engine = {
|
|
926
|
+
createInstance: () => Promise.reject(new Error("a live PR is adopted, never re-created")),
|
|
927
|
+
searchProcessInstances: () => Promise.resolve([{ processInstanceKey: "PI-LOOP", state: loopState }]),
|
|
928
|
+
setVariables: (req: unknown) => {
|
|
929
|
+
narrowed.push(req);
|
|
930
|
+
return Promise.resolve();
|
|
931
|
+
},
|
|
932
|
+
} as any;
|
|
933
|
+
return { data, engine, narrowed };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
|
|
937
|
+
await withGithubOff(async () => {
|
|
938
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
939
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
940
|
+
assertEquals(await submitPr(live.data, live.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
941
|
+
assertEquals(live.narrowed, [{ scopeKey: "PI-LOOP", variables: { humanApproval: true } }]);
|
|
942
|
+
|
|
943
|
+
const ungated = adoptLoop("converging", "ACTIVE");
|
|
944
|
+
await submitPr(ungated.data, ungated.engine, pr);
|
|
945
|
+
assertEquals(ungated.narrowed, [], "an ungated adopter leaves the loop's mode alone");
|
|
946
|
+
|
|
947
|
+
const convergeOnly = adoptLoop("converging", "ACTIVE");
|
|
948
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr, [], 20, true, null, true);
|
|
949
|
+
assertEquals(convergeOnly.narrowed, [], "a converge-only adopter never merges, so never gates");
|
|
950
|
+
|
|
951
|
+
const merging = adoptLoop("waiting_deps", "ACTIVE", "2026-01-01T00:00:00.000Z");
|
|
952
|
+
assertEquals(await submitPr(merging.data, merging.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
953
|
+
assertEquals(merging.narrowed, [], "the live merge-loop of a finalized PR is never written to");
|
|
954
|
+
|
|
955
|
+
const ended = adoptLoop("converging", "COMPLETED");
|
|
956
|
+
await submitPr(ended.data, ended.engine, pr, [], 20, false, null, true);
|
|
957
|
+
assertEquals(ended.narrowed, [], "a loop the engine no longer reports running is left alone");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
|
|
962
|
+
// `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
|
|
963
|
+
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
|
964
|
+
process.env["NANO_PR_AUTO_MERGE"] = "0";
|
|
965
|
+
try {
|
|
966
|
+
const mod: typeof import("./service.ts") = await import(`./service.ts?autoMergeOff=${Date.now()}`);
|
|
967
|
+
await withGithubOff(async () => {
|
|
968
|
+
const fresh = captureVars();
|
|
969
|
+
const pr15 = { repo: "owner/repo", number: 15, url: "https://github.com/owner/repo/pull/15", prKey: "owner/repo#15" };
|
|
970
|
+
await mod.submitPr(fresh.data, fresh.engine, pr15, [], 20, false, null, true);
|
|
971
|
+
assertEquals(fresh.get()?.humanApproval, false, "no approval task for a merge that never happens");
|
|
972
|
+
|
|
973
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
974
|
+
const pr14 = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
975
|
+
await mod.submitPr(live.data, live.engine, pr14, [], 20, false, null, true);
|
|
976
|
+
assertEquals(live.narrowed, [], "nor is an adopted loop narrowed");
|
|
977
|
+
});
|
|
978
|
+
} finally {
|
|
979
|
+
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
980
|
+
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
879
984
|
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
880
985
|
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
881
986
|
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
package/app/service.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
type OpenEscalation,
|
|
108
108
|
PLAN_REVIEW_ELEMENT,
|
|
109
109
|
PR_ESCALATION_PRODUCER_ELEMENTS,
|
|
110
|
+
PR_MERGE_APPROVAL_ELEMENT,
|
|
110
111
|
PR_RECORD_ANSWER_ELEMENT,
|
|
111
112
|
PR_RECORD_MERGE_ANSWER_ELEMENT,
|
|
112
113
|
PR_WAIT_ANSWER_ELEMENT,
|
|
@@ -573,9 +574,10 @@ export async function submitPr(
|
|
|
573
574
|
maxRounds: number = MAX_ROUNDS,
|
|
574
575
|
convergeOnly = false,
|
|
575
576
|
rootRequestKey: string | null = null,
|
|
577
|
+
humanApproval = false,
|
|
576
578
|
) {
|
|
577
579
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
578
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
|
|
580
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
|
|
579
581
|
);
|
|
580
582
|
}
|
|
581
583
|
|
|
@@ -587,7 +589,12 @@ async function submitPrCritical(
|
|
|
587
589
|
maxRounds: number = MAX_ROUNDS,
|
|
588
590
|
convergeOnly = false,
|
|
589
591
|
rootRequestKey: string | null = null,
|
|
592
|
+
humanApproval = false,
|
|
590
593
|
) {
|
|
594
|
+
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
595
|
+
// merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
|
|
596
|
+
// (review-only, or the global switch off) must not park for a pointless approval.
|
|
597
|
+
const gated = humanApproval && !convergeOnly && AUTO_MERGE;
|
|
591
598
|
const table = prs(data);
|
|
592
599
|
const existing = await table.get(parsed.prKey);
|
|
593
600
|
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
@@ -607,6 +614,23 @@ async function submitPrCritical(
|
|
|
607
614
|
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
608
615
|
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
609
616
|
if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
617
|
+
// Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
|
|
618
|
+
// ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
|
|
619
|
+
// Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
|
|
620
|
+
// running is left alone; one already past its `human approval?` gateway can no longer be gated. A
|
|
621
|
+
// finalized PR (`converged_at` set) is past it, and `startMerge` has re-pointed its `process_key` at
|
|
622
|
+
// the merge-loop, so it is skipped. Mid-handoff (key re-pointed, `converged_at` not yet stamped) the
|
|
623
|
+
// write reaches the merge-loop but is inert: nothing there reads `humanApproval`.
|
|
624
|
+
if (gated && existing.converged_at == null) {
|
|
625
|
+
const loopKey = existing.process_key;
|
|
626
|
+
const match = (await engine.searchProcessInstances({ processInstanceKeys: [loopKey] })).find(
|
|
627
|
+
(s) => String(s.processInstanceKey) === loopKey,
|
|
628
|
+
);
|
|
629
|
+
const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
|
|
630
|
+
if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) {
|
|
631
|
+
await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
610
634
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
611
635
|
}
|
|
612
636
|
|
|
@@ -724,6 +748,9 @@ async function submitPrCritical(
|
|
|
724
748
|
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
725
749
|
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
726
750
|
convergeOnly,
|
|
751
|
+
// Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
|
|
752
|
+
// task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
|
|
753
|
+
humanApproval: gated,
|
|
727
754
|
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
728
755
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
729
756
|
abandonUrl: abUrl,
|
|
@@ -2469,13 +2496,24 @@ export async function pollFeatureDelivery(
|
|
|
2469
2496
|
// it would wedge `converging` forever with no PR process.
|
|
2470
2497
|
// Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
|
|
2471
2498
|
// on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
|
|
2472
|
-
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
|
|
2473
|
-
//
|
|
2499
|
+
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly` and
|
|
2500
|
+
// `humanApproval` mirror `converge-feature` — derived from the run's `auto_merge`/`human_approval`.
|
|
2474
2501
|
const partiallyEnrolled =
|
|
2475
2502
|
trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
|
|
2476
2503
|
if (prStatus === null || partiallyEnrolled) {
|
|
2477
2504
|
const parsed = parsePr(run.pr_key);
|
|
2478
|
-
if (parsed)
|
|
2505
|
+
if (parsed) {
|
|
2506
|
+
await submitPr(
|
|
2507
|
+
data,
|
|
2508
|
+
engine,
|
|
2509
|
+
parsed,
|
|
2510
|
+
[],
|
|
2511
|
+
MAX_ROUNDS,
|
|
2512
|
+
run.auto_merge !== 1,
|
|
2513
|
+
run.feature_key,
|
|
2514
|
+
run.human_approval === 1,
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2479
2517
|
}
|
|
2480
2518
|
const { status, label } = deriveFeatureDelivery(prStatus);
|
|
2481
2519
|
if (run.status !== status || run.delivery_label !== label) {
|
|
@@ -3057,6 +3095,7 @@ export async function pollUserTasks(
|
|
|
3057
3095
|
[READINESS_ESCALATION_ELEMENT]: "plan",
|
|
3058
3096
|
[PR_WAIT_ANSWER_ELEMENT]: "pr",
|
|
3059
3097
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
|
|
3098
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "pr",
|
|
3060
3099
|
};
|
|
3061
3100
|
|
|
3062
3101
|
// The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
|
package/app/userTasks.ts
CHANGED
|
@@ -52,6 +52,12 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
|
52
52
|
* canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
|
|
53
53
|
export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
54
54
|
|
|
55
|
+
/** The optional human merge-approval user task (convergence-loop.bpmn, issue #826) — a converged PR
|
|
56
|
+
* parks here before `pr.finalize` hands it to the merge-loop. Approve merges; request changes loops
|
|
57
|
+
* the guidance (`answer`) back to the review agent on the same PR. HUMAN-only: never agent-answerable,
|
|
58
|
+
* so the fleet can never auto-approve a merge. */
|
|
59
|
+
export const PR_MERGE_APPROVAL_ELEMENT = "merge-approval";
|
|
60
|
+
|
|
55
61
|
/** The PR review-loop answer-recording service task (convergence-loop.bpmn) — the `pr.answer-escalation`
|
|
56
62
|
* job the token moves to IMMEDIATELY when `wait-answer` completes. Its ACTIVE element instance is the
|
|
57
63
|
* positive-evidence marker that an operator's answer is in-flight (recorded but the loop hasn't looped
|
|
@@ -237,6 +243,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
237
243
|
[TRIAL_MERGE_ELEMENT]: "Trial merge",
|
|
238
244
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
239
245
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
246
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "PR merge approval",
|
|
240
247
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
241
248
|
[READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
|
|
242
249
|
[READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Issue #826: optional human approval before merge. 1 ⇒ the converged PR parks at the
|
|
2
|
+
-- `merge-approval` user task in convergence-loop.bpmn before the merge-loop runs. Additive (expand).
|
|
3
|
+
ALTER TABLE feature_runs ADD COLUMN human_approval INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
|
|
5
|
+
-- Mirror the new column through the `delivery_units` aggregate so the `feature_runs__units` compat VIEW
|
|
6
|
+
-- stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in app/deliveryUnit.test.ts): the
|
|
7
|
+
-- Feature sync triggers (089) and VIEW (091) are re-created verbatim plus `human_approval`.
|
|
8
|
+
ALTER TABLE delivery_units ADD COLUMN human_approval INTEGER;
|
|
9
|
+
UPDATE delivery_units SET human_approval = 0 WHERE kind = 'feature';
|
|
10
|
+
|
|
11
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
12
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
13
|
+
BEGIN
|
|
14
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
15
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
16
|
+
END;
|
|
17
|
+
|
|
18
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
19
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
20
|
+
BEGIN
|
|
21
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
22
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
23
|
+
END;
|
|
24
|
+
|
|
25
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
26
|
+
CREATE VIEW feature_runs__units AS
|
|
27
|
+
SELECT
|
|
28
|
+
du.legacy_key AS feature_key,
|
|
29
|
+
du.repo AS repo,
|
|
30
|
+
du.issue_number AS issue_number,
|
|
31
|
+
du.issue_url AS issue_url,
|
|
32
|
+
du.base_branch AS base_branch,
|
|
33
|
+
du.status AS status,
|
|
34
|
+
du.process_key AS process_key,
|
|
35
|
+
du.pr_key AS pr_key,
|
|
36
|
+
du.converge AS converge,
|
|
37
|
+
du.auto_merge AS auto_merge,
|
|
38
|
+
du.human_approval AS human_approval,
|
|
39
|
+
du.outcome AS outcome,
|
|
40
|
+
du.created_at AS created_at,
|
|
41
|
+
du.updated_at AS updated_at,
|
|
42
|
+
du.delivery_label AS delivery_label,
|
|
43
|
+
du.title AS title,
|
|
44
|
+
du.acknowledged_at AS acknowledged_at,
|
|
45
|
+
du.stage AS stage,
|
|
46
|
+
du.stage_state AS stage_state,
|
|
47
|
+
du.stage_skipped AS stage_skipped,
|
|
48
|
+
du.attention AS attention,
|
|
49
|
+
du.list_bucket AS list_bucket
|
|
50
|
+
FROM delivery_units du
|
|
51
|
+
WHERE du.kind = 'feature';
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -62,6 +62,7 @@ interface FeatureRow {
|
|
|
62
62
|
}
|
|
63
63
|
interface PrRow {
|
|
64
64
|
pr_key: string;
|
|
65
|
+
process_key: string | null;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
@@ -184,6 +185,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
184
185
|
);
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
test("raise + converge + human approval: the enrolled convergence loop carries the approval gate (issue #826)", async () => {
|
|
189
|
+
await withApp(
|
|
190
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#103", summary: "built it" }) },
|
|
191
|
+
{ baseBranch: "epic/e2e", converge: true, autoMerge: true, humanApproval: true },
|
|
192
|
+
async ({ app }) => {
|
|
193
|
+
// The flag must survive the real handoff: feature.bpmn → converge-cell callActivity →
|
|
194
|
+
// pr.converge-feature → submitPr → the live loop instance (still ACTIVE, so its vars are readable).
|
|
195
|
+
const pr = await app.db.table<PrRow>("pull_requests", "pr_key").findOne({ pr_key: "owner/repo#103" });
|
|
196
|
+
assert.ok(pr?.process_key, "converge enrolled the raised PR into a live convergence loop");
|
|
197
|
+
const instances = app.snapshot().instances;
|
|
198
|
+
assert.ok(Array.isArray(instances), "snapshot.instances is an array of instance rows");
|
|
199
|
+
const loop = instances.find((i) => i?.key === pr.process_key);
|
|
200
|
+
assert.equal(loop?.variables?.humanApproval, true, "the convergence loop was seeded with humanApproval");
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
187
205
|
test("blocked: parks at the operators' user task (non-terminal), never enrolls a PR, settles on ack", async () => {
|
|
188
206
|
await withApp(
|
|
189
207
|
{ "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
|
package/openapi.yaml
CHANGED
|
@@ -3442,6 +3442,12 @@ components:
|
|
|
3442
3442
|
When true (and `converge` is true), drive the merge-loop after convergence only when
|
|
3443
3443
|
`NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
|
|
3444
3444
|
Moot when `converge` is false.
|
|
3445
|
+
humanApproval:
|
|
3446
|
+
type: boolean
|
|
3447
|
+
description: >-
|
|
3448
|
+
When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the
|
|
3449
|
+
merge or request changes; the agent applies them to the same PR and convergence re-runs.
|
|
3450
|
+
Moot when the run does not merge (see `autoMerge`).
|
|
3445
3451
|
confirmDefaultBase:
|
|
3446
3452
|
type: boolean
|
|
3447
3453
|
description: >-
|
|
@@ -3513,6 +3519,9 @@ components:
|
|
|
3513
3519
|
autoMerge:
|
|
3514
3520
|
type: boolean
|
|
3515
3521
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
3522
|
+
humanApproval:
|
|
3523
|
+
type: boolean
|
|
3524
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
3516
3525
|
confirmDefaultBase:
|
|
3517
3526
|
type: boolean
|
|
3518
3527
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6181,6 +6190,9 @@ paths:
|
|
|
6181
6190
|
autoMerge:
|
|
6182
6191
|
type: boolean
|
|
6183
6192
|
description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
|
|
6193
|
+
humanApproval:
|
|
6194
|
+
type: boolean
|
|
6195
|
+
description: When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the merge or request changes; the agent applies them to the same PR and convergence re-runs. Moot when the run does not merge (see `autoMerge`).
|
|
6184
6196
|
confirmDefaultBase:
|
|
6185
6197
|
type: boolean
|
|
6186
6198
|
description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6326,6 +6338,9 @@ paths:
|
|
|
6326
6338
|
autoMerge:
|
|
6327
6339
|
type: boolean
|
|
6328
6340
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
6341
|
+
humanApproval:
|
|
6342
|
+
type: boolean
|
|
6343
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
6329
6344
|
confirmDefaultBase:
|
|
6330
6345
|
type: boolean
|
|
6331
6346
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -193,3 +193,17 @@ test("malformed readiness descriptor → 400 at the edge (never a 500)", async (
|
|
|
193
193
|
assertEquals(started.length, 0);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
// ── human approval before merge (issue #826): threaded through the edge, pinned off without autoMerge ─
|
|
198
|
+
|
|
199
|
+
test("humanApproval reaches the run only together with autoMerge", async () => {
|
|
200
|
+
await withGithub(REPO, async () => {
|
|
201
|
+
for (const [autoMerge, expected] of [[true, true], [false, false]] as const) {
|
|
202
|
+
const { app, started } = makeApp();
|
|
203
|
+
const body = { issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge, humanApproval: true };
|
|
204
|
+
const res = (await startFeature(input(body), app)) as any;
|
|
205
|
+
assertEquals(res.status, 202);
|
|
206
|
+
assertEquals(started[0].variables?.humanApproval, expected);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -116,6 +116,8 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
116
116
|
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
117
117
|
// the persisted row and the process variable can't disagree.
|
|
118
118
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
119
|
+
// Human approval gates the merge-loop, so it is moot (pinned off) without auto-merge (issue #826).
|
|
120
|
+
const humanApproval = autoMerge && "humanApproval" in body && body.humanApproval === true;
|
|
119
121
|
// Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
|
|
120
122
|
// normalized to null downstream (startFeature) so it never appends an empty instruction block.
|
|
121
123
|
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
@@ -176,12 +178,14 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
176
178
|
autoMerge,
|
|
177
179
|
customInstructions,
|
|
178
180
|
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
|
|
181
|
+
humanApproval,
|
|
179
182
|
);
|
|
180
183
|
app.log.info("feature run intake", {
|
|
181
184
|
featureKey: parsed.planKey,
|
|
182
185
|
requestedBaseBranch: normalizedBase,
|
|
183
186
|
converge,
|
|
184
187
|
autoMerge,
|
|
188
|
+
humanApproval,
|
|
185
189
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
186
190
|
readinessProbes: readiness.probes.length,
|
|
187
191
|
outcome: result.outcome,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.0",
|
|
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",
|
|
@@ -316,9 +316,24 @@
|
|
|
316
316
|
display: flex;
|
|
317
317
|
gap: 8px;
|
|
318
318
|
align-items: baseline;
|
|
319
|
-
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
list-style: none;
|
|
321
|
+
user-select: none;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.cockpit-agent-turn-meta::-webkit-details-marker { display: none; }
|
|
325
|
+
|
|
326
|
+
.cockpit-agent-turn-meta::before {
|
|
327
|
+
content: "\25B8";
|
|
328
|
+
color: var(--cockpit-muted);
|
|
329
|
+
font-size: 11px;
|
|
330
|
+
width: 10px;
|
|
320
331
|
}
|
|
321
332
|
|
|
333
|
+
.cockpit-agent-turn[open] > .cockpit-agent-turn-meta::before { content: "\25BE"; }
|
|
334
|
+
|
|
335
|
+
.cockpit-agent-turn-body { margin-top: 4px; padding-left: 18px; }
|
|
336
|
+
|
|
322
337
|
.cockpit-agent-turn-role {
|
|
323
338
|
font-size: 11px;
|
|
324
339
|
text-transform: uppercase;
|
|
@@ -327,6 +342,7 @@
|
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
.cockpit-agent-turn-iter,
|
|
345
|
+
.cockpit-agent-turn-time,
|
|
330
346
|
.cockpit-agent-turn-metrics {
|
|
331
347
|
font-size: 11px;
|
|
332
348
|
color: var(--cockpit-muted);
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -574,6 +574,15 @@ function turnMetrics(m) {
|
|
|
574
574
|
return dur != null ? `${base} \u00b7 ${dur}` : base;
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
function turnTime(iso) {
|
|
578
|
+
if (iso == null || iso === "") return undefined;
|
|
579
|
+
const ms = Date.parse(iso);
|
|
580
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
581
|
+
const d = new Date(ms);
|
|
582
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
583
|
+
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
577
586
|
function agentHistoryView(report) {
|
|
578
587
|
const turns = (report.records ?? []).map((r) => ({
|
|
579
588
|
historyItemKey: r.historyItemKey,
|
|
@@ -582,6 +591,8 @@ function agentHistoryView(report) {
|
|
|
582
591
|
text: turnText(r),
|
|
583
592
|
toolCalls: (r.toolCalls ?? []).map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, elementId: c.elementId })),
|
|
584
593
|
metrics: turnMetrics(r.metrics),
|
|
594
|
+
producedAt: r.producedAt != null && r.producedAt !== "" ? r.producedAt : undefined,
|
|
595
|
+
time: turnTime(r.producedAt),
|
|
585
596
|
}));
|
|
586
597
|
return {
|
|
587
598
|
agentInstanceKey: report.agentInstanceKey,
|
|
@@ -639,17 +650,28 @@ function renderAgentSessions(host, doc, view, onSelect, activeInstanceKey) {
|
|
|
639
650
|
host.appendChild(root);
|
|
640
651
|
}
|
|
641
652
|
|
|
653
|
+
// One turn as a native collapsible <details> (collapsed by default); its <summary> heading (role,
|
|
654
|
+
// iteration, timestamp, metrics) toggles the details body on click (#831).
|
|
642
655
|
function agentTurnBlock(doc, t) {
|
|
643
|
-
const block = el(doc, "
|
|
656
|
+
const block = el(doc, "details", "cockpit-agent-turn");
|
|
644
657
|
block.setAttribute("data-history-item-key", t.historyItemKey);
|
|
645
658
|
block.setAttribute("data-role", t.role);
|
|
646
659
|
block.setAttribute("data-loop-iteration", String(t.loopIteration));
|
|
647
|
-
const meta = el(doc, "
|
|
660
|
+
const meta = el(doc, "summary", "cockpit-agent-turn-meta");
|
|
648
661
|
meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
|
|
649
662
|
meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
|
|
663
|
+
if (t.time != null) {
|
|
664
|
+
const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
|
|
665
|
+
if (t.producedAt != null) {
|
|
666
|
+
time.setAttribute("datetime", t.producedAt);
|
|
667
|
+
time.setAttribute("title", t.producedAt);
|
|
668
|
+
}
|
|
669
|
+
meta.appendChild(time);
|
|
670
|
+
}
|
|
650
671
|
if (t.metrics != null) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
|
|
651
672
|
block.appendChild(meta);
|
|
652
|
-
|
|
673
|
+
const body = el(doc, "div", "cockpit-agent-turn-body");
|
|
674
|
+
if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
|
|
653
675
|
if (t.toolCalls.length > 0) {
|
|
654
676
|
const tools = el(doc, "ul", "cockpit-agent-turn-tools");
|
|
655
677
|
for (const call of t.toolCalls) {
|
|
@@ -657,8 +679,9 @@ function agentTurnBlock(doc, t) {
|
|
|
657
679
|
li.setAttribute("data-tool-call-id", call.toolCallId);
|
|
658
680
|
tools.appendChild(li);
|
|
659
681
|
}
|
|
660
|
-
|
|
682
|
+
body.appendChild(tools);
|
|
661
683
|
}
|
|
684
|
+
block.appendChild(body);
|
|
662
685
|
return block;
|
|
663
686
|
}
|
|
664
687
|
|
package/pages/feature.page.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
91
91
|
{ "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
|
|
92
92
|
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
93
|
+
{ "key": "humanApproval", "label": "Human approval before merge \u2014 a converged PR waits in Tasks for you to approve the merge or request changes (only applies when Auto-merge is on)", "type": "checkbox" },
|
|
93
94
|
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
94
95
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
|
95
96
|
{ "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "merge-approval",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"label": "Approve merge",
|
|
9
|
+
"text": "This PR passed review convergence. Approve it to hand it to the merge loop, or request changes: your guidance goes to the review agent, which updates this same PR. Convergence then re-runs and approval is asked again."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "select",
|
|
13
|
+
"key": "mergeDecision",
|
|
14
|
+
"label": "Decision",
|
|
15
|
+
"values": [
|
|
16
|
+
{ "label": "Approve — merge the PR", "value": "approve" },
|
|
17
|
+
{ "label": "Request changes — send guidance to the agent", "value": "revise" }
|
|
18
|
+
],
|
|
19
|
+
"validate": {
|
|
20
|
+
"required": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "textarea",
|
|
25
|
+
"key": "answer",
|
|
26
|
+
"label": "Guidance for the agent",
|
|
27
|
+
"description": "What should change before this PR merges? The agent implements it on the same PR.",
|
|
28
|
+
"validate": {
|
|
29
|
+
"required": true
|
|
30
|
+
},
|
|
31
|
+
"conditional": {
|
|
32
|
+
"hide": "=mergeDecision != \"revise\""
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|