@nanobpm/nano-workforce 0.208.3 → 0.208.5
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/AGENTS.md +17 -0
- package/CHANGELOG.md +12 -0
- package/app/agentCompletion.test.ts +155 -0
- package/app/agentCompletion.ts +39 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- package/app/mergePolicyLock.test.ts +44 -0
- package/app/mergePolicyLock.ts +25 -0
- package/app/mergeStatus.test.ts +14 -12
- package/app/migration132.test.ts +72 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- package/app/service.test.ts +168 -1
- package/app/service.ts +182 -26
- package/build-info.json +4 -4
- package/db/migrations/132_pull_requests_merge_policy.sql +22 -0
- package/db/migrations/133_pull_requests_read_model_merge_policy.sql +51 -0
- package/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -0
- package/openapi.yaml +13 -6
- package/operations/listActivePrs.test.ts +207 -3
- package/operations/listActivePrs.ts +13 -9
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
package/app/service.ts
CHANGED
|
@@ -64,6 +64,7 @@ import { pollLineage } from "./lineage.ts";
|
|
|
64
64
|
import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
65
65
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
66
66
|
import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
|
|
67
|
+
import { withMergePolicyLock } from "./mergePolicyLock.ts";
|
|
67
68
|
import {
|
|
68
69
|
freshHeadRunAction,
|
|
69
70
|
headRunPresenceCount,
|
|
@@ -431,6 +432,16 @@ export interface PullRequest {
|
|
|
431
432
|
// Operator Dismiss stamp (app/acknowledge.ts). Run-scoped: cleared on reopen so a dismissal of a
|
|
432
433
|
// PRIOR run never hides an orphaning of the current one.
|
|
433
434
|
acknowledged_at?: string | null;
|
|
435
|
+
// Durable merge-policy projection (132_pull_requests_merge_policy.sql, issue #947): the run's
|
|
436
|
+
// merge intent (`merge_requested` 0|1) and its decided merge-approval state
|
|
437
|
+
// (`merge_human_approval` NULL = outstanding/undecided, 0|1 once decided), stamped by the writers
|
|
438
|
+
// that already know them — `submitPr` (submit/reopen, and the gated-adopt `setVariables` branch),
|
|
439
|
+
// `completeUserTaskAttributed` (a `merge-approval` approve clears it to 0, app/agentCompletion.ts),
|
|
440
|
+
// and `startMerge` — so `/status` serves the policy from the app's own DB with ZERO engine reads.
|
|
441
|
+
// `merge_requested` NULL marks a row written before this projection existed; only those legacy
|
|
442
|
+
// rows fall back to the #937 batched engine read. Run-scoped: reset by the atomic reopen.
|
|
443
|
+
merge_requested?: number | null;
|
|
444
|
+
merge_human_approval?: number | null;
|
|
434
445
|
}
|
|
435
446
|
|
|
436
447
|
interface PrDependency {
|
|
@@ -742,8 +753,27 @@ async function submitPrCritical(
|
|
|
742
753
|
(s) => String(s.processInstanceKey) === loopKey,
|
|
743
754
|
);
|
|
744
755
|
const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
|
|
745
|
-
|
|
756
|
+
// Only the CONVERGENCE loop reads `humanApproval` (PR #950 review): mid-handoff `startMerge`
|
|
757
|
+
// has already re-pointed `process_key` at the merge-loop (and stamped its approval policy), so a
|
|
758
|
+
// key that resolves to any other definition must never be gated.
|
|
759
|
+
const isConvergenceLoop = match?.processDefinitionId == null || match.processDefinitionId === PROCESS_ID;
|
|
760
|
+
if (state !== null && !ENGINE_TERMINAL_STATES.has(state) && isConvergenceLoop) {
|
|
761
|
+
// Serialized with merge-approval completion on this loop (PR #950 review): the engine write
|
|
762
|
+
// and projection stamp form one pair, so an approval can never interleave between them.
|
|
763
|
+
await withMergePolicyLock(loopKey, async () => {
|
|
746
764
|
await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
|
|
765
|
+
// Mirror the gate onto the durable /status projection (issue #947): the loop variable is
|
|
766
|
+
// the process's own copy; the row column is what `/status` reads without an engine
|
|
767
|
+
// round-trip. NULL would read as "outstanding" too, but stamping 1 keeps the projection
|
|
768
|
+
// explicit (decided-and-gated) rather than undecided.
|
|
769
|
+
// Atomic compare-and-set on the SAME owner we gated, still pre-finalization: a concurrent
|
|
770
|
+
// merge handoff (re-pointed key) or reopen between the read and this write leaves its own
|
|
771
|
+
// policy intact instead of being overwritten with a stale gate.
|
|
772
|
+
await data.open().exec(
|
|
773
|
+
`UPDATE "pull_requests" SET "merge_human_approval" = 1 WHERE "pr_key" = ? AND "process_key" = ? AND "converged_at" IS NULL`,
|
|
774
|
+
[parsed.prKey, loopKey],
|
|
775
|
+
);
|
|
776
|
+
});
|
|
747
777
|
}
|
|
748
778
|
}
|
|
749
779
|
// Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop —
|
|
@@ -838,6 +868,12 @@ async function submitPrCritical(
|
|
|
838
868
|
abandon_token: abandonToken,
|
|
839
869
|
root_request_key: effectiveRoot,
|
|
840
870
|
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
871
|
+
// Merge-policy projection (issue #947): stamp the run's intent so `/status` serves it from
|
|
872
|
+
// the row with no engine read. The approve at the `merge-approval` gate later maps to 0.
|
|
873
|
+
merge_requested: mergeIntent ? 1 : 0,
|
|
874
|
+
// Mirror the engine seed EXACTLY (PR #950 review): an ungated loop is seeded
|
|
875
|
+
// `humanApproval: false` (decided — no gate), a gated one `true` (outstanding).
|
|
876
|
+
merge_human_approval: gated ? 1 : 0,
|
|
841
877
|
created_at: ts,
|
|
842
878
|
updated_at: ts,
|
|
843
879
|
});
|
|
@@ -935,7 +971,16 @@ async function submitPrCritical(
|
|
|
935
971
|
let reopened = false;
|
|
936
972
|
// Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
|
|
937
973
|
const prior = existing
|
|
938
|
-
? {
|
|
974
|
+
? {
|
|
975
|
+
status: existing.status,
|
|
976
|
+
current_round: existing.current_round,
|
|
977
|
+
process_key: existing.process_key,
|
|
978
|
+
// Restore the merge-policy projection WITH ownership (PR #950 review): a rollback that put the
|
|
979
|
+
// old `process_key` back but kept the failed run's policy would serve a mismatched policy as
|
|
980
|
+
// authoritative (`stale: false`) in `/status`.
|
|
981
|
+
merge_requested: existing.merge_requested ?? null,
|
|
982
|
+
merge_human_approval: existing.merge_human_approval ?? null,
|
|
983
|
+
}
|
|
939
984
|
: null;
|
|
940
985
|
try {
|
|
941
986
|
if (existing) {
|
|
@@ -993,6 +1038,10 @@ async function submitPrCritical(
|
|
|
993
1038
|
// Record the opt-in on the row (the durable source the steward's eligibility reads) so a
|
|
994
1039
|
// fresh run's flag is authoritative even across a reopen (issue #889).
|
|
995
1040
|
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
1041
|
+
// Merge-policy projection (issue #947): run-scoped like every other field in this reset —
|
|
1042
|
+
// the fresh run's intent wins, and the approval state mirrors the fresh loop's `humanApproval` seed.
|
|
1043
|
+
merge_requested: mergeIntent ? 1 : 0,
|
|
1044
|
+
merge_human_approval: gated ? 1 : 0,
|
|
996
1045
|
// Publish the fresh run's identity in the SAME write as the status flip (may be null if the
|
|
997
1046
|
// instance could not be created — a phantom row a retry re-enrolls, as on first submit).
|
|
998
1047
|
process_key: processKey,
|
|
@@ -1129,7 +1178,15 @@ export async function startMerge(
|
|
|
1129
1178
|
},
|
|
1130
1179
|
});
|
|
1131
1180
|
if (processInstanceKey != null) {
|
|
1132
|
-
await prs(data).update(pr.prKey, {
|
|
1181
|
+
await prs(data).update(pr.prKey, {
|
|
1182
|
+
process_key: String(processInstanceKey),
|
|
1183
|
+
// Merge-policy projection (issue #947): a merge loop only starts for a requested, approved
|
|
1184
|
+
// merge (`pr.finalize`'s gate), so the merge-stage policy is known here — stamp it and the
|
|
1185
|
+
// merge stage of `/status` never pays an engine read either.
|
|
1186
|
+
merge_requested: 1,
|
|
1187
|
+
merge_human_approval: 0,
|
|
1188
|
+
updated_at: now(),
|
|
1189
|
+
});
|
|
1133
1190
|
}
|
|
1134
1191
|
return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
|
|
1135
1192
|
}
|
|
@@ -1150,6 +1207,11 @@ export interface MergePolicy {
|
|
|
1150
1207
|
* (convergence-loop.bpmn), so the blocker lifts as soon as approval is complete — even while
|
|
1151
1208
|
* the `pr.finalize` handoff is still queued or incidented. */
|
|
1152
1209
|
blockedBy: "env" | "human-approval" | null;
|
|
1210
|
+
/** The timing-guard marker (issue #947): `true` when the policy could not be verified and the row
|
|
1211
|
+
* was served from the historical default instead of blocking the serve (a legacy row — written
|
|
1212
|
+
* before the durable projection existed — whose fallback engine read failed). Rows served from
|
|
1213
|
+
* the `pull_requests` projection always read `false`. */
|
|
1214
|
+
stale: boolean;
|
|
1153
1215
|
}
|
|
1154
1216
|
|
|
1155
1217
|
/** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
|
|
@@ -1263,18 +1325,35 @@ export async function activePrs(
|
|
|
1263
1325
|
// Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
|
|
1264
1326
|
// variables per PR so the effective merge policy is visible without opening the engine.
|
|
1265
1327
|
//
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1328
|
+
// Issue #947: the policy is served from the app's OWN projection — the `merge_requested` /
|
|
1329
|
+
// `merge_human_approval` columns `submitPr`/`startMerge` stamp on the row (the writers already
|
|
1330
|
+
// know the policy) — so the hot read path pays ZERO engine round-trips. The #937 batched engine
|
|
1331
|
+
// read below is kept ONLY for legacy rows (`merge_requested` NULL — written before the projection
|
|
1332
|
+
// existed): it is a CONSTANT 3 name-filtered reads for the whole legacy set, never a per-row
|
|
1333
|
+
// fan-out, and it shrinks to the empty set as rows reopen onto the projection. See
|
|
1334
|
+
// `readMergePolicyVars` for the legacy completeness contract (single-page seam, root scope only,
|
|
1335
|
+
// one per-name root probe only for a page cut or legacy instance). Best-effort: a failed batched
|
|
1336
|
+
// read degrades every legacy row to the historical default (merge requested, flagged `stale`); a
|
|
1337
|
+
// failed per-instance fallback degrades only that row.
|
|
1273
1338
|
const mergeByPr = new Map<string, MergePolicy>();
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1339
|
+
const legacyKeys: string[] = [];
|
|
1340
|
+
for (const p of active) {
|
|
1341
|
+
const projected = mergePolicyFromRow(p);
|
|
1342
|
+
if (projected) {
|
|
1343
|
+
mergeByPr.set(p.pr_key, projected);
|
|
1344
|
+
} else if (p.process_key != null && p.process_key !== "") {
|
|
1345
|
+
legacyKeys.push(p.process_key);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const varsByInstance = await readMergePolicyVars(engine, legacyKeys);
|
|
1276
1349
|
for (const p of active) {
|
|
1277
|
-
mergeByPr.
|
|
1350
|
+
if (mergeByPr.has(p.pr_key)) continue;
|
|
1351
|
+
const vars = varsByInstance?.get(p.process_key ?? "");
|
|
1352
|
+
mergeByPr.set(p.pr_key, mergePolicyFromVars(vars));
|
|
1353
|
+
// Self-healing hydration (PR #950 review): a legacy row whose policy the engine just proved is
|
|
1354
|
+
// written back onto the projection, so it pays the engine read at most ONCE — the legacy set
|
|
1355
|
+
// drains on the first serves after upgrade instead of keeping every /status on the slow path.
|
|
1356
|
+
await hydrateLegacyMergePolicy(data, p, vars);
|
|
1278
1357
|
}
|
|
1279
1358
|
return active.map((p) => {
|
|
1280
1359
|
// Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
|
|
@@ -1352,9 +1431,25 @@ function hasOwnTerminationReason(p: {
|
|
|
1352
1431
|
|
|
1353
1432
|
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
1354
1433
|
* unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
|
|
1355
|
-
* default, so the projection must NOT read such a PR as review-only.
|
|
1434
|
+
* default, so the projection must NOT read such a PR as review-only. Flagged `stale` (issue #947):
|
|
1435
|
+
* the policy could not be verified — the row was served from the default rather than blocking. */
|
|
1356
1436
|
function mergePolicyDefault(): MergePolicy {
|
|
1357
|
-
return mergePolicyFrom(true, false);
|
|
1437
|
+
return { ...mergePolicyFrom(true, false), stale: true };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Derive a PR's effective merge policy from its DURABLE row projection (issue #947) — the
|
|
1441
|
+
* `merge_requested` / `merge_human_approval` columns `submitPr`/`startMerge` stamp. Returns
|
|
1442
|
+
* `undefined` for a legacy row (`merge_requested` NULL — written before the projection existed),
|
|
1443
|
+
* which the caller falls back to the #937 batched engine read. `merge_human_approval` NULL means
|
|
1444
|
+
* the approval is outstanding/undecided (the gate still holds); 0|1 is the decided state the
|
|
1445
|
+
* `merge-approval` task's `humanApproval` variable write mirrors. A projected policy is always
|
|
1446
|
+
* fresh (`stale: false`) — it never blocks the serve on an engine round-trip. */
|
|
1447
|
+
function mergePolicyFromRow(p: {
|
|
1448
|
+
merge_requested?: number | null;
|
|
1449
|
+
merge_human_approval?: number | null;
|
|
1450
|
+
}): MergePolicy | undefined {
|
|
1451
|
+
if (p.merge_requested == null) return undefined;
|
|
1452
|
+
return { ...mergePolicyFrom(p.merge_requested === 1, p.merge_human_approval !== 0), stale: false };
|
|
1358
1453
|
}
|
|
1359
1454
|
|
|
1360
1455
|
/** Combine a merge intent + human-approval gate with the global switch into the effective policy.
|
|
@@ -1362,7 +1457,7 @@ function mergePolicyDefault(): MergePolicy {
|
|
|
1362
1457
|
* and the `merge-approval` user task clears it on an explicit "approve", so an approved PR whose
|
|
1363
1458
|
* finalization is still queued/incidented reads `effective` with no blocker rather than a stale
|
|
1364
1459
|
* `blockedBy: "human-approval"`. */
|
|
1365
|
-
function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolicy {
|
|
1460
|
+
function mergePolicyFrom(requested: boolean, humanApproval: boolean): Omit<MergePolicy, "stale"> {
|
|
1366
1461
|
const enabled = AUTO_MERGE;
|
|
1367
1462
|
const effective = requested && enabled && !humanApproval;
|
|
1368
1463
|
const blockedBy = !requested ? null : !enabled ? "env" : humanApproval ? "human-approval" : null;
|
|
@@ -1376,6 +1471,9 @@ type MergePolicyVars = {
|
|
|
1376
1471
|
autoMerge: boolean | undefined;
|
|
1377
1472
|
convergeOnly: boolean | undefined;
|
|
1378
1473
|
humanApproval: boolean | undefined;
|
|
1474
|
+
/** True once every policy name for this instance was either seen or proven absent by an
|
|
1475
|
+
* untruncatable root probe — absence of a name is then real, not a page cut. */
|
|
1476
|
+
proven?: boolean;
|
|
1379
1477
|
};
|
|
1380
1478
|
|
|
1381
1479
|
function emptyMergePolicyVars(): MergePolicyVars {
|
|
@@ -1398,6 +1496,46 @@ function isMergePolicyName(name: string): name is MergePolicyName {
|
|
|
1398
1496
|
return MERGE_POLICY_NAMES.some((n) => n === name);
|
|
1399
1497
|
}
|
|
1400
1498
|
|
|
1499
|
+
/** Write a legacy row's engine-proven merge policy back onto its `pull_requests` projection (PR #950
|
|
1500
|
+
* review). Only a COMPLETE read is persisted — a known intent (`autoMerge`, or the legacy inverted
|
|
1501
|
+
* `convergeOnly`) AND a known `humanApproval` — so a guess is never made authoritative. The UPDATE is
|
|
1502
|
+
* guarded on the row still being unstamped AND still owned by the instance read, so a concurrent
|
|
1503
|
+
* submit/reopen stamp always wins. Best-effort: a failed write just leaves the row legacy. */
|
|
1504
|
+
async function hydrateLegacyMergePolicy(
|
|
1505
|
+
data: DataLayer,
|
|
1506
|
+
row: { pr_key: string; process_key?: string | null },
|
|
1507
|
+
vars: MergePolicyVars | undefined,
|
|
1508
|
+
): Promise<void> {
|
|
1509
|
+
if (!vars || !row.process_key) return;
|
|
1510
|
+
// A legacy loop that predates `humanApproval` legitimately lacks it; once a COMPLETE read proved
|
|
1511
|
+
// that absence it reads as "no outstanding approval", so the row can leave the probe queue
|
|
1512
|
+
// instead of starving later legacy rows (PR #950 review).
|
|
1513
|
+
const humanApproval = vars.humanApproval ?? (vars.proven ? false : undefined);
|
|
1514
|
+
if (humanApproval === undefined) return;
|
|
1515
|
+
const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? undefined : !vars.convergeOnly);
|
|
1516
|
+
if (requested === undefined) return;
|
|
1517
|
+
try {
|
|
1518
|
+
// `COALESCE` preserves an approval stamp written concurrently (approval / gated adoption leave
|
|
1519
|
+
// `merge_requested` NULL), so a hydration that read the engine BEFORE that decision can never
|
|
1520
|
+
// overwrite it with the older value (PR #950 review).
|
|
1521
|
+
await data.open().exec(
|
|
1522
|
+
`UPDATE "pull_requests" SET "merge_requested" = ?, "merge_human_approval" = COALESCE("merge_human_approval", ?) WHERE "pr_key" = ? AND "process_key" = ? AND "merge_requested" IS NULL`,
|
|
1523
|
+
[requested ? 1 : 0, humanApproval ? 1 : 0, row.pr_key, row.process_key],
|
|
1524
|
+
);
|
|
1525
|
+
} catch {
|
|
1526
|
+
// swallow — hydration is an optimisation; the row simply stays on the legacy read
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** Upper bound on legacy instances that may pay per-instance root probes in ONE /status serve (PR
|
|
1531
|
+
* #950 review). Beyond it, an instance missing from the batched pages degrades to the stale default
|
|
1532
|
+
* for this serve (and is hydrated on a later one), so request-time engine calls stay bounded at
|
|
1533
|
+
* `3 + 3 * MAX_PROBED_LEGACY_INSTANCES` regardless of how many legacy PRs exist. */
|
|
1534
|
+
export const MAX_PROBED_LEGACY_INSTANCES = 4;
|
|
1535
|
+
|
|
1536
|
+
/** Rotating start offset into the legacy probe queue (see {@link readMergePolicyVars}). */
|
|
1537
|
+
let legacyProbeCursor = 0;
|
|
1538
|
+
|
|
1401
1539
|
/** Batched merge-policy read (issue #936): ONE name-filtered engine-wide read per policy variable
|
|
1402
1540
|
* (3 concurrent calls, independent of row count), bucketed by owning instance.
|
|
1403
1541
|
*
|
|
@@ -1452,15 +1590,27 @@ async function readMergePolicyVars(
|
|
|
1452
1590
|
// `convergeOnly` is skipped once a usable boolean `autoMerge` is known (the canonical intent wins). Every loop
|
|
1453
1591
|
// seeds all three names at creation (submitPr; startMerge seeds the merge loop's policy too), so
|
|
1454
1592
|
// the steady state is exactly 3 calls; probes are only paid for page cuts and legacy instances.
|
|
1593
|
+
const needsProbe = (pk: string) =>
|
|
1594
|
+
MERGE_POLICY_NAMES.filter(
|
|
1595
|
+
(name) => !seen.get(pk)?.has(name) && !(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
|
|
1596
|
+
);
|
|
1597
|
+
// Bound the request-time fallback (PR #950 review): only the first MAX_PROBED_LEGACY_INSTANCES
|
|
1598
|
+
// instances (callers pass them most-recently-updated first) are probed; the rest degrade fully to
|
|
1599
|
+
// the stale default for this serve — never a half-read policy.
|
|
1600
|
+
for (const pk of wanted) {
|
|
1601
|
+
const vars = byInstance.get(pk);
|
|
1602
|
+
if (vars && needsProbe(pk).length === 0) vars.proven = true;
|
|
1603
|
+
}
|
|
1604
|
+
const probing = [...wanted].filter((pk) => needsProbe(pk).length > 0);
|
|
1605
|
+
// Rotate the probe window across serves (PR #950 review) so a leading row that can never be
|
|
1606
|
+
// hydrated cannot permanently starve the rows behind it.
|
|
1607
|
+
const offset = probing.length > 0 ? legacyProbeCursor % probing.length : 0;
|
|
1608
|
+
const rotated = [...probing.slice(offset), ...probing.slice(0, offset)];
|
|
1609
|
+
legacyProbeCursor += MAX_PROBED_LEGACY_INSTANCES;
|
|
1610
|
+
for (const pk of rotated.slice(MAX_PROBED_LEGACY_INSTANCES)) byInstance.delete(pk);
|
|
1455
1611
|
await Promise.all(
|
|
1456
|
-
|
|
1457
|
-
const
|
|
1458
|
-
const probe = MERGE_POLICY_NAMES.filter(
|
|
1459
|
-
(name) =>
|
|
1460
|
-
!got?.has(name) &&
|
|
1461
|
-
!(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
|
|
1462
|
-
);
|
|
1463
|
-
if (probe.length === 0) return;
|
|
1612
|
+
rotated.slice(0, MAX_PROBED_LEGACY_INSTANCES).map(async (pk) => {
|
|
1613
|
+
const probe = needsProbe(pk);
|
|
1464
1614
|
try {
|
|
1465
1615
|
// Collect ALL probe results first and record only once every probe succeeded: recording
|
|
1466
1616
|
// inside each probe would let a slower successful sibling re-create the entry the catch
|
|
@@ -1473,6 +1623,9 @@ async function readMergePolicyVars(
|
|
|
1473
1623
|
),
|
|
1474
1624
|
);
|
|
1475
1625
|
for (const rows of results) for (const row of rows) record(row);
|
|
1626
|
+
const vars = byInstance.get(pk) ?? emptyMergePolicyVars();
|
|
1627
|
+
vars.proven = true;
|
|
1628
|
+
byInstance.set(pk, vars);
|
|
1476
1629
|
} catch {
|
|
1477
1630
|
// Degrade ONLY this instance — and fully: drop any partial entry the batch recorded so
|
|
1478
1631
|
// the caller projects the default for it, never a half-read policy.
|
|
@@ -1490,11 +1643,14 @@ async function readMergePolicyVars(
|
|
|
1490
1643
|
* canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
|
|
1491
1644
|
* in-flight loops seeded before `autoMerge` existed; an instance carrying neither (or absent from
|
|
1492
1645
|
* the batch — mid-handoff row, instance gone) degrades to the historical default (merge
|
|
1493
|
-
* requested) rather than failing the status read. */
|
|
1646
|
+
* requested, flagged `stale`) rather than failing the status read. */
|
|
1494
1647
|
function mergePolicyFromVars(vars: MergePolicyVars | undefined): MergePolicy {
|
|
1495
1648
|
if (!vars) return mergePolicyDefault();
|
|
1649
|
+
if (vars.autoMerge === undefined && vars.convergeOnly === undefined && vars.humanApproval === undefined) {
|
|
1650
|
+
return mergePolicyDefault();
|
|
1651
|
+
}
|
|
1496
1652
|
const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? true : !vars.convergeOnly);
|
|
1497
|
-
return mergePolicyFrom(requested, vars.humanApproval === true);
|
|
1653
|
+
return { ...mergePolicyFrom(requested, vars.humanApproval === true), stale: false };
|
|
1498
1654
|
}
|
|
1499
1655
|
|
|
1500
1656
|
/** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
|
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.5",
|
|
3
|
+
"gitSha": "c3f19d50ef6cf4f731f50ab778b5cd7ebdb3454e",
|
|
4
|
+
"gitTag": "v0.208.5",
|
|
5
|
+
"builtAt": "2026-11-11T09:26:21.393Z"
|
|
6
6
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Durable merge-policy projection for /status (issue #947). `/app/api/status` regressed to ~30 s
|
|
2
|
+
-- (502 behind the console proxy) on merlin with only 9 PRs: the #937 batching left the merge-policy
|
|
3
|
+
-- read at 3 engine-wide `searchVariables` pages plus a per-(instance, name) probe for any name cut
|
|
4
|
+
-- from its (single, truncating) page — and on a loaded host each engine read costs ~1 s, so the
|
|
5
|
+
-- serve was O(PRs x reads). The fix for the CLASS is to serve the hot read path from the app's own
|
|
6
|
+
-- projection: the writers that already KNOW the policy stamp it on the row —
|
|
7
|
+
--
|
|
8
|
+
-- * `submitPr` stamps `merge_requested` (the run's merge intent) at submit/reopen and
|
|
9
|
+
-- `merge_human_approval = 1` when a gated adopt narrows a live loop;
|
|
10
|
+
-- * `completeUserTaskAttributed` (app/agentCompletion.ts) stamps `merge_human_approval = 0` when a
|
|
11
|
+
-- human APPROVES at the `merge-approval` task (mirroring the task's `humanApproval`
|
|
12
|
+
-- output mapping), so /status lifts the blocker the moment approval lands;
|
|
13
|
+
-- * `startMerge` stamps `merge_requested = 1, merge_human_approval = 0` (a merge loop only starts
|
|
14
|
+
-- for a requested, approved merge).
|
|
15
|
+
--
|
|
16
|
+
-- `merge_requested` is NULL on every pre-#947 row: those legacy rows keep the #937 batched engine
|
|
17
|
+
-- read (constant, never per-row) until they next reopen. `merge_human_approval` is NULL while an
|
|
18
|
+
-- approval is outstanding and 0/1 once decided — mirroring the loop's `humanApproval` variable,
|
|
19
|
+
-- which the `merge-approval` user task clears to `false` on an explicit approve. Both columns are
|
|
20
|
+
-- run-scoped: `submitPr`'s atomic reopen resets them to the fresh run's intent.
|
|
21
|
+
ALTER TABLE pull_requests ADD COLUMN merge_requested INTEGER;
|
|
22
|
+
ALTER TABLE pull_requests ADD COLUMN merge_human_approval INTEGER;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base columns (the VIEW must pass
|
|
2
|
+
-- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
|
|
3
|
+
-- re-emission of 131 with `merge_requested` / `merge_human_approval` (132, issue #947) added; the derived
|
|
4
|
+
-- columns are emitted VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the
|
|
5
|
+
-- runner wraps each file). Numbered after 132.
|
|
6
|
+
|
|
7
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
8
|
+
|
|
9
|
+
CREATE VIEW pull_requests_read_model AS
|
|
10
|
+
SELECT
|
|
11
|
+
pr.pr_key AS pr_key,
|
|
12
|
+
pr.repo AS repo,
|
|
13
|
+
pr.number AS number,
|
|
14
|
+
pr.url AS url,
|
|
15
|
+
pr.title AS title,
|
|
16
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
17
|
+
pr.current_round AS current_round,
|
|
18
|
+
pr.process_key AS process_key,
|
|
19
|
+
pr.waiting_since AS waiting_since,
|
|
20
|
+
pr.last_review_id AS last_review_id,
|
|
21
|
+
pr.outcome AS outcome,
|
|
22
|
+
pr.created_at AS created_at,
|
|
23
|
+
pr.updated_at AS updated_at,
|
|
24
|
+
pr.converged_at AS converged_at,
|
|
25
|
+
pr.merged_at AS merged_at,
|
|
26
|
+
pr.active_worker AS active_worker,
|
|
27
|
+
pr.lease_until AS lease_until,
|
|
28
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
29
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
30
|
+
pr.abandon_token AS abandon_token,
|
|
31
|
+
pr.incident_key AS incident_key,
|
|
32
|
+
pr.incident_type AS incident_type,
|
|
33
|
+
pr.incident_since AS incident_since,
|
|
34
|
+
pr.incident_message AS incident_message,
|
|
35
|
+
pr.last_round_head AS last_round_head,
|
|
36
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
37
|
+
pr.last_progress_result AS last_progress_result,
|
|
38
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
39
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
40
|
+
pr.root_request_key AS root_request_key,
|
|
41
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
42
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
43
|
+
pr.auto_resolve_escalations AS auto_resolve_escalations,
|
|
44
|
+
pr.termination_reason AS termination_reason,
|
|
45
|
+
pr.termination_process_key AS termination_process_key,
|
|
46
|
+
pr.retire_pending_key AS retire_pending_key,
|
|
47
|
+
pr.merge_requested AS merge_requested,
|
|
48
|
+
pr.merge_human_approval AS merge_human_approval,
|
|
49
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
50
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
51
|
+
FROM pull_requests__tracking pr;
|
|
@@ -159,7 +159,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
159
159
|
assert.ok(
|
|
160
160
|
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
161
|
flows.includes("record-feature-implementing->implement") &&
|
|
162
|
-
flows.includes("
|
|
162
|
+
flows.includes("slice-branch->implement-task"),
|
|
163
163
|
`the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
|
|
164
164
|
);
|
|
165
165
|
// A green probe never escalates.
|
|
@@ -192,7 +192,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
192
192
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
193
193
|
assert.ok(
|
|
194
194
|
flows.includes("record-feature-implementing->implement") &&
|
|
195
|
-
flows.includes("
|
|
195
|
+
flows.includes("slice-branch->implement-task"),
|
|
196
196
|
"an ungated feature reaches the implement agent (via the implement cell)",
|
|
197
197
|
);
|
|
198
198
|
} finally {
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
|
+
import { recoverBranchCreate } from "../app/repoEnvelope.ts";
|
|
24
25
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
26
|
import { advancePastTimer, settleFully } from "./support/time.ts";
|
|
26
27
|
|
|
@@ -127,32 +128,127 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
127
128
|
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
128
129
|
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
129
130
|
|
|
130
|
-
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
|
|
131
|
+
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope AND the deterministic branch.create (#684/#945)", async () => {
|
|
131
132
|
// The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
|
|
132
133
|
// agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
|
|
133
134
|
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
134
135
|
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
135
|
-
// `feat/<task.id>` differs per MI child, so
|
|
136
|
-
|
|
136
|
+
// `feat/<task.id>` differs per MI child, so it can't be named at seed time. Issue #945: the
|
|
137
|
+
// per-instance `slice-branch` worker (first step of each implement-cell) now stamps the deterministic `branch.create` (`feat/<task.id>`)
|
|
138
|
+
// onto the envelope as a FLAT dotted process variable (`io.nanobpm.agentTask.branch.create`), which
|
|
139
|
+
// the c8ctl harness deep-merges with the whole-object seed — so a retry re-derives the SAME branch
|
|
140
|
+
// and the durable WIP checkpoint restore matches (previously every retry cut a random
|
|
141
|
+
// `nano/agent-work/*` fallback and discarded the prior session's work).
|
|
142
|
+
let agentVars: Record<string, unknown> = {};
|
|
137
143
|
await withApp(
|
|
138
144
|
{
|
|
139
145
|
"senior:plan": singleTaskPlan,
|
|
140
146
|
"senior:plan-review": approveReview,
|
|
141
147
|
"senior:feature": (job) => {
|
|
142
|
-
|
|
148
|
+
agentVars = job.variables as Record<string, unknown>;
|
|
143
149
|
return { status: "blocked", summary: "n/a" };
|
|
144
150
|
},
|
|
145
151
|
},
|
|
146
152
|
async ({ app }) => {
|
|
147
153
|
await app.settle();
|
|
154
|
+
const agentTask = agentVars["io.nanobpm.agentTask"] as Record<string, any> | undefined;
|
|
148
155
|
assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
149
156
|
const agentRepo = agentTask.repository;
|
|
150
157
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
151
158
|
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
152
|
-
// `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
|
|
153
|
-
assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
154
159
|
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
155
160
|
assert.equal(agentRepo.filter, "blob:none");
|
|
161
|
+
// The whole-object seed still omits `branch` (it can't name a per-child branch)…
|
|
162
|
+
assert.equal("branch" in agentTask, false, "the whole-object seed carries no per-slice branch");
|
|
163
|
+
// …but the per-instance slice-branch worker stamps it as a FLAT dotted variable, which the harness
|
|
164
|
+
// merges onto the SAME envelope. Assert the dotted variable is present and names the
|
|
165
|
+
// deterministic `feat/<task.id>` branch (the single task here is `t1`).
|
|
166
|
+
assert.equal(
|
|
167
|
+
agentVars["io.nanobpm.agentTask.branch.create"],
|
|
168
|
+
"feat/t1",
|
|
169
|
+
"slice-branch stamps the deterministic per-slice branch.create as a flat dotted variable (#945)",
|
|
170
|
+
);
|
|
171
|
+
// Round-trip through the harness normalizer shape: the merged envelope must recover
|
|
172
|
+
// `branch.create` from the top level (proving the dotted variable and the whole-object seed
|
|
173
|
+
// merge into one envelope the harness reads).
|
|
174
|
+
assert.equal(
|
|
175
|
+
recoverBranchCreate({}, agentVars),
|
|
176
|
+
"feat/t1",
|
|
177
|
+
"the harness merges the dotted branch.create with the whole-object seed envelope (#945)",
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("sibling isolation: two parallel slices each see their OWN branch.create, never a sibling's (#945)", async () => {
|
|
184
|
+
// A per-slice branch emitted into the SHARED plan-fanout MI body lands on the epic root, so a
|
|
185
|
+
// sibling's emission overwrites it and a slice can inherit `feat/<sibling>` — sending its work and
|
|
186
|
+
// checkpoint to the wrong branch. The emitter runs inside each implement-cell instance instead, so
|
|
187
|
+
// every activation's recovered branch must be `feat/<its own task.id>`.
|
|
188
|
+
const seen: Array<{ id: unknown; branch: string | undefined }> = [];
|
|
189
|
+
await withApp(
|
|
190
|
+
{
|
|
191
|
+
"senior:plan": () => ({
|
|
192
|
+
tasks: [
|
|
193
|
+
{ id: "t1", title: "T1", prompt: "do t1" },
|
|
194
|
+
{ id: "t2", title: "T2", prompt: "do t2" },
|
|
195
|
+
],
|
|
196
|
+
}),
|
|
197
|
+
"senior:plan-review": approveReview,
|
|
198
|
+
"senior:feature": (job) => {
|
|
199
|
+
const vars = job.variables as Record<string, any>;
|
|
200
|
+
seen.push({ id: vars.task?.id, branch: recoverBranchCreate({}, vars) });
|
|
201
|
+
return { status: "blocked", summary: "n/a" };
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
async ({ app }) => {
|
|
205
|
+
await app.settle();
|
|
206
|
+
assert.deepEqual(
|
|
207
|
+
seen.map((s) => s.id).sort(),
|
|
208
|
+
["t1", "t2"],
|
|
209
|
+
"both sibling slices activated their implement-task",
|
|
210
|
+
);
|
|
211
|
+
for (const s of seen) {
|
|
212
|
+
assert.equal(s.branch, `feat/${String(s.id)}`, `slice ${String(s.id)} must see its OWN branch.create`);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("retry determinism: a re-dispatched implement-task names the SAME branch.create, so the WIP checkpoint restore matches (#945)", async () => {
|
|
219
|
+
// Issue #945's field failure: every c8ctl retry of an implement-cell slice cut a NEW random
|
|
220
|
+
// `nano/agent-work/*` fallback branch, so the durable WIP checkpoint (keyed on the deterministic
|
|
221
|
+
// `feat/<task.id>`) was NEVER restored — each retry started from the epic base and discarded the
|
|
222
|
+
// prior session's work. The fix makes `branch.create` a pure function of `task.id`, so a retry
|
|
223
|
+
// re-derives the SAME branch and the checkpoint restore matches. Drive the implement-task through
|
|
224
|
+
// TWO activations of the SAME slice (an escalation parks it; answering re-dispatches it — the same
|
|
225
|
+
// re-activation an idle-kill retry produces) and assert both carry the identical recovered
|
|
226
|
+
// `branch.create`.
|
|
227
|
+
const seenBranches: Array<string | undefined> = [];
|
|
228
|
+
await withApp(
|
|
229
|
+
{
|
|
230
|
+
"senior:plan": singleTaskPlan,
|
|
231
|
+
"senior:plan-review": approveReview,
|
|
232
|
+
"senior:feature": (job) => {
|
|
233
|
+
seenBranches.push(recoverBranchCreate({}, job.variables as Record<string, unknown>));
|
|
234
|
+
// First activation escalates (parks for a human); the resume (second activation) blocks.
|
|
235
|
+
return seenBranches.length === 1
|
|
236
|
+
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
237
|
+
: { status: "blocked", summary: "resumed after answer" };
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
async ({ app, processKey }) => {
|
|
241
|
+
// First activation parked on the human-escalation task; answer it to re-dispatch the SAME slice.
|
|
242
|
+
const task = await openTask(app, processKey, "escalation");
|
|
243
|
+
await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
244
|
+
await app.settle();
|
|
245
|
+
assert.equal(seenBranches.length, 2, "the implement-task was activated twice (initial + retry)");
|
|
246
|
+
assert.equal(seenBranches[0], "feat/t1", "the FIRST activation carries the deterministic branch.create");
|
|
247
|
+
assert.equal(
|
|
248
|
+
seenBranches[1],
|
|
249
|
+
seenBranches[0],
|
|
250
|
+
"the RETRY activation re-derives the SAME branch.create — the WIP checkpoint restore matches (#945)",
|
|
251
|
+
);
|
|
156
252
|
},
|
|
157
253
|
);
|
|
158
254
|
});
|
|
@@ -532,7 +628,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
532
628
|
|
|
533
629
|
const flows = takenFlows(app);
|
|
534
630
|
assert.ok(
|
|
535
|
-
flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("
|
|
631
|
+
flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("slice-branch->implement-task"),
|
|
536
632
|
`caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
|
|
537
633
|
);
|
|
538
634
|
assert.equal(
|
|
@@ -614,7 +710,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
614
710
|
assert.ok(
|
|
615
711
|
answered.includes("w_gw_answer->record-task-implementing") &&
|
|
616
712
|
answered.includes("record-task-implementing->implement-cell-call") &&
|
|
617
|
-
answered.includes("
|
|
713
|
+
answered.includes("slice-branch->implement-task"),
|
|
618
714
|
`answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${answered.join(", ")})`,
|
|
619
715
|
);
|
|
620
716
|
assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
|
|
@@ -668,7 +764,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
668
764
|
assert.ok(
|
|
669
765
|
entered.includes("w_gw_answer->record-task-implementing") &&
|
|
670
766
|
entered.includes("record-task-implementing->implement-cell-call") &&
|
|
671
|
-
entered.includes("
|
|
767
|
+
entered.includes("slice-branch->implement-task"),
|
|
672
768
|
`answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${entered.join(", ")})`,
|
|
673
769
|
);
|
|
674
770
|
|
package/nano.app.json
CHANGED
|
@@ -180,6 +180,10 @@
|
|
|
180
180
|
"taskType": "pr.select-wave",
|
|
181
181
|
"handler": "workers/select-wave/worker.ts"
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
"taskType": "pr.slice-branch",
|
|
185
|
+
"handler": "workers/slice-branch/worker.ts"
|
|
186
|
+
},
|
|
183
187
|
{
|
|
184
188
|
"taskType": "pr.caps-prepare",
|
|
185
189
|
"handler": "workers/caps-prepare/worker.ts"
|