@nanobpm/nano-workforce 0.198.1 → 0.200.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/service.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CONFORMANCE_ESCALATION_ELEMENT,
28
28
  conformanceEscalationQuestion,
29
29
  } from "./conformance.ts";
30
+ import { envContract, readEnvOr } from "./contracts.ts";
30
31
  import { makeDefaultReadHead } from "./currentHead.ts";
31
32
  import { isUniqueConstraintFence } from "./dbFence.ts";
32
33
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
@@ -136,6 +137,18 @@ export const MERGE_PROCESS_ID = "merge-loop";
136
137
  * in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
137
138
  * activation poll keys off it to tell "agent working" from "queued". */
138
139
  const REVIEW_JOB_TYPE = "senior:pr-review";
140
+ /** Job type of the local adversarial-review agent (the `adversarial-review` service task's
141
+ * `zeebe:taskDefinition` in convergence-loop.bpmn, issue #844). Like {@link REVIEW_JOB_TYPE} it is
142
+ * serviced by an external harness while the PR stays `converging`, so the activation poll must
143
+ * treat it as another live convergence agent — otherwise the grid reports "queued" for the whole
144
+ * adversarial pass even though an agent has leased the job. */
145
+ const ADVERSARIAL_JOB_TYPE = "senior:adversarial-review";
146
+ /** Every external-agent job type the convergence loop parks on while the PR reads `converging`.
147
+ * `pollJobActivation` reconciles `active_worker`/`lease_until` against ANY of these so the
148
+ * review-round ↔ adversarial-review handoff (same `converging` status, different job type) never
149
+ * flashes a phantom "queued". Typed `readonly string[]` (not `as const`) so `.includes(aString)`
150
+ * needs no `as` cast at the read site. */
151
+ const CONVERGENCE_AGENT_JOB_TYPES: readonly string[] = [REVIEW_JOB_TYPE, ADVERSARIAL_JOB_TYPE];
139
152
 
140
153
  /** Default round cap before the loop escalates to a human. A per-submit override (submit form /
141
154
  * webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
@@ -179,6 +192,18 @@ export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_
179
192
  * (allows 0 = disable, ceiling-capped). */
180
193
  export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
181
194
 
195
+ /** Local adversarial-review passes per convergence round (issue #844). An `addressed` round runs the
196
+ * `senior:adversarial-review` agent over its diff BEFORE the round parks on `waiting_review` (the only
197
+ * status the poller solicits a Copilot review for); non-blank findings re-dispatch `review-round`, up
198
+ * to this many passes per round. Default 1; `NANO_PR_MAX_ADVERSARIAL_PASSES=0` disables the stage.
199
+ * Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). The clamp fallback is
200
+ * derived from the registered `default` so the schema stays the single source of truth (a
201
+ * registry edit can't drift from a second literal here). */
202
+ export const MAX_ADVERSARIAL_PASSES = clampCiFixBudget(
203
+ readEnvOr("NANO_PR_MAX_ADVERSARIAL_PASSES"),
204
+ Number(envContract("NANO_PR_MAX_ADVERSARIAL_PASSES").default),
205
+ );
206
+
182
207
  /** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
183
208
  * mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
184
209
  * human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
@@ -740,6 +765,10 @@ async function submitPrCritical(
740
765
  // solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
741
766
  ackRetryRound: 0,
742
767
  ackRetryMax: MAX_ACK_RETRIES,
768
+ // Local adversarial review (issue #844): `advPass` counts this round's adversarial passes (reset
769
+ // by `capture-head` each round); `advMax` bounds them. 0 skips the stage.
770
+ advPass: 0,
771
+ advMax: MAX_ADVERSARIAL_PASSES,
743
772
  // Lineage (issue #245): carry the origin identity onto the convergence instance so every
744
773
  // descendant (and any message it correlates) is stitched back to the originating request.
745
774
  // A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
@@ -1669,15 +1698,19 @@ interface JobSearchItem {
1669
1698
  worker?: string;
1670
1699
  deadline?: string | null;
1671
1700
  state?: string;
1701
+ /** The job's `zeebe:taskDefinition` type — read to defensively re-filter the wire `$in` type
1702
+ * filter so a non-agent job can never masquerade as a leasing convergence agent. */
1703
+ type?: string;
1672
1704
  }
1673
1705
 
