@nanobpm/nano-workforce 0.201.0 → 0.201.2
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 +153 -0
- package/app/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +251 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1345 -30
- package/app/deliveryGraphCompiler.ts +882 -240
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/mergeLoopBehaviour.test.ts +46 -1
- package/app/redactText.ts +88 -0
- package/app/service.ts +110 -2
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +11 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.201.2](https://github.com/nanobpm/nano-workforce/compare/v0.201.1...v0.201.2) (2026-10-08)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* distinguish never-started agent jobs from hung ones in SLA escalations ([#881](https://github.com/nanobpm/nano-workforce/issues/881)) ([da2bc79](https://github.com/nanobpm/nano-workforce/commit/da2bc7915c6cd8ef72bae7bdec2069d09ba091eb)), closes [#879](https://github.com/nanobpm/nano-workforce/issues/879) [#879](https://github.com/nanobpm/nano-workforce/issues/879) [#879](https://github.com/nanobpm/nano-workforce/issues/879)
|
|
6
|
+
|
|
7
|
+
## [0.201.1](https://github.com/nanobpm/nano-workforce/compare/v0.201.0...v0.201.1) (2026-10-07)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** actionable node escalations — node-local agent results, retry-node, inputs preflight ([#863](https://github.com/nanobpm/nano-workforce/issues/863)) ([50f7595](https://github.com/nanobpm/nano-workforce/commit/50f7595f3156b2e1e9d52e30e87d3e1c22951dc8)), closes [nano-bpm#1335](https://github.com/nanobpm/nano-bpm/issues/1335) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nanobpm/nano-workforce#866](https://github.com/nanobpm/nano-workforce/issues/866) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#946](https://github.com/nanobpm/nano-bpm/issues/946) [#543](https://github.com/nanobpm/nano-workforce/issues/543) [#866](https://github.com/nanobpm/nano-workforce/issues/866) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#461](https://github.com/nanobpm/nano-workforce/issues/461) [#876](https://github.com/nanobpm/nano-workforce/issues/876)
|
|
12
|
+
|
|
1
13
|
## [0.201.0](https://github.com/nanobpm/nano-workforce/compare/v0.200.9...v0.201.0) (2026-10-06)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/SPEC.md
CHANGED
|
@@ -1039,6 +1039,159 @@ decision. The target behaviour is an **autonomy ladder**:
|
|
|
1039
1039
|
A human escalation is then reserved for its one true case: **two slices that each pass
|
|
1040
1040
|
but encode incompatible decisions about a shared contract** — a genuine design call.
|
|
1041
1041
|
|
|
1042
|
+
## 13.3 Delivery-graph node escalation — Continue / Retry / reset (PR #863)
|
|
1043
|
+
|
|
1044
|
+
The delivery-graph compiler (`app/deliveryGraphCompiler.ts`, ADR 0005) compiles each
|
|
1045
|
+
`agent`/`connector` node to a `start → serviceTask → end` subProcess guarded by a
|
|
1046
|
+
**bounded `=nodeTimeout` boundary**. On timeout (or, for an `agent`, on a broken
|
|
1047
|
+
producer contract — issue #731) the stalled node parks on a **human-completable
|
|
1048
|
+
escalation user task** (`delivery-human-task__<el>__esc`, and the agent-only
|
|
1049
|
+
`__contract` twin), surfaced in the Tasks inbox and answerable by a human or an agent
|
|
1050
|
+
(ADR 0046). Every such escalation names **two exits** (the `delivery-escalation` form's
|
|
1051
|
+
`decision` select):
|
|
1052
|
+
|
|
1053
|
+
- **Continue** (`decision="continue"`, the default) completes the node as resolved. If
|
|
1054
|
+
the node declares **exactly one** emit **and that emit is a downstream-required fact**,
|
|
1055
|
+
the form presents a typed-value field and maps
|
|
1056
|
+
the operator-supplied `value` onto the node's emit-source var (`factSourceVar`), so the
|
|
1057
|
+
subProcess output publishes the same `<el>_<fact>` a normal completion would —
|
|
1058
|
+
letting work finished out of band (a draft PR the stalled agent already opened) be
|
|
1059
|
+
handed onward instead of threading a null downstream. This applies to **both** `agent`
|
|
1060
|
+
and `connector` emits: a connector has no producer-contract gate, so its resume keys
|
|
1061
|
+
off the node's **own** declared `emits` (emit source = the fact's own name), never the
|
|
1062
|
+
agent-only gate metadata. The single-emit boundary is a hard cardinality rule: the
|
|
1063
|
+
form captures ONE value, so a node declaring **zero** emits has nothing to resume (the
|
|
1064
|
+
field stays blank) and a node declaring **two or more** emits is **not** value-resumable
|
|
1065
|
+
— one value cannot satisfy multiple distinct typed facts without corrupting them.
|
|
1066
|
+
**The value is also inert for a node with no *required* (downstream-consumed) resume
|
|
1067
|
+
target** — the resume keys off the **required** emit set, not the raw declared set, so a
|
|
1068
|
+
node whose facts nothing downstream reads has no required resume target and its `value` is
|
|
1069
|
+
discarded (there is nothing to publish it to). This covers a node that declares **zero**
|
|
1070
|
+
emits, exactly **one** routing-only/unconsumed emit, or **several** routing-only/unconsumed
|
|
1071
|
+
emits alike: because the required resume set (`resumeEmits`) is empty, **no post-escalation
|
|
1072
|
+
validation gate is compiled at all**, so **Continue simply proceeds past the node to its
|
|
1073
|
+
default (fallback) branch** — there is nothing to re-park (`serviceBodyLines` /
|
|
1074
|
+
`escalationTaskLines` add the `validTarget` gate only when `resumeEmits` is non-empty). The
|
|
1075
|
+
fail-closed validation gate exists **only** for a node that still owes a **required** emit:
|
|
1076
|
+
there, on Continue it **fails closed and loops
|
|
1077
|
+
back to the escalation** (it does NOT write nulls and does NOT advance to a fallback branch)
|
|
1078
|
+
— **Retry this step** is the only way to actually produce the facts, except that a node
|
|
1079
|
+
declaring exactly **one** emit — that single declared emit being its only required resume
|
|
1080
|
+
target — may instead satisfy it by entering the coerced `value` (the value field stays inert
|
|
1081
|
+
for a node declaring two or more emits even when only one is required, matching
|
|
1082
|
+
`declaredEmits.length === 1 && emits.length === 1` in the compiler).
|
|
1083
|
+
(A **wait** gate owing **two or more**
|
|
1084
|
+
emits is a special case: it has no Retry exit and its single value field can resume
|
|
1085
|
+
none of them, so its escalation grows **no** validation gate at all and Continue simply
|
|
1086
|
+
acknowledges and proceeds to the node end — see issue #863 — rather than looping forever.)
|
|
1087
|
+
Because the form
|
|
1088
|
+
captures `value` as textfield **text**, the single-emit resume **coerces/validates it to
|
|
1089
|
+
the emit's declared type** before publishing (`escalationResumeValueFeel`): a `boolean`
|
|
1090
|
+
emit accepts `"true"`/`"false"` **case-insensitively and surrounding-whitespace-trimmed**
|
|
1091
|
+
(the validity gate and the coercion share one `lower case(trim(string(value)))`
|
|
1092
|
+
normalization, so a value one accepts the other can never convert differently) and
|
|
1093
|
+
publishes a real boolean, a `number` emit parses via `number(trim(string(value)))`, and
|
|
1094
|
+
any other (text-valued) type passes through. An **invalid** entry
|
|
1095
|
+
publishes `null` — the defined failure path — so a required-emit producer gate escalates
|
|
1096
|
+
and a guarded split takes its default rather than routing on a mistyped value (a raw
|
|
1097
|
+
`"true"` string would silently skip a `= true` guard).
|
|
1098
|
+
- **Retry this step** (`decision="retry"`) re-runs the node. A none intermediate throw
|
|
1099
|
+
event (never a scriptTask) resets the node-local scratch — the decision, the captured
|
|
1100
|
+
`value`/`escalationNote`, and (for an agent) the **full declared result set**
|
|
1101
|
+
(`AGENT_RESULT_LOCAL_VARS` — the self-reported `status`/`summary`/… **and** every other
|
|
1102
|
+
node-local result field such as `transcriptUrl`, `agentCheckpoint`, and the PR aliases)
|
|
1103
|
+
— or, **for a connector**, its fixed result metadata (`CONNECTOR_RESULT_LOCAL_VARS` —
|
|
1104
|
+
`connectorOutcome`/`connectorDedupeKey`/`connectorDetail`) — plus the previous attempt's
|
|
1105
|
+
emits — and appends the operator's `escalationNote` to the agent
|
|
1106
|
+
prompt as guidance for the next attempt (re-derived from the runner-seeded
|
|
1107
|
+
`nodeInputs.<el>.appendPrompt` baseline, so consecutive retries never accumulate stale
|
|
1108
|
+
guidance). The guidance control is the escalation-specific `escalationNote`, never the
|
|
1109
|
+
plain `note`: `note` is a worker **result** field (`plan.md` returns one) that
|
|
1110
|
+
nearest-scope propagation lands in the same subProcess scope, so reading it would feed a
|
|
1111
|
+
worker-produced note back as "operator guidance" on a retry submitted without one. Clearing
|
|
1112
|
+
the whole declared set (not just the status fields) stops a retried
|
|
1113
|
+
worker that omits an optional field from republishing the previous attempt's value
|
|
1114
|
+
downstream or surfacing it in the next escalation.
|
|
1115
|
+
|
|
1116
|
+
All retry/reset targets are **node-local** (declared on the subProcess by
|
|
1117
|
+
`ioMappingLines`), so a node's result vars never leak to the root and two parallel nodes
|
|
1118
|
+
declaring the same emit never cross-publish — with a few deliberate publish-onward
|
|
1119
|
+
exceptions, all written under node-unique or intentionally-shared names rather than the
|
|
1120
|
+
raw result var. The **outward data** exceptions: each declared emit is republished to the
|
|
1121
|
+
root as `<el>_<fact>` (node-unique, so siblings never collide), and an agent's
|
|
1122
|
+
`transcriptUrl` is propagated back to the root under the shared `transcriptUrl` name so
|
|
1123
|
+
Nano Explorer can render the run→transcript link (app/deliveryGraphCompiler.ts:1872-1877).
|
|
1124
|
+
That one shared name means parallel agents can overwrite the root `transcriptUrl` —
|
|
1125
|
+
acceptable, since it is a display-only correlation link, not a result a downstream node
|
|
1126
|
+
binds. In addition, the per-node **control signals** `<el>_contractMet` (the agent
|
|
1127
|
+
contract-gate's proceed flag) and `<el>_retryRequested` (the per-escalation retry gate's
|
|
1128
|
+
route flag) are **also root-scoped** — their gateways live outside the node's subProcess
|
|
1129
|
+
and so cannot read node scope, and the `<el>_`-prefixed, node-unique names can never
|
|
1130
|
+
collide across parallel nodes. So the node-local rule governs a node's *result/emit scratch*;
|
|
1131
|
+
these node-unique control booleans are intentionally published to root so the routing
|
|
1132
|
+
gateways can read them on either engine's scope semantics. A **preflight `assert`** on the inner leaf
|
|
1133
|
+
task's input fails LOUD (raising an incident naming the missing `nodeInputs.<el>`)
|
|
1134
|
+
before any job exists when the runner-seeded config was lost — see the `KNOWN
|
|
1135
|
+
LIMITATION` in `serviceBodyLines` (fail-loud-only, nano-workforce#866). Resolving that
|
|
1136
|
+
incident re-evaluates **only the leaf's inputs** — the subProcess-level config mappings
|
|
1137
|
+
(`prompt`/`appendPrompt`/`nodeTimeout`, connector `target`/`payload`/`dedupeKey`) ran once
|
|
1138
|
+
at subProcess entry and are **not** re-mapped on a leaf resolve, so the node would
|
|
1139
|
+
activate **unconfigured**. The in-subprocess **"Retry this step"** loop does **not**
|
|
1140
|
+
help here either: it loops directly back to the inner service task (to reset the
|
|
1141
|
+
node-local scratch), bypassing the subProcess entry mappings, so it too leaves the
|
|
1142
|
+
config null. **Recovery therefore requires re-entering the sub-process from OUTSIDE —
|
|
1143
|
+
relaunch/re-enter the node so its subProcess input mappings re-evaluate against the
|
|
1144
|
+
restored `nodeInputs`** — not merely resolving the incident or using "Retry this step".
|
|
1145
|
+
The correct in-model fix is to run this check at sub-process *entry* (Camunda parity,
|
|
1146
|
+
nano-bpm#1336); until then the preflight fails loud so the node never runs blind
|
|
1147
|
+
*without* an incident, and the operator re-enters the sub-process to recover.
|
|
1148
|
+
|
|
1149
|
+
Because an `agent`/`connector` node's emit source **is the fact's own name**, declared
|
|
1150
|
+
node-local in the same subProcess scope as the escalation form controls and the node's
|
|
1151
|
+
seeded config, a fact name is not fully unrestricted: `validateDeliveryGraph` rejects an
|
|
1152
|
+
emit named after a **reserved delivery variable** (`reservedDeliveryFactNames(kind)`,
|
|
1153
|
+
app/deliveryGraph.ts) fail-closed at authoring time. The reserved set is **kind-aware**
|
|
1154
|
+
(issue #863 review): the **escalation controls** (`decision`/`value`/`escalationNote` — an escalation
|
|
1155
|
+
Continue would overwrite the fact, publishing `<el>_decision="continue"` instead of the
|
|
1156
|
+
agent's routing value) and the shared late-binding/preflight **scaffolding**
|
|
1157
|
+
(`boundFacts`/`nodeInputs`/`nodeInputsPresent`) are reserved for **every** kind, while a
|
|
1158
|
+
**config variable** is reserved only for the kind whose own config it is — and only for
|
|
1159
|
+
`agent`/`connector`, the kinds whose emit source is the fact's own name (the retry reset's
|
|
1160
|
+
clear-the-emits pass would null the node's own configuration, so the retried node activates
|
|
1161
|
+
unconfigured). A `wait`/`human` emit's source is a fixed intermediate (`detail`/
|
|
1162
|
+
`humanEmitValue`/…), never the fact's own name, so those kinds reserve only the escalation
|
|
1163
|
+
controls + scaffolding — a `wait` emitting `target` or a `human` emitting `prompt` is
|
|
1164
|
+
allowed. The node-local **result** fields
|
|
1165
|
+
(`AGENT_RESULT_LOCAL_VARS`/`CONNECTOR_RESULT_LOCAL_VARS`) are deliberately **not**
|
|
1166
|
+
reserved — with **one exception**: an agent emitting `pr` (the canonical
|
|
1167
|
+
`agent → connector[converge] → wait[pr]` shape) writes the same node-local value the
|
|
1168
|
+
result field holds, and the retry reset correctly clears both — reserving them would
|
|
1169
|
+
forbid that flagship pattern. The exception is the agent completion-control **`status`**:
|
|
1170
|
+
the producer status gate reads that same node-local field as the node's completion status
|
|
1171
|
+
and accepts only `done`/`opened`/`skipped`, so an agent emit named `status` carrying any
|
|
1172
|
+
other routing value (`approved`, …) always escalates, and `status="done"` can never take an
|
|
1173
|
+
`approved` branch — the emit contract and the gate conflict. `status` is therefore reserved
|
|
1174
|
+
for an **`agent`** (a connector has no producer status gate, so a connector `status` emit
|
|
1175
|
+
stays allowed); the rest of the result set remains unreserved.
|
|
1176
|
+
|
|
1177
|
+
A **`human` node's required emit is gated fail-closed on completion** (PR #863 review,
|
|
1178
|
+
thread r4198662345). A human node has no worker self-report to producer-gate on, but its
|
|
1179
|
+
single emit can be a downstream-**required** fact — and the captured value is coerced and
|
|
1180
|
+
validated to the emit's declared type (`coerceFactValueFeel`), so an invalid entry becomes
|
|
1181
|
+
`null`. Without a completion gate the node ended and the unconditional edge activated the
|
|
1182
|
+
consumer with a `null` required fact. The compiler therefore grows a **completion gate**
|
|
1183
|
+
when the node's single emit is downstream-required: the user task's own output mapping
|
|
1184
|
+
computes a node-unique validity flag (`resumeValidVar`) from the *raw* captured field via
|
|
1185
|
+
the **same** per-type `resumeValueCondition` grammar the escalation resume enforces
|
|
1186
|
+
(presence included, so a blank required entry is invalid), and a post-task exclusive
|
|
1187
|
+
gateway routes a **valid** completion to the node end while an **invalid** one loops back
|
|
1188
|
+
to the human task for re-entry — the human analogue of the escalation resume's fail-closed
|
|
1189
|
+
re-park (a human node has no separate escalation twin to re-park onto). The gate is grown
|
|
1190
|
+
**only** when the single emit is required: a routing-only/unconsumed emit keeps the direct
|
|
1191
|
+
`task → end` flow (its `null` simply takes a guarded split's deadlock-safe default, exactly
|
|
1192
|
+
like the escalation resume's no-required-target case), and a no-emit acknowledgement node
|
|
1193
|
+
has nothing to validate.
|
|
1194
|
+
|
|
1042
1195
|
## 14. Open questions / future
|
|
1043
1196
|
|
|
1044
1197
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Issue #879: the activation ledger upsert must capture the FIRST activation only, be idempotent on a
|
|
2
|
+
// steady state, and never move/clear an earlier activation timestamp — so the SLA escalation reads a
|
|
3
|
+
// stable never-started-vs-activated signal regardless of how many poll passes observe the job.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { getAgentJobActivation, recordAgentJobObservation } from "./agentActivationLedger.ts";
|
|
7
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
8
|
+
|
|
9
|
+
function ledger() {
|
|
10
|
+
return memDataFor(["119_agent_job_activations.sql"]).data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("first observation of a queued job records first_seen_at, no activation", async () => {
|
|
14
|
+
const data = ledger();
|
|
15
|
+
await recordAgentJobObservation(data, {
|
|
16
|
+
processInstanceKey: "PI-1",
|
|
17
|
+
jobType: "senior:feature",
|
|
18
|
+
worker: null,
|
|
19
|
+
now: "t0",
|
|
20
|
+
});
|
|
21
|
+
const row = await getAgentJobActivation(data, "PI-1", "senior:feature");
|
|
22
|
+
assertEquals(row?.first_seen_at, "t0");
|
|
23
|
+
assertEquals(row?.activated_at, null);
|
|
24
|
+
assertEquals(row?.worker, null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a later observation with a worker records the activation", async () => {
|
|
28
|
+
const data = ledger();
|
|
29
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-2", jobType: "senior:feature", worker: null, now: "t0" });
|
|
30
|
+
await recordAgentJobObservation(data, {
|
|
31
|
+
processInstanceKey: "PI-2",
|
|
32
|
+
jobType: "senior:feature",
|
|
33
|
+
worker: "agent-a",
|
|
34
|
+
now: "t1",
|
|
35
|
+
});
|
|
36
|
+
const row = await getAgentJobActivation(data, "PI-2", "senior:feature");
|
|
37
|
+
assertEquals(row?.first_seen_at, "t0");
|
|
38
|
+
assertEquals(row?.activated_at, "t1");
|
|
39
|
+
assertEquals(row?.worker, "agent-a");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the FIRST activation is sticky — a later re-observation never moves it", async () => {
|
|
43
|
+
const data = ledger();
|
|
44
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-3", jobType: "senior:feature", worker: "agent-a", now: "t1" });
|
|
45
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-3", jobType: "senior:feature", worker: "agent-b", now: "t2" });
|
|
46
|
+
const row = await getAgentJobActivation(data, "PI-3", "senior:feature");
|
|
47
|
+
assertEquals(row?.activated_at, "t1");
|
|
48
|
+
assertEquals(row?.worker, "agent-a");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("observations are keyed by (instance, jobType) — distinct jobs do not collide", async () => {
|
|
52
|
+
const data = ledger();
|
|
53
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-4", jobType: "senior:conformance", worker: null, now: "t0" });
|
|
54
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-4", jobType: "senior:retro", worker: "agent-c", now: "t0" });
|
|
55
|
+
const conf = await getAgentJobActivation(data, "PI-4", "senior:conformance");
|
|
56
|
+
const retro = await getAgentJobActivation(data, "PI-4", "senior:retro");
|
|
57
|
+
assertEquals(conf?.activated_at, null);
|
|
58
|
+
assertEquals(retro?.worker, "agent-c");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an empty-string worker is treated as not-yet-activated", async () => {
|
|
62
|
+
const data = ledger();
|
|
63
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-5", jobType: "senior:feature", worker: "", now: "t0" });
|
|
64
|
+
const row = await getAgentJobActivation(data, "PI-5", "senior:feature");
|
|
65
|
+
assertEquals(row?.activated_at, null);
|
|
66
|
+
assertEquals(row?.worker, null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a new job key (a retry) RESETS the row — an activated attempt followed by an unactivated retry reads never-activated", async () => {
|
|
70
|
+
// Issue #879 review: implement-cell/merge-cell/merge-loop retry the SAME (instance, jobType), so a
|
|
71
|
+
// sticky first-activation would make a queued retry read the EARLIER attempt's worker and misdiagnose
|
|
72
|
+
// it as started/hung. The attempt's engine jobKey discriminates them.
|
|
73
|
+
const data = ledger();
|
|
74
|
+
// Attempt 1: activated by agent-a.
|
|
75
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-6", jobType: "senior:feature", worker: "agent-a", jobKey: "J1", now: "t1" });
|
|
76
|
+
// A steady re-observation of the SAME attempt never moves the first activation.
|
|
77
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-6", jobType: "senior:feature", worker: "agent-a", jobKey: "J1", now: "t2" });
|
|
78
|
+
let row = await getAgentJobActivation(data, "PI-6", "senior:feature");
|
|
79
|
+
assertEquals(row?.activated_at, "t1");
|
|
80
|
+
assertEquals(row?.worker, "agent-a");
|
|
81
|
+
// Attempt 2 (retry): a DIFFERENT jobKey, still queued → the row resets to the current attempt.
|
|
82
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-6", jobType: "senior:feature", worker: null, jobKey: "J2", now: "t3" });
|
|
83
|
+
row = await getAgentJobActivation(data, "PI-6", "senior:feature");
|
|
84
|
+
assertEquals(row?.first_seen_at, "t3");
|
|
85
|
+
assertEquals(row?.activated_at, null);
|
|
86
|
+
assertEquals(row?.worker, null);
|
|
87
|
+
assertEquals(row?.job_key, "J2");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a new job key that is itself activated reflects the CURRENT attempt's worker, not the prior one", async () => {
|
|
91
|
+
const data = ledger();
|
|
92
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-7", jobType: "senior:trial-merge", worker: "agent-a", jobKey: "J1", now: "t1" });
|
|
93
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-7", jobType: "senior:trial-merge", worker: "agent-b", jobKey: "J2", now: "t2" });
|
|
94
|
+
const row = await getAgentJobActivation(data, "PI-7", "senior:trial-merge");
|
|
95
|
+
assertEquals(row?.activated_at, "t2");
|
|
96
|
+
assertEquals(row?.worker, "agent-b");
|
|
97
|
+
assertEquals(row?.job_key, "J2");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the SAME job key keeps the first-activation sticky across re-observations", async () => {
|
|
101
|
+
const data = ledger();
|
|
102
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-8", jobType: "senior:feature", worker: null, jobKey: "J1", now: "t0" });
|
|
103
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-8", jobType: "senior:feature", worker: "agent-a", jobKey: "J1", now: "t1" });
|
|
104
|
+
await recordAgentJobObservation(data, { processInstanceKey: "PI-8", jobType: "senior:feature", worker: "agent-b", jobKey: "J1", now: "t2" });
|
|
105
|
+
const row = await getAgentJobActivation(data, "PI-8", "senior:feature");
|
|
106
|
+
assertEquals(row?.first_seen_at, "t0");
|
|
107
|
+
assertEquals(row?.activated_at, "t1");
|
|
108
|
+
assertEquals(row?.worker, "agent-a");
|
|
109
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Agent-job activation ledger (issue #879) — the durable "did a worker ever pick this agent job up?"
|
|
2
|
+
// record every agent-task SLA-escalation reads to tell a NEVER-ACTIVATED (queue-starved) job apart
|
|
3
|
+
// from an ACTIVATED-but-hung one.
|
|
4
|
+
//
|
|
5
|
+
// Why it exists: an agent-task SLA boundary timer (`<bpmn:timeDuration>=agentSlaTimeout`) starts when
|
|
6
|
+
// the service task is ENTERED, so it bounds QUEUE time + RUN time together. Under fleet saturation a
|
|
7
|
+
// job can sit CREATED (no worker can take its type) for the whole budget and the boundary fires — but
|
|
8
|
+
// the escalation reported it as "hung or looping" and told the human to "check its progress" when
|
|
9
|
+
// there is none (issue #879: a `senior:feature` job queued 2h, never activated, then SLA-cancelled).
|
|
10
|
+
// The engine knows JobState::Created vs ::Activated, but once the boundary CANCELS the job that state
|
|
11
|
+
// is gone — so the observation must be captured WHILE the job is live and persisted. `pollAgentActivations`
|
|
12
|
+
// (app/service.ts) does exactly that, writing this ledger; the SLA-escalation recorders read it.
|
|
13
|
+
//
|
|
14
|
+
// The activation signal is the same one the review-round visibility uses (migration 005): the
|
|
15
|
+
// Camunda-8 `/v2/jobs/search` wire API collapses Activated -> CREATED (no ACTIVATED enum), so a leased
|
|
16
|
+
// job is recognised by its `worker` + `deadline` fields — a merely-queued job carries neither. This
|
|
17
|
+
// ledger generalises that to EVERY bounded agent job type and persists first-seen + first-activated.
|
|
18
|
+
//
|
|
19
|
+
// Advisory & best-effort (ADR 0056): this is an app-tier read model over the engine's job read model.
|
|
20
|
+
// It never activates/completes a job, publishes a message, or gates a BPMN sequence flow — it only
|
|
21
|
+
// enriches the human-facing escalation wording. A missing row (poller never observed the job) degrades
|
|
22
|
+
// to "activation unknown", never an error.
|
|
23
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
24
|
+
|
|
25
|
+
/** The agent job types that today carry an interrupting SLA timer boundary whose elapse escalates to a
|
|
26
|
+
* human with a "hung or looping" reason — the class issue #879 guards. Kept as one list so the
|
|
27
|
+
* activation poll's `/v2/jobs/search` type filter and the escalation recorders agree on exactly which
|
|
28
|
+
* jobs are tracked. (The convergence-loop / plan-fanout agent tasks are NOT yet bounded — pre-seeded
|
|
29
|
+
* for #868 — so they are deliberately absent until their boundaries land.) */
|
|
30
|
+
export const AGENT_SLA_JOB_TYPES: readonly string[] = [
|
|
31
|
+
"senior:feature", // implement-cell
|
|
32
|
+
"senior:trial-merge", // merge-cell
|
|
33
|
+
"senior:conformance", // retro conformance
|
|
34
|
+
"senior:retro", // retro synthesize
|
|
35
|
+
"senior:fix-ci", // merge-loop CI-fix cell (be_fixci_sla)
|
|
36
|
+
"senior:rebase", // merge-loop rebase cell (be_rebase_sla)
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** One ledger row: whether (and when) a worker picked up an agent job for a given process instance. */
|
|
40
|
+
export interface AgentJobActivation {
|
|
41
|
+
/** Surrogate key `<process_instance_key>:<job_type>`. */
|
|
42
|
+
id: string;
|
|
43
|
+
process_instance_key: string;
|
|
44
|
+
job_type: string;
|
|
45
|
+
/** ISO ts the poller first observed the job (queued/CREATED). */
|
|
46
|
+
first_seen_at: string;
|
|
47
|
+
/** ISO ts first observed ACTIVATED (a leasing worker appeared); NULL ⇒ never started. */
|
|
48
|
+
activated_at: string | null;
|
|
49
|
+
/** The leasing worker's name at activation; NULL until activated. */
|
|
50
|
+
worker: string | null;
|
|
51
|
+
/** The engine job key of the attempt this row currently tracks; NULL when unknown (an old row or a
|
|
52
|
+
* caller that did not supply one). A poll observation carrying a DIFFERENT job key is a new attempt
|
|
53
|
+
* and resets the row — see {@link recordAgentJobObservation}. */
|
|
54
|
+
job_key: string | null;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The surrogate key for a (process instance, job type) pair — the agent job and its SLA-escalation
|
|
59
|
+
* recorder share one process instance, so both sides key the ledger identically. */
|
|
60
|
+
export function agentActivationId(processInstanceKey: string, jobType: string): string {
|
|
61
|
+
return `${processInstanceKey}:${jobType}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Record gateway over the activation ledger. */
|
|
65
|
+
export const agentActivations = (data: DataLayer) =>
|
|
66
|
+
data.table<AgentJobActivation>("agent_job_activations", "id");
|
|
67
|
+
|
|
68
|
+
/** Upsert one poll observation of an agent job. On first sight records `first_seen_at`; the FIRST time
|
|
69
|
+
* a leasing `worker` is observed it records `activated_at` + `worker` (a later re-observation of the
|
|
70
|
+
* SAME attempt never moves the first-activation timestamp). Idempotent on a steady state.
|
|
71
|
+
*
|
|
72
|
+
* Retry isolation (issue #879 review): the same (process instance, job type) runs MULTIPLE times within
|
|
73
|
+
* one instance when its cell retries (`ic_reImplement`, `mc_rerun`, merge-loop fix-ci/rebase reloops).
|
|
74
|
+
* Each attempt is a distinct engine job with its own `jobKey`, so when an observation carries a job key
|
|
75
|
+
* DIFFERENT from the one the row tracks, it is a NEW attempt and the row is RESET to it (first_seen_at /
|
|
76
|
+
* activated_at / worker), discarding the earlier attempt's activation. Without this a sticky
|
|
77
|
+
* first-activation row would make a later queued retry read the earlier attempt's worker and misdiagnose
|
|
78
|
+
* a queue-starved retry as started/hung, and its elapsed times would fold in the prior attempt + human
|
|
79
|
+
* wait. When the job key is unknown on either side the row keeps its prior first-sticky-activation
|
|
80
|
+
* behaviour (the caller cannot prove a new attempt). */
|
|
81
|
+
export async function recordAgentJobObservation(
|
|
82
|
+
data: DataLayer,
|
|
83
|
+
input: {
|
|
84
|
+
processInstanceKey: string;
|
|
85
|
+
jobType: string;
|
|
86
|
+
worker: string | null;
|
|
87
|
+
jobKey?: string | null;
|
|
88
|
+
now: string;
|
|
89
|
+
},
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const table = agentActivations(data);
|
|
92
|
+
const id = agentActivationId(input.processInstanceKey, input.jobType);
|
|
93
|
+
const worker = input.worker && input.worker.length > 0 ? input.worker : null;
|
|
94
|
+
const jobKey = input.jobKey && input.jobKey.length > 0 ? input.jobKey : null;
|
|
95
|
+
const existing = await table.get(id);
|
|
96
|
+
if (!existing) {
|
|
97
|
+
await table.insert({
|
|
98
|
+
id,
|
|
99
|
+
process_instance_key: input.processInstanceKey,
|
|
100
|
+
job_type: input.jobType,
|
|
101
|
+
first_seen_at: input.now,
|
|
102
|
+
activated_at: worker ? input.now : null,
|
|
103
|
+
worker,
|
|
104
|
+
job_key: jobKey,
|
|
105
|
+
updated_at: input.now,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const existingKey = existing.job_key && existing.job_key.length > 0 ? existing.job_key : null;
|
|
110
|
+
// A different engine job key for the same (instance, jobType) is a NEW attempt — reset the row so it
|
|
111
|
+
// reflects only the current attempt, never a stale earlier activation/wait.
|
|
112
|
+
if (jobKey && existingKey && jobKey !== existingKey) {
|
|
113
|
+
await table.update(id, {
|
|
114
|
+
first_seen_at: input.now,
|
|
115
|
+
activated_at: worker ? input.now : null,
|
|
116
|
+
worker,
|
|
117
|
+
job_key: jobKey,
|
|
118
|
+
updated_at: input.now,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Same attempt (or job key unknown): capture the FIRST activation only, and adopt a newly-known job
|
|
123
|
+
// key so a subsequent different one is recognised as a new attempt.
|
|
124
|
+
const patch: Partial<AgentJobActivation> = {};
|
|
125
|
+
if (worker && !existing.activated_at) {
|
|
126
|
+
patch.activated_at = input.now;
|
|
127
|
+
patch.worker = worker;
|
|
128
|
+
}
|
|
129
|
+
if (jobKey && !existingKey) patch.job_key = jobKey;
|
|
130
|
+
if (Object.keys(patch).length > 0) {
|
|
131
|
+
patch.updated_at = input.now;
|
|
132
|
+
await table.update(id, patch);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read the activation record for a (process instance, job type), or `undefined` when the poller never
|
|
137
|
+
* observed the job. */
|
|
138
|
+
export async function getAgentJobActivation(
|
|
139
|
+
data: DataLayer,
|
|
140
|
+
processInstanceKey: string,
|
|
141
|
+
jobType: string,
|
|
142
|
+
): Promise<AgentJobActivation | undefined> {
|
|
143
|
+
return agentActivations(data).get(agentActivationId(processInstanceKey, jobType));
|
|
144
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Issue #879: `pollAgentActivationsImpl` must persist, for every bounded agent job type, whether a
|
|
2
|
+
// worker ever leased the job — keyed by (processInstanceKey, jobType) — so a later SLA escalation can
|
|
3
|
+
// tell a never-activated (queue-starved) job from an activated one even after the boundary cancels it.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { getAgentJobActivation } from "./agentActivationLedger.ts";
|
|
7
|
+
import { pollAgentActivationsImpl } from "./service.ts";
|
|
8
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
9
|
+
|
|
10
|
+
// biome-ignore lint/suspicious/noExplicitAny: test stub over the engine job-search wire shape.
|
|
11
|
+
type JobItem = any;
|
|
12
|
+
|
|
13
|
+
function jobsFetch(items: JobItem[], opts: { ignoreTypeFilter?: boolean } = {}) {
|
|
14
|
+
return (_url: string, init?: { body?: string }) => {
|
|
15
|
+
const body = init?.body ? JSON.parse(init.body) : {};
|
|
16
|
+
const t = body.filter?.type;
|
|
17
|
+
const wanted: string[] | null = Array.isArray(t?.$in) ? t.$in : typeof t === "string" ? [t] : null;
|
|
18
|
+
const matched = opts.ignoreTypeFilter || wanted == null ? items : items.filter((j) => wanted.includes(j.type ?? ""));
|
|
19
|
+
// Honour the `page: { from, limit }` paging contract so a pagination regression can observe
|
|
20
|
+
// whether the poller actually advances past the first page.
|
|
21
|
+
const from = typeof body.page?.from === "number" ? body.page.from : 0;
|
|
22
|
+
const limit = typeof body.page?.limit === "number" ? body.page.limit : matched.length;
|
|
23
|
+
const out = matched.slice(from, from + limit);
|
|
24
|
+
return Promise.resolve(
|
|
25
|
+
new Response(JSON.stringify({ items: out }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const headers = { "content-type": "application/json" };
|
|
31
|
+
|
|
32
|
+
test("poll records a queued (unleased) agent job as not-yet-activated", async () => {
|
|
33
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
34
|
+
const prev = globalThis.fetch;
|
|
35
|
+
globalThis.fetch = jobsFetch([
|
|
36
|
+
{ type: "senior:feature", processInstanceKey: "PI-10", state: "CREATED" },
|
|
37
|
+
]) as typeof fetch;
|
|
38
|
+
try {
|
|
39
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
40
|
+
} finally {
|
|
41
|
+
globalThis.fetch = prev;
|
|
42
|
+
}
|
|
43
|
+
const row = await getAgentJobActivation(data, "PI-10", "senior:feature");
|
|
44
|
+
assertEquals(row?.activated_at, null);
|
|
45
|
+
assertEquals(row?.worker, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("poll records an activation when a leasing worker appears, across passes", async () => {
|
|
49
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
50
|
+
const prev = globalThis.fetch;
|
|
51
|
+
try {
|
|
52
|
+
globalThis.fetch = jobsFetch([
|
|
53
|
+
{ type: "senior:trial-merge", processInstanceKey: "PI-11", state: "CREATED" },
|
|
54
|
+
]) as typeof fetch;
|
|
55
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
56
|
+
assertEquals((await getAgentJobActivation(data, "PI-11", "senior:trial-merge"))?.activated_at, null);
|
|
57
|
+
|
|
58
|
+
globalThis.fetch = jobsFetch([
|
|
59
|
+
{ type: "senior:trial-merge", processInstanceKey: "PI-11", worker: "agent-m", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
|
|
60
|
+
]) as typeof fetch;
|
|
61
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
62
|
+
const row = await getAgentJobActivation(data, "PI-11", "senior:trial-merge");
|
|
63
|
+
assertEquals(row?.worker, "agent-m");
|
|
64
|
+
assertEquals(typeof row?.activated_at === "string", true);
|
|
65
|
+
} finally {
|
|
66
|
+
globalThis.fetch = prev;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("poll defensively excludes a non-agent job even if the wire $in filter is ignored", async () => {
|
|
71
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
72
|
+
const prev = globalThis.fetch;
|
|
73
|
+
globalThis.fetch = jobsFetch(
|
|
74
|
+
[{ type: "pr.capture-head", processInstanceKey: "PI-12", worker: "host-internal", state: "CREATED" }],
|
|
75
|
+
{ ignoreTypeFilter: true },
|
|
76
|
+
) as typeof fetch;
|
|
77
|
+
try {
|
|
78
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
79
|
+
} finally {
|
|
80
|
+
globalThis.fetch = prev;
|
|
81
|
+
}
|
|
82
|
+
assertEquals(await getAgentJobActivation(data, "PI-12", "pr.capture-head"), undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("poll skips an item with no process instance to key on", async () => {
|
|
86
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
87
|
+
const prev = globalThis.fetch;
|
|
88
|
+
globalThis.fetch = jobsFetch([{ type: "senior:feature", state: "CREATED" }]) as typeof fetch;
|
|
89
|
+
try {
|
|
90
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
91
|
+
} finally {
|
|
92
|
+
globalThis.fetch = prev;
|
|
93
|
+
}
|
|
94
|
+
assertEquals(await getAgentJobActivation(data, "", "senior:feature"), undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("poll leaves the ledger untouched when the engine search fails", async () => {
|
|
98
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
99
|
+
const prev = globalThis.fetch;
|
|
100
|
+
globalThis.fetch = (() =>
|
|
101
|
+
Promise.resolve(new Response("boom", { status: 500 }))) as typeof fetch;
|
|
102
|
+
try {
|
|
103
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
104
|
+
} finally {
|
|
105
|
+
globalThis.fetch = prev;
|
|
106
|
+
}
|
|
107
|
+
assertEquals(await getAgentJobActivation(data, "PI-13", "senior:feature"), undefined);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("poll records the merge-loop bounded agent jobs (senior:fix-ci, senior:rebase)", async () => {
|
|
111
|
+
// Regression for the #881 adversarial finding: merge-loop.bpmn arms an interrupting SLA boundary
|
|
112
|
+
// on its fix-ci and rebase agent tasks and routes the escalation through agentSlaEscalationQuestion
|
|
113
|
+
// — but if AGENT_SLA_JOB_TYPES omits those job types the poller never observes them and every such
|
|
114
|
+
// escalation degrades to "activation unknown" instead of the never-started/hung distinction.
|
|
115
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
116
|
+
const prev = globalThis.fetch;
|
|
117
|
+
globalThis.fetch = jobsFetch([
|
|
118
|
+
{ type: "senior:fix-ci", processInstanceKey: "PI-20", state: "CREATED" },
|
|
119
|
+
{ type: "senior:rebase", processInstanceKey: "PI-20", worker: "agent-r", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
|
|
120
|
+
]) as typeof fetch;
|
|
121
|
+
try {
|
|
122
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
123
|
+
} finally {
|
|
124
|
+
globalThis.fetch = prev;
|
|
125
|
+
}
|
|
126
|
+
const fixCi = await getAgentJobActivation(data, "PI-20", "senior:fix-ci");
|
|
127
|
+
assertEquals(fixCi?.activated_at, null);
|
|
128
|
+
const rebase = await getAgentJobActivation(data, "PI-20", "senior:rebase");
|
|
129
|
+
assertEquals(rebase?.worker, "agent-r");
|
|
130
|
+
assertEquals(typeof rebase?.activated_at === "string", true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("poll records jobs BEYOND the first page (pagination)", async () => {
|
|
134
|
+
// Issue #881 review: a single first-page read lets long-lived jobs occupy the page every pass while
|
|
135
|
+
// later jobs time out with no activation recorded. 450 queued jobs > one 200-item page must all land.
|
|
136
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
137
|
+
const many: JobItem[] = Array.from({ length: 450 }, (_v, i) => ({
|
|
138
|
+
type: "senior:feature",
|
|
139
|
+
processInstanceKey: `PI-${i}`,
|
|
140
|
+
jobKey: `J-${i}`,
|
|
141
|
+
state: "CREATED",
|
|
142
|
+
}));
|
|
143
|
+
const prev = globalThis.fetch;
|
|
144
|
+
globalThis.fetch = jobsFetch(many) as typeof fetch;
|
|
145
|
+
try {
|
|
146
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
147
|
+
} finally {
|
|
148
|
+
globalThis.fetch = prev;
|
|
149
|
+
}
|
|
150
|
+
// A job on the first page, one straddling the page boundary, and the very last job are all recorded.
|
|
151
|
+
assertEquals((await getAgentJobActivation(data, "PI-0", "senior:feature"))?.first_seen_at != null, true);
|
|
152
|
+
assertEquals((await getAgentJobActivation(data, "PI-200", "senior:feature"))?.first_seen_at != null, true);
|
|
153
|
+
assertEquals((await getAgentJobActivation(data, "PI-449", "senior:feature"))?.first_seen_at != null, true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("poll resets the ledger to a retry (new jobKey) so an earlier attempt's activation is not replayed", async () => {
|
|
157
|
+
// Issue #881 review: a retrying agent task reuses (instance, jobType); a new engine jobKey is a new
|
|
158
|
+
// attempt and must reset the activation the recorder reads.
|
|
159
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
160
|
+
const prev = globalThis.fetch;
|
|
161
|
+
try {
|
|
162
|
+
globalThis.fetch = jobsFetch([
|
|
163
|
+
{ type: "senior:feature", processInstanceKey: "PI-30", jobKey: "A1", worker: "agent-a", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
|
|
164
|
+
]) as typeof fetch;
|
|
165
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
166
|
+
assertEquals((await getAgentJobActivation(data, "PI-30", "senior:feature"))?.worker, "agent-a");
|
|
167
|
+
|
|
168
|
+
// The retry is a different jobKey, still queued → the recorder must see never-activated, not agent-a.
|
|
169
|
+
globalThis.fetch = jobsFetch([
|
|
170
|
+
{ type: "senior:feature", processInstanceKey: "PI-30", jobKey: "A2", state: "CREATED" },
|
|
171
|
+
]) as typeof fetch;
|
|
172
|
+
await pollAgentActivationsImpl(data, "http://engine/v2", headers);
|
|
173
|
+
const row = await getAgentJobActivation(data, "PI-30", "senior:feature");
|
|
174
|
+
assertEquals(row?.activated_at, null);
|
|
175
|
+
assertEquals(row?.worker, null);
|
|
176
|
+
assertEquals(row?.job_key, "A2");
|
|
177
|
+
} finally {
|
|
178
|
+
globalThis.fetch = prev;
|
|
179
|
+
}
|
|
180
|
+
});
|