@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +41 -0
- package/app/agentSla.test.ts +48 -0
- package/app/agentSla.ts +36 -11
- package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
- package/app/agentic/vocab/job-types.ts +60 -0
- package/app/conformance.test.ts +114 -0
- package/app/conformance.ts +72 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.ts +5 -0
- package/app/feature.ts +5 -0
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/plan.ts +12 -0
- package/app/retro.test.ts +5 -0
- package/app/retro.ts +4 -0
- package/app/service.ts +27 -8
- package/e2e/agent-sla-boundary.e2e.ts +598 -0
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/e2e/support/pin-agent-sla.ts +47 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +122 -47
- package/resources/processes/merge-cell.bpmn +60 -12
- package/resources/processes/plan-fanout.bpmn +237 -211
- package/resources/processes/retro.bpmn +100 -3
- package/resources/prompts/feature.md +16 -1
- package/test/derivation-parity/derivation-parity.test.ts +35 -11
- package/test/derivation-parity/flows.ts +14 -1
- package/workers/conformance-record/worker.test.ts +87 -1
- package/workers/conformance-record/worker.ts +34 -7
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- 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
|
|
package/app/agentSla.test.ts
CHANGED
|
@@ -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 (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
}
|
package/app/conformance.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/app/conformance.ts
CHANGED
|
@@ -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
|