@nanobpm/nano-workforce 0.187.6 → 0.188.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 +6 -0
- package/README.md +1 -0
- package/SPEC.md +69 -21
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +236 -5
- package/app/convergeGate.ts +41 -5
- package/app/github.ts +47 -13
- package/app/service.test.ts +40 -1
- package/app/service.ts +19 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +56 -22
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +42 -10
package/app/convergeGate.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { assert, assertEquals, assertNotEquals, assertStringIncludes } from "#te
|
|
|
15
15
|
import { evaluateConvergeGate } from "./convergeGate.ts";
|
|
16
16
|
import {
|
|
17
17
|
advisoryStableKey,
|
|
18
|
+
isAckThread,
|
|
18
19
|
parseAckedAdvisories,
|
|
19
20
|
parseReviewThreadsPage,
|
|
20
21
|
parseSuppressedAdvisories,
|
|
@@ -29,12 +30,15 @@ test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) co
|
|
|
29
30
|
const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
30
31
|
assertEquals(r.convergeBlocked, false);
|
|
31
32
|
assertEquals(r.convergeBlockReason, "");
|
|
33
|
+
assertEquals(r.ackOnly, false);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
|
|
35
37
|
const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
36
38
|
assertEquals(r.convergeBlocked, true);
|
|
37
39
|
assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
|
|
40
|
+
// An unresolved inline thread needs the round agent's code/reply work — NOT ack-only (#796).
|
|
41
|
+
assertEquals(r.ackOnly, false);
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
|
|
@@ -47,6 +51,8 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
|
|
|
47
51
|
assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
|
|
48
52
|
// Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
|
|
49
53
|
assertStringIncludes(r.convergeBlockReason, "1 unacknowledged suppressed advisory (");
|
|
54
|
+
// Blocked SOLELY on an unacked advisory → the recoverable, bounded-auto-ack case (#796).
|
|
55
|
+
assertEquals(r.ackOnly, true);
|
|
50
56
|
});
|
|
51
57
|
|
|
52
58
|
test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
|
|
@@ -99,6 +105,9 @@ test("evaluateConvergeGate: reports both a thread and an advisory when both are
|
|
|
99
105
|
assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
|
|
100
106
|
assertStringIncludes(r.convergeBlockReason, "y.ts:20");
|
|
101
107
|
assert(!r.convergeBlockReason.includes("x.ts:10"), "an acknowledged advisory must not be listed");
|
|
108
|
+
// A mix of an unresolved thread AND an unacked advisory is NOT ack-only — the thread still needs
|
|
109
|
+
// the round agent's code/reply work, so it escalates to a human as before (#796).
|
|
110
|
+
assertEquals(r.ackOnly, false);
|
|
102
111
|
});
|
|
103
112
|
|
|
104
113
|
// ── The parsers (app/github.ts) ─────────────────────────────────────────────
|
|
@@ -139,6 +148,122 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
|
|
|
139
148
|
assertEquals(parseSuppressedAdvisories(undefined), []);
|
|
140
149
|
});
|
|
141
150
|
|
|
151
|
+
// An UNRESOLVED ack thread (one the round agent posted but has not resolved yet) must NOT count as
|
|
152
|
+
// an unresolved *review* thread: it is a partially-completed acknowledgement the bounded #796
|
|
153
|
+
// auto-ack retry can finish, so counting it would flip an otherwise ack-only block off the
|
|
154
|
+
// recoverable path and escalate to a human despite there being no substantive open code-review
|
|
155
|
+
// thread. `isAckThread` is the single source of truth the converge-gate worker filters on.
|
|
156
|
+
test("isAckThread: a thread carrying a nano-ack marker is an ack thread (resolved or not)", () => {
|
|
157
|
+
assert(
|
|
158
|
+
isAckThread({
|
|
159
|
+
isResolved: false,
|
|
160
|
+
path: "a.ts",
|
|
161
|
+
bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."],
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
assert(
|
|
165
|
+
isAckThread({ isResolved: true, path: "a.ts", bodies: ["Declined. nano-ack: app/x.ts :: Narrow this type."] }),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("isAckThread: a substantive review thread (no nano-ack marker) is NOT an ack thread", () => {
|
|
170
|
+
assertEquals(isAckThread({ isResolved: false, path: "a.ts", bodies: ["This can NPE on empty input."] }), false);
|
|
171
|
+
assertEquals(isAckThread({ isResolved: false, path: "a.ts", bodies: [] }), false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// FAIL-CLOSED guard #1 — the retired bare `nano-ack: <path>:<line>` form is prose-blind and NOT
|
|
175
|
+
// honoured by `parseAckedAdvisories`; classifying it as an ack thread would drop a genuine unresolved
|
|
176
|
+
// thread from the count and let the gate finalize with it still open. It must stay counted.
|
|
177
|
+
test("isAckThread: a bare `<path>:<line>` marker is NOT a dedicated ack thread (stays counted)", () => {
|
|
178
|
+
assertEquals(
|
|
179
|
+
isAckThread({ isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts:12"] }),
|
|
180
|
+
false,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// FAIL-CLOSED guard #2 — a substantive reviewer thread whose ROOT is the finding, with a later reply
|
|
185
|
+
// merely QUOTING a canonical marker, must NOT be classified as an ack thread. Only the thread root is
|
|
186
|
+
// inspected, and the root here carries no marker, so the substantive thread stays counted.
|
|
187
|
+
test("isAckThread: a substantive thread that only quotes nano-ack in a reply is NOT an ack thread", () => {
|
|
188
|
+
assertEquals(
|
|
189
|
+
isAckThread({
|
|
190
|
+
isResolved: false,
|
|
191
|
+
path: "a.ts",
|
|
192
|
+
bodies: [
|
|
193
|
+
"This can NPE on empty input.",
|
|
194
|
+
"Re: your `nano-ack: app/x.ts :: Guard the empty input.` — that is unrelated to this finding.",
|
|
195
|
+
],
|
|
196
|
+
}),
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Mirrors the converge-gate worker's split: an unresolved ack thread is classified separately (it
|
|
202
|
+
// still BLOCKS but stays ack-only/recoverable), while a substantive unresolved thread escalates.
|
|
203
|
+
test("converge gate: an unresolved ack thread does not count as a SUBSTANTIVE thread (stays ack-only)", () => {
|
|
204
|
+
const threads: ReviewThread[] = [
|
|
205
|
+
{ isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
|
|
206
|
+
{ isResolved: true, path: "b.ts", bodies: ["already fixed"] },
|
|
207
|
+
];
|
|
208
|
+
const unresolved = threads.filter((t) => !t.isResolved);
|
|
209
|
+
const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
|
|
210
|
+
const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
|
|
211
|
+
assertEquals(unresolvedThreadCount, 0);
|
|
212
|
+
assertEquals(unresolvedAckThreadCount, 1);
|
|
213
|
+
const r = evaluateConvergeGate({
|
|
214
|
+
unresolvedThreadCount,
|
|
215
|
+
unresolvedAckThreadCount,
|
|
216
|
+
suppressedAdvisories: [{ key: "app/x.ts#deadbeef", label: "app/x.ts:12" }],
|
|
217
|
+
acknowledgedKeys: [],
|
|
218
|
+
});
|
|
219
|
+
assertEquals(r.convergeBlocked, true);
|
|
220
|
+
assertEquals(r.ackOnly, true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// FAIL-CLOSED regression (thread 2): an unresolved ack thread with NO outstanding advisory must NOT
|
|
224
|
+
// finalize the gate — dropping it entirely (the old `!isAckThread` filter) let the process converge
|
|
225
|
+
// with a genuinely-open GitHub thread. It now BLOCKS, classified ack-only (recoverable).
|
|
226
|
+
test("converge gate: a lone unresolved ack thread (no advisory) BLOCKS, ack-only (no fail-open finalize)", () => {
|
|
227
|
+
const threads: ReviewThread[] = [
|
|
228
|
+
{ isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
|
|
229
|
+
];
|
|
230
|
+
const unresolved = threads.filter((t) => !t.isResolved);
|
|
231
|
+
const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
|
|
232
|
+
const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
|
|
233
|
+
assertEquals(unresolvedThreadCount, 0);
|
|
234
|
+
assertEquals(unresolvedAckThreadCount, 1);
|
|
235
|
+
const r = evaluateConvergeGate({
|
|
236
|
+
unresolvedThreadCount,
|
|
237
|
+
unresolvedAckThreadCount,
|
|
238
|
+
suppressedAdvisories: [],
|
|
239
|
+
acknowledgedKeys: [],
|
|
240
|
+
});
|
|
241
|
+
assertEquals(r.convergeBlocked, true);
|
|
242
|
+
assertEquals(r.ackOnly, true);
|
|
243
|
+
assertStringIncludes(r.convergeBlockReason, "unresolved acknowledgement thread");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// A genuine reviewer thread left open still blocks off the ack-only path (fail-closed intact).
|
|
247
|
+
test("converge gate: a substantive unresolved thread still counts (not ack-only)", () => {
|
|
248
|
+
const threads: ReviewThread[] = [
|
|
249
|
+
{ isResolved: false, path: "a.ts", bodies: ["This can NPE on empty input."] },
|
|
250
|
+
{ isResolved: false, path: "b.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
|
|
251
|
+
];
|
|
252
|
+
const unresolved = threads.filter((t) => !t.isResolved);
|
|
253
|
+
const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
|
|
254
|
+
const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
|
|
255
|
+
assertEquals(unresolvedThreadCount, 1);
|
|
256
|
+
assertEquals(unresolvedAckThreadCount, 1);
|
|
257
|
+
const r = evaluateConvergeGate({
|
|
258
|
+
unresolvedThreadCount,
|
|
259
|
+
unresolvedAckThreadCount,
|
|
260
|
+
suppressedAdvisories: [{ key: "app/x.ts#deadbeef", label: "app/x.ts:12" }],
|
|
261
|
+
acknowledgedKeys: [],
|
|
262
|
+
});
|
|
263
|
+
assertEquals(r.convergeBlocked, true);
|
|
264
|
+
assertEquals(r.ackOnly, false);
|
|
265
|
+
});
|
|
266
|
+
|
|
142
267
|
test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
|
|
143
268
|
const threads: ReviewThread[] = [
|
|
144
269
|
{
|
|
@@ -498,7 +623,7 @@ test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
|
498
623
|
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
499
624
|
});
|
|
500
625
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
501
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
626
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
502
627
|
});
|
|
503
628
|
|
|
504
629
|
test("converge-gate: an unresolved thread blocks convergence", async () => {
|
|
@@ -509,6 +634,7 @@ test("converge-gate: an unresolved thread blocks convergence", async () => {
|
|
|
509
634
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
510
635
|
assertEquals(out.convergeBlocked, true);
|
|
511
636
|
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
|
|
637
|
+
assertEquals(out.convergeAckOnly, false);
|
|
512
638
|
});
|
|
513
639
|
|
|
514
640
|
test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
|
|
@@ -519,6 +645,8 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
|
|
|
519
645
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
520
646
|
assertEquals(out.convergeBlocked, true);
|
|
521
647
|
assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
|
|
648
|
+
// Blocked SOLELY on unacked advisories → ack-only, so the loop auto-acks before a human (#796).
|
|
649
|
+
assertEquals(out.convergeAckOnly, true);
|
|
522
650
|
});
|
|
523
651
|
|
|
524
652
|
test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
|
|
@@ -540,7 +668,56 @@ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed",
|
|
|
540
668
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
541
669
|
});
|
|
542
670
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
543
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
671
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// WIRING regression guard (through makeHandler, not a local re-implementation of the filter): an
|
|
675
|
+
// UNRESOLVED ack thread is classified separately (ack-only), so a block whose only substantive cause
|
|
676
|
+
// is unacked advisories stays ack-only. If the worker regressed to counting an unresolved ack thread
|
|
677
|
+
// as a SUBSTANTIVE thread, convergeAckOnly would flip to false and this handler-level test would fail.
|
|
678
|
+
test("converge-gate: an unresolved ack thread is classified ack-only, not substantive (through the handler)", async () => {
|
|
679
|
+
const handler = await makeUnderTest({
|
|
680
|
+
readThreads: async () => [
|
|
681
|
+
// A partially-completed acknowledgement (posted, not yet resolved) — its root carries the
|
|
682
|
+
// canonical marker, so isAckThread classifies it as an (unresolved) ack thread, not substantive.
|
|
683
|
+
{
|
|
684
|
+
isResolved: false,
|
|
685
|
+
path: "spec-app/nano-app.schema.json",
|
|
686
|
+
bodies: [
|
|
687
|
+
"Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
692
|
+
});
|
|
693
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
694
|
+
assertEquals(out.convergeBlocked, true);
|
|
695
|
+
// No SUBSTANTIVE unresolved thread was counted → the block is ack-only (recoverable).
|
|
696
|
+
assertEquals(out.convergeAckOnly, true);
|
|
697
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "unacknowledged suppressed");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// FAIL-CLOSED wiring guard (thread 2, through the handler): a lone UNRESOLVED ack thread with NO
|
|
701
|
+
// outstanding advisory must NOT let the gate finalize. The old `!isAckThread` filter dropped it
|
|
702
|
+
// entirely (unresolvedThreadCount = 0, no advisory) → convergeBlocked = false → the process could
|
|
703
|
+
// converge with a genuinely-open GitHub thread. It must now BLOCK (ack-only, recoverable).
|
|
704
|
+
test("converge-gate: a lone unresolved ack thread (no advisory) BLOCKS, ack-only — no fail-open finalize (through the handler)", async () => {
|
|
705
|
+
const handler = await makeUnderTest({
|
|
706
|
+
readThreads: async () => [
|
|
707
|
+
{
|
|
708
|
+
isResolved: false,
|
|
709
|
+
path: "spec-app/nano-app.schema.json",
|
|
710
|
+
bodies: [
|
|
711
|
+
"Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
|
|
712
|
+
],
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
716
|
+
});
|
|
717
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
718
|
+
assertEquals(out.convergeBlocked, true);
|
|
719
|
+
assertEquals(out.convergeAckOnly, true);
|
|
720
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved acknowledgement thread");
|
|
544
721
|
});
|
|
545
722
|
|
|
546
723
|
test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
|
|
@@ -551,6 +728,8 @@ test("converge-gate: FAILS CLOSED when the threads read returns null (no transpo
|
|
|
551
728
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
552
729
|
assertEquals(out.convergeBlocked, true);
|
|
553
730
|
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
731
|
+
// An unverifiable block is NOT ack-only — it must go to a human, never the auto-ack path (#796).
|
|
732
|
+
assertEquals(out.convergeAckOnly, false);
|
|
554
733
|
});
|
|
555
734
|
|
|
556
735
|
test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
|
|
@@ -596,7 +775,7 @@ test("converge-gate: a non-string prKey does not throw — resolves from repo/pr
|
|
|
596
775
|
readReviewBody: async () => "",
|
|
597
776
|
});
|
|
598
777
|
const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
599
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
|
|
778
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
600
779
|
});
|
|
601
780
|
|
|
602
781
|
test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
|
|
@@ -641,7 +820,7 @@ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not bloc
|
|
|
641
820
|
headSha: "newsha2222222222222222222222222222222222",
|
|
642
821
|
});
|
|
643
822
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
644
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: true });
|
|
823
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true });
|
|
645
824
|
});
|
|
646
825
|
|
|
647
826
|
test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
|
|
@@ -705,10 +884,62 @@ test("check-converge runs the deterministic converge-gate job and feeds gw-conve
|
|
|
705
884
|
test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
|
|
706
885
|
const f = flowElement("f_convergeBlocked");
|
|
707
886
|
assert(f, "f_convergeBlocked flow missing");
|
|
708
|
-
|
|
887
|
+
// A block now routes to the bounded auto-ack gateway first (#796), not straight to the human.
|
|
888
|
+
assertStringIncludes(f, 'targetRef="gw-ack-retry"');
|
|
709
889
|
assertStringIncludes(f, "convergeBlocked = true");
|
|
710
890
|
});
|
|
711
891
|
|
|
892
|
+
// ── Bounded agent auto-ack before human escalation (#796) ────────────────────
|
|
893
|
+
// A converge-gate block whose SOLE cause is unacked suppressed advisories is recoverable by
|
|
894
|
+
// re-dispatching the review-round agent to post the missing acks. gw-ack-retry sends such an
|
|
895
|
+
// ack-only block back into review-round (bounded by ackRetryMax), and only a non-ack-only block
|
|
896
|
+
// (an unresolved inline thread) or an exhausted budget escalates to the human wait-answer.
|
|
897
|
+
|
|
898
|
+
test("gw-ack-retry routes an ack-only block (within budget) back into the review-round agent", () => {
|
|
899
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ack-retry"[^>]*>/);
|
|
900
|
+
assert(gw, "gw-ack-retry gateway missing");
|
|
901
|
+
assertStringIncludes(gw[0], 'default="f_ackEscalate"');
|
|
902
|
+
const retry = flowElement("f_ackRetry");
|
|
903
|
+
assert(retry, "f_ackRetry flow missing");
|
|
904
|
+
assertStringIncludes(retry, 'sourceRef="gw-ack-retry"');
|
|
905
|
+
// The re-entry rejoins the loop at `capture-head` (the loop head that captures the round-entry
|
|
906
|
+
// head, #786) which then flows straight into `review-round` — so the ack-only block re-dispatches
|
|
907
|
+
// the review-round agent, freshly baselined.
|
|
908
|
+
assertStringIncludes(retry, 'targetRef="capture-head"');
|
|
909
|
+
assertStringIncludes(retry, "convergeAckOnly = true");
|
|
910
|
+
// Bounded: re-dispatch only while the ack-retry budget is not exhausted.
|
|
911
|
+
assert(/ackRetryRound <= ackRetryMax|ackRetryRound <= ackRetryMax/.test(retry), "f_ackRetry must be budget-bounded");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("gw-ack-retry default arm escalates to the human (threads or exhausted budget)", () => {
|
|
915
|
+
const esc = flowElement("f_ackEscalate");
|
|
916
|
+
assert(esc, "f_ackEscalate flow missing");
|
|
917
|
+
assertStringIncludes(esc, 'sourceRef="gw-ack-retry"');
|
|
918
|
+
assertStringIncludes(esc, 'targetRef="persist-escalation-blockedcomments"');
|
|
919
|
+
assert(!/conditionExpression/.test(esc), "the escalate arm is the default — no conditionExpression");
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("capture-head accepts the ack-retry re-entry flow", () => {
|
|
923
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="capture-head"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
924
|
+
assert(task, "capture-head task missing");
|
|
925
|
+
assertStringIncludes(task[0], "<bpmn:incoming>f_ackRetry</bpmn:incoming>");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("check-converge advances the ack-retry counter only on an ack-only block (bounded)", () => {
|
|
929
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="check-converge"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
930
|
+
assert(task, "check-converge task missing");
|
|
931
|
+
assertStringIncludes(
|
|
932
|
+
task[0],
|
|
933
|
+
"if convergeBlocked = true and convergeAckOnly = true then ackRetryRound + 1 else ackRetryRound",
|
|
934
|
+
);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("PrConvergeGateOut carries the convergeAckOnly signal for the auto-ack router", () => {
|
|
938
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="PrConvergeGateOut"[^>]*>.*?<\/nano:shape>/);
|
|
939
|
+
assert(shape, "PrConvergeGateOut envelope missing");
|
|
940
|
+
assertStringIncludes(shape[0], 'name="convergeAckOnly"');
|
|
941
|
+
});
|
|
942
|
+
|
|
712
943
|
test("gw-converge-gate routes a STALE review back to persist-round (re-solicit), not to escalation (#799)", () => {
|
|
713
944
|
const f = flowElement("f_convergeStale");
|
|
714
945
|
assert(f, "f_convergeStale flow missing");
|
package/app/convergeGate.ts
CHANGED
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
// to only say "converged" once every Copilot comment is addressed — which failed on
|
|
5
5
|
// Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
|
|
6
6
|
// the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
|
|
7
|
-
// while
|
|
8
|
-
// • any review THREAD is still unresolved, or
|
|
7
|
+
// while any of:
|
|
8
|
+
// • any SUBSTANTIVE review THREAD is still unresolved, or
|
|
9
|
+
// • any partially-completed `nano-ack:` ACK THREAD is still unresolved (blocks, but recoverable —
|
|
10
|
+
// see `unresolvedAckThreadCount`), or
|
|
9
11
|
// • any SUPPRESSED advisory (in the latest Copilot review body) lacks a matching
|
|
10
12
|
// RESOLVED ack thread (a thread carrying a line-stable `nano-ack: <path> :: <text>` marker; the
|
|
11
13
|
// bare `nano-ack: <path>:<line>` form is NOT honoured — its `path:line` key is prose-blind and
|
|
12
14
|
// would false-OPEN a new advisory re-emitted at a previously-acked line).
|
|
13
|
-
// A blocked gate
|
|
15
|
+
// A blocked gate is recoverable, never a hard wedge — but the route depends on WHY it blocked: an
|
|
16
|
+
// ack-only block (sole cause unacked advisories and/or an unresolved `nano-ack:` thread) re-enters
|
|
17
|
+
// the `review-round` agent first, bounded by `ackRetryMax`, and only escalates to the human
|
|
18
|
+
// wait-answer task once that budget is exhausted; a substantive unresolved thread escalates to
|
|
19
|
+
// wait-answer immediately.
|
|
14
20
|
|
|
15
21
|
export interface ConvergeGateInput {
|
|
16
|
-
/** Count of review threads
|
|
22
|
+
/** Count of unresolved review threads that are NOT `nano-ack:` ack threads (substantive reviewer
|
|
23
|
+
* findings). Any of these forces the block off the ack-only path — it needs the round agent's
|
|
24
|
+
* code/reply work, so it escalates to a human. */
|
|
17
25
|
unresolvedThreadCount: number;
|
|
26
|
+
/** Count of unresolved threads the worker classified as `nano-ack:` ACK threads (partially-completed
|
|
27
|
+
* acknowledgements: posted but not yet resolved). These NEVER silently drop out of the gate — an
|
|
28
|
+
* unresolved ack thread is still a genuinely-open GitHub thread, so it BLOCKS convergence; but the
|
|
29
|
+
* block stays ack-only (recoverable by the bounded #796 auto-ack retry, which re-posts/resolves).
|
|
30
|
+
* Keeping this a blocking condition (rather than filtering the thread away) is what makes the
|
|
31
|
+
* `isAckThread` root-marker classifier FAIL-CLOSED: even if it mis-labels a substantive thread as
|
|
32
|
+
* an ack, that thread still BLOCKS (as ack-only) instead of finalizing with the finding open — and
|
|
33
|
+
* the bounded retry cannot ack a non-advisory, so it escalates to a human on exhaustion. Optional
|
|
34
|
+
* (defaults to 0) so the pure function stays total for callers that only track substantive threads. */
|
|
35
|
+
unresolvedAckThreadCount?: number;
|
|
18
36
|
/** Copilot's suppressed advisories (latest review body), each with its line-stable key + label. */
|
|
19
37
|
suppressedAdvisories: { key: string; label: string }[];
|
|
20
38
|
/** Acknowledged line-stable keys (`<path>#<fp>`) from RESOLVED `nano-ack:` threads. An advisory is
|
|
@@ -25,6 +43,15 @@ export interface ConvergeGateInput {
|
|
|
25
43
|
export interface ConvergeGateResult {
|
|
26
44
|
convergeBlocked: boolean;
|
|
27
45
|
convergeBlockReason: string;
|
|
46
|
+
/** True when the block is caused SOLELY by recoverable, agent-fixable state and no SUBSTANTIVE
|
|
47
|
+
* (non-ack) reviewer thread is open: unacknowledged suppressed advisories and/or a
|
|
48
|
+
* partially-completed (unresolved) `nano-ack:` ack thread. This is the case the loop can auto-ack:
|
|
49
|
+
* re-dispatching the review-round agent posts/resolves the missing ack threads and converges, so
|
|
50
|
+
* the process routes an ack-only block through a bounded agent auto-ack step BEFORE the human
|
|
51
|
+
* `wait-answer` (issue #796). A block that includes any unresolved SUBSTANTIVE inline thread (which
|
|
52
|
+
* needs the round agent's code/reply work) is NOT ack-only and escalates to a human as before.
|
|
53
|
+
* Always false when not blocked. */
|
|
54
|
+
ackOnly: boolean;
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
/** Decide whether a self-reported "converged" round may proceed to finalize. Pure; the worker
|
|
@@ -36,20 +63,29 @@ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResu
|
|
|
36
63
|
// NOT consulted: it would let a resolved ack for one advisory silently acknowledge a genuinely new
|
|
37
64
|
// advisory re-emitted at the same line (a false-OPEN this gate exists to prevent).
|
|
38
65
|
const unacked = input.suppressedAdvisories.filter((a) => !acked.has(a.key));
|
|
66
|
+
const unresolvedAck = input.unresolvedAckThreadCount ?? 0;
|
|
39
67
|
const reasons: string[] = [];
|
|
40
68
|
if (input.unresolvedThreadCount > 0) {
|
|
41
69
|
const n = input.unresolvedThreadCount;
|
|
42
70
|
reasons.push(`${n} unresolved review thread${n === 1 ? "" : "s"}`);
|
|
43
71
|
}
|
|
72
|
+
if (unresolvedAck > 0) {
|
|
73
|
+
reasons.push(`${unresolvedAck} unresolved acknowledgement thread${unresolvedAck === 1 ? "" : "s"}`);
|
|
74
|
+
}
|
|
44
75
|
if (unacked.length > 0) {
|
|
45
76
|
const noun = unacked.length === 1 ? "advisory" : "advisories";
|
|
46
77
|
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.map((a) => a.label).join(", ")})`);
|
|
47
78
|
}
|
|
48
79
|
if (reasons.length === 0) {
|
|
49
|
-
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
80
|
+
return { convergeBlocked: false, convergeBlockReason: "", ackOnly: false };
|
|
50
81
|
}
|
|
51
82
|
return {
|
|
52
83
|
convergeBlocked: true,
|
|
53
84
|
convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path> :: <verbatim advisory text>) for each suppressed advisory before converging.`,
|
|
85
|
+
// Ack-only iff NO substantive (non-ack) reviewer thread is open — the only remaining causes are
|
|
86
|
+
// recoverable by re-dispatching the review-round agent (#796): unacked suppressed advisories
|
|
87
|
+
// and/or a partially-completed (unresolved) ack thread it can re-post and resolve. A substantive
|
|
88
|
+
// unresolved thread needs the round agent's code/reply work and escalates to a human.
|
|
89
|
+
ackOnly: input.unresolvedThreadCount === 0 && (unacked.length > 0 || unresolvedAck > 0),
|
|
54
90
|
};
|
|
55
91
|
}
|
package/app/github.ts
CHANGED
|
@@ -281,25 +281,59 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
|
|
|
281
281
|
return out;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/** The line-stable advisory keys carried by a SINGLE comment body's canonical `nano-ack: <path> ::
|
|
285
|
+
* <text>` markers. This is the SOLE recognizer of an acknowledgement, shared by `isAckThread` and
|
|
286
|
+
* `parseAckedAdvisories` so "is this an ack?" has ONE canonical implementation (derivation over
|
|
287
|
+
* duplication — no drift between the two consumers). The bare `nano-ack: <path>:<line>` form yields
|
|
288
|
+
* NOTHING here: `NEW_ACK` requires the ` :: <text>` prose (a bare `path:line` is prose-blind and
|
|
289
|
+
* would false-OPEN a new advisory re-emitted at a previously-acked line). */
|
|
290
|
+
function canonicalAckKeys(body: string): string[] {
|
|
291
|
+
const keys: string[] = [];
|
|
292
|
+
ACK_MARKER.lastIndex = 0;
|
|
293
|
+
let m: RegExpExecArray | null;
|
|
294
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
295
|
+
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
296
|
+
const nw = NEW_ACK.exec(m[1].trim());
|
|
297
|
+
if (nw) keys.push(advisoryStableKey(nw[1], nw[2]));
|
|
298
|
+
}
|
|
299
|
+
return keys;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** True when a review thread is a DEDICATED `nano-ack:` acknowledgement thread — one whose ROOT
|
|
303
|
+
* comment (`bodies[0]`, the thread-opening comment) carries a valid canonical `nano-ack: <path> ::
|
|
304
|
+
* <text>` marker — rather than a substantive code-review thread. This is a CLASSIFICATION only: the
|
|
305
|
+
* converge gate never DROPS an unresolved thread on the strength of this predicate. An unresolved ack
|
|
306
|
+
* thread still BLOCKS convergence (it is a genuinely-open GitHub thread); the classification only
|
|
307
|
+
* routes that block onto the recoverable ack-only path — a partially-completed acknowledgement the
|
|
308
|
+
* bounded #796 auto-ack retry can finish (post-and-resolve) — instead of escalating a human. A
|
|
309
|
+
* substantive unresolved thread escalates to a human.
|
|
310
|
+
*
|
|
311
|
+
* Because the gate BLOCKS either way, this predicate is FAIL-CLOSED even under a false positive:
|
|
312
|
+
* 1. Only the canonical prose-keyed form counts (via `canonicalAckKeys`); the retired bare
|
|
313
|
+
* `nano-ack: <path>:<line>` form does NOT — matching `parseAckedAdvisories`.
|
|
314
|
+
* 2. Only the ROOT comment is inspected — a reviewer's substantive finding is ALWAYS its thread's
|
|
315
|
+
* root and (canonical-form) never carries this marker, so a substantive thread that merely
|
|
316
|
+
* quotes or replies `nano-ack:` in a later comment is not mis-classified.
|
|
317
|
+
* 3. Even if a root DID quote the canonical marker mid-prose and were mis-labelled an ack, the
|
|
318
|
+
* thread is NOT excluded — it still blocks (as ack-only), and the bounded auto-ack retry cannot
|
|
319
|
+
* ack a non-advisory, so it escalates to a human on exhaustion. Marker presence never finalizes
|
|
320
|
+
* the gate with an open thread (the fail-OPEN this design forecloses). */
|
|
321
|
+
export function isAckThread(thread: ReviewThread): boolean {
|
|
322
|
+
const root = thread.bodies[0];
|
|
323
|
+
return root !== undefined && canonicalAckKeys(root).length > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
284
326
|
/** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
|
|
285
327
|
* count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
|
|
286
|
-
* parsed from the `nano-ack: <path> :: <text>` form ONLY
|
|
287
|
-
* intentionally NOT honoured: its `path:line` key is blind to the
|
|
288
|
-
* a genuinely new advisory re-emitted at a previously-acked line.
|
|
289
|
-
* acked iff its stable key appears here. */
|
|
328
|
+
* parsed from the `nano-ack: <path> :: <text>` form ONLY (via the shared `canonicalAckKeys`). A bare
|
|
329
|
+
* `nano-ack: <path>:<line>` marker is intentionally NOT honoured: its `path:line` key is blind to the
|
|
330
|
+
* advisory prose and would false-OPEN a genuinely new advisory re-emitted at a previously-acked line.
|
|
331
|
+
* The gate treats an advisory as acked iff its stable key appears here. */
|
|
290
332
|
export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
291
333
|
const acked = new Set<string>();
|
|
292
334
|
for (const t of threads) {
|
|
293
335
|
if (!t.isResolved) continue;
|
|
294
|
-
for (const body of t.bodies)
|
|
295
|
-
ACK_MARKER.lastIndex = 0;
|
|
296
|
-
let m: RegExpExecArray | null;
|
|
297
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
298
|
-
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
299
|
-
const nw = NEW_ACK.exec(m[1].trim());
|
|
300
|
-
if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
336
|
+
for (const body of t.bodies) for (const k of canonicalAckKeys(body)) acked.add(k);
|
|
303
337
|
}
|
|
304
338
|
return [...acked];
|
|
305
339
|
}
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
17
|
|
|
@@ -378,6 +378,45 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
378
378
|
});
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
// #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
|
|
382
|
+
// available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
|
|
383
|
+
// `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring
|
|
384
|
+
// this seed would leave deployed loops on the escalation default while every added behaviour test
|
|
385
|
+
// still passes. Assert both the initial counter and the configured max propagate onto the instance.
|
|
386
|
+
function captureVars() {
|
|
387
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
388
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
389
|
+
escalations: { rows: [], key: "id" },
|
|
390
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
391
|
+
};
|
|
392
|
+
const data = {
|
|
393
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
394
|
+
} as any;
|
|
395
|
+
let captured: Record<string, unknown> | undefined;
|
|
396
|
+
const engine = {
|
|
397
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
398
|
+
captured = req.variables;
|
|
399
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
400
|
+
},
|
|
401
|
+
} as any;
|
|
402
|
+
return { data, engine, get: () => captured };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0, ackRetryMax=MAX_ACK_RETRIES)", async () => {
|
|
406
|
+
await withGithubOff(async () => {
|
|
407
|
+
const { data, engine, get } = captureVars();
|
|
408
|
+
await submitPr(data, engine, {
|
|
409
|
+
repo: "owner/repo",
|
|
410
|
+
number: 10,
|
|
411
|
+
url: "https://github.com/owner/repo/pull/10",
|
|
412
|
+
prKey: "owner/repo#10",
|
|
413
|
+
});
|
|
414
|
+
const vars = get();
|
|
415
|
+
assertEquals(vars?.ackRetryRound, 0);
|
|
416
|
+
assertEquals(vars?.ackRetryMax, MAX_ACK_RETRIES);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
381
420
|
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
382
421
|
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
383
422
|
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
package/app/service.ts
CHANGED
|
@@ -159,6 +159,20 @@ export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE
|
|
|
159
159
|
* retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
|
|
160
160
|
export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
|
|
161
161
|
|
|
162
|
+
/** How many times the convergence loop will re-dispatch the `senior:pr-review` (review-round) agent
|
|
163
|
+
* to auto-ack unacked suppressed advisories before escalating to a human. When the converge-gate
|
|
164
|
+
* blocks SOLELY on unacknowledged suppressed advisories (no unresolved inline threads), the block is
|
|
165
|
+
* recoverable: re-running the review-round agent posts the missing `nano-ack:` threads and converges,
|
|
166
|
+
* so the loop tries that — bounded — before parking the human `wait-answer` (issue #796). A resolved
|
|
167
|
+
* `Declined … nano-ack:` advisory is an acknowledgement and CONVERGES (issue #787), so a decline does
|
|
168
|
+
* not escalate; only the agent returning `needs_input` (a genuinely contested advisory it cannot
|
|
169
|
+
* decide) or `blocked` (an external blocker it reports with a question — the `gw-status` arm at
|
|
170
|
+
* `convergence-loop.bpmn:442-443` routes both to `wait-answer`), or this budget being exhausted,
|
|
171
|
+
* escalates. Default 2; set
|
|
172
|
+
* `NANO_PR_MAX_ACK_RETRIES=0` to escalate on the first ack-only block. Reuses the CI-fix budget clamp
|
|
173
|
+
* (allows 0 = disable, ceiling-capped). */
|
|
174
|
+
export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
|
|
175
|
+
|
|
162
176
|
/** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
|
|
163
177
|
* mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
|
|
164
178
|
* human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
|
|
@@ -644,6 +658,11 @@ export async function submitPr(
|
|
|
644
658
|
round: 1,
|
|
645
659
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
646
660
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
661
|
+
// Bounded agent auto-ack (issue #796): the convergence loop re-dispatches the review-round
|
|
662
|
+
// agent up to `ackRetryMax` times to ack suppressed advisories when the converge-gate blocks
|
|
663
|
+
// solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
|
|
664
|
+
ackRetryRound: 0,
|
|
665
|
+
ackRetryMax: MAX_ACK_RETRIES,
|
|
647
666
|
// Lineage (issue #245): carry the origin identity onto the convergence instance so every
|
|
648
667
|
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
649
668
|
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.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",
|