@nanobpm/nano-workforce 0.208.1 → 0.208.2
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/app/mergeStatus.test.ts +63 -8
- package/app/service.test.ts +18 -1
- package/app/service.ts +135 -33
- package/build-info.json +4 -4
- package/operations/listActivePrs.test.ts +289 -0
- package/operations/listActivePrs.ts +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.208.2](https://github.com/nanobpm/nano-workforce/compare/v0.208.1...v0.208.2) (2026-11-10)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **status:** batch the /status merge-policy engine read into one call ([#937](https://github.com/nanobpm/nano-workforce/issues/937)) ([a9778e1](https://github.com/nanobpm/nano-workforce/commit/a9778e1c7db7205fe0b3b5dfebc597468c4e66f4)), closes [issue-#916](https://github.com/nanobpm/issue-/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#936](https://github.com/nanobpm/nano-workforce/issues/936)
|
|
6
|
+
|
|
1
7
|
## [0.208.1](https://github.com/nanobpm/nano-workforce/compare/v0.208.0...v0.208.1) (2026-11-10)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/app/mergeStatus.test.ts
CHANGED
|
@@ -53,16 +53,22 @@ function memData(stores: Stores) {
|
|
|
53
53
|
} as any;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** A fake engine whose `searchVariables` serves the given per-instance variables.
|
|
56
|
+
/** A fake engine whose `searchVariables` serves the given per-instance variables. A name-less,
|
|
57
|
+
* key-less query (`{}` — the batched /status read, issue #936) returns EVERY variable across all
|
|
58
|
+
* instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does. */
|
|
57
59
|
function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
|
|
58
60
|
return {
|
|
59
|
-
searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
searchVariables: (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
|
|
62
|
+
const rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = [];
|
|
63
|
+
for (const [piKey, vars] of Object.entries(varsByInstance)) {
|
|
64
|
+
if (q.processInstanceKey != null && piKey !== String(q.processInstanceKey)) continue;
|
|
65
|
+
if (q.scopeKey != null && piKey !== String(q.scopeKey)) continue;
|
|
66
|
+
for (const [name, v] of Object.entries(vars)) {
|
|
67
|
+
if (q.name != null && name !== q.name) continue;
|
|
68
|
+
rows.push({ name, value: JSON.stringify(v), processInstanceKey: piKey, scopeKey: piKey });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Promise.resolve(rows);
|
|
66
72
|
},
|
|
67
73
|
} as any;
|
|
68
74
|
}
|
|
@@ -167,3 +173,52 @@ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never
|
|
|
167
173
|
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
168
174
|
}
|
|
169
175
|
});
|
|
176
|
+
|
|
177
|
+
function activeStores() {
|
|
178
|
+
return {
|
|
179
|
+
pull_requests: { rows: [prRow({})], key: "pr_key" },
|
|
180
|
+
user_tasks: { rows: [], key: "id" },
|
|
181
|
+
} satisfies Stores;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Row = { name: string; value: string; processInstanceKey: string; scopeKey: string };
|
|
185
|
+
const row = (name: string, v: unknown): Row => ({ name, value: JSON.stringify(v), processInstanceKey: "PI-1", scopeKey: "PI-1" });
|
|
186
|
+
|
|
187
|
+
test("merge policy: an unusable autoMerge row still probes a page-cut convergeOnly (PR #937 review)", async () => {
|
|
188
|
+
// Batch: autoMerge present but null (unusable), convergeOnly cut from its page; the root probe finds it.
|
|
189
|
+
const engine = {
|
|
190
|
+
searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
|
|
191
|
+
if (q.processInstanceKey == null) {
|
|
192
|
+
if (q.name === "autoMerge") return Promise.resolve([row("autoMerge", null)]);
|
|
193
|
+
if (q.name === "humanApproval") return Promise.resolve([row("humanApproval", false)]);
|
|
194
|
+
return Promise.resolve([]);
|
|
195
|
+
}
|
|
196
|
+
return Promise.resolve(q.name === "convergeOnly" ? [row("convergeOnly", true)] : []);
|
|
197
|
+
},
|
|
198
|
+
} as any;
|
|
199
|
+
const [pr] = await activePrs(memData(activeStores()), engine);
|
|
200
|
+
assertEquals(pr.merge.requested, false, "the legacy convergeOnly:true must be honoured when autoMerge is unusable");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("merge policy: a delayed successful probe cannot resurrect a policy after a sibling probe failed (PR #937 review)", async () => {
|
|
204
|
+
// Batch cuts every name. PI-1: autoMerge probe rejects early, humanApproval:true resolves late.
|
|
205
|
+
// PI-2 (another PR) has a slower probe, so the projection is still pending when PI-1's late probe lands.
|
|
206
|
+
const stores = {
|
|
207
|
+
pull_requests: {
|
|
208
|
+
rows: [prRow({}), prRow({ pr_key: "owner/repo#2", number: 2, process_key: "PI-2" })],
|
|
209
|
+
key: "pr_key",
|
|
210
|
+
},
|
|
211
|
+
user_tasks: { rows: [], key: "id" },
|
|
212
|
+
} satisfies Stores;
|
|
213
|
+
const engine = {
|
|
214
|
+
searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
|
|
215
|
+
if (q.processInstanceKey == null) return Promise.resolve([]);
|
|
216
|
+
if (q.processInstanceKey === "PI-2") return new Promise<Row[]>((r) => setTimeout(() => r([]), 60));
|
|
217
|
+
if (q.name === "autoMerge") return Promise.reject(new Error("boom"));
|
|
218
|
+
return new Promise<Row[]>((r) => setTimeout(() => r([row(q.name ?? "", true)]), 20));
|
|
219
|
+
},
|
|
220
|
+
} as any;
|
|
221
|
+
const prs = await activePrs(memData(stores), engine);
|
|
222
|
+
const pr = prs.find((p: { prKey?: string; pr_key?: string; number?: number }) => p.number === 1);
|
|
223
|
+
assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null }, "failed probe → default, not a half-read policy");
|
|
224
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -1358,13 +1358,15 @@ function captureRoot() {
|
|
|
1358
1358
|
open: () => memOpen(stores),
|
|
1359
1359
|
} as any;
|
|
1360
1360
|
let captured: unknown;
|
|
1361
|
+
let vars: Record<string, unknown> | undefined;
|
|
1361
1362
|
const engine = {
|
|
1362
1363
|
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
1363
1364
|
captured = req.variables?.rootRequestKey;
|
|
1365
|
+
vars = req.variables;
|
|
1364
1366
|
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
1365
1367
|
},
|
|
1366
1368
|
} as any;
|
|
1367
|
-
return { data, engine, stores, get: () => captured };
|
|
1369
|
+
return { data, engine, stores, get: () => captured, vars: () => vars };
|
|
1368
1370
|
}
|
|
1369
1371
|
|
|
1370
1372
|
test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
|
|
@@ -1447,6 +1449,21 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
|
|
|
1447
1449
|
});
|
|
1448
1450
|
});
|
|
1449
1451
|
|
|
1452
|
+
test("startMerge seeds the merge-policy triple so /status needs no per-instance probes (PR #937)", async () => {
|
|
1453
|
+
await withGithubOff(async () => {
|
|
1454
|
+
const { data, engine, vars } = captureRoot();
|
|
1455
|
+
await startMerge(data, engine, {
|
|
1456
|
+
repo: "owner/repo",
|
|
1457
|
+
number: 9,
|
|
1458
|
+
url: "https://github.com/owner/repo/pull/9",
|
|
1459
|
+
prKey: "owner/repo#9",
|
|
1460
|
+
round: 1,
|
|
1461
|
+
});
|
|
1462
|
+
const seeded = vars() ?? {};
|
|
1463
|
+
assertEquals([seeded.autoMerge, seeded.convergeOnly, seeded.humanApproval], [true, false, false]);
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1450
1467
|
// The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
|
|
1451
1468
|
// emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
|
|
1452
1469
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
package/app/service.ts
CHANGED
|
@@ -1091,6 +1091,12 @@ export async function startMerge(
|
|
|
1091
1091
|
reviewRequest: null,
|
|
1092
1092
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
1093
1093
|
rootRequestKey,
|
|
1094
|
+
// Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
|
|
1095
|
+
// the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
|
|
1096
|
+
// the merge-loop process reads none of them.
|
|
1097
|
+
autoMerge: true,
|
|
1098
|
+
convergeOnly: false,
|
|
1099
|
+
humanApproval: false,
|
|
1094
1100
|
abandonUrl: abUrl,
|
|
1095
1101
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
1096
1102
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
@@ -1230,15 +1236,21 @@ export async function activePrs(
|
|
|
1230
1236
|
openEscByPr.set(t.subject_key, toOpenEscalation(t));
|
|
1231
1237
|
}
|
|
1232
1238
|
// Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
|
|
1233
|
-
// variables per PR so the effective merge policy is visible without opening the engine.
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1239
|
+
// variables per PR so the effective merge policy is visible without opening the engine.
|
|
1240
|
+
//
|
|
1241
|
+
// A CONSTANT number of batched engine reads per /status call (issue #936) — never a per-row
|
|
1242
|
+
// fan-out. The pre-#936 shape issued 3 `searchVariables` round-trips PER active PR (12 calls at 4
|
|
1243
|
+
// rows ≈ 13 s; concurrent callers stacked until the console proxy 502'd). See
|
|
1244
|
+
// `readMergePolicyVars` for the completeness contract (single-page seam, root scope only, one
|
|
1245
|
+
// per-name root probe only for a page cut or legacy instance). Best-effort: a failed batched read degrades every
|
|
1246
|
+
// PR to the historical default (merge requested); a failed per-instance fallback degrades only
|
|
1247
|
+
// that PR.
|
|
1236
1248
|
const mergeByPr = new Map<string, MergePolicy>();
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1249
|
+
const activeKeys = active.map((p) => p.process_key).filter((k): k is string => k != null && k !== "");
|
|
1250
|
+
const varsByInstance = await readMergePolicyVars(engine, activeKeys);
|
|
1251
|
+
for (const p of active) {
|
|
1252
|
+
mergeByPr.set(p.pr_key, mergePolicyFromVars(varsByInstance?.get(p.process_key ?? "")));
|
|
1253
|
+
}
|
|
1242
1254
|
return active.map((p) => {
|
|
1243
1255
|
// Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
|
|
1244
1256
|
// kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
|
|
@@ -1284,44 +1296,134 @@ function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolic
|
|
|
1284
1296
|
return { requested, enabled, effective, blockedBy };
|
|
1285
1297
|
}
|
|
1286
1298
|
|
|
1287
|
-
/**
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1299
|
+
/** The merge-policy variable triple for one instance: the values of `autoMerge`, `convergeOnly`,
|
|
1300
|
+
* and `humanApproval`, each `undefined` when the instance does not carry it (or it is not a
|
|
1301
|
+
* boolean). */
|
|
1302
|
+
type MergePolicyVars = {
|
|
1303
|
+
autoMerge: boolean | undefined;
|
|
1304
|
+
convergeOnly: boolean | undefined;
|
|
1305
|
+
humanApproval: boolean | undefined;
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
function emptyMergePolicyVars(): MergePolicyVars {
|
|
1309
|
+
return { autoMerge: undefined, convergeOnly: undefined, humanApproval: undefined };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function parseBoolValue(value: string): boolean | undefined {
|
|
1295
1313
|
try {
|
|
1296
|
-
const v: unknown = JSON.parse(
|
|
1314
|
+
const v: unknown = JSON.parse(value);
|
|
1297
1315
|
return typeof v === "boolean" ? v : undefined;
|
|
1298
1316
|
} catch {
|
|
1299
1317
|
return undefined;
|
|
1300
1318
|
}
|
|
1301
1319
|
}
|
|
1302
1320
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1321
|
+
const MERGE_POLICY_NAMES = ["autoMerge", "convergeOnly", "humanApproval"] as const;
|
|
1322
|
+
type MergePolicyName = (typeof MERGE_POLICY_NAMES)[number];
|
|
1323
|
+
|
|
1324
|
+
function isMergePolicyName(name: string): name is MergePolicyName {
|
|
1325
|
+
return MERGE_POLICY_NAMES.some((n) => n === name);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Batched merge-policy read (issue #936): ONE name-filtered engine-wide read per policy variable
|
|
1329
|
+
* (3 concurrent calls, independent of row count), bucketed by owning instance.
|
|
1330
|
+
*
|
|
1331
|
+
* Completeness: the engine seam returns a single page with no cursor, so an engine-wide read can
|
|
1332
|
+
* silently omit rows, and the three name pages truncate independently. Completeness is therefore
|
|
1333
|
+
* tracked per (instance, name): every name an active instance is missing from is re-probed with a
|
|
1334
|
+
* name-filtered root read (<=1 row, cannot be truncated). Every loop seeds all three names at
|
|
1335
|
+
* creation (submitPr for the review loop, startMerge for the merge loop), so the steady state is a
|
|
1336
|
+
* constant 3 calls; probes are paid only for genuine page cuts or legacy instances.
|
|
1337
|
+
*
|
|
1338
|
+
* Scope: only the PROCESS-ROOT variable (`scopeKey === processInstanceKey`) is the loop's policy; a
|
|
1339
|
+
* same-named child-scope variable is ignored so it can never overwrite the root value.
|
|
1340
|
+
*
|
|
1341
|
+
* Failure isolation: a failed BATCH read rejects the whole projection (its page is partial, so
|
|
1342
|
+
* absence can no longer be distinguished from a page cut) and the caller degrades every row to
|
|
1343
|
+
* the default. A failed TARGETED read degrades only its own instance (its partial entry is dropped,
|
|
1344
|
+
* so it projects the default, never a half-read policy) — the policies already
|
|
1345
|
+
* fetched for every other PR are preserved (a rejected fallback must not discard a sibling's
|
|
1346
|
+
* `autoMerge:false` or `humanApproval:true` and silently un-block its merge).
|
|
1347
|
+
*
|
|
1348
|
+
* Skips the engine entirely when no active row carries a process key. Returns `undefined` when no
|
|
1349
|
+
* engine was handed in or a batched read fails — the caller degrades every row to the default. */
|
|
1350
|
+
async function readMergePolicyVars(
|
|
1309
1351
|
engine: Pick<EngineClient, "searchVariables"> | undefined,
|
|
1310
|
-
|
|
1311
|
-
|
|
1352
|
+
activeKeys: readonly string[],
|
|
1353
|
+
): Promise<Map<string, MergePolicyVars> | undefined> {
|
|
1354
|
+
if (!engine || activeKeys.length === 0) return undefined;
|
|
1355
|
+
const wanted = new Set(activeKeys);
|
|
1312
1356
|
try {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1357
|
+
const byInstance = new Map<string, MergePolicyVars>();
|
|
1358
|
+
const seen = new Map<string, Set<MergePolicyName>>();
|
|
1359
|
+
const record = (row: { name: string; processInstanceKey: string; scopeKey: string; value: string }) => {
|
|
1360
|
+
const pk = String(row.processInstanceKey);
|
|
1361
|
+
if (!wanted.has(pk) || String(row.scopeKey) !== pk) return;
|
|
1362
|
+
if (!isMergePolicyName(row.name)) return;
|
|
1363
|
+
const vars = byInstance.get(pk) ?? emptyMergePolicyVars();
|
|
1364
|
+
vars[row.name] = parseBoolValue(row.value);
|
|
1365
|
+
byInstance.set(pk, vars);
|
|
1366
|
+
const names = seen.get(pk) ?? new Set<MergePolicyName>();
|
|
1367
|
+
names.add(row.name);
|
|
1368
|
+
seen.set(pk, names);
|
|
1369
|
+
};
|
|
1370
|
+
await Promise.all(
|
|
1371
|
+
MERGE_POLICY_NAMES.map(async (name) => {
|
|
1372
|
+
for (const row of await engine.searchVariables({ name })) if (row.name === name) record(row);
|
|
1373
|
+
}),
|
|
1374
|
+
);
|
|
1375
|
+
// Completeness is tracked per (instance, NAME), not per instance: the three name pages truncate
|
|
1376
|
+
// independently, so an instance present in one page (e.g. `autoMerge`) may still have been cut
|
|
1377
|
+
// from another (e.g. `humanApproval`). Each name an instance is missing from is re-probed with a
|
|
1378
|
+
// name-filtered ROOT read — a <=1-row result that cannot be truncated, so it proves absence.
|
|
1379
|
+
// `convergeOnly` is skipped once a usable boolean `autoMerge` is known (the canonical intent wins). Every loop
|
|
1380
|
+
// seeds all three names at creation (submitPr; startMerge seeds the merge loop's policy too), so
|
|
1381
|
+
// the steady state is exactly 3 calls; probes are only paid for page cuts and legacy instances.
|
|
1382
|
+
await Promise.all(
|
|
1383
|
+
[...wanted].map(async (pk) => {
|
|
1384
|
+
const got = seen.get(pk);
|
|
1385
|
+
const probe = MERGE_POLICY_NAMES.filter(
|
|
1386
|
+
(name) =>
|
|
1387
|
+
!got?.has(name) &&
|
|
1388
|
+
!(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
|
|
1389
|
+
);
|
|
1390
|
+
if (probe.length === 0) return;
|
|
1391
|
+
try {
|
|
1392
|
+
// Collect ALL probe results first and record only once every probe succeeded: recording
|
|
1393
|
+
// inside each probe would let a slower successful sibling re-create the entry the catch
|
|
1394
|
+
// below deleted after an earlier rejection, exposing a half-read policy.
|
|
1395
|
+
const results = await Promise.all(
|
|
1396
|
+
probe.map(async (name) =>
|
|
1397
|
+
(await engine.searchVariables({ processInstanceKey: pk, scopeKey: pk, name })).filter(
|
|
1398
|
+
(row) => row.name === name,
|
|
1399
|
+
),
|
|
1400
|
+
),
|
|
1401
|
+
);
|
|
1402
|
+
for (const rows of results) for (const row of rows) record(row);
|
|
1403
|
+
} catch {
|
|
1404
|
+
// Degrade ONLY this instance — and fully: drop any partial entry the batch recorded so
|
|
1405
|
+
// the caller projects the default for it, never a half-read policy.
|
|
1406
|
+
byInstance.delete(pk);
|
|
1407
|
+
}
|
|
1408
|
+
}),
|
|
1409
|
+
);
|
|
1410
|
+
return byInstance;
|
|
1320
1411
|
} catch {
|
|
1321
|
-
return
|
|
1412
|
+
return undefined;
|
|
1322
1413
|
}
|
|
1323
1414
|
}
|
|
1324
1415
|
|
|
1416
|
+
/** Derive a PR's effective merge policy from an already-read variable triple (issue #916). The
|
|
1417
|
+
* canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
|
|
1418
|
+
* in-flight loops seeded before `autoMerge` existed; an instance carrying neither (or absent from
|
|
1419
|
+
* the batch — mid-handoff row, instance gone) degrades to the historical default (merge
|
|
1420
|
+
* requested) rather than failing the status read. */
|
|
1421
|
+
function mergePolicyFromVars(vars: MergePolicyVars | undefined): MergePolicy {
|
|
1422
|
+
if (!vars) return mergePolicyDefault();
|
|
1423
|
+
const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? true : !vars.convergeOnly);
|
|
1424
|
+
return mergePolicyFrom(requested, vars.humanApproval === true);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1325
1427
|
/** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
|
|
1326
1428
|
* "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
|
|
1327
1429
|
export interface ActiveDelivery {
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.208.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.208.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.208.2",
|
|
3
|
+
"gitSha": "a9778e1c7db7205fe0b3b5dfebc597468c4e66f4",
|
|
4
|
+
"gitTag": "v0.208.2",
|
|
5
|
+
"builtAt": "2026-11-10T20:28:50.098Z"
|
|
6
6
|
}
|
|
@@ -189,3 +189,292 @@ test("a delivery run with no opt-in row flag surfaces autoResolveEscalations fal
|
|
|
189
189
|
assertEquals(res.body.deliveries[0].openEscalation, null);
|
|
190
190
|
assertEquals(res.body.deliveries[0].processKey, null, "a launch-in-flight run has no stamped process_key yet");
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
// ── Issue #936: /status latency + concurrency regression guard ────────────────────────────────
|
|
194
|
+
// The defect: `activePrs` fanned the merge-policy read out per row — 3 `searchVariables` engine
|
|
195
|
+
// round-trips PER active PR — so 4 rows cost 12 sequential-ish upstream calls (~13 s) and K
|
|
196
|
+
// concurrent callers stacked (~50 s → the console proxy 502'd). The fix batches the engine read:
|
|
197
|
+
// ONE name-less `searchVariables({})` per /status serve, bucketed in memory. These tests pin the
|
|
198
|
+
// contract against a fake engine with a REALISTIC per-call delay: the engine-call count must stay
|
|
199
|
+
// at one per serve regardless of row count, and K concurrent serves must overlap (wall time ≈ one
|
|
200
|
+
// upstream round-trip, not K ×).
|
|
201
|
+
|
|
202
|
+
/** A fake engine that serves the batched variable read after a realistic upstream delay, counting
|
|
203
|
+
* calls and tracking peak in-flight concurrency. */
|
|
204
|
+
function slowEngine(varsByInstance: Record<string, Record<string, unknown>>, delayMs: number) {
|
|
205
|
+
let calls = 0;
|
|
206
|
+
let inFlight = 0;
|
|
207
|
+
let maxInFlight = 0;
|
|
208
|
+
return {
|
|
209
|
+
calls: () => calls,
|
|
210
|
+
maxInFlight: () => maxInFlight,
|
|
211
|
+
searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
|
|
212
|
+
calls += 1;
|
|
213
|
+
inFlight += 1;
|
|
214
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
216
|
+
inFlight -= 1;
|
|
217
|
+
const rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = [];
|
|
218
|
+
for (const [piKey, vars] of Object.entries(varsByInstance)) {
|
|
219
|
+
if (q.processInstanceKey != null && piKey !== String(q.processInstanceKey)) continue;
|
|
220
|
+
if (q.scopeKey != null && piKey !== String(q.scopeKey)) continue;
|
|
221
|
+
for (const [name, v] of Object.entries(vars)) {
|
|
222
|
+
if (q.name != null && name !== q.name) continue;
|
|
223
|
+
rows.push({ name, value: JSON.stringify(v), processInstanceKey: piKey, scopeKey: piKey });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return rows;
|
|
227
|
+
},
|
|
228
|
+
} as any;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function prRow936(n: number) {
|
|
232
|
+
return {
|
|
233
|
+
pr_key: `o/r#${n}`,
|
|
234
|
+
repo: "o/r",
|
|
235
|
+
number: n,
|
|
236
|
+
url: `https://github.com/o/r/pull/${n}`,
|
|
237
|
+
title: `PR ${n}`,
|
|
238
|
+
status: "converging",
|
|
239
|
+
current_round: 1,
|
|
240
|
+
process_key: `PI-${n}`,
|
|
241
|
+
updated_at: `2026-01-${String(n).padStart(2, "0")}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
test("issue #936: N active rows cost a constant 3 engine variable reads, not 3N (the 13 s fan-out)", async () => {
|
|
246
|
+
const N = 8;
|
|
247
|
+
const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
|
|
248
|
+
const vars: Record<string, Record<string, unknown>> = {};
|
|
249
|
+
for (let i = 1; i <= N; i++) vars[`PI-${i}`] = { autoMerge: true, convergeOnly: false, humanApproval: false };
|
|
250
|
+
const engine = slowEngine(vars, 25);
|
|
251
|
+
const app = { ...memApp(rows), engine } as AppApi;
|
|
252
|
+
const started = Date.now();
|
|
253
|
+
const res = (await handler(input(), app)) as any;
|
|
254
|
+
const elapsed = Date.now() - started;
|
|
255
|
+
assertEquals(res.status, 200);
|
|
256
|
+
assertEquals(res.body.count, N);
|
|
257
|
+
assertEquals(
|
|
258
|
+
engine.calls(),
|
|
259
|
+
3,
|
|
260
|
+
"the merge-policy projection batches to ONE read per policy name (3) per serve — a per-row fan-out would show 3N calls",
|
|
261
|
+
);
|
|
262
|
+
assert(
|
|
263
|
+
elapsed < 25 * N,
|
|
264
|
+
`one batched ~25 ms upstream read serves ${N} rows (took ${elapsed} ms); the per-row fan-out this regresses to would take ≥ ${25 * N} ms`,
|
|
265
|
+
);
|
|
266
|
+
// The batch still projects the per-row policy correctly.
|
|
267
|
+
for (const pr of res.body.prs) {
|
|
268
|
+
assertEquals(pr.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("issue #936: K concurrent /status callers overlap instead of queueing behind each other", async () => {
|
|
273
|
+
const K = 4;
|
|
274
|
+
const rows = [prRow936(1), prRow936(2)];
|
|
275
|
+
const vars = {
|
|
276
|
+
"PI-1": { autoMerge: true, convergeOnly: false, humanApproval: false },
|
|
277
|
+
"PI-2": { autoMerge: false, convergeOnly: true, humanApproval: false },
|
|
278
|
+
};
|
|
279
|
+
// A realistic slow upstream: each engine read takes 100 ms. Serialized callers would stack to
|
|
280
|
+
// K × 100 ms (the measured 35–50 s at production scale); overlapped callers finish in ~100 ms.
|
|
281
|
+
const engine = slowEngine(vars, 100);
|
|
282
|
+
const app = { ...memApp(rows), engine } as AppApi;
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
const results = await Promise.all(Array.from({ length: K }, () => handler(input(), app)));
|
|
285
|
+
const elapsed = Date.now() - started;
|
|
286
|
+
for (const res of results) {
|
|
287
|
+
assertEquals((res as any).status, 200);
|
|
288
|
+
assertEquals((res as any).body.count, 2);
|
|
289
|
+
}
|
|
290
|
+
assertEquals(engine.calls(), 3 * K, "each serve issues its own batched reads (no cross-request cache serving stale policy)");
|
|
291
|
+
assertEquals(
|
|
292
|
+
engine.maxInFlight(),
|
|
293
|
+
3 * K,
|
|
294
|
+
"all K engine reads were in flight at once — the serves overlap rather than serialise",
|
|
295
|
+
);
|
|
296
|
+
assert(
|
|
297
|
+
elapsed < K * 100,
|
|
298
|
+
`${K} concurrent serves over a 100 ms upstream finished in ${elapsed} ms (< ${K * 100} ms) — no caller queued behind another`,
|
|
299
|
+
);
|
|
300
|
+
// Mixed intents still project per row under the batched read.
|
|
301
|
+
const body = (results[0] as any).body;
|
|
302
|
+
assertEquals(body.prs.find((p: any) => p.prKey === "o/r#1").merge.requested, true);
|
|
303
|
+
assertEquals(body.prs.find((p: any) => p.prKey === "o/r#2").merge.requested, false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── PR #937 review: completeness, root scope, idle skip ──────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** A fake engine with explicit rows (incl. child scopes) whose engine-wide reads are cut to one
|
|
309
|
+
* `pageSize` page — the production seam returns a single page with no cursor. */
|
|
310
|
+
function pagedEngine(
|
|
311
|
+
rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[],
|
|
312
|
+
pageSize: number,
|
|
313
|
+
) {
|
|
314
|
+
const queries: { processInstanceKey?: string; scopeKey?: string; name?: string }[] = [];
|
|
315
|
+
return {
|
|
316
|
+
queries,
|
|
317
|
+
searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
|
|
318
|
+
queries.push(q);
|
|
319
|
+
return rows
|
|
320
|
+
.filter(
|
|
321
|
+
(r) =>
|
|
322
|
+
(q.processInstanceKey == null || r.processInstanceKey === q.processInstanceKey) &&
|
|
323
|
+
(q.scopeKey == null || r.scopeKey === q.scopeKey) &&
|
|
324
|
+
(q.name == null || r.name === q.name),
|
|
325
|
+
)
|
|
326
|
+
.slice(0, pageSize);
|
|
327
|
+
},
|
|
328
|
+
} as any;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function v(pk: string, name: string, value: unknown, scopeKey = pk) {
|
|
332
|
+
return { name, value: JSON.stringify(value), processInstanceKey: pk, scopeKey };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
test("PR #937: an active PR's policy cut off the first engine page is still read (targeted fallback)", async () => {
|
|
336
|
+
const rows = [];
|
|
337
|
+
// 50 unrelated (completed/other) instances fill the single page ahead of the active PR.
|
|
338
|
+
for (let i = 0; i < 50; i++) {
|
|
339
|
+
for (const n of ["autoMerge", "convergeOnly", "humanApproval"]) rows.push(v(`OTHER-${i}`, n, true));
|
|
340
|
+
}
|
|
341
|
+
rows.push(v("PI-1", "autoMerge", false), v("PI-1", "convergeOnly", true), v("PI-1", "humanApproval", true));
|
|
342
|
+
const engine = pagedEngine(rows, 10);
|
|
343
|
+
const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
|
|
344
|
+
assertEquals(res.body.prs[0].merge.requested, false, "autoMerge:false beyond page 1 must not degrade to requested");
|
|
345
|
+
const withApproval = pagedEngine(
|
|
346
|
+
[...rows.filter((r) => r.processInstanceKey !== "PI-1"), v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true)],
|
|
347
|
+
10,
|
|
348
|
+
);
|
|
349
|
+
const res2 = (await handler(input(), { ...memApp([prRow936(1)]), engine: withApproval } as AppApi)) as any;
|
|
350
|
+
assertEquals(res2.body.prs[0].merge.blockedBy != null, true, "the human-approval blocker beyond page 1 is not dropped");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
for (const order of ["root-first", "child-first"] as const) {
|
|
354
|
+
test(`PR #937: child-scoped policy vars never override the root policy (${order})`, async () => {
|
|
355
|
+
const root = [v("PI-1", "autoMerge", false), v("PI-1", "humanApproval", true)];
|
|
356
|
+
const child = [v("PI-1", "autoMerge", true, "EI-9"), v("PI-1", "humanApproval", false, "EI-9")];
|
|
357
|
+
const engine = pagedEngine(order === "root-first" ? [...root, ...child] : [...child, ...root], 100);
|
|
358
|
+
const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
|
|
359
|
+
assertEquals(res.body.prs[0].merge.requested, false);
|
|
360
|
+
const engine2 = pagedEngine(
|
|
361
|
+
order === "root-first"
|
|
362
|
+
? [v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true), ...child]
|
|
363
|
+
: [...child, v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true)],
|
|
364
|
+
100,
|
|
365
|
+
);
|
|
366
|
+
const res2 = (await handler(input(), { ...memApp([prRow936(1)]), engine: engine2 } as AppApi)) as any;
|
|
367
|
+
assertEquals(res2.body.prs[0].merge.blockedBy != null, true, "a child humanApproval:false cannot hide the root gate");
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
test("PR #937: no engine read when no active row has a process key", async () => {
|
|
372
|
+
const engine = pagedEngine([], 100);
|
|
373
|
+
await handler(input(), { ...memApp([]), engine } as AppApi);
|
|
374
|
+
await handler(input(), { ...memApp([{ ...prRow936(1), process_key: null }]), engine } as AppApi);
|
|
375
|
+
assertEquals(engine.queries.length, 0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ── PR #937 round-3 review: bounded fallback fan-out, per-instance failure isolation ─────────
|
|
379
|
+
|
|
380
|
+
test("PR #937 r4: populated merge-loop roots cost a constant 3 reads (startMerge seeds the policy)", async () => {
|
|
381
|
+
// Round-3 defect: merge-loop roots carry non-policy vars, so the name-less fallback was non-empty
|
|
382
|
+
// and each merging PR paid 1 + 3 probes (19 calls at N=4). startMerge now seeds the policy triple,
|
|
383
|
+
// so populated merge-loop roots are fully represented in the batch and need no probes.
|
|
384
|
+
const N = 4;
|
|
385
|
+
const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
|
|
386
|
+
const vars = [];
|
|
387
|
+
for (let i = 1; i <= N; i++) {
|
|
388
|
+
const pk = `PI-${i}`;
|
|
389
|
+
vars.push(v(pk, "repo", "o/r"), v(pk, "prNumber", i), v(pk, "ciFixRound", 0));
|
|
390
|
+
vars.push(v(pk, "autoMerge", true), v(pk, "convergeOnly", false), v(pk, "humanApproval", false));
|
|
391
|
+
}
|
|
392
|
+
const engine = pagedEngine(vars, 100);
|
|
393
|
+
const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
|
|
394
|
+
assertEquals(res.body.count, N);
|
|
395
|
+
assertEquals(engine.queries.length, 3, "populated merge-loop roots need no per-instance probes");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("PR #937 r4: a name cut from ONE page is re-probed even when the instance appears in another", async () => {
|
|
399
|
+
// Round-3 defect: an instance present in the autoMerge page was treated as complete, so a
|
|
400
|
+
// humanApproval:true cut from its own page was dropped and /status reported an unblocked merge.
|
|
401
|
+
const rows = [v("PI-1", "autoMerge", true), v("PI-1", "convergeOnly", false)];
|
|
402
|
+
for (let i = 0; i < 20; i++) rows.push(v(`OTHER-${i}`, "humanApproval", false));
|
|
403
|
+
rows.push(v("PI-1", "humanApproval", true));
|
|
404
|
+
const engine = pagedEngine(rows, 10);
|
|
405
|
+
const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
|
|
406
|
+
assertEquals(res.body.prs[0].merge.blockedBy, "human-approval", "the cut humanApproval gate is recovered");
|
|
407
|
+
const targeted = engine.queries.filter((q: any) => q.processInstanceKey === "PI-1");
|
|
408
|
+
assertEquals(targeted.length, 1, "only the missing name is probed");
|
|
409
|
+
assertEquals(targeted[0].name, "humanApproval");
|
|
410
|
+
assertEquals(targeted[0].scopeKey, "PI-1", "the probe is confined to the process-root scope");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("PR #937 r4: a failed probe after a partial batch drops the partial entry (default, not half-read)", async () => {
|
|
414
|
+
const failing = {
|
|
415
|
+
searchVariables: async (q: { processInstanceKey?: string; name?: string }) => {
|
|
416
|
+
if (q.processInstanceKey === "PI-1") throw new Error("engine read timed out");
|
|
417
|
+
if (q.name === "humanApproval") return [v("PI-1", "humanApproval", true)];
|
|
418
|
+
return [];
|
|
419
|
+
},
|
|
420
|
+
} as any;
|
|
421
|
+
const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
|
|
422
|
+
assertEquals(res.status, 200);
|
|
423
|
+
assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("PR #937 r4: a cut-off instance is recovered with name-filtered (untruncatable) root probes", async () => {
|
|
427
|
+
const rows = [];
|
|
428
|
+
for (let i = 0; i < 50; i++) {
|
|
429
|
+
for (const n of ["autoMerge", "convergeOnly", "humanApproval"]) rows.push(v(`OTHER-${i}`, n, true));
|
|
430
|
+
}
|
|
431
|
+
rows.push(v("PI-1", "autoMerge", true), v("PI-1", "convergeOnly", false));
|
|
432
|
+
rows.push(v("PI-2", "autoMerge", true), v("PI-2", "convergeOnly", false), v("PI-2", "humanApproval", true));
|
|
433
|
+
const engine = pagedEngine(rows, 10);
|
|
434
|
+
const res = (await handler(input(), { ...memApp([prRow936(1), prRow936(2)]), engine } as AppApi)) as any;
|
|
435
|
+
const pr2 = res.body.prs.find((p: any) => p.prKey === "o/r#2");
|
|
436
|
+
assertEquals(pr2.merge.requested, true);
|
|
437
|
+
assertEquals(pr2.merge.blockedBy, "human-approval");
|
|
438
|
+
for (const q of engine.queries.filter((q: any) => q.processInstanceKey != null)) {
|
|
439
|
+
assert(q.name != null, "every fallback read is name-filtered (<=1 row, cannot be truncated)");
|
|
440
|
+
assertEquals(q.scopeKey, q.processInstanceKey);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("PR #937 r3: one failed targeted read degrades only its own instance, not every other PR's policy", async () => {
|
|
445
|
+
// Round-2 defect: a rejected targeted read propagated to the OUTER catch, so the whole batch
|
|
446
|
+
// read was discarded and PI-1's already-fetched policy silently degraded to "merge requested,
|
|
447
|
+
// no approval blocker". Each instance's fallback must fail independently. PI-1 requests the
|
|
448
|
+
// merge AND carries an outstanding approval gate; PI-2's fallback rejects.
|
|
449
|
+
const failing = {
|
|
450
|
+
searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
|
|
451
|
+
if (q.processInstanceKey === "PI-2") throw new Error("engine read timed out");
|
|
452
|
+
if (q.name === "autoMerge") return [v("PI-1", "autoMerge", true)];
|
|
453
|
+
if (q.name === "humanApproval") return [v("PI-1", "humanApproval", true)];
|
|
454
|
+
return [];
|
|
455
|
+
},
|
|
456
|
+
} as any;
|
|
457
|
+
const res = (await handler(input(), { ...memApp([prRow936(1), prRow936(2)]), engine: failing } as AppApi)) as any;
|
|
458
|
+
assertEquals(res.status, 200);
|
|
459
|
+
const pr1 = res.body.prs.find((p: any) => p.prKey === "o/r#1");
|
|
460
|
+
assertEquals(pr1.merge.requested, true, "PI-1's fetched merge intent survives PI-2's failed fallback");
|
|
461
|
+
assertEquals(pr1.merge.blockedBy, "human-approval", "PI-1's fetched approval blocker survives PI-2's failed fallback");
|
|
462
|
+
const pr2 = res.body.prs.find((p: any) => p.prKey === "o/r#2");
|
|
463
|
+
assertEquals(pr2.merge.requested, true, "the failed instance degrades to the historical default (merge requested)");
|
|
464
|
+
assertEquals(pr2.merge.blockedBy, null, "the failed instance shows no approval blocker it could not verify");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("PR #937 r3: a failed BATCH read still degrades every row to the default (no partial page)", async () => {
|
|
468
|
+
// The batched name reads are the completeness backbone — if one rejects, its page is partial
|
|
469
|
+
// and absence can no longer be distinguished from a page cut, so the whole projection degrades.
|
|
470
|
+
const failing = {
|
|
471
|
+
searchVariables: async (q: { name?: string }) => {
|
|
472
|
+
if (q.name === "humanApproval") throw new Error("engine read timed out");
|
|
473
|
+
if (q.name === "autoMerge") return [v("PI-1", "autoMerge", false)];
|
|
474
|
+
return [];
|
|
475
|
+
},
|
|
476
|
+
} as any;
|
|
477
|
+
const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
|
|
478
|
+
assertEquals(res.status, 200);
|
|
479
|
+
assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
480
|
+
});
|
|
@@ -20,9 +20,29 @@ 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
|
+
// Per-phase timing (issue #936): /status regressed to ~13 s (and serialised concurrent callers
|
|
24
|
+
// into proxy 502s) on an unprofiled per-row engine fan-out. The merge-policy engine read is now
|
|
25
|
+
// ONE batched call inside `activePrs`, and these phase timings make any future regression
|
|
26
|
+
// attributable from the logs instead of rediscovered by hand. Debug level: /status is a hot
|
|
27
|
+
// polling endpoint, so this must not spam info.
|
|
28
|
+
const t0 = Date.now();
|
|
23
29
|
const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
|
|
30
|
+
const readMs = Date.now() - t0;
|
|
24
31
|
// The running app's identity, derived from the SAME app/version.ts getter /version uses (never
|
|
25
32
|
// re-read), so the endpoint everyone polls answers "is the fix live?" in one call (issue #925).
|
|
26
33
|
const { version, gitSha } = buildVersionInfo();
|
|
34
|
+
app.log.debug("listActivePrs served", {
|
|
35
|
+
ms: readMs,
|
|
36
|
+
prRows: prs.length,
|
|
37
|
+
deliveryRows: deliveries.length,
|
|
38
|
+
});
|
|
39
|
+
if (readMs > STATUS_SLOW_MS) {
|
|
40
|
+
app.log.warn("listActivePrs slow", { ms: readMs, prRows: prs.length, deliveryRows: deliveries.length });
|
|
41
|
+
}
|
|
27
42
|
return { status: 200, body: { count: prs.length, prs, deliveries, version: { version, gitSha } } };
|
|
28
43
|
});
|
|
44
|
+
|
|
45
|
+
/** The wall-clock budget over which a /status serve is logged as slow (issue #936). The endpoint
|
|
46
|
+
* targets well under 1 s for a handful of rows; anything past this is a regression signal an
|
|
47
|
+
* operator can grep for. */
|
|
48
|
+
const STATUS_SLOW_MS = 1_000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.208.
|
|
3
|
+
"version": "0.208.2",
|
|
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",
|