1674
- /** One job-activation poll pass. The `converging` status means the process is parked at the
1675
- * `review-round` service task with a `senior:pr-review` job outstanding — but it does not say
1676
- * whether an external agent has *activated* (leased) that job yet. This pass reads that off the
1677
- * engine's Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock
1678
- * `deadline`; a merely-created (queued) one carries neither. (The wire `state` can't tell them
1679
- * apart — Camunda's JobStateEnum has no ACTIVATED value, so the engine projects Activated ->
1680
- * CREATED; the `worker`/`deadline` fields are the compatible activation signal.)
1706
+ /** One job-activation poll pass. The `converging` status means the process is parked at a
1707
+ * convergence-agent service task (`review-round`'s `senior:pr-review`, or `adversarial-review`'s
1708
+ * `senior:adversarial-review` — issue #844) with a job outstanding — but it does not say whether an
1709
+ * external agent has *activated* (leased) that job yet. This pass reads that off the engine's
1710
+ * Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock `deadline`; a
1711
+ * merely-created (queued) one carries neither. (The wire `state` can't tell them apart — Camunda's
1712
+ * JobStateEnum has no ACTIVATED value, so the engine projects Activated -> CREATED; the
1713
+ * `worker`/`deadline` fields are the compatible activation signal.)
1681
1714
  *
1682
1715
  * It writes `active_worker` + `lease_until` onto the PR row so the pages surface can show
1683
1716
  * "agent working" vs "queued (awaiting an agent)", updating (and bumping `updated_at`) only on
@@ -1691,10 +1724,21 @@ async function pollJobActivation(
1691
1724
  const base = restAddress.replace(/\/+$/, "");
1692
1725
  const headers: Record<string, string> = { "content-type": "application/json" };
1693
1726
  if (engineToken) headers.authorization = `Bearer ${engineToken}`;
1727
+ await pollJobActivationImpl(data, base, headers);
1728
+ }
1694
1729
 
1730
+ /** Testable core of {@link pollJobActivation}: given the normalised `base` URL and prepared auth
1731
+ * `headers`, reconcile every `converging` PR row against the engine's open convergence-agent jobs.
1732
+ * Split out so tests can exercise the reconciliation with a stubbed `fetch` without re-deriving
1733
+ * transport wiring. */
1734
+ export async function pollJobActivationImpl(
1735
+ data: DataLayer,
1736
+ base: string,
1737
+ headers: Record<string, string>,
1738
+ ) {
1695
1739
  const all = await prsTracking(data).all();
1696
1740
  for (const pr of all) {
1697
- // Only a `converging` PR has a live review-round job. Any other status with a stale worker
1741
+ // Only a `converging` PR has a live convergence-agent job. Any other status with a stale worker
1698
1742
  // set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
1699
1743
  // phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
1700
1744
  // a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
@@ -1731,7 +1775,15 @@ async function pollJobActivation(
1731
1775
  method: "POST",
1732
1776
  headers,
1733
1777
  body: JSON.stringify({
1734
- filter: { type: REVIEW_JOB_TYPE, processInstanceKey: pr.process_key, state: "CREATED" },
1778
+ filter: {
1779
+ // ANY convergence-agent job type: the loop moves between `review-round`
1780
+ // (`senior:pr-review`) and `adversarial-review` (`senior:adversarial-review`) without
1781
+ // leaving `converging`, so filtering on only one would clear the lease mid-handoff and
1782
+ // report "queued" while the other agent is actively working (issue #845 review).
1783
+ type: { $in: [...CONVERGENCE_AGENT_JOB_TYPES] },
1784
+ processInstanceKey: pr.process_key,
1785
+ state: "CREATED",
1786
+ },
1735
1787
  page: { limit: 20 },
1736
1788
  }),
1737
1789
  });
@@ -1739,9 +1791,16 @@ async function pollJobActivation(
1739
1791
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1740
1792
  const body = (await res.json()) as { items?: JobSearchItem[] };
1741
1793
  // An open job with a leasing worker means an agent has activated it. Prefer the one with
1742
- // the latest deadline if several are open (there is normally at most one).
1794
+ // the latest deadline if several are open (there is normally at most one). Re-filter on the
1795
+ // job type defensively in case the wire `$in` filter is ignored, so a non-agent job (e.g. an
1796
+ // internal `pr.*` worker) can never masquerade as a leasing convergence agent.
1743
1797
  const activated = (body.items ?? [])
1744
- .filter((j) => typeof j.worker === "string" && j.worker.length > 0)
1798
+ .filter(
1799
+ (j) =>
1800
+ typeof j.worker === "string" &&
1801
+ j.worker.length > 0 &&
1802
+ CONVERGENCE_AGENT_JOB_TYPES.includes(j.type ?? ""),
1803
+ )
1745
1804
  .sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""))
1746
1805
  .pop();
1747
1806
  if (activated) {
package/nano.app.json CHANGED
@@ -255,7 +255,8 @@
255
255
  "senior:trial-merge",
256
256
  "senior:retro",
257
257
  "senior:conformance",
258
- "senior:scope-classify"
258
+ "senior:scope-classify",
259
+ "senior:adversarial-review"
259
260
  ],
260
261
  "surfaces": {
261
262
  "taskInbox": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.198.1",
3
+ "version": "0.200.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",