@nanobpm/nano-workforce 0.200.7 → 0.200.9

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +41 -0
  3. package/app/agentSla.test.ts +48 -0
  4. package/app/agentSla.ts +36 -11
  5. package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
  6. package/app/agentic/vocab/job-types.ts +60 -0
  7. package/app/conformance.test.ts +114 -0
  8. package/app/conformance.ts +72 -0
  9. package/app/contracts.ts +2 -2
  10. package/app/deliveryRunner.ts +5 -0
  11. package/app/feature.ts +5 -0
  12. package/app/implementEscalationReason.test.ts +216 -0
  13. package/app/implementEscalationReason.ts +213 -0
  14. package/app/implementReconcile.test.ts +168 -11
  15. package/app/implementReconcile.ts +112 -18
  16. package/app/plan.ts +12 -0
  17. package/app/retro.test.ts +5 -0
  18. package/app/retro.ts +4 -0
  19. package/app/service.ts +27 -8
  20. package/e2e/agent-sla-boundary.e2e.ts +598 -0
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/e2e/support/pin-agent-sla.ts +47 -0
  24. package/package.json +1 -1
  25. package/resources/processes/feature.bpmn +1 -0
  26. package/resources/processes/implement-cell.bpmn +122 -47
  27. package/resources/processes/merge-cell.bpmn +60 -12
  28. package/resources/processes/plan-fanout.bpmn +237 -211
  29. package/resources/processes/retro.bpmn +100 -3
  30. package/resources/prompts/feature.md +16 -1
  31. package/test/derivation-parity/derivation-parity.test.ts +35 -11
  32. package/test/derivation-parity/flows.ts +14 -1
  33. package/workers/conformance-record/worker.test.ts +87 -1
  34. package/workers/conformance-record/worker.ts +34 -7
  35. package/workers/reconcile-implement/worker.test.ts +31 -3
  36. package/workers/reconcile-implement/worker.ts +5 -0
  37. package/workers/record-feature-escalation/worker.test.ts +58 -3
  38. package/workers/record-feature-escalation/worker.ts +24 -10
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.200.9](https://github.com/nanobpm/nano-workforce/compare/v0.200.8...v0.200.9) (2026-10-05)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **implement-cell:** accurate escalation reason, adopt/retry a claimed completion without delivery ([#869](https://github.com/nanobpm/nano-workforce/issues/869)) ([9d7b3a8](https://github.com/nanobpm/nano-workforce/commit/9d7b3a83a4728ca831c519ffc3ad4eade00cc45e)), closes [#865](https://github.com/nanobpm/nano-workforce/issues/865) [#863](https://github.com/nanobpm/nano-workforce/issues/863) [#865](https://github.com/nanobpm/nano-workforce/issues/865) [#865](https://github.com/nanobpm/nano-workforce/issues/865)
6
+
7
+ ## [0.200.8](https://github.com/nanobpm/nano-workforce/compare/v0.200.7...v0.200.8) (2026-10-05)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **processes:** bound external agent tasks with an SLA timer boundary (implement-cell, merge-cell, retro) ([#864](https://github.com/nanobpm/nano-workforce/issues/864)) ([0ea4955](https://github.com/nanobpm/nano-workforce/commit/0ea4955cc2ec0fe61fbc81e8d7dcb9e842d29313)), closes [#849](https://github.com/nanobpm/nano-workforce/issues/849) [#849](https://github.com/nanobpm/nano-workforce/issues/849) [#849](https://github.com/nanobpm/nano-workforce/issues/849) [#867](https://github.com/nanobpm/nano-workforce/issues/867) [#868](https://github.com/nanobpm/nano-workforce/issues/868) [#849](https://github.com/nanobpm/nano-workforce/issues/849) [#867](https://github.com/nanobpm/nano-workforce/issues/867) [#868](https://github.com/nanobpm/nano-workforce/issues/868) [#868](https://github.com/nanobpm/nano-workforce/issues/868) [#227](https://github.com/nanobpm/nano-workforce/issues/227) [#868](https://github.com/nanobpm/nano-workforce/issues/868)
12
+
1
13
  ## [0.200.7](https://github.com/nanobpm/nano-workforce/compare/v0.200.6...v0.200.7) (2026-10-05)
2
14
 
3
15
  ### Documentation
package/SPEC.md CHANGED
@@ -790,6 +790,47 @@ queries skip (`merging`), so a slow pass can't double-signal.
790
790
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
791
791
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT30M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
792
792
  | `NANO_PR_REVIEW_NUDGE_MINUTES` | 5 | cooldown between poller Copilot re-request nudges per PR (clamped 1–1440) |
793
+ | `NANO_PR_AGENT_SLA_TIMEOUT` | PT2H | ISO-8601 liveness bound on an external **agent** service task (see §12.1); seeded as `agentSlaTimeout` at every process start that hosts one. Malformed → default |
794
+
795
+ ### 12.1 Agent-task liveness SLA (issue #849)
796
+
797
+ An external **agent** service task (`<zeebe:agentDefinition agentType="external"/>`) is a durable
798
+ wait on an external actor with **no human in the loop**: the worker renews the job's deadline for as
799
+ long as the agent process is alive, so an unstaffed capability or a hung/looping agent that never
800
+ fails its job would otherwise park the token forever (no incident, no escalation — the
801
+ `classify-scope` 7h27m incident, nanobpm/nano-bpm#1308). The agent-task SLA closes that gap with a
802
+ durable, in-process backstop — no external watchdog.
803
+
804
+ - **Mechanism.** Every process start that hosts an external agent task seeds the validated
805
+ `agentSlaTimeout` process variable (`app/agentSla.ts` `AGENT_SLA_TIMEOUT`, env
806
+ `NANO_PR_AGENT_SLA_TIMEOUT`, default `PT2H`). Each *bounded* agent task carries an **interrupting
807
+ timer boundary** whose `<bpmn:timeDuration>=agentSlaTimeout` is evaluated at timer creation
808
+ (FEEL-expression duration). When the SLA elapses the boundary fires, cancels the stuck job, and
809
+ routes the token to the process's escalation path so a human is pulled in. It is deliberately much
810
+ shorter than the human-decision escalation SLA (`NANO_ESCALATION_SLA_TIMEOUT`, PT24H) so a stuck
811
+ agent surfaces quickly without interrupting a legitimately long task.
812
+ - **Bounded today:** the implement-cell (`implement-task`), the merge-cell's trial-merge
813
+ (`trial-merge`), the merge-loop's `rebase` / `fix-ci`, and retro's `conformance` / `synthesize`.
814
+ The implement-cell is reached by **both** parents — a standalone `feature` run and a plan-fanout
815
+ wave slice — and each parent's callActivity maps `agentSlaTimeout` into the child explicitly.
816
+ - **Pre-seeded, not yet bounded:** the convergence-loop (`review-round` / `adversarial-review` /
817
+ `classify-scope`) and plan-fanout (`plan` / `review-plan`) agent tasks sit on a back-edge loop
818
+ whose boundary the auto-layouter cannot yet route (#867), so their SLA boundaries are
819
+ intentionally deferred to #868. Plan-fanout's **inline** wave `trial-merge`
820
+ (`plan-fanout.bpmn:524-542`) — distinct from the standalone `merge-cell` process, which has no
821
+ callActivity caller — is likewise an external agent task with rerun back-edges and no
822
+ `agentSlaTimeout` boundary, and is deferred to #868 alongside them. They are seeded now as
823
+ preparation; the defect-class guard
824
+ (`app/agentic/vocab/agent-sla-boundary.test.ts`) covers exactly the bounded subset so a regression
825
+ on a *bounded* process is caught while the deferred ones stay out.
826
+ - **Escalation routing.** The boundary never drops the token on the floor: implement-cell routes
827
+ `be_implement_sla` → `record-escalation-sla` (synthesises an SLA-specific question) → the shared
828
+ `human-escalation` cell; merge-cell routes `be_trial_agent_sla` → `record-trial-merge-sla` (its dedicated SLA recorder,
829
+ distinct from the normal-path `record-trial-merge`; persists a trial-merge audit row + answerable
830
+ question for the timed-out attempt) → `gw-trial` → `trial-merge-decision`;
831
+ retro routes each agent boundary → a `record-*-sla` task (persists a `plan_conformance` row at
832
+ `review_status='reviewing'` so the always-following `conformance-ack` settles it) →
833
+ `conformance-escalation`. Runtime coverage: `e2e/agent-sla-boundary.e2e.ts`.
793
834
 
794
835
  ## 13. Planning fan-out (`plan-fanout.bpmn`) — issue #14
795
836
 
@@ -5,8 +5,15 @@
5
5
  // `node --test`.
6
6
 
7
7
  import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
8
11
  import { test } from "node:test";
9
12
  import { agentSlaTimeout, DEFAULT_AGENT_SLA_TIMEOUT } from "./agentSla.ts";
13
+ import { readEnv } from "./contracts.ts";
14
+
15
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
16
+ const AGENT_SLA_SOURCE = readFileSync(join(REPO_ROOT, "app", "agentSla.ts"), "utf8");
10
17
 
11
18
  test("agentSlaTimeout: blank / absent / malformed → default", () => {
12
19
  assert.equal(agentSlaTimeout(undefined), DEFAULT_AGENT_SLA_TIMEOUT);
@@ -37,3 +44,44 @@ test("the default is itself a well-formed ISO-8601 duration (never an uninterpre
37
44
  assert.notEqual(DEFAULT_AGENT_SLA_TIMEOUT, sentinel);
38
45
  assert.equal(agentSlaTimeout(DEFAULT_AGENT_SLA_TIMEOUT, sentinel), DEFAULT_AGENT_SLA_TIMEOUT);
39
46
  });
47
+
48
+ test("the canonical constant reads NANO_PR_AGENT_SLA_TIMEOUT through the ONE typed env schema", () => {
49
+ // Regression for review r3 (PR #864): the constant must go through `readEnv` (the typed
50
+ // ENV_CONTRACTS reader), not a raw `process.env` lookup. `readEnv` trims and collapses a
51
+ // blank/whitespace value to `undefined`, so this composition is only blank-safe when the constant
52
+ // is built from `readEnv(...)` — a raw `process.env.NANO_PR_AGENT_SLA_TIMEOUT` would pass the
53
+ // untrimmed blank straight through. (The synonym/typo rejection is the compile-time half, pinned
54
+ // by `npm run check:contracts`; this pins the runtime half.)
55
+ assert.equal(agentSlaTimeout(readEnv("NANO_PR_AGENT_SLA_TIMEOUT", {})), DEFAULT_AGENT_SLA_TIMEOUT);
56
+ assert.equal(
57
+ agentSlaTimeout(readEnv("NANO_PR_AGENT_SLA_TIMEOUT", { NANO_PR_AGENT_SLA_TIMEOUT: " " })),
58
+ DEFAULT_AGENT_SLA_TIMEOUT,
59
+ "a blank env value collapses to undefined → default, never an uninterpretable timer",
60
+ );
61
+ assert.equal(
62
+ agentSlaTimeout(readEnv("NANO_PR_AGENT_SLA_TIMEOUT", { NANO_PR_AGENT_SLA_TIMEOUT: " pt90m " })),
63
+ "PT90M",
64
+ "a valid operator override is trimmed, validated, and honoured",
65
+ );
66
+ });
67
+
68
+ test("SOURCE GUARD: AGENT_SLA_TIMEOUT is built from readEnv, never a raw process.env read", () => {
69
+ // Regression for review r8 (PR #864, "Previously missed"): the composition test above recomputes
70
+ // `agentSlaTimeout(readEnv(...))` inline, so it stays green even if the exported constant regresses
71
+ // to `agentSlaTimeout(process.env.NANO_PR_AGENT_SLA_TIMEOUT)` — a raw read that bypasses the typed
72
+ // schema's trim/blank-collapse AND the compile-time synonym/typo check. `AGENT_SLA_TIMEOUT` is
73
+ // frozen once at import time, so a same-process test cannot re-drive it with a controlled env; the
74
+ // deterministic guard is source-level. Pin both halves: (1) no raw `process.env` read of the key
75
+ // anywhere in the module, and (2) the constant is composed from the typed `readEnv` reader.
76
+ assert.equal(
77
+ AGENT_SLA_SOURCE.includes("process.env.NANO_PR_AGENT_SLA_TIMEOUT"),
78
+ false,
79
+ "agentSla.ts must not read process.env.NANO_PR_AGENT_SLA_TIMEOUT directly — route it through " +
80
+ "readEnv (the ONE typed env schema) so a synonym/typo is a compile-time error",
81
+ );
82
+ assert.match(
83
+ AGENT_SLA_SOURCE,
84
+ /AGENT_SLA_TIMEOUT\s*=\s*agentSlaTimeout\(\s*readEnv\(\s*"NANO_PR_AGENT_SLA_TIMEOUT"/,
85
+ "AGENT_SLA_TIMEOUT must be composed from readEnv(\"NANO_PR_AGENT_SLA_TIMEOUT\")",
86
+ );
87
+ });
package/app/agentSla.ts CHANGED
@@ -1,24 +1,39 @@
1
- // Agent-task liveness SLA policy — kept as a pure module (no env, no I/O) so it is trivially
2
- // testable, mirroring app/escalationSla.ts. `app/service.ts` seeds the validated `agentSlaTimeout`
3
- // process variable when it starts the merge-loop; the merge-loop's AGENT service tasks (rebase,
4
- // fix-ci) carry an interrupting timer boundary whose `<bpmn:timeDuration>=agentSlaTimeout` evaluates
5
- // it at timer creation (FEEL-expression timer durations, engine-native).
1
+ // Agent-task liveness SLA policy — kept as a pure module (the only I/O is the single canonical
2
+ // env read for the constant, through the ONE typed env schema) so it is trivially testable,
3
+ // mirroring app/escalationSla.ts. Every
4
+ // process start that hosts an external agent task seeds the validated `agentSlaTimeout` process
5
+ // variable (issue #849): `app/service.ts` for the convergence- and merge-loops, `app/feature.ts`
6
+ // for a single-issue feature run, `app/plan.ts` for the epic plan-fanout, `app/retro.ts` for the
7
+ // retrospective, and `app/deliveryRunner.ts` for a compiled delivery graph. Only a SUBSET of those
8
+ // tasks is currently bounded — the implement-cell, the merge-cell's trial-merge, the merge-loop's
9
+ // rebase / fix-ci, and retro's conformance / synthesize each carry an interrupting timer boundary
10
+ // whose `<bpmn:timeDuration>=agentSlaTimeout` evaluates the seeded variable at timer creation
11
+ // (FEEL-expression timer durations, engine-native). The convergence-loop and plan-fanout agent
12
+ // tasks are only PRE-SEEDED here — their boundaries are intentionally deferred to #868 (a
13
+ // bpmn-auto-layout back-edge routing limitation, #867). That deferred set is the convergence-loop's
14
+ // review-round / adversarial-review / classify-scope and plan-fanout's plan / review-plan, PLUS
15
+ // plan-fanout's inline wave `trial-merge` (`plan-fanout.bpmn:524-542`) — a separate external agent
16
+ // task from the standalone `merge-cell` (which has no callActivity caller) that sits on a rerun
17
+ // back-edge with no boundary yet. Seeding them now is preparation, not a claim of effective
18
+ // coverage.
6
19
  //
7
20
  // This closes the agent-task liveness gap: unlike an escalation *user* task (whose SLA the
8
21
  // escalationSla policy already bounds), an AGENT service task has no human in the loop — if no
9
22
  // worker holds its capability, or the agent hangs/crashes without failing the job, the token parks
10
23
  // on the task forever (no incident, no escalation). The boundary timer makes that impossible: when
11
24
  // the SLA elapses the boundary fires, cancels the stuck job, and routes the token to the existing
12
- // merge escalation so a human is pulled in. It is a durable, in-process backstop — no external
25
+ // escalation path so a human is pulled in. It is a durable, in-process backstop — no external
13
26
  // watchdog required.
14
27
 
28
+ import { readEnv } from "./contracts.ts";
15
29
  import { isoDuration } from "./reviewWait.ts";
16
30
 
17
- /** Default agent-task SLA (ISO-8601 duration): how long a merge-loop agent service task (rebase /
18
- * fix-ci) may sit without completing before its interrupting timer boundary fires and the process
19
- * escalates for human attention. Deliberately much shorter than the human-decision escalation SLA
20
- * (PT24H): an agent that has not even started (unstaffed capability) or is stuck should surface to a
21
- * human quickly, while still being generous enough not to interrupt a legitimately long rebase. */
31
+ /** Default agent-task SLA (ISO-8601 duration): how long an external agent service task may sit
32
+ * without completing before its interrupting timer boundary fires and the process escalates for
33
+ * human attention. Deliberately much shorter than the human-decision escalation SLA (PT24H): an
34
+ * agent that has not even started (unstaffed capability) or is stuck should surface to a human
35
+ * quickly, while still being generous enough not to interrupt a legitimately long task (e.g. an
36
+ * implementation slice). */
22
37
  export const DEFAULT_AGENT_SLA_TIMEOUT = "PT2H";
23
38
 
24
39
  /** Validate the operator-supplied agent SLA (env `NANO_PR_AGENT_SLA_TIMEOUT`, ISO-8601 duration),
@@ -31,3 +46,13 @@ export function agentSlaTimeout(
31
46
  ): string {
32
47
  return isoDuration(raw, def);
33
48
  }
49
+
50
+ /** The one canonical, validated agent-task SLA every process start seeds as `agentSlaTimeout`
51
+ * (issue #849). Lives in this leaf module (not `app/service.ts`) so every seeder — service,
52
+ * feature, plan, retro, deliveryRunner — imports it without an import cycle. Seeding is universal,
53
+ * but only the bounded subset (implement-cell, merge-cell, merge-loop, retro) evaluates it via a
54
+ * boundary timer today; convergence-loop / plan-fanout are pre-seeded for #868. The env key is read
55
+ * through the ONE typed env schema (`readEnv` over the `NANO_PR_AGENT_SLA_TIMEOUT` registry entry,
56
+ * AGENTS.md issue #227) — never `process.env` directly — so a synonym/typo is a compile-time error,
57
+ * not a silent runtime fallback. */
58
+ export const AGENT_SLA_TIMEOUT = agentSlaTimeout(readEnv("NANO_PR_AGENT_SLA_TIMEOUT"));
@@ -0,0 +1,170 @@
1
+ // DEFECT-CLASS GUARD for the agent-task liveness SLA (issue #849): every deployed
2
+ // `<zeebe:agentDefinition agentType="external" />` service task must carry an interrupting
3
+ // TIMER boundary event, so a hung or looping agent escalates within its SLA instead of parking
4
+ // the process forever (the nanobpm/nano-bpm#1308 incident: `classify-scope` ran 7h27m unbounded
5
+ // while the worker kept renewing the job's deadline).
6
+ //
7
+ // The guard parses the BOUNDED_PROCESSES subset of `resources/processes/*.bpmn` and fails if any
8
+ // external agent task in them lacks an attached interrupting timer boundary — a newly-added agent
9
+ // task on a *bounded* process can't ship unbounded. The convergence-loop and plan-fanout agent
10
+ // tasks are deliberately excluded (their back-edge-loop boundaries are deferred to #868; see
11
+ // BOUNDED_PROCESSES below), so the guard does not yet cover the whole deployed corpus. It
12
+ // mirrors the sibling defect-class guard for the external AgentTask marker itself
13
+ // (`agent-marker.test.ts`, issue #745): the scan helper lives in `app/agentic/vocab/job-types.ts`
14
+ // and this test applies it to the bounded subset of the deployed corpus (see BOUNDED_PROCESSES).
15
+ import { readFileSync } from "node:fs";
16
+ import { test } from "node:test";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { assert, assertEquals } from "#test-assert";
20
+ import { externalAgentTasksMissingSlaBoundary } from "./job-types.ts";
21
+
22
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
23
+
24
+ test("externalAgentTasksMissingSlaBoundary flags an external agent task with no timer boundary", () => {
25
+ const xml = `
26
+ <bpmn:serviceTask id="agent">
27
+ <bpmn:extensionElements>
28
+ <zeebe:taskDefinition type="senior:feature" />
29
+ <zeebe:agentDefinition agentType="external" />
30
+ </bpmn:extensionElements>
31
+ </bpmn:serviceTask>`;
32
+ assertEquals(externalAgentTasksMissingSlaBoundary(xml), ["agent"]);
33
+ });
34
+
35
+ test("externalAgentTasksMissingSlaBoundary passes a bounded agent task and ignores a plain host task", () => {
36
+ const xml = `
37
+ <bpmn:serviceTask id="host">
38
+ <bpmn:extensionElements>
39
+ <zeebe:taskDefinition type="pr.finalize" />
40
+ </bpmn:extensionElements>
41
+ </bpmn:serviceTask>
42
+ <bpmn:serviceTask id="agent">
43
+ <bpmn:extensionElements>
44
+ <zeebe:taskDefinition type="senior:feature" />
45
+ <zeebe:agentDefinition agentType="external" />
46
+ </bpmn:extensionElements>
47
+ </bpmn:serviceTask>
48
+ <bpmn:boundaryEvent id="be_agent_sla" attachedToRef="agent">
49
+ <bpmn:outgoing>f_agent_sla</bpmn:outgoing>
50
+ <bpmn:timerEventDefinition id="ted_agent_sla">
51
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
52
+ </bpmn:timerEventDefinition>
53
+ </bpmn:boundaryEvent>
54
+ <bpmn:sequenceFlow id="f_agent_sla" sourceRef="be_agent_sla" targetRef="somewhere" />`;
55
+ assertEquals(externalAgentTasksMissingSlaBoundary(xml), []);
56
+ });
57
+
58
+ test("externalAgentTasksMissingSlaBoundary rejects a non-timer boundary and a non-interrupting one", () => {
59
+ const xml = `
60
+ <bpmn:serviceTask id="msgBounded">
61
+ <bpmn:extensionElements>
62
+ <zeebe:taskDefinition type="senior:a" />
63
+ <zeebe:agentDefinition agentType="external" />
64
+ </bpmn:extensionElements>
65
+ </bpmn:serviceTask>
66
+ <bpmn:boundaryEvent id="be_msg" attachedToRef="msgBounded">
67
+ <bpmn:messageEventDefinition id="med_msg" messageRef="Message_x" />
68
+ </bpmn:boundaryEvent>
69
+ <bpmn:serviceTask id="nonInterrupting">
70
+ <bpmn:extensionElements>
71
+ <zeebe:taskDefinition type="senior:b" />
72
+ <zeebe:agentDefinition agentType="external" />
73
+ </bpmn:extensionElements>
74
+ </bpmn:serviceTask>
75
+ <bpmn:boundaryEvent id="be_ni" attachedToRef="nonInterrupting" cancelActivity="false">
76
+ <bpmn:timerEventDefinition id="ted_ni">
77
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
78
+ </bpmn:timerEventDefinition>
79
+ </bpmn:boundaryEvent>
80
+ <bpmn:serviceTask id="nonInterruptingZero">
81
+ <bpmn:extensionElements>
82
+ <zeebe:taskDefinition type="senior:c" />
83
+ <zeebe:agentDefinition agentType="external" />
84
+ </bpmn:extensionElements>
85
+ </bpmn:serviceTask>
86
+ <bpmn:boundaryEvent id="be_ni0" attachedToRef="nonInterruptingZero" cancelActivity="0">
87
+ <bpmn:outgoing>f_ni0</bpmn:outgoing>
88
+ <bpmn:timerEventDefinition id="ted_ni0">
89
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
90
+ </bpmn:timerEventDefinition>
91
+ </bpmn:boundaryEvent>
92
+ <bpmn:sequenceFlow id="f_ni0" sourceRef="be_ni0" targetRef="esc" />`;
93
+ // cancelActivity="0" is the OTHER XSD-boolean false form — it is just as non-interrupting as
94
+ // "false", so the guard must reject it too (else a hung agent runs on past the boundary).
95
+ assertEquals(externalAgentTasksMissingSlaBoundary(xml), ["msgBounded", "nonInterrupting", "nonInterruptingZero"]);
96
+ });
97
+
98
+ // The defect class a too-loose scanner would bless (review finding): an interrupting TIMER boundary
99
+ // is necessary but NOT sufficient — it must also arm the canonical `=agentSlaTimeout` duration AND
100
+ // route somewhere. Each case below is a boundary that LOOKS like an SLA bound but is not.
101
+ test("externalAgentTasksMissingSlaBoundary rejects a timer with no timeDuration, a wrong expression, or no outgoing flow", () => {
102
+ const xml = `
103
+ <bpmn:serviceTask id="noDuration">
104
+ <bpmn:extensionElements>
105
+ <zeebe:taskDefinition type="senior:a" />
106
+ <zeebe:agentDefinition agentType="external" />
107
+ </bpmn:extensionElements>
108
+ </bpmn:serviceTask>
109
+ <bpmn:boundaryEvent id="be_no_duration" attachedToRef="noDuration">
110
+ <bpmn:outgoing>f_no_duration</bpmn:outgoing>
111
+ <bpmn:timerEventDefinition id="ted_no_duration" />
112
+ </bpmn:boundaryEvent>
113
+ <bpmn:sequenceFlow id="f_no_duration" sourceRef="be_no_duration" targetRef="esc" />
114
+ <bpmn:serviceTask id="wrongExpr">
115
+ <bpmn:extensionElements>
116
+ <zeebe:taskDefinition type="senior:b" />
117
+ <zeebe:agentDefinition agentType="external" />
118
+ </bpmn:extensionElements>
119
+ </bpmn:serviceTask>
120
+ <bpmn:boundaryEvent id="be_wrong_expr" attachedToRef="wrongExpr">
121
+ <bpmn:outgoing>f_wrong_expr</bpmn:outgoing>
122
+ <bpmn:timerEventDefinition id="ted_wrong_expr">
123
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">PT2H</bpmn:timeDuration>
124
+ </bpmn:timerEventDefinition>
125
+ </bpmn:boundaryEvent>
126
+ <bpmn:sequenceFlow id="f_wrong_expr" sourceRef="be_wrong_expr" targetRef="esc" />
127
+ <bpmn:serviceTask id="disconnected">
128
+ <bpmn:extensionElements>
129
+ <zeebe:taskDefinition type="senior:c" />
130
+ <zeebe:agentDefinition agentType="external" />
131
+ </bpmn:extensionElements>
132
+ </bpmn:serviceTask>
133
+ <bpmn:boundaryEvent id="be_disconnected" attachedToRef="disconnected">
134
+ <bpmn:timerEventDefinition id="ted_disconnected">
135
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
136
+ </bpmn:timerEventDefinition>
137
+ </bpmn:boundaryEvent>`;
138
+ // noDuration: timer without a timeDuration never arms the SLA. wrongExpr: a hard-coded PT2H does
139
+ // not track the seeded `agentSlaTimeout`. disconnected: a boundary with no outgoing flow cancels
140
+ // the agent without escalating. All three must be flagged.
141
+ assertEquals(externalAgentTasksMissingSlaBoundary(xml), ["noDuration", "wrongExpr", "disconnected"]);
142
+ });
143
+
144
+ // Processes whose external agent tasks are bounded by an SLA timer boundary (issue #849).
145
+ // convergence-loop.bpmn and plan-fanout.bpmn are INTENTIONALLY absent: their agent tasks
146
+ // (`review-round` / `adversarial-review` / `classify-scope`, and `plan`) sit on a back-edge-loop
147
+ // target, and bpmn-auto-layout cannot route a bottom-exit timer boundary there (ROUTING_FAILED —
148
+ // layouter bug #867; the bound-the-rest follow-up is #868). They are bounded once the layouter
149
+ // fix lands; until then this list is the landed scope, and the corpus assertion below covers
150
+ // exactly these processes so a regression on a *bounded* process is caught while the deferred
151
+ // two stay out. merge-loop.bpmn's agent tasks (`fix-ci` / `rebase`) were bounded in an earlier
152
+ // #849 commit, so it belongs here too — every already-bounded process stays regression-guarded.
153
+ const BOUNDED_PROCESSES = ["implement-cell.bpmn", "merge-cell.bpmn", "merge-loop.bpmn", "retro.bpmn"] as const;
154
+
155
+ test("DEFECT-CLASS GUARD: every external agent task in a bounded process carries an interrupting timer SLA boundary", () => {
156
+ let anyAgentTasks = false;
157
+ for (const file of BOUNDED_PROCESSES) {
158
+ const xml = readFileSync(join(PROCESSES_DIR, file), "utf8");
159
+ if (/<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/.test(xml)) anyAgentTasks = true;
160
+ const missing = externalAgentTasksMissingSlaBoundary(xml);
161
+ assert(
162
+ missing.length === 0,
163
+ `${file}: external agent task(s) with no interrupting timer SLA boundary — ${missing.join(", ")} ` +
164
+ `(issue #849: an unbounded agent task parks the process forever; attach a timer boundary ` +
165
+ `whose <bpmn:timeDuration>=agentSlaTimeout routes to the process's escalation path)`,
166
+ );
167
+ }
168
+ // Sanity: the bounded models really do declare external agent tasks (guard is not vacuously green).
169
+ assert(anyAgentTasks, "the bounded models declare external agent tasks");
170
+ });
@@ -214,3 +214,63 @@ export function agentTaskTypesOptedOutMissingExternalMarker(xml: string): string
214
214
  }
215
215
  return offending;
216
216
  }
217
+
218
+ // A `<bpmn:boundaryEvent>` block with an interrupting TIMER definition attached to a given task.
219
+ // The SLA bound is only real when the boundary carries a `<bpmn:timerEventDefinition>` (a message
220
+ // or signal boundary would not bound a hung agent) and is interrupting (`cancelActivity` absent —
221
+ // interrupting is the BPMN default — or explicitly `"true"`/`"1"`); a non-interrupting boundary would
222
+ // leave the stuck job running. `cancelActivity` is an XSD boolean, so BOTH false lexical forms
223
+ // (`"false"` and `"0"`) mark it non-interrupting — reject either, or the guard would bless a
224
+ // `cancelActivity="0"` boundary that leaves the hung agent running.
225
+ const BOUNDARY_EVENT = /<(?:\w+:)?boundaryEvent\b[^>]*>[\s\S]*?<\/(?:\w+:)?boundaryEvent>/g;
226
+ const BOUNDARY_ATTACHED_TO = /\battachedToRef="([^"]*)"/;
227
+ const BOUNDARY_ID = /<(?:\w+:)?boundaryEvent\b[^>]*\bid="([^"]*)"/;
228
+ const TIMER_DEFINITION = /<(?:\w+:)?timerEventDefinition\b/;
229
+ const NON_INTERRUPTING = /\bcancelActivity="(?:false|0)"/;
230
+ const SERVICE_TASK_ID = /<(?:\w+:)?serviceTask\b[^>]*\bid="([^"]*)"/;
231
+ // The boundary must actually arm the seeded SLA: a `<bpmn:timeDuration>` whose FEEL expression is
232
+ // `=agentSlaTimeout`. A timer with no duration (or a hard-coded/other expression) does not bound the
233
+ // agent by the canonical SLA, so it is not a real SLA boundary. Match the duration body loosely
234
+ // (whitespace/`xsi:type` attribute agnostic) and require the `=agentSlaTimeout` reference.
235
+ const SLA_DURATION = /<(?:\w+:)?timeDuration\b[^>]*>\s*=\s*agentSlaTimeout\s*<\/(?:\w+:)?timeDuration>/;
236
+ // A sequence flow sourced from a node id (`sourceRef="<id>"`). The boundary must route SOMEWHERE —
237
+ // a disconnected timer would cancel the agent without escalating, so a boundary with no outgoing
238
+ // flow is not a valid SLA bound.
239
+ const sequenceFlowFrom = (id: string): RegExp =>
240
+ new RegExp(`<(?:\\w+:)?sequenceFlow\\b[^>]*\\bsourceRef="${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`);
241
+
242
+ /**
243
+ * DEFECT-CLASS GUARD (issue #849): scan one BPMN document for the ids of EXTERNAL agent service
244
+ * tasks (`<zeebe:agentDefinition agentType="external" />`) that have NO interrupting timer
245
+ * boundary event attached. An external agent task is a durable wait on an external actor — the
246
+ * worker renews the job's deadline for as long as the agent process is alive, so a hung or
247
+ * looping agent parks the token forever with no incident and no escalation (the
248
+ * nanobpm/nano-bpm#1308 incident: `classify-scope` ran 7h27m unbounded). Every external agent
249
+ * task must carry an interrupting timer boundary (conventionally
250
+ * `<bpmn:timeDuration>=agentSlaTimeout</bpmn:timeDuration>`, seeded at instance start) that routes
251
+ * to the process's escalation path. Returns the offending task ids in first-occurrence order
252
+ * (empty when every external agent task is bounded).
253
+ */
254
+ export function externalAgentTasksMissingSlaBoundary(xml: string): string[] {
255
+ const bounded = new Set<string>();
256
+ for (const [block] of xml.matchAll(BOUNDARY_EVENT)) {
257
+ // A real SLA bound is an INTERRUPTING TIMER boundary …
258
+ if (!TIMER_DEFINITION.test(block) || NON_INTERRUPTING.test(block)) continue;
259
+ // … that arms the canonical seeded SLA (`=agentSlaTimeout`) …
260
+ if (!SLA_DURATION.test(block)) continue;
261
+ const attached = block.match(BOUNDARY_ATTACHED_TO)?.[1];
262
+ if (!attached) continue;
263
+ // … and routes somewhere (a disconnected boundary cancels the agent without escalating).
264
+ const boundaryId = block.match(BOUNDARY_ID)?.[1];
265
+ if (!boundaryId || !sequenceFlowFrom(boundaryId).test(xml)) continue;
266
+ bounded.add(attached);
267
+ }
268
+ const missing: string[] = [];
269
+ for (const [block] of xml.matchAll(SERVICE_TASK)) {
270
+ if (!EXTERNAL_AGENT_MARKER.test(extensionElementsOf(block))) continue;
271
+ const id = block.match(SERVICE_TASK_ID)?.[1];
272
+ if (id === undefined || id.length === 0 || bounded.has(id)) continue;
273
+ missing.push(id);
274
+ }
275
+ return missing;
276
+ }
@@ -11,6 +11,7 @@ import {
11
11
  gatherConformance,
12
12
  hasDeliveredImplementation,
13
13
  hasDeliveredImplementationForPlan,
14
+ markConformanceSynthesisSla,
14
15
  recordConformance,
15
16
  renderConformanceBrief,
16
17
  } from "./conformance.ts";
