@nanobpm/nano-workforce 0.201.1 → 0.201.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.test.ts +14 -11
- package/app/feature.test.ts +5 -3
- package/app/mergeLoopBehaviour.test.ts +46 -1
- package/app/plan.test.ts +5 -2
- package/app/repoEnvelope.test.ts +139 -0
- package/app/repoEnvelope.ts +24 -15
- package/app/service.test.ts +21 -16
- package/app/service.ts +94 -0
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- package/e2e/feature-run.e2e.ts +6 -4
- package/e2e/plan-fanout.e2e.ts +6 -4
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Contract test for the agent-task repository envelope (issue #882): the producer
|
|
2
|
+
// (`app/repoEnvelope.ts`) and the consumer (the c8ctl nano worker harness's
|
|
3
|
+
// `normalizeTaskEnvelope`, c8ctl-plugin.js) must agree on WHERE the deterministic
|
|
4
|
+
// `branch.create` lives. The harness reads it from the TOP LEVEL of the envelope
|
|
5
|
+
// (`raw.branch`), NOT nested under `repository`. The producer once nested it under
|
|
6
|
+
// `repository.branch` — a shape the consumer never reads — so `branch.create`
|
|
7
|
+
// silently never took effect (retries couldn't resume their WIP checkpoint, pushes
|
|
8
|
+
// were refused off the work branch; nanobpm/nano-bpm#1344). This is the
|
|
9
|
+
// two-sides-of-one-contract drift #227 guards against, so this test round-trips the
|
|
10
|
+
// emitted envelope through a faithful mirror of the harness's normalizer shape and
|
|
11
|
+
// asserts the consumer actually recovers `branch.create` — pinning producer and
|
|
12
|
+
// consumer together so they cannot drift again.
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
16
|
+
|
|
17
|
+
// ─── A faithful mirror of the harness's consumer shape ───────────────────────────────
|
|
18
|
+
// These mirror the EXACT semantics of c8ctl-plugin-nano `normalizeTaskEnvelope`
|
|
19
|
+
// (c8ctl-plugin.js): `collectEnvelopeFrom` reassembles the reserved
|
|
20
|
+
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
|
|
21
|
+
// dotpath keys, `deepMerge`s the two sources, and `normalizeTaskEnvelope` then reads
|
|
22
|
+
// `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`),
|
|
23
|
+
// NOT from `raw.repository.branch`. Keeping this mirror in lock-step with the harness
|
|
24
|
+
// is the point of the test: if the producer emits a shape the consumer doesn't read,
|
|
25
|
+
// the round-trip below drops `branch.create` and fails.
|
|
26
|
+
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
27
|
+
|
|
28
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function deepMerge(base: unknown, over: unknown): unknown {
|
|
33
|
+
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
34
|
+
const out: Record<string, unknown> = { ...base };
|
|
35
|
+
if (!isPlainObject(over)) return out;
|
|
36
|
+
for (const [k, v] of Object.entries(over)) {
|
|
37
|
+
if (v === undefined) continue;
|
|
38
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
|
44
|
+
let cur = obj;
|
|
45
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
46
|
+
const k = path[i];
|
|
47
|
+
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
48
|
+
cur = cur[k] as Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
cur[path[path.length - 1]] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
|
|
54
|
+
* whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
|
|
55
|
+
* latter). */
|
|
56
|
+
function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
+
const out: Record<string, unknown> = {};
|
|
58
|
+
if (!isPlainObject(source)) return out;
|
|
59
|
+
const whole = source[AGENT_TASK_NS];
|
|
60
|
+
if (whole != null) {
|
|
61
|
+
let val: unknown = whole;
|
|
62
|
+
if (typeof whole === "string") {
|
|
63
|
+
try {
|
|
64
|
+
val = JSON.parse(whole);
|
|
65
|
+
} catch {
|
|
66
|
+
val = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
70
|
+
}
|
|
71
|
+
const prefix = `${AGENT_TASK_NS}.`;
|
|
72
|
+
for (const [key, value] of Object.entries(source)) {
|
|
73
|
+
if (!key.startsWith(prefix)) continue;
|
|
74
|
+
const rest = key.slice(prefix.length);
|
|
75
|
+
if (!rest) continue;
|
|
76
|
+
setPath(out, rest.split("."), value);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
|
|
82
|
+
* LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
|
|
83
|
+
* `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
|
|
84
|
+
* producer put it somewhere the consumer doesn't look). */
|
|
85
|
+
function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
|
|
86
|
+
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
|
|
87
|
+
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
88
|
+
const create = branch.create;
|
|
89
|
+
return create == null ? undefined : String(create);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── The round-trip contract ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test("contract: repoEnvelopeVars emits branch.create where the harness reads it (envelope TOP LEVEL, not repository.branch) (#882)", () => {
|
|
95
|
+
// The pre-PR implementation path: a deterministic feat/<task.id> branch the harness must cut.
|
|
96
|
+
const vars = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7");
|
|
97
|
+
// Round-trip the emitted PROCESS-VARIABLE envelope through the harness normalizer shape.
|
|
98
|
+
const recovered = harnessReadsBranchCreate({}, vars);
|
|
99
|
+
assertEquals(
|
|
100
|
+
recovered,
|
|
101
|
+
"feat/issue-7",
|
|
102
|
+
"the harness reads branch.create from the envelope top level — nesting it under repository.branch drops it",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("contract: repoEnvelopeVars PR path (ref == create == head) round-trips branch.create to the harness (#882/#231)", () => {
|
|
107
|
+
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12");
|
|
108
|
+
assertEquals(harnessReadsBranchCreate({}, vars), "feat/issue-12");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("contract: agentNodeRepoEnvelope round-trips the deterministic per-node branch through the harness (#882/#776)", () => {
|
|
112
|
+
const vars = agentNodeRepoEnvelope("acme/one", "main", "feat/n1");
|
|
113
|
+
assertEquals(harnessReadsBranchCreate({}, vars), "feat/n1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("contract: the FLATTENED per-cell task headers round-trip branch.create to the harness (#882/#739)", () => {
|
|
117
|
+
// The delivery-graph path delivers the envelope as FLAT dotted task headers, which the harness
|
|
118
|
+
// reassembles via collectEnvelopeFrom's dotpath branch. Round-trip the flattened headers through
|
|
119
|
+
// the same normalizer shape so the header channel can't drift from the consumer either.
|
|
120
|
+
const envelope = agentNodeRepoEnvelope("acme/one", "main", "feat/open-b");
|
|
121
|
+
const flat = flattenAgentTaskEnvelope(envelope);
|
|
122
|
+
// The flattened key must be the TOP-LEVEL `branch.create`, NOT `repository.branch.create`.
|
|
123
|
+
assert(
|
|
124
|
+
"io.nanobpm.agentTask.branch.create" in flat,
|
|
125
|
+
`expected a top-level io.nanobpm.agentTask.branch.create header, got ${JSON.stringify(Object.keys(flat))}`,
|
|
126
|
+
);
|
|
127
|
+
assert(
|
|
128
|
+
!("io.nanobpm.agentTask.repository.branch.create" in flat),
|
|
129
|
+
"branch.create must NOT be nested under repository — the harness never reads it there",
|
|
130
|
+
);
|
|
131
|
+
const recovered = harnessReadsBranchCreate(flat, {});
|
|
132
|
+
assertEquals(recovered, "feat/open-b", "the harness reassembles the flattened header into branch.create");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("contract: an envelope with NO branch round-trips to undefined (the consumer's default), never a bogus value (#882)", () => {
|
|
136
|
+
// A blank branchCreate omits the key entirely; the harness must then see no branch.create.
|
|
137
|
+
const vars = repoEnvelopeVars("owner/repo", "main", null, null, null);
|
|
138
|
+
assertEquals(harnessReadsBranchCreate({}, vars), undefined);
|
|
139
|
+
});
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -187,19 +187,25 @@ export function repoEnvelopeVars(
|
|
|
187
187
|
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
188
188
|
// when the PR has no durable push-checkpoint yet.
|
|
189
189
|
...(isCommitSha(commitSha) ? { sha: commitSha } : {}),
|
|
190
|
-
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
191
|
-
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
192
|
-
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
193
|
-
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
194
|
-
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
195
|
-
// The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
|
|
196
|
-
// it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
|
|
197
|
-
// `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
|
|
198
|
-
// head branch. Only a blank/omitted value drops the key.
|
|
199
|
-
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
200
|
-
? { branch: { create: branchCreate.trim() } }
|
|
201
|
-
: {}),
|
|
202
190
|
},
|
|
191
|
+
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
192
|
+
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
193
|
+
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
194
|
+
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
195
|
+
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
196
|
+
// The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
|
|
197
|
+
// it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
|
|
198
|
+
// `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
|
|
199
|
+
// head branch. Only a blank/omitted value drops the key.
|
|
200
|
+
//
|
|
201
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
|
|
202
|
+
// `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from
|
|
203
|
+
// `repository.branch`. Nesting it under `repository` was the two-sides-of-one-contract drift
|
|
204
|
+
// (#227) that left `branch.create` silently unread (retries couldn't resume, pushes were
|
|
205
|
+
// refused — nanobpm/nano-bpm#1344).
|
|
206
|
+
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
207
|
+
? { branch: { create: branchCreate.trim() } }
|
|
208
|
+
: {}),
|
|
203
209
|
// Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the
|
|
204
210
|
// git credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository
|
|
205
211
|
// provisioning when `task.allowPr` is truthy — `resolveJobSecrets` pulls the token behind that
|
|
@@ -251,10 +257,13 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null, branchC
|
|
|
251
257
|
// A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
|
|
252
258
|
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
253
259
|
...(ref ? { ref, baseRef: ref } : {}),
|
|
254
|
-
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
255
|
-
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
256
|
-
...(create ? { branch: { create } } : {}),
|
|
257
260
|
},
|
|
261
|
+
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
262
|
+
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
263
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
|
|
264
|
+
// `normalizeTaskEnvelope` reads it from the envelope root, NOT from `repository.branch`, so the
|
|
265
|
+
// flattened per-cell header is `io.nanobpm.agentTask.branch.create` (not `…repository.branch.create`).
|
|
266
|
+
...(create ? { branch: { create } } : {}),
|
|
258
267
|
// Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
|
|
259
268
|
// opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
|
|
260
269
|
// `unable to get password from user`. Emitted on every resolved cell (past the `owner/repo`
|
package/app/service.test.ts
CHANGED
|
@@ -1415,27 +1415,30 @@ test("repoEnvelopeVars emits the push-checkpoint under the harness-read `sha` ke
|
|
|
1415
1415
|
// to `ref`) so the harness commits/pushes to the PR head branch instead of cutting a throwaway
|
|
1416
1416
|
// `nano/agent-work/*` fallback (jwulf/c8ctl-plugin-nano#231); see the PR-path test below.
|
|
1417
1417
|
test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1418
|
+
const envelope = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any;
|
|
1419
|
+
const ns = envelope["io.nanobpm.agentTask"];
|
|
1420
|
+
const repo = ns.repository;
|
|
1420
1421
|
assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
|
|
1421
|
-
|
|
1422
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
|
|
1423
|
+
// envelope root, NOT from `repository.branch`.
|
|
1424
|
+
assertEquals(ns.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
|
|
1425
|
+
assertEquals("branch" in repo, false, "branch must NOT be nested under repository — the harness never reads it there");
|
|
1422
1426
|
// Still branch-scoped and blobless like the PR-based envelope.
|
|
1423
1427
|
assertEquals(repo.singleBranch, true);
|
|
1424
1428
|
assertEquals(repo.filter, "blob:none");
|
|
1425
1429
|
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
|
|
1426
1430
|
// PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
|
|
1427
1431
|
assertEquals(
|
|
1428
|
-
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"]
|
|
1432
|
+
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"]
|
|
1429
1433
|
.branch.create,
|
|
1430
1434
|
"feat/issue-9",
|
|
1431
1435
|
);
|
|
1432
1436
|
for (const blank of [null, undefined, "", " "]) {
|
|
1433
|
-
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
|
|
1434
|
-
.repository;
|
|
1437
|
+
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"];
|
|
1435
1438
|
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
1436
1439
|
}
|
|
1437
1440
|
// The default (4-arg) PR-based call never emits a branch.create.
|
|
1438
|
-
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"]
|
|
1441
|
+
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"], false);
|
|
1439
1442
|
});
|
|
1440
1443
|
|
|
1441
1444
|
// PR-path provisioning (jwulf/c8ctl-plugin-nano#231): the review-round / fix-ci / rebase call sites
|
|
@@ -1445,11 +1448,13 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
|
|
|
1445
1448
|
// `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR
|
|
1446
1449
|
// head branch itself.
|
|
1447
1450
|
test("repoEnvelopeVars emits branch.create = head on the PR path (ref == create == head) (#231)", () => {
|
|
1448
|
-
const
|
|
1451
|
+
const ns = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12") as any)[
|
|
1449
1452
|
"io.nanobpm.agentTask"
|
|
1450
|
-
]
|
|
1453
|
+
];
|
|
1454
|
+
const repo = ns.repository;
|
|
1451
1455
|
assertEquals(repo.ref, "feat/issue-12", "the PR-path envelope checks out the head branch");
|
|
1452
|
-
|
|
1456
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882), read by the harness from the root.
|
|
1457
|
+
assertEquals(ns.branch.create, "feat/issue-12", "and asks the harness to work on that same head branch, not a fallback");
|
|
1453
1458
|
// Still branch-scoped and blobless, and the base tip stays reachable for the 3-dot diff.
|
|
1454
1459
|
assertEquals(repo.singleBranch, true);
|
|
1455
1460
|
assertEquals(repo.filter, "blob:none");
|
|
@@ -1496,9 +1501,9 @@ test("submitPr threads the resolved PR head into the envelope branch.create (#23
|
|
|
1496
1501
|
url: "https://github.com/owner/repo/pull/12",
|
|
1497
1502
|
prKey: "owner/repo#12",
|
|
1498
1503
|
});
|
|
1499
|
-
const
|
|
1500
|
-
assertEquals(
|
|
1501
|
-
assertEquals(
|
|
1504
|
+
const ns = get()?.["io.nanobpm.agentTask"] as any;
|
|
1505
|
+
assertEquals(ns?.repository?.ref, "feat/issue-12", "submitPr checks out the PR head branch");
|
|
1506
|
+
assertEquals(ns?.branch?.create, "feat/issue-12", "submitPr passes headRef as branch.create, not a fallback");
|
|
1502
1507
|
});
|
|
1503
1508
|
});
|
|
1504
1509
|
|
|
@@ -1521,9 +1526,9 @@ test("startMerge threads the resolved PR head into the envelope branch.create (#
|
|
|
1521
1526
|
prKey: "owner/repo#13",
|
|
1522
1527
|
round: 2,
|
|
1523
1528
|
});
|
|
1524
|
-
const
|
|
1525
|
-
assertEquals(
|
|
1526
|
-
assertEquals(
|
|
1529
|
+
const ns = get()?.["io.nanobpm.agentTask"] as any;
|
|
1530
|
+
assertEquals(ns?.repository?.ref, "feat/issue-13", "startMerge checks out the PR head branch");
|
|
1531
|
+
assertEquals(ns?.branch?.create, "feat/issue-13", "startMerge passes headRef as branch.create, not a fallback");
|
|
1527
1532
|
});
|
|
1528
1533
|
});
|
|
1529
1534
|
|
package/app/service.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
11
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
12
|
import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudications.ts";
|
|
13
|
+
import { AGENT_SLA_JOB_TYPES, recordAgentJobObservation } from "./agentActivationLedger.ts";
|
|
13
14
|
import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
|
|
14
15
|
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
15
16
|
import {
|
|
@@ -1733,6 +1734,98 @@ interface JobSearchItem {
|
|
|
1733
1734
|
/** The job's `zeebe:taskDefinition` type — read to defensively re-filter the wire `$in` type
|
|
1734
1735
|
* filter so a non-agent job can never masquerade as a leasing convergence agent. */
|
|
1735
1736
|
type?: string;
|
|
1737
|
+
/** The owning process instance — keyed into the activation ledger (issue #879). */
|
|
1738
|
+
processInstanceKey?: string;
|
|
1739
|
+
/** The engine job key — the unique id of THIS job attempt. The activation ledger uses it to tell one
|
|
1740
|
+
* attempt of a retrying agent task from the next (issue #879 review). */
|
|
1741
|
+
jobKey?: string;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/** One agent-activation ledger poll pass (issue #879). Unlike {@link pollJobActivationImpl} (which
|
|
1745
|
+
* tracks only the review-round for the pages grid), this records — for EVERY bounded agent job type —
|
|
1746
|
+
* whether a worker ever picked the job up, into the durable `agent_job_activations` ledger. When an
|
|
1747
|
+
* agent-task SLA boundary later cancels a job, the job's engine state is gone, so the escalation
|
|
1748
|
+
* recorder cannot ask the engine whether it was ever activated; this ledger, captured WHILE the job is
|
|
1749
|
+
* live, is what lets the escalation distinguish a NEVER-ACTIVATED (queue-starved) job from an
|
|
1750
|
+
* ACTIVATED-but-hung one.
|
|
1751
|
+
*
|
|
1752
|
+
* It reads the same activation signal as the review-round pass: the Camunda-8 `/v2/jobs/search` wire
|
|
1753
|
+
* API collapses Activated -> CREATED (no ACTIVATED enum), so a leased job is recognised by its
|
|
1754
|
+
* `worker`/`deadline` fields. One paged search across all bounded agent types per pass upserts the
|
|
1755
|
+
* ledger by (processInstanceKey, type), keyed to the current attempt by `jobKey`. Best-effort: a
|
|
1756
|
+
* transport failure leaves the ledger untouched and the next pass retries. */
|
|
1757
|
+
async function pollAgentActivations(
|
|
1758
|
+
data: DataLayer,
|
|
1759
|
+
restAddress: string,
|
|
1760
|
+
engineToken: string | undefined,
|
|
1761
|
+
) {
|
|
1762
|
+
const base = restAddress.replace(/\/+$/, "");
|
|
1763
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1764
|
+
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
1765
|
+
await pollAgentActivationsImpl(data, base, headers);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** Testable core of {@link pollAgentActivations}: query the engine for every CREATED bounded-agent job
|
|
1769
|
+
* and upsert the activation ledger. Split out so tests can drive it with a stubbed `fetch`. */
|
|
1770
|
+
export async function pollAgentActivationsImpl(
|
|
1771
|
+
data: DataLayer,
|
|
1772
|
+
base: string,
|
|
1773
|
+
headers: Record<string, string>,
|
|
1774
|
+
) {
|
|
1775
|
+
// Page through EVERY matching open job: a single first-page read would, under fleet saturation, let
|
|
1776
|
+
// long-lived jobs occupy the page on every pass while later jobs time out with their activation never
|
|
1777
|
+
// recorded — exactly when the never-started/hung distinction matters most. Mirror the paging contract
|
|
1778
|
+
// the other engine sweeps use (`page: { from, limit }`, stop on a short page). (#881 review)
|
|
1779
|
+
const ts = now();
|
|
1780
|
+
const limit = 200;
|
|
1781
|
+
let from = 0;
|
|
1782
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
1783
|
+
let items: JobSearchItem[] = [];
|
|
1784
|
+
try {
|
|
1785
|
+
const res = await fetch(`${base}/jobs/search`, {
|
|
1786
|
+
method: "POST",
|
|
1787
|
+
headers,
|
|
1788
|
+
body: JSON.stringify({
|
|
1789
|
+
filter: {
|
|
1790
|
+
type: { $in: [...AGENT_SLA_JOB_TYPES] },
|
|
1791
|
+
// A merely-created (queued) AND an activated-but-not-yet-completed job both project to
|
|
1792
|
+
// CREATED on the wire (Camunda has no ACTIVATED enum) — so this one filter captures both,
|
|
1793
|
+
// and the leasing `worker` field discriminates them at upsert time.
|
|
1794
|
+
state: "CREATED",
|
|
1795
|
+
},
|
|
1796
|
+
page: { from, limit },
|
|
1797
|
+
}),
|
|
1798
|
+
});
|
|
1799
|
+
if (!res.ok) return; // engine unhappy → keep the ledger as-is, retry next pass
|
|
1800
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1801
|
+
const body = (await res.json()) as { items?: JobSearchItem[] };
|
|
1802
|
+
items = body.items ?? [];
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
console.error(`[poller] agent-activation search: ${err}`);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
for (const j of items) {
|
|
1808
|
+
const jobType = j.type ?? "";
|
|
1809
|
+
const pik = j.processInstanceKey ?? "";
|
|
1810
|
+
// Defensively re-filter the wire `$in` type filter and require a process instance to key on.
|
|
1811
|
+
if (pik.length === 0 || !AGENT_SLA_JOB_TYPES.includes(jobType)) continue;
|
|
1812
|
+
const worker = typeof j.worker === "string" && j.worker.length > 0 ? j.worker : null;
|
|
1813
|
+
const jobKey = typeof j.jobKey === "string" && j.jobKey.length > 0 ? j.jobKey : null;
|
|
1814
|
+
try {
|
|
1815
|
+
await recordAgentJobObservation(data, {
|
|
1816
|
+
processInstanceKey: pik,
|
|
1817
|
+
jobType,
|
|
1818
|
+
worker,
|
|
1819
|
+
jobKey,
|
|
1820
|
+
now: ts,
|
|
1821
|
+
});
|
|
1822
|
+
} catch (err) {
|
|
1823
|
+
console.error(`[poller] agent-activation upsert ${pik}:${jobType}: ${err}`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
if (items.length < limit) break; // last page
|
|
1827
|
+
from += items.length;
|
|
1828
|
+
}
|
|
1736
1829
|
}
|
|
1737
1830
|
|
|
1738
1831
|
/** One job-activation poll pass. The `converging` status means the process is parked at a
|
|
@@ -3717,6 +3810,7 @@ export async function pollOnce(
|
|
|
3717
3810
|
await pollWaveGatesImpl(data, engine, token, base, headers);
|
|
3718
3811
|
await pollCapabilityGatesImpl(data, engine, base, headers);
|
|
3719
3812
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
3813
|
+
await pollAgentActivations(data, engineRest.restAddress, engineRest.token);
|
|
3720
3814
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
3721
3815
|
}
|
|
3722
3816
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- 119_agent_job_activations.sql — issue #879: distinguish a NEVER-ACTIVATED (queue-starved) agent
|
|
2
|
+
-- job from an ACTIVATED-but-hung one when an agent-task SLA boundary fires.
|
|
3
|
+
--
|
|
4
|
+
-- The agent-task SLA timer (`<bpmn:timeDuration>=agentSlaTimeout`) starts when the service task is
|
|
5
|
+
-- ENTERED, so it measures QUEUE time + RUN time. When no worker can take the job type (fleet
|
|
6
|
+
-- saturation / no capacity), the job sits CREATED (never leased) for the whole budget and the
|
|
7
|
+
-- boundary fires — yet the escalation reported it as "hung or looping", telling the human to "check
|
|
8
|
+
-- its progress" when there is none. (The reported case: a `senior:feature` job queued 2h, never
|
|
9
|
+
-- activated, then cancelled by its SLA boundary.)
|
|
10
|
+
--
|
|
11
|
+
-- The engine tracks JobState::Created vs ::Activated, but the Camunda-8 `/v2/jobs/search` wire API
|
|
12
|
+
-- collapses Activated -> CREATED (no ACTIVATED enum value); activation is read off the `worker` +
|
|
13
|
+
-- `deadline` fields — an activated job carries a leasing worker's name and a lock deadline, a
|
|
14
|
+
-- merely-queued one carries neither (same signal `pull_requests.active_worker`/`lease_until` use for
|
|
15
|
+
-- the review-round, migration 005). This ledger generalises that observation to EVERY bounded agent
|
|
16
|
+
-- job type (not just the review-round) and PERSISTS it, so when the SLA boundary cancels the job the
|
|
17
|
+
-- escalation recorder can still tell whether a worker ever picked it up.
|
|
18
|
+
--
|
|
19
|
+
-- The poller (`pollAgentActivations`, app/service.ts) upserts one row per (process_instance_key,
|
|
20
|
+
-- job_type) each pass: `first_seen_at` on first observation (job CREATED), and `activated_at` +
|
|
21
|
+
-- `worker` the first time it observes a leasing worker. The SLA-escalation recorders read it by the
|
|
22
|
+
-- same key (the agent job and its escalation recorder share one process instance) to word the
|
|
23
|
+
-- escalation accurately — "never started (queue starvation)" vs "started but exceeded its budget" —
|
|
24
|
+
-- including the job type and how long it waited.
|
|
25
|
+
--
|
|
26
|
+
-- `job_key` holds the engine job key of the CURRENT attempt so the row tracks that attempt, not a
|
|
27
|
+
-- stale earlier one: the same (process_instance_key, job_type) runs MULTIPLE times within one instance
|
|
28
|
+
-- when its cell retries (implement-cell `ic_reImplement`, merge-cell `mc_rerun`, merge-loop fix-ci /
|
|
29
|
+
-- rebase reloops). Keyed only by (instance, job_type) a sticky first-activation would make a later
|
|
30
|
+
-- queued retry read the EARLIER attempt's worker and misdiagnose it as started/hung. When the poller
|
|
31
|
+
-- observes a different `job_key` for the pair it RESETS the row to the new attempt (first_seen_at /
|
|
32
|
+
-- activated_at / worker), so the escalation always reflects the attempt the SLA boundary cancelled.
|
|
33
|
+
--
|
|
34
|
+
-- Keyed by a surrogate TEXT id (`<process_instance_key>:<job_type>`) so the single-pk `data.table`
|
|
35
|
+
-- accessor can upsert it; the (process_instance_key, job_type) pair is unique per id. FK-free by
|
|
36
|
+
-- design — the owning instance lives in the durable ENGINE store, not app.db, so there is no app-tier
|
|
37
|
+
-- parent row to reference (mirrors `worker_durable_resume`/`worker_harness_protocol`). EXPAND
|
|
38
|
+
-- (additive) phase: one new table; nothing is dropped or renamed. The runner wraps each file in its
|
|
39
|
+
-- own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
40
|
+
CREATE TABLE IF NOT EXISTS agent_job_activations (
|
|
41
|
+
id TEXT PRIMARY KEY, -- surrogate: `<process_instance_key>:<job_type>`
|
|
42
|
+
process_instance_key TEXT NOT NULL, -- the engine instance the agent job (and its SLA recorder) runs in
|
|
43
|
+
job_type TEXT NOT NULL, -- the agent job's `<zeebe:taskDefinition>` type (e.g. senior:feature)
|
|
44
|
+
first_seen_at TEXT NOT NULL, -- ISO ts the poller first observed the job (CREATED/queued)
|
|
45
|
+
activated_at TEXT, -- ISO ts first observed ACTIVATED (leasing worker seen); NULL = never started
|
|
46
|
+
worker TEXT, -- the leasing worker's name at activation; NULL until activated
|
|
47
|
+
job_key TEXT, -- the engine job key of the CURRENT attempt; a new key RESETS the row (retry isolation)
|
|
48
|
+
updated_at TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS ix_agent_job_activations_instance ON agent_job_activations (process_instance_key);
|
|
@@ -307,13 +307,25 @@ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849
|
|
|
307
307
|
);
|
|
308
308
|
const task = await openTaskById(app, processKey, "trial-merge-decision");
|
|
309
309
|
assert.ok(task?.userTaskKey, "the merge-cell parked a completable trial-merge decision task");
|
|
310
|
+
|
|
311
|
+
// UNKNOWN activation fixture (issue #881 review): no activation-ledger row exists for this attempt
|
|
312
|
+
// (the in-test poller records none), so the recorder must word the escalation honestly as
|
|
313
|
+
// "activation unknown" — never the removed blanket "hung or looping".
|
|
314
|
+
const slaAudit = (
|
|
315
|
+
await app.db
|
|
316
|
+
.table<{ id: number; plan_key: string; summary: string | null }>("plan_trial_merges", "id")
|
|
317
|
+
.find({ plan_key: "owner/repo#1" })
|
|
318
|
+
).sort((a, b) => b.id - a.id)[0];
|
|
319
|
+
assert.ok(
|
|
320
|
+
(slaAudit?.summary ?? "").includes("no worker-activation was ever recorded"),
|
|
321
|
+
`an unobserved job escalates with honest ambiguity (got: ${slaAudit?.summary})`,
|
|
322
|
+
);
|
|
310
323
|
} finally {
|
|
311
324
|
await app.stop();
|
|
312
325
|
rmSync(dbDir, { recursive: true, force: true });
|
|
313
326
|
}
|
|
314
327
|
});
|
|
315
328
|
|
|
316
|
-
// Regression for PR #864 review r3: on an SLA during a RERUN the recorder must not replay the
|
|
317
329
|
// prior COMPLETED attempt's process-scope `result`/`summary`/`conflicts`/`failing`. The
|
|
318
330
|
// `=null` ioMapping on `trial-merge` is task-local, so without a dedicated SLA recorder the
|
|
319
331
|
// interrupted second attempt would inherit attempt one's stale verdict. Drive ONE instance: a
|
|
@@ -381,6 +393,19 @@ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849
|
|
|
381
393
|
);
|
|
382
394
|
|
|
383
395
|
// The second agent hangs; the SLA fires. The DEDICATED SLA recorder must pin the TIMEOUT verdict.
|
|
396
|
+
// Seed an ACTIVATED activation-ledger fixture for THIS attempt (issue #881 review) so the recorder
|
|
397
|
+
// reads the ledger and produces the activation-aware "started but hung" reason — exercising the
|
|
398
|
+
// builder's activated branch end-to-end through record-trial-merge-sla, not just the pure builder.
|
|
399
|
+
await app.db.table("agent_job_activations", "id").insert({
|
|
400
|
+
id: `${processKey}:senior:trial-merge`,
|
|
401
|
+
process_instance_key: processKey,
|
|
402
|
+
job_type: "senior:trial-merge",
|
|
403
|
+
first_seen_at: "2024-01-01T00:00:00Z",
|
|
404
|
+
activated_at: "2024-01-01T00:05:00Z",
|
|
405
|
+
worker: "agent-stuck",
|
|
406
|
+
job_key: "rerun-attempt",
|
|
407
|
+
updated_at: "2024-01-01T00:05:00Z",
|
|
408
|
+
});
|
|
384
409
|
await advancePastTimer(app, PAST_AGENT_SLA_MS);
|
|
385
410
|
flows = takenFlows(app);
|
|
386
411
|
assert.ok(
|
|
@@ -402,11 +427,15 @@ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849
|
|
|
402
427
|
.find({ plan_key: "owner/repo#1" });
|
|
403
428
|
const slaRow = audits.sort((a, b) => b.id - a.id)[0];
|
|
404
429
|
assert.equal(slaRow.result, "suite-failed", "the SLA row records the timeout verdict");
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
430
|
+
// Activation-aware summary: names the job type, the leasing worker, and the hung diagnosis — NOT
|
|
431
|
+
// the stale first-attempt summary, and NOT the removed blanket wording.
|
|
432
|
+
assert.ok(
|
|
433
|
+
(slaRow.summary ?? "").includes("senior:trial-merge") &&
|
|
434
|
+
(slaRow.summary ?? "").includes("hung or looping") &&
|
|
435
|
+
(slaRow.summary ?? "").includes("agent-stuck"),
|
|
436
|
+
`the SLA row pins the activation-aware timeout summary (got: ${slaRow.summary})`,
|
|
409
437
|
);
|
|
438
|
+
assert.equal((slaRow.summary ?? "").includes("stale prior-attempt summary"), false, "not the stale summary");
|
|
410
439
|
assert.equal(slaRow.conflicts, null, "the SLA row clears the stale prior-attempt conflicts");
|
|
411
440
|
assert.equal(slaRow.failing, null, "the SLA row clears the stale prior-attempt failing list");
|
|
412
441
|
} finally {
|
|
@@ -552,7 +581,20 @@ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849
|
|
|
552
581
|
assert.equal(filed?.status, "filed", "the conformance verdict was filed before synthesize");
|
|
553
582
|
assert.equal(filed?.slices_met, 3);
|
|
554
583
|
|
|
555
|
-
// Fire the synthesize SLA.
|
|
584
|
+
// Fire the synthesize SLA. Seed a QUEUED (never-activated) activation-ledger fixture for the
|
|
585
|
+
// synthesize (senior:retro) attempt (issue #881 review) so the recorder reads it and APPENDS the
|
|
586
|
+
// activation-aware never-observed-activated reason — exercising the builder's queued branch
|
|
587
|
+
// end-to-end through conformance-record, not just the pure builder.
|
|
588
|
+
await app.db.table("agent_job_activations", "id").insert({
|
|
589
|
+
id: `${processKey}:senior:retro`,
|
|
590
|
+
process_instance_key: processKey,
|
|
591
|
+
job_type: "senior:retro",
|
|
592
|
+
first_seen_at: "2024-01-01T00:00:00Z",
|
|
593
|
+
activated_at: null,
|
|
594
|
+
worker: null,
|
|
595
|
+
job_key: "synthesize-attempt",
|
|
596
|
+
updated_at: "2024-01-01T00:00:00Z",
|
|
597
|
+
});
|
|
556
598
|
await advancePastTimer(app, PAST_AGENT_SLA_MS);
|
|
557
599
|
const flows = takenFlows(app);
|
|
558
600
|
assert.ok(
|
|
@@ -587,8 +629,9 @@ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849
|
|
|
587
629
|
`the audit summary is preserved and the SLA reason appended (got: ${row?.summary})`,
|
|
588
630
|
);
|
|
589
631
|
assert.ok(
|
|
590
|
-
(row?.summary ?? "").includes("
|
|
591
|
-
|
|
632
|
+
(row?.summary ?? "").includes("No worker was ever observed to pick up") &&
|
|
633
|
+
(row?.summary ?? "").includes("senior:retro"),
|
|
634
|
+
`the activation-aware synthesis-timeout context is appended to the summary (got: ${row?.summary})`,
|
|
592
635
|
);
|
|
593
636
|
} finally {
|
|
594
637
|
await app.stop();
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -133,11 +133,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
133
133
|
// carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
|
|
134
134
|
// `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
|
|
135
135
|
// all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
|
|
136
|
-
let
|
|
136
|
+
let agentTask: any = null;
|
|
137
137
|
await withApp(
|
|
138
138
|
{
|
|
139
139
|
"senior:feature": (job) => {
|
|
140
|
-
|
|
140
|
+
agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
|
|
141
141
|
return { status: "opened", pr: "owner/repo#101", summary: "built it" };
|
|
142
142
|
},
|
|
143
143
|
},
|
|
@@ -150,10 +150,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
150
150
|
);
|
|
151
151
|
assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
|
|
152
152
|
|
|
153
|
-
assert.ok(
|
|
153
|
+
assert.ok(agentTask, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
|
|
154
|
+
const agentRepo = agentTask.repository;
|
|
154
155
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
155
156
|
assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
|
|
156
|
-
|
|
157
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
|
|
158
|
+
assert.equal(agentTask.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
|
|
157
159
|
|
|
158
160
|
const run = await featureRow(app, featureKey);
|
|
159
161
|
assert.equal(run.status, "opened", "the run settled at opened");
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -131,22 +131,24 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
131
131
|
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
132
132
|
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
133
133
|
// `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
|
|
134
|
-
let
|
|
134
|
+
let agentTask: any = null;
|
|
135
135
|
await withApp(
|
|
136
136
|
{
|
|
137
137
|
"senior:plan": singleTaskPlan,
|
|
138
138
|
"senior:plan-review": approveReview,
|
|
139
139
|
"senior:feature": (job) => {
|
|
140
|
-
|
|
140
|
+
agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
|
|
141
141
|
return { status: "blocked", summary: "n/a" };
|
|
142
142
|
},
|
|
143
143
|
},
|
|
144
144
|
async ({ app }) => {
|
|
145
145
|
await app.settle();
|
|
146
|
-
assert.ok(
|
|
146
|
+
assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
147
|
+
const agentRepo = agentTask.repository;
|
|
147
148
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
148
149
|
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
149
|
-
|
|
150
|
+
// `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
|
|
151
|
+
assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
150
152
|
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
151
153
|
assert.equal(agentRepo.filter, "blob:none");
|
|
152
154
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.201.
|
|
3
|
+
"version": "0.201.3",
|
|
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",
|