@@ -312,3 +313,116 @@ test("acknowledgeConformance: a blank note settles the row without changing the
312
313
  assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
313
314
  assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
314
315
  });
316
+
317
+ // Synthesis-SLA preserve path (PR #864 review): the synthesize-SLA arm runs AFTER record-conformance
318
+ // already filed a verdict, so it must NOT overwrite the audit — only flip review_status + append the
319
+ // synthesis-timeout context.
320
+ test("markConformanceSynthesisSla: preserves a filed verdict, only marks reviewing + appends the SLA reason", async () => {
321
+ const { data, stores } = memData();
322
+ await recordConformance(data, PLAN, {
323
+ status: "filed",
324
+ commentUrl: "https://x/7#issuecomment-1",
325
+ slicesMet: 4,
326
+ slicesReduced: 1,
327
+ slicesNotVerified: 0,
328
+ deviationsRaised: 1,
329
+ deviationsUnraised: 0,
330
+ hasDeviations: true,
331
+ summary: "6 items, 4 met",
332
+ report: "full conformance report",
333
+ });
334
+ await markConformanceSynthesisSla(data, PLAN, {
335
+ summary: "The retrospective-synthesis agent exceeded its time budget without completing.",
336
+ processKey: "p9",
337
+ });
338
+ assertEquals(stores["plan_conformance"].length, 1, "must not duplicate the plan_key row");
339
+ const row = stores["plan_conformance"][0];
340
+ // The filed verdict survives untouched.
341
+ assertEquals(row.status, "filed");
342
+ assertEquals(row.comment_url, "https://x/7#issuecomment-1");
343
+ assertEquals(row.slices_met, 4);
344
+ assertEquals(row.slices_reduced, 1);
345
+ assertEquals(row.has_deviations, 1);
346
+ assertEquals(row.report, "full conformance report");
347
+ // Only review_status flips and the SLA reason is APPENDED to the existing summary.
348
+ assertEquals(row.review_status, "reviewing");
349
+ assertEquals(row.process_key, "p9");
350
+ assertEquals(
351
+ row.summary,
352
+ "6 items, 4 met\n\nThe retrospective-synthesis agent exceeded its time budget without completing.",
353
+ );
354
+ });
355
+
356
+ test("markConformanceSynthesisSla: appends the SLA reason even when the prior summary is null", async () => {
357
+ const { data, stores } = memData();
358
+ stores["plan_conformance"] = [
359
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewed", status: "skipped", summary: null },
360
+ ];
361
+ await markConformanceSynthesisSla(data, PLAN, { summary: "synthesis timed out", processKey: "p2" });
362
+ const row = stores["plan_conformance"][0];
363
+ assertEquals(row.status, "skipped", "verdict preserved");
364
+ assertEquals(row.review_status, "reviewing");
365
+ assertEquals(row.summary, "synthesis timed out", "no prior text -> the reason stands alone");
366
+ });
367
+
368
+ test("markConformanceSynthesisSla: inserts a minimal reviewing placeholder when no verdict row exists", async () => {
369
+ const { data, stores } = memData();
370
+ await markConformanceSynthesisSla(data, PLAN, { summary: "synthesis timed out", processKey: "p3" });
371
+ assertEquals(stores["plan_conformance"].length, 1, "a placeholder row is created so the ack never wedges");
372
+ const row = stores["plan_conformance"][0];
373
+ assertEquals(row.status, "skipped");
374
+ assertEquals(row.review_status, "reviewing");
375
+ assertEquals(row.process_key, "p3");
376
+ assertEquals(row.summary, "synthesis timed out");
377
+ });
378
+
379
+ // At-least-once worker delivery can retry `record-synthesize-sla` after it already updated the row
380
+ // but before the job completes. The preserve-append must be idempotent: a retry carrying the same
381
+ // timeout text must NOT append a duplicate paragraph to the audit trail.
382
+ test("markConformanceSynthesisSla: is idempotent on an at-least-once retry (no duplicate SLA paragraph)", async () => {
383
+ const { data, stores } = memData();
384
+ await recordConformance(data, PLAN, {
385
+ status: "filed",
386
+ commentUrl: "https://x/7#issuecomment-1",
387
+ slicesMet: 4,
388
+ slicesReduced: 1,
389
+ slicesNotVerified: 0,
390
+ deviationsRaised: 1,
391
+ deviationsUnraised: 0,
392
+ hasDeviations: true,
393
+ summary: "6 items, 4 met",
394
+ report: "full conformance report",
395
+ });
396
+ const sla = {
397
+ summary: "The retrospective-synthesis agent exceeded its time budget without completing.",
398
+ processKey: "p9",
399
+ };
400
+ await markConformanceSynthesisSla(data, PLAN, sla);
401
+ await markConformanceSynthesisSla(data, PLAN, sla); // retry
402
+ const row = stores["plan_conformance"][0];
403
+ assertEquals(
404
+ row.summary,
405
+ "6 items, 4 met\n\nThe retrospective-synthesis agent exceeded its time budget without completing.",
406
+ "a retry must not append the timeout paragraph twice",
407
+ );
408
+ });
409
+
410
+ // The placeholder-insert branch is likewise retry-safe: a retry finds the row it inserted and must
411
+ // not append a second copy of the reason it already carries.
412
+ test("markConformanceSynthesisSla: placeholder insert is idempotent on retry", async () => {
413
+ const { data, stores } = memData();
414
+ const sla = { summary: "synthesis timed out", processKey: "p3" };
415
+ await markConformanceSynthesisSla(data, PLAN, sla);
416
+ await markConformanceSynthesisSla(data, PLAN, sla); // retry
417
+ assertEquals(stores["plan_conformance"].length, 1);
418
+ assertEquals(stores["plan_conformance"][0].summary, "synthesis timed out", "no duplicate reason on retry");
419
+ });
420
+
421
+ test("markConformanceSynthesisSla: rejects an untrackable reviewing row with a null processKey", async () => {
422
+ const { data } = memData();
423
+ await assertRejects(
424
+ () => markConformanceSynthesisSla(data, PLAN, { summary: "synthesis timed out", processKey: null }),
425
+ Error,
426
+ "no process_key",
427
+ );
428
+ });
@@ -298,6 +298,78 @@ export async function recordConformance(
298
298
  }
299
299
  }
300
300
 
301
+ /** Mark a conformance row whose *synthesis* (retro) agent blew its SLA — WITHOUT touching the filed
302
+ * verdict. The synthesize-SLA arm (`record-synthesize-sla` in retro.bpmn) runs AFTER
303
+ * `record-conformance` has already persisted a real audit (the token must pass through it to reach
304
+ * `synthesize`), so routing it through the upsert above would rewrite `status`/counts/`report` with
305
+ * blanks/zeros and clobber `summary` (incl. any operator acknowledgement) with the timeout text —
306
+ * destroying the verdict the escalation is supposed to surface (PR #864 review). Instead this only
307
+ * flips `review_status` to `reviewing` (so the shared `conformance-escalation` ack task is tracked)
308
+ * and APPENDS the synthesis-timeout context to the existing `summary`, leaving the audit intact.
309
+ *
310
+ * Edge: if no row exists yet (a bypassed/gather-only path where `record-conformance` never ran),
311
+ * insert a minimal placeholder so the ack's `acknowledgeConformance` — which throws on a missing row
312
+ * — still settles, and the escalation is never untrackable. */
313
+ export async function markConformanceSynthesisSla(
314
+ data: DataLayer,
315
+ planKey: string,
316
+ input: { summary: string; processKey: string | null },
317
+ ): Promise<void> {
318
+ const ts = now();
319
+ const processKey = input.processKey ?? null;
320
+ // Same trackability invariant as recordConformance: a `reviewing` row with no process_key can
321
+ // never be surfaced by `pollUserTasks` nor cleared by `onTerminated` — it wedges forever.
322
+ if (processKey == null) {
323
+ throw new Error(
324
+ `markConformanceSynthesisSla: ${planKey} would persist review_status='reviewing' with no process_key — ` +
325
+ "refusing to record an untrackable synthesis-SLA escalation that no poller or onTerminated binding can clear",
326
+ );
327
+ }
328
+ const existing = await conformanceTbl(data).get(planKey);
329
+ if (!existing) {
330
+ // No conformance verdict was recorded (record-conformance never ran). Persist a minimal
331
+ // `reviewing` placeholder carrying only the synthesis-timeout reason so the ack settles it.
332
+ await conformanceTbl(data).insert({
333
+ plan_key: planKey,
334
+ created_at: ts,
335
+ status: "skipped",
336
+ comment_url: null,
337
+ slices_met: 0,
338
+ slices_reduced: 0,
339
+ slices_not_verified: 0,
340
+ deviations_raised: 0,
341
+ deviations_unraised: 0,
342
+ has_deviations: 0,
343
+ summary: input.summary,
344
+ report: null,
345
+ process_key: processKey,
346
+ review_status: CONFORMANCE_REVIEWING_STATUS,
347
+ updated_at: ts,
348
+ });
349
+ return;
350
+ }
351
+ // Preserve the filed verdict: only flip review_status and append the synthesis-timeout context to
352
+ // the existing summary (never replace it — it may carry the audit's own text + an operator ack).
353
+ // At-least-once worker semantics can retry `record-synthesize-sla` after it already updated the
354
+ // row but before the job completes; the append below is NOT idempotent, so a retry would duplicate
355
+ // the identical timeout paragraph. If the row is already parked at `reviewing` and its summary
356
+ // already ends with this exact reason, the update already landed — short-circuit so the retry is a
357
+ // no-op (mirrors acknowledgeConformance's idempotency guard). A genuinely later timeout only
358
+ // recurs after the operator ack flips status back to `reviewed`, so that path still appends afresh.
359
+ const existingSummary = typeof existing.summary === "string" ? existing.summary : "";
360
+ if (existing.review_status === CONFORMANCE_REVIEWING_STATUS && existingSummary.endsWith(input.summary)) {
361
+ return;
362
+ }
363
+ const prior = typeof existing.summary === "string" && existing.summary.trim() ? existing.summary : null;
364
+ const summary = prior ? `${prior}\n\n${input.summary}` : input.summary;
365
+ await conformanceTbl(data).update(planKey, {
366
+ process_key: processKey,
367
+ review_status: CONFORMANCE_REVIEWING_STATUS,
368
+ summary,
369
+ updated_at: ts,
370
+ });
371
+ }
372
+
301
373
  /** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
302
374
  * `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
303
375
  * closes) and stamp the disposition note into `summary` for the audit trail. Needed because the