@nanobpm/nano-workforce 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.41.0](https://github.com/nanobpm/nano-workforce/compare/v0.40.2...v0.41.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **api:** serve an agent operator guide at GET /app/api/agent ([#118](https://github.com/nanobpm/nano-workforce/issues/118)) ([6768407](https://github.com/nanobpm/nano-workforce/commit/67684075cfe4706db7dfc63f8c536990254a7480))
7
+
8
+ ## [0.40.2](https://github.com/nanobpm/nano-workforce/compare/v0.40.1...v0.40.2) (2026-08-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** escape wait-landed when a queued PR is evicted on conflict ([#117](https://github.com/nanobpm/nano-workforce/issues/117)) ([df7f850](https://github.com/nanobpm/nano-workforce/commit/df7f85050624f11490d715c90e054a5c7fb6d0fa)), closes [Magikcraft/nano-bpm#727](https://github.com/Magikcraft/nano-bpm/issues/727)
14
+
1
15
  ## [0.40.1](https://github.com/nanobpm/nano-workforce/compare/v0.40.0...v0.40.1) (2026-08-11)
2
16
 
3
17
 
package/README.md CHANGED
@@ -279,6 +279,26 @@ per-instance context (e.g. a human's escalation answer) is appended by the harne
279
279
 
280
280
  ---
281
281
 
282
+ ## Point an agent at it (self-serve guide)
283
+
284
+ The running app serves a live **agent operator guide** at
285
+ `GET /app/api/agent` — how to submit PRs (review-only vs. merge), hand over an epic,
286
+ answer escalations, and **debug** the system (find engine instances, relate them to
287
+ PRs, inspect the models/prompts, unstick stuck processes, and raise issues/PRs). Its
288
+ examples are keyed to the instance you fetched it from, so a coding agent can drive
289
+ **and** debug the workforce with no extra context:
290
+
291
+ ```bash
292
+ curl -sS http://localhost:3000/app/api/agent | jq -r .instructions
293
+ ```
294
+
295
+ Like `/version` and `/status`, this endpoint honours the optional
296
+ `NANO_PR_WEBHOOK_SECRET` guard (`X-Hook-Secret` header): when that secret is set it
297
+ returns `401` without the matching header; unset = open. The source lives in
298
+ `resources/agent-guide.md`.
299
+
300
+ ---
301
+
282
302
  ## Architecture & contributing
283
303
 
284
304
  - **[SPEC.md](SPEC.md)** — the behavioural source of truth for the processes.
@@ -0,0 +1,64 @@
1
+ // The agent operator guide served by GET /app/api/agent (operationId `getAgentInstructions`).
2
+ //
3
+ // The guide itself is authored as plain markdown in `resources/agent-guide.md` (kept out of
4
+ // `prompts/` so it is NOT treated as a deployable agent template) and read from the checkout at
5
+ // module load — same "run the .ts sources directly, inspect the working tree at runtime" approach
6
+ // as version.ts. Two placeholders are substituted per request/deployment so the embedded examples
7
+ // are copy-pasteable against THIS instance:
8
+ // • __BASE__ → the app control-API base the caller reached us on (e.g. https://host/app/api)
9
+ // • __ENGINE__ → the engine's Camunda-8 v2 REST base this app is configured to talk to
10
+ //
11
+ // Reading is best-effort: a missing file yields a short built-in fallback rather than throwing, so
12
+ // the endpoint never 500s just because the doc is absent from a stripped-down deploy.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const GUIDE_PATH = join(REPO_ROOT, "resources", "agent-guide.md");
19
+
20
+ // Read the raw guide once, at module load. Frozen for the life of the process.
21
+ const RAW_GUIDE: string = (() => {
22
+ try {
23
+ return readFileSync(GUIDE_PATH, "utf8");
24
+ } catch {
25
+ return [
26
+ "# Nano Workforce — agent operator guide",
27
+ "",
28
+ "The full guide document could not be read from this deployment.",
29
+ "",
30
+ "Key endpoints (under the app control-API base `__BASE__`):",
31
+ "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
32
+ "- `GET /version` — which code is live.",
33
+ "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
+ "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue }`).",
35
+ "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
+ "",
37
+ "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
38
+ "Source repository: `nanobpm/nano-workforce`.",
39
+ "",
40
+ ].join("\n");
41
+ }
42
+ })();
43
+
44
+ /**
45
+ * The engine's Camunda-8 v2 REST base this app talks to, resolved exactly as `main.ts` does:
46
+ * an explicit `CAMUNDA_REST_ADDRESS` wins, else `${NANOBPMN_BASE_URL}/v2` (default localhost:8080).
47
+ * Trailing slashes are trimmed so the guide's `__ENGINE__/jobs/search` examples are well-formed.
48
+ */
49
+ export function resolveEngineBase(): string {
50
+ const explicit = process.env.CAMUNDA_REST_ADDRESS;
51
+ const base = explicit?.trim()
52
+ ? explicit.trim()
53
+ : `${(process.env.NANOBPMN_BASE_URL ?? "http://localhost:8080").replace(/\/+$/, "")}/v2`;
54
+ return base.replace(/\/+$/, "");
55
+ }
56
+
57
+ /**
58
+ * Render the guide for a given app control-API base (e.g. "https://host/app/api"). The engine base
59
+ * is resolved from the environment. Substitutes every `__BASE__`/`__ENGINE__` occurrence.
60
+ */
61
+ export function renderAgentGuide(apiBase: string): string {
62
+ const base = apiBase.replace(/\/+$/, "");
63
+ return RAW_GUIDE.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
64
+ }
@@ -0,0 +1,40 @@
1
+ // The merge-loop enqueues a "ready" PR (mergeStatus="queued") and parks at `wait-landed`. This
2
+ // decision drives what the poller does next from the PR's live GitHub state. The regression it
3
+ // guards: a PR that develops a merge CONFLICT after being enqueued (#727/instance 729) must be
4
+ // EVICTED back to the mergeable gate, not left waiting forever — while a PR still legitimately in
5
+ // the queue (reported BLOCKED/UNSTABLE by GitHub) must keep waiting, never be falsely evicted.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { PrState } from "./github.ts";
9
+ import { queuedVerdict } from "./service.ts";
10
+
11
+ function st(over: Partial<PrState>): PrState {
12
+ return {
13
+ merged: false,
14
+ mergeStateStatus: "CLEAN",
15
+ failingChecks: 0,
16
+ failingCheckNames: [],
17
+ presentCheckNames: [],
18
+ totalChecks: 0,
19
+ isDraft: false,
20
+ headRefOid: null,
21
+ ...over,
22
+ };
23
+ }
24
+
25
+ test("a landed PR advances (merge-landed)", () => {
26
+ assertEquals(queuedVerdict(st({ merged: true, mergeStateStatus: "CLEAN" })), "landed");
27
+ // `merged` wins even if a stale mergeStateStatus lags.
28
+ assertEquals(queuedVerdict(st({ merged: true, mergeStateStatus: "DIRTY" })), "landed");
29
+ });
30
+
31
+ test("a DIRTY (conflicting) PR is evicted — this is the #727 wedge", () => {
32
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: "DIRTY" })), "evicted");
33
+ });
34
+
35
+ test("a PR still legitimately in the queue keeps waiting (never falsely evicted)", () => {
36
+ // Queuing PRs commonly report these; none is a conflict, so none may evict.
37
+ for (const s of ["CLEAN", "BLOCKED", "UNSTABLE", "BEHIND", "HAS_HOOKS", "UNKNOWN", "DRAFT"]) {
38
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s })), "waiting", s);
39
+ }
40
+ });
package/app/service.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  fetchPrState,
18
18
  hasPendingCopilotReviewer,
19
19
  type MergeMethod,
20
+ type PrState,
20
21
  requestCopilotReview,
21
22
  } from "./github.ts";
22
23
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
@@ -637,7 +638,7 @@ async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "op
637
638
  * • waiting_deps → every declared dependency has merged → `deps-cleared`
638
639
  * • waiting_merge → GitHub settled the PR as mergeable/blocked → `merge-ready` {mergeState}
639
640
  * • waiting_lane → predecessor in same exclusion lane merged → re-arm `waiting_merge`
640
- * • queued → the queued PR has landed on GitHub → `merge-landed`
641
+ * • queued → the queued PR landed → `merge-landed`; or it conflicts (DIRTY) → `merge-evicted`
641
642
  * On publish we flip status to the transient `merging` (which no branch scans) so a slow pass
642
643
  * can't double-signal, exactly as `pollReviews` flips to `converging`; `flipToMergingThenPublish`
643
644
  * reverts the flip if the publish fails so a failed handoff can't wedge the PR. */
@@ -756,25 +757,60 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
756
757
  }
757
758
  }
758
759
 
759
- // 4) Queued PR landed?
760
+ // 4) Queued PR landed — or fell out of the merge queue?
761
+ //
762
+ // A PR enqueued by `attempt-merge` (mergeStatus="queued") parks the process at `wait-landed`.
763
+ // Two things can end that wait: the queue lands the PR (→ `merge-landed`), or the PR is EVICTED
764
+ // from the queue because its base moved under it and it now conflicts (`DIRTY`). Without the
765
+ // eviction path a conflicted-after-enqueue PR waits forever (nothing ever publishes
766
+ // `merge-landed`), which is exactly how #727/instance 729 wedged. We must NOT treat a merely
767
+ // "not yet landed" PR as evicted: while it is legitimately queuing GitHub often reports it as
768
+ // BLOCKED (a pending queue check) or UNSTABLE, which `classifyMergeability` calls not-ready. Only
769
+ // a genuine merge CONFLICT (`DIRTY`) means it has dropped out — evict on that alone and re-arm the
770
+ // merge poller (`merge-evicted` → `arm-merge`), which re-runs the mergeable gate so the existing
771
+ // auto-rebase / escalate machinery resolves the conflict.
760
772
  for (const pr of await prs(data).find({ status: "queued" })) {
761
773
  const { repo, number, pr_key: prKey } = pr;
762
774
  try {
763
775
  const st = await fetchPrState(repo, number, token);
764
776
  if (st === null) continue; // no transport → skip this PR (others may still advance)
765
- if (!st.merged) continue; // still in the queue
766
- await flipToMergingThenPublish(data, engine, prKey, "queued", {
767
- name: "merge-landed",
768
- correlationKey: prKey,
769
- variables: {},
770
- });
771
- console.log(`[poller] queued PR landed -> ${prKey}`);
777
+ const verdict = queuedVerdict(st);
778
+ if (verdict === "landed") {
779
+ await flipToMergingThenPublish(data, engine, prKey, "queued", {
780
+ name: "merge-landed",
781
+ correlationKey: prKey,
782
+ variables: {},
783
+ });
784
+ console.log(`[poller] queued PR landed -> ${prKey}`);
785
+ } else if (verdict === "evicted") {
786
+ await flipToMergingThenPublish(data, engine, prKey, "queued", {
787
+ name: "merge-evicted",
788
+ correlationKey: prKey,
789
+ variables: {},
790
+ });
791
+ console.log(`[poller] queued PR evicted (conflict) -> ${prKey}`);
792
+ }
793
+ // otherwise: still legitimately in the queue — keep waiting.
772
794
  } catch (err) {
773
795
  console.error(`[poller] queued ${prKey}: ${err}`);
774
796
  }
775
797
  }
776
798
  }
777
799
 
800
+ /** Decide what to do with a PR the process enqueued (parked at `wait-landed`), from its current
801
+ * GitHub merge state:
802
+ * • `landed` — the queue merged it → publish `merge-landed` (advance to mark-merged).
803
+ * • `evicted` — it fell out of the queue with a real merge CONFLICT (`DIRTY`) → publish
804
+ * `merge-evicted` so the process re-arms the merge poller and the mergeable gate re-runs
805
+ * (auto-rebase / escalate). Without this a conflicted-after-enqueue PR waits forever.
806
+ * • `waiting` — still legitimately in the queue. Crucially, a queuing PR is frequently reported
807
+ * BLOCKED/UNSTABLE (a pending queue check), which is NOT eviction — only `DIRTY` is. */
808
+ export function queuedVerdict(st: PrState): "landed" | "evicted" | "waiting" {
809
+ if (st.merged) return "landed";
810
+ if (st.mergeStateStatus === "DIRTY") return "evicted";
811
+ return "waiting";
812
+ }
813
+
778
814
  /** The subset of a Camunda-8 `/v2/jobs/search` result item this app reads. `worker` is the
779
815
  * leasing worker's name (empty/absent until an agent activates the job); `deadline` is the
780
816
  * activation lock's expiry (ISO ts). */
package/openapi.yaml CHANGED
@@ -137,6 +137,42 @@ components:
137
137
  type: string
138
138
  uptimeSeconds:
139
139
  type: integer
140
+ AgentInstructions:
141
+ type: object
142
+ description: The agent operator guide — how to drive (submit PRs/epics, answer escalations)
143
+ and debug (find engine instances, relate them to PRs, inspect models/prompts, unstick stuck
144
+ processes, raise issues/PRs) this Nano Workforce instance. The `instructions` markdown has
145
+ its example commands keyed to this instance's `baseUrl`/`engineBase`.
146
+ additionalProperties: false
147
+ required:
148
+ - format
149
+ - appVersion
150
+ - generatedAt
151
+ - baseUrl
152
+ - engineBase
153
+ - instructions
154
+ properties:
155
+ format:
156
+ type: string
157
+ description: The `instructions` media format. Always "markdown".
158
+ enum:
159
+ - markdown
160
+ appVersion:
161
+ type: string
162
+ nullable: true
163
+ description: The running app version this guide matches (null when unreadable).
164
+ generatedAt:
165
+ type: string
166
+ description: When this response was rendered (ISO-8601).
167
+ baseUrl:
168
+ type: string
169
+ description: The app control-API base the examples target (e.g. https://host/app/api).
170
+ engineBase:
171
+ type: string
172
+ description: The engine's Camunda-8 v2 REST base this app talks to, for debugging queries.
173
+ instructions:
174
+ type: string
175
+ description: The full operator guide as markdown.
140
176
  SubmitResult:
141
177
  type: object
142
178
  required:
@@ -387,6 +423,27 @@ paths:
387
423
  application/json:
388
424
  schema:
389
425
  $ref: "#/components/schemas/ErrorBody"
426
+ /agent:
427
+ get:
428
+ operationId: getAgentInstructions
429
+ summary: The agent operator guide (markdown) — how to submit PRs/epics, answer escalations, and
430
+ debug this instance. Point a coding agent at this URL to drive and debug the workforce.
431
+ security:
432
+ - hookSecret: []
433
+ - {}
434
+ responses:
435
+ "200":
436
+ description: The operator guide, with examples keyed to this instance.
437
+ content:
438
+ application/json:
439
+ schema:
440
+ $ref: "#/components/schemas/AgentInstructions"
441
+ "401":
442
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
443
+ content:
444
+ application/json:
445
+ schema:
446
+ $ref: "#/components/schemas/ErrorBody"
390
447
  /actions/start/convergence-loop:
391
448
  post:
392
449
  operationId: startConvergenceLoop
@@ -0,0 +1,100 @@
1
+ // Tests for GET /app/api/agent → operation `getAgentInstructions` (ADR 0058 OpenAPI surface).
2
+ // The guide markdown is served as the `instructions` field with its examples keyed to the request's
3
+ // control-API base + the configured engine base. Mirrors the getVersion test's request shape and
4
+ // shared-secret guard pattern.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import type { AppApi } from "@nanobpm/urban";
8
+ import handler from "./getAgentInstructions.ts";
9
+
10
+ const app = {} as any as AppApi;
11
+
12
+ function input(headers: Record<string, string> = {}, path = "/app/api/agent") {
13
+ return {
14
+ req: {
15
+ method: "GET",
16
+ path,
17
+ query: new URLSearchParams(),
18
+ headers: new Headers(headers),
19
+ text: async () => "",
20
+ } as any,
21
+ params: {},
22
+ query: {},
23
+ body: undefined,
24
+ };
25
+ }
26
+
27
+ test("returns 200 with the markdown guide and metadata", async () => {
28
+ const r = (await handler(input(), app)) as any;
29
+ assertEquals(r.status, 200);
30
+ assertEquals(r.body.format, "markdown");
31
+ assert("appVersion" in r.body); // nullable, but always present
32
+ assert(typeof r.body.generatedAt === "string" && r.body.generatedAt.length > 0);
33
+ assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
34
+ assert(typeof r.body.engineBase === "string" && r.body.engineBase.length > 0);
35
+ assert(typeof r.body.instructions === "string" && r.body.instructions.length > 200);
36
+ });
37
+
38
+ test("the guide covers every capability the endpoint promises", async () => {
39
+ const md = ((await handler(input(), app)) as any).body.instructions as string;
40
+ // Submit a PR (converge vs. merge), submit an epic, answer escalations…
41
+ assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
42
+ assert(md.includes("convergeOnly"), "documents review-only vs. merge");
43
+ assert(md.includes("start/plan-fanout"), "covers submitting an epic");
44
+ assert(md.includes("escalation-answered"), "covers answering escalations");
45
+ // …debug the system.
46
+ assert(md.includes("/jobs/search") && md.includes("/incidents/search"), "covers engine REST debugging");
47
+ assert(md.includes("processKey") || md.includes("process_key"), "relates instances to PRs");
48
+ assert(md.includes("resources/processes") && md.includes("prompts/"), "covers debugging models + prompts");
49
+ assert(md.includes("nanobpm/nano-workforce"), "covers raising issues/PRs against the repo");
50
+ });
51
+
52
+ test("examples are keyed to the request's control-API base and leave no placeholders", async () => {
53
+ const forwarded = input({ host: "wf.example.com", "x-forwarded-proto": "https" });
54
+ const md = ((await handler(forwarded, app)) as any).body.instructions as string;
55
+ const body = (await handler(forwarded, app)) as any;
56
+ assertEquals(body.body.baseUrl, "https://wf.example.com/app/api");
57
+ assert(md.includes("https://wf.example.com/app/api/version"), "base URL substituted into examples");
58
+ assert(!md.includes("__BASE__"), "no unsubstituted __BASE__ placeholder");
59
+ assert(!md.includes("__ENGINE__"), "no unsubstituted __ENGINE__ placeholder");
60
+ });
61
+
62
+ test("x-forwarded-proto is restricted to http/https", async () => {
63
+ const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
64
+ const body = (await handler(spoofed, app)) as any;
65
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "unsafe scheme falls back to http");
66
+ });
67
+
68
+ test("engine base follows CAMUNDA_REST_ADDRESS / NANOBPMN_BASE_URL", async () => {
69
+ const prevCamunda = process.env["CAMUNDA_REST_ADDRESS"];
70
+ const prevBase = process.env["NANOBPMN_BASE_URL"];
71
+ try {
72
+ delete process.env["CAMUNDA_REST_ADDRESS"];
73
+ process.env["NANOBPMN_BASE_URL"] = "http://engine.internal:8080";
74
+ const r = (await handler(input(), app)) as any;
75
+ assertEquals(r.body.engineBase, "http://engine.internal:8080/v2");
76
+ assert(r.body.instructions.includes("http://engine.internal:8080/v2/jobs/search"));
77
+ } finally {
78
+ if (prevCamunda === undefined) delete process.env["CAMUNDA_REST_ADDRESS"];
79
+ else process.env["CAMUNDA_REST_ADDRESS"] = prevCamunda;
80
+ if (prevBase === undefined) delete process.env["NANOBPMN_BASE_URL"];
81
+ else process.env["NANOBPMN_BASE_URL"] = prevBase;
82
+ }
83
+ });
84
+
85
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
86
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
87
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
88
+ try {
89
+ // SECRET is bound at import time, so import a cache-busted copy to observe the guard.
90
+ const mod = await import(`./getAgentInstructions.ts?guard=${Date.now()}`);
91
+ const guarded = mod.default as typeof handler;
92
+ const bad = (await guarded(input(), app)) as any;
93
+ assertEquals(bad.status, 401);
94
+ const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
95
+ assertEquals(ok.status, 200);
96
+ } finally {
97
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
98
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
99
+ }
100
+ });
@@ -0,0 +1,52 @@
1
+ // GET /app/api/agent → operationId `getAgentInstructions` (ADR 0058/0059 OpenAPI surface, base
2
+ // /app/api). Serves the agent operator guide: how to submit a PR for convergence (review-only vs.
3
+ // merge), submit an epic, answer escalations, and debug the system (find engine instances, relate
4
+ // them to PRs, inspect the models/prompts, unstick stuck processes, and raise issues/PRs). A user
5
+ // can point their coding agent at this URL and it can drive AND debug the workforce.
6
+ //
7
+ // The runtime serializes an operation body as JSON, so the markdown guide is returned as the
8
+ // `instructions` string field (alongside the app version + the base URLs the examples are keyed
9
+ // to), rather than as a raw text/markdown body. The embedded examples are rewritten to THIS
10
+ // instance's control-API base (derived from the request) and engine base (from the environment).
11
+ //
12
+ // Read-only. The optional shared-secret guard mirrors /version: enforced HERE only when
13
+ // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
14
+
15
+ import { renderAgentGuide, resolveEngineBase } from "../app/agentGuide.ts";
16
+ import { buildVersionInfo, envVar } from "../app/version.ts";
17
+ import { defineOperation } from "../nano-generated/operations.ts";
18
+
19
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
20
+
21
+ /**
22
+ * Reconstruct the app control-API base the caller reached us on (e.g. "https://host/app/api"), so
23
+ * the guide's example commands are copy-pasteable. Honour reverse-proxy forwarding headers; fall
24
+ * back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
25
+ */
26
+ function resolveApiBase(req: { path: string; headers: Headers }): string {
27
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
28
+ // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
29
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
30
+ const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
31
+ // The op is mounted at "<base>/agent"; strip the trailing segment to recover the base path.
32
+ const basePath = req.path.replace(/\/agent\/?$/, "") || "/app/api";
33
+ return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
34
+ }
35
+
36
+ export default defineOperation("getAgentInstructions", ({ req }) => {
37
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
38
+ return { status: 401, body: { error: "unauthorized" } };
39
+ }
40
+ const baseUrl = resolveApiBase(req);
41
+ return {
42
+ status: 200,
43
+ body: {
44
+ format: "markdown",
45
+ appVersion: buildVersionInfo().version,
46
+ generatedAt: new Date().toISOString(),
47
+ baseUrl,
48
+ engineBase: resolveEngineBase(),
49
+ instructions: renderAgentGuide(baseUrl),
50
+ },
51
+ };
52
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,302 @@
1
+ # Nano Workforce — agent operator guide
2
+
3
+ You are an AI assistant helping a human operate a running **Nano Workforce**
4
+ instance. Nano Workforce is a durable orchestration app (a [Nano](https://nanobpm.io)
5
+ Urban app) that drives pull requests to **review convergence** against an automated
6
+ reviewer, then **merges** them, and can take a whole issue and **plan → implement →
7
+ converge** it across a fleet of coding agents.
8
+
9
+ This document is served live by the running app so you always match the deployed
10
+ version. Use it to **drive** the workforce (submit work, answer escalations) and to
11
+ **debug** it (find stuck instances, relate them to PRs, inspect the models/prompts,
12
+ and unstick or report problems).
13
+
14
+ - **App control API base:** `__BASE__`
15
+ - **Engine (Nano/Camunda-8 v2 REST) base:** `__ENGINE__`
16
+ - **Source repository:** `nanobpm/nano-workforce`
17
+
18
+ Everything below assumes the app control API is reachable at `__BASE__`. Most app
19
+ endpoints are mounted under that base (ADR 0059), but a few siblings sit outside it —
20
+ notably the interactive docs (Swagger UI) at `__BASE__/../api-docs` and the action
21
+ endpoints (e.g. the cancel action at `/app/actions/cancel`). Paths below are written
22
+ in full so you can tell which are under the control-API base and which are not.
23
+
24
+ ---
25
+
26
+ ## 0. Orient yourself first
27
+
28
+ Before acting, confirm what is running and what is in flight:
29
+
30
+ ```bash
31
+ # Which code is live (app version, urban version, git sha/branch, uptime):
32
+ curl -sS __BASE__/version | jq
33
+
34
+ # Every PR currently in flight (not converged/abandoned), with its engine
35
+ # process key, status, round, and any open escalation:
36
+ curl -sS __BASE__/status | jq
37
+ ```
38
+
39
+ `/status` is your primary situational-awareness endpoint. Each entry carries:
40
+ `prKey` (`owner/repo#123`), `status`, `round`, `processKey` (the **engine process
41
+ instance key** — the bridge to the engine REST API, §5), `openEscalation`,
42
+ `activeWorker`/`leaseUntil` (is an agent actually working the round, or is the job
43
+ just queued), and `updatedAt`.
44
+
45
+ ---
46
+
47
+ ## 1. Submit a PR for review convergence
48
+
49
+ One PR → one durable `convergence-loop`. Each round dispatches a `senior:pr-review`
50
+ agent; between rounds the process parks on a durable message-catch, so review latency
51
+ never holds an agent slot. The app's poller watches GitHub and republishes
52
+ `review-ready` when a new review lands.
53
+
54
+ ```bash
55
+ # Minimal: converge, then merge (if NANO_PR_AUTO_MERGE is on — the default).
56
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
57
+ -H 'content-type: application/json' \
58
+ -d '{ "pr": "owner/repo#123" }'
59
+ ```
60
+
61
+ The body is flat. Fields:
62
+
63
+ | field | type | meaning |
64
+ |---|---|---|
65
+ | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
66
+ | `convergeOnly` | boolean | **`true` = review only.** The PR stops at `converged` and is **never** handed to the merge loop, even when `NANO_PR_AUTO_MERGE` is on. Omit / `false` = converge **then merge**. |
67
+ | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
68
+ | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
69
+
70
+ **Converge-only vs. converge-and-merge — choose deliberately:**
71
+
72
+ ```bash
73
+ # Review only — do NOT merge (use when the human wants to merge by hand,
74
+ # or is only after a review pass):
75
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
76
+ -H 'content-type: application/json' \
77
+ -d '{ "pr": "owner/repo#123", "convergeOnly": true }'
78
+
79
+ # Converge then merge, with a dependency barrier and a tighter round cap:
80
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
81
+ -H 'content-type: application/json' \
82
+ -d '{ "pr": "owner/repo#42", "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
83
+ ```
84
+
85
+ Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
86
+ aggregate rather than starting a duplicate loop. The response (202) echoes the
87
+ `prKey` and the engine `processKey`.
88
+
89
+ ---
90
+
91
+ ## 2. Submit an epic (plan → implement → converge)
92
+
93
+ Hand the fleet a whole issue. A planning agent decomposes it into levelized tasks; a
94
+ parallel fan-out drives one implementation agent per task (one PR each); every opened
95
+ PR is then enrolled into its own convergence loop (§1).
96
+
97
+ ```bash
98
+ curl -sS -X POST __BASE__/actions/start/plan-fanout \
99
+ -H 'content-type: application/json' \
100
+ -d '{ "issue": "owner/repo#123" }'
101
+ ```
102
+
103
+ The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL. Starting a
104
+ plan is idempotent on the plan key; an already-running plan short-circuits. The
105
+ response (202) echoes the `planKey` and engine `processKey`.
106
+
107
+ Track a plan the same way you track PRs — its `process_key` is an engine instance you
108
+ can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
109
+ loops.
110
+
111
+ ---
112
+
113
+ ## 3. Answer escalations (unblock a human-in-the-loop wait)
114
+
115
+ A loop escalates only when an agent returns `needs_input`/`blocked`, or a safety net
116
+ fires (round cap, a review that never arrives, a merge conflict, an unfixable CI
117
+ failure). The parked process waits for a human answer.
118
+
119
+ Find the open escalations, then answer them:
120
+
121
+ ```bash
122
+ # Which in-flight PRs have an open escalation waiting for a human?
123
+ curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
124
+ | { prKey, status, round, openEscalation }'
125
+ ```
126
+
127
+ **Answer a PR/merge escalation** (convergence-loop or merge-loop). Use the message
128
+ name `escalation-answered`; correlate by the PR key:
129
+
130
+ ```bash
131
+ curl -sS -X POST __BASE__/actions/message \
132
+ -H 'content-type: application/json' \
133
+ -d '{
134
+ "name": "escalation-answered",
135
+ "correlationKey": "owner/repo#123",
136
+ "variables": { "answer": "Yes — cap the retries at 5 and proceed." }
137
+ }'
138
+ ```
139
+
140
+ The answer is delivered to the agent as the `answer` variable on its next round, and
141
+ the loop resumes.
142
+
143
+ **Answer an implementation-phase (feature) task escalation** raised during a
144
+ plan fan-out — a dedicated webhook operation:
145
+
146
+ ```bash
147
+ curl -sS -X POST __BASE__/hooks/feature-answer \
148
+ -H 'content-type: application/json' \
149
+ -d '{ "correlationKey": "<task-or-pr-key>", "answer": "…" }'
150
+ ```
151
+
152
+ If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
153
+
154
+ Guidance for the human you assist: read the escalation `question` first (it is the
155
+ exact blocker text the agent surfaced), decide the smallest unblocking answer, and
156
+ answer it precisely — the answer becomes the agent's next-round context.
157
+
158
+ ---
159
+
160
+ ## 4. The lifecycle & statuses (so you can reason about state)
161
+
162
+ ```
163
+ submit ──► convergence-loop
164
+ round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
165
+ ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
166
+ └─ needs_input/blocked ──► escalate ──► wait escalation-answered
167
+ merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
168
+ blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
169
+ ```
170
+
171
+ PR `status` values you will see in `/status`:
172
+ `converging` (a review round is live), `waiting_review` (parked for a fresh review),
173
+ `escalated` (waiting on a human answer), `converged`, `waiting_deps` / `waiting_merge`
174
+ / `queued` / `merging` (merge stage), `merged`, `abandoned`. A separate
175
+ `incident` signal (§5) can overlay any live status when the engine parks the token on
176
+ a technical fault.
177
+
178
+ ---
179
+
180
+ ## 5. Debug: find engine instances and relate them to PRs
181
+
182
+ The app stores each PR/plan's engine **process instance key** in its `process_key`
183
+ column and surfaces it as `processKey` in `/status`. That key is the join between the
184
+ app's business view and the engine's execution view.
185
+
186
+ **Find the instance for a PR:** take `processKey` from `/status`, then query the
187
+ engine's Camunda-8 v2 REST API:
188
+
189
+ ```bash
190
+ PK=<processKey-from-status>
191
+
192
+ # The instance itself (state, the BPMN process it is running, start time):
193
+ curl -sS -X POST __ENGINE__/process-instances/search \
194
+ -H 'content-type: application/json' \
195
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
196
+
197
+ # Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
198
+ # with a `worker` set means an agent has leased the round; none means it is queued):
199
+ curl -sS -X POST __ENGINE__/jobs/search \
200
+ -H 'content-type: application/json' \
201
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"CREATED\" } }" | jq
202
+
203
+ # Is it dead-in-the-water on a technical fault? — active incidents:
204
+ curl -sS -X POST __ENGINE__/incidents/search \
205
+ -H 'content-type: application/json' \
206
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"ACTIVE\" } }" | jq
207
+
208
+ # What are the element/flow-node instances (which BPMN element is it sitting on)?
209
+ curl -sS -X POST __ENGINE__/element-instances/search \
210
+ -H 'content-type: application/json' \
211
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
212
+ ```
213
+
214
+ The app already mirrors an ACTIVE incident onto the PR row (`incident`/incident
215
+ message), so a PR that shows an incident in the UI is parked on an engine fault —
216
+ inspect it with `incidents/search` above. If the engine is not at the default, the
217
+ deployment's engine base is `__ENGINE__` (set via `NANOBPMN_BASE_URL` or
218
+ `CAMUNDA_REST_ADDRESS`).
219
+
220
+ **Relate an instance back to a PR:** if you have a `processKey` but not the PR, match
221
+ it against `/status` (`.prs[] | select(.processKey == "<PK>")`). A terminal PR is no
222
+ longer in `/status`; its instance has already completed or been cancelled.
223
+
224
+ ---
225
+
226
+ ## 6. Debug the models and the prompts
227
+
228
+ The behaviour is defined by durable BPMN processes and model-authored agent prompts —
229
+ both live in the source repo, not in the job payload.
230
+
231
+ - **Processes:** `resources/processes/*.bpmn` — `convergence-loop.bpmn` (review),
232
+ `merge-loop.bpmn` (merge/CI-fix/rebase), `plan-fanout.bpmn` (planning),
233
+ `retro.bpmn`. These are the source of truth for routing. To understand *why* an
234
+ instance went where it did, read the gateway conditions (FEEL expressions on the
235
+ sequence flows) for the element it is parked on (§5).
236
+ - **Prompts (agent base instructions):** `prompts/*.md` — `review-round.md`,
237
+ `plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. An
238
+ agent's base prompt is **not** a job variable: it is delivered as a model
239
+ **template header** (`{{review-round}}`, `{{plan}}`, …) substituted from these files
240
+ at deploy time. If an agent misbehaves systematically, the prompt is the first thing
241
+ to inspect/fix.
242
+ - **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
243
+ answer? }` and must return a flat result `{ status, summary, question? }` with
244
+ `status ∈ { converged, addressed, waiting, needs_input, blocked }`. A round that
245
+ pushes anything (including a rebase/force-push) is `addressed`; a round with an
246
+ unknown/empty result is treated as a safe `addressed` and re-enters the review wait
247
+ rather than escalating.
248
+
249
+ To validate a model/prompt change locally: `npm run layout:check` (BPMN diagram
250
+ freshness), `npm run check:prompts` (every template resolves), `npm run check`
251
+ (manifest), `npm run typecheck`, `npm run lint`, `npm test`.
252
+
253
+ ---
254
+
255
+ ## 7. Unstick a stuck process
256
+
257
+ Work through this order:
258
+
259
+ 1. **Confirm it is actually stuck.** From `/status`, a PR `converging` with an
260
+ `activeWorker` set is *working*, not stuck — an agent holds the round. No worker
261
+ for a long time means the job is queued: is a fleet `c8ctl nano work` daemon
262
+ running and subscribed to the `senior:*` task types?
263
+ 2. **Check for an incident** (§5). An ACTIVE incident parks the token; the underlying
264
+ fault must be resolved (or the instance cancelled and the work resubmitted). The
265
+ app surfaces the incident message on the PR row.
266
+ 3. **Check for an open escalation** (§3) — the process may simply be waiting for a
267
+ human answer. Answer it.
268
+ 4. **A review that never arrives** escalates on its own after
269
+ `NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT20M`); the poller also re-nudges the
270
+ reviewer periodically. If the reviewer bot is not provisioned on the repo, no
271
+ review will ever land — that is a repo-config problem, not an app bug.
272
+ 5. **Cancel + resubmit** as a last resort. Cancel the instance via the app (the UI's
273
+ per-row Cancel, `POST /app/actions/cancel { "processInstanceKey": "<PK>" }`), which
274
+ marks the PR `abandoned`, then re-submit the PR (§1) to start a fresh loop. Do not
275
+ cancel a raw engine instance out from under the app — go through the app so its
276
+ record state stays consistent.
277
+
278
+ ---
279
+
280
+ ## 8. Raise an issue or a PR against nano-workforce
281
+
282
+ When you find a genuine bug or a missing capability in the orchestration itself
283
+ (not a transient repo/agent problem), help the human file it against
284
+ `nanobpm/nano-workforce`:
285
+
286
+ - **Every change needs a tracked issue or PR** — no silent fixes. Open an issue
287
+ first if one does not exist.
288
+ - **DCO is enforced:** every commit needs a `Signed-off-by` trailer — commit with
289
+ `git commit -s` (or `git rebase --signoff`).
290
+ - **Work in a git worktree** off `origin/main`, on a `feat/*` or `fix/*` branch.
291
+ - **Author the BPMN semantics, generate the diagram.** Never hand-edit the
292
+ `bpmndi:BPMNDiagram`; run `npm run layout <file.bpmn>` and commit the result. CI
293
+ fails on stale DI.
294
+ - **Match the CI gates locally before pushing:** `npm run lint`, `npm run typecheck`,
295
+ `npm run check`, `npm run layout:check`, `npm run check:prompts`, `npm test`.
296
+ - **Copilot code review is provisioned** — drive the PR to convergence against it.
297
+ - Read `AGENTS.md` (engineering principles + gates) and `SPEC.md` (behavioural source
298
+ of truth) before proposing a change to a process.
299
+
300
+ When describing the bug, include the concrete evidence you gathered here: the
301
+ `prKey`, the engine `processKey`, the parked element / incident message (§5), and the
302
+ BPMN/prompt file you believe is responsible (§6).
@@ -18,6 +18,11 @@
18
18
  <zeebe:subscription correlationKey="=prKey" />
19
19
  </bpmn:extensionElements>
20
20
  </bpmn:message>
21
+ <bpmn:message id="Message_mergeEvicted" name="merge-evicted">
22
+ <bpmn:extensionElements>
23
+ <zeebe:subscription correlationKey="=prKey" />
24
+ </bpmn:extensionElements>
25
+ </bpmn:message>
21
26
  <bpmn:message id="Message_mergeEscAnswered" name="escalation-answered">
22
27
  <bpmn:extensionElements>
23
28
  <zeebe:subscription correlationKey="=prKey" />
@@ -109,6 +114,7 @@
109
114
  <bpmn:incoming>f_m_answer</bpmn:incoming>
110
115
  <bpmn:incoming>f_ci_fixed</bpmn:incoming>
111
116
  <bpmn:incoming>f_reb_rebased</bpmn:incoming>
117
+ <bpmn:incoming>f_m_evicted</bpmn:incoming>
112
118
  <bpmn:outgoing>f_m_arm</bpmn:outgoing>
113
119
  </bpmn:serviceTask>
114
120
  <bpmn:intermediateCatchEvent id="wait-mergeable" name="Wait: mergeable">
@@ -140,11 +146,21 @@
140
146
  <bpmn:outgoing>f_m_gQueued</bpmn:outgoing>
141
147
  <bpmn:outgoing>f_m_gBlocked</bpmn:outgoing>
142
148
  </bpmn:exclusiveGateway>
143
- <bpmn:intermediateCatchEvent id="wait-landed" name="Wait: merge queue landed">
149
+ <bpmn:eventBasedGateway id="eg-landed" name="landed or evicted?">
144
150
  <bpmn:incoming>f_m_gQueued</bpmn:incoming>
151
+ <bpmn:outgoing>f_eg_landed</bpmn:outgoing>
152
+ <bpmn:outgoing>f_eg_evicted</bpmn:outgoing>
153
+ </bpmn:eventBasedGateway>
154
+ <bpmn:intermediateCatchEvent id="wait-landed" name="Wait: merge queue landed">
155
+ <bpmn:incoming>f_eg_landed</bpmn:incoming>
145
156
  <bpmn:outgoing>f_m_landed</bpmn:outgoing>
146
157
  <bpmn:messageEventDefinition id="med_mergeLanded" messageRef="Message_mergeLanded" />
147
158
  </bpmn:intermediateCatchEvent>
159
+ <bpmn:intermediateCatchEvent id="wait-evicted" name="Wait: evicted from queue">
160
+ <bpmn:incoming>f_eg_evicted</bpmn:incoming>
161
+ <bpmn:outgoing>f_m_evicted</bpmn:outgoing>
162
+ <bpmn:messageEventDefinition id="med_mergeEvicted" messageRef="Message_mergeEvicted" />
163
+ </bpmn:intermediateCatchEvent>
148
164
  <bpmn:serviceTask id="mark-merged" name="Mark merged">
149
165
  <bpmn:extensionElements>
150
166
  <zeebe:taskDefinition type="pr.mark-merged" />
@@ -287,9 +303,12 @@
287
303
  <bpmn:sequenceFlow id="f_m_gMerged" name="merged" sourceRef="gw-merge" targetRef="mark-merged">
288
304
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "merged"</bpmn:conditionExpression>
289
305
  </bpmn:sequenceFlow>
290
- <bpmn:sequenceFlow id="f_m_gQueued" name="queued" sourceRef="gw-merge" targetRef="wait-landed">
306
+ <bpmn:sequenceFlow id="f_m_gQueued" name="queued" sourceRef="gw-merge" targetRef="eg-landed">
291
307
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "queued"</bpmn:conditionExpression>
292
308
  </bpmn:sequenceFlow>
309
+ <bpmn:sequenceFlow id="f_eg_landed" sourceRef="eg-landed" targetRef="wait-landed" />
310
+ <bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
311
+ <bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
293
312
  <bpmn:sequenceFlow id="f_m_gBlocked" name="blocked" sourceRef="gw-merge" targetRef="merge-esc-attempt" />
294
313
  <bpmn:sequenceFlow id="f_m_landed" sourceRef="wait-landed" targetRef="mark-merged" />
295
314
  <bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
@@ -332,49 +351,61 @@
332
351
  <bpmndi:BPMNShape id="BPMNShape_gw-merge" bpmnElement="gw-merge" isMarkerVisible="true">
333
352
  <dc:Bounds x="1063" y="95" width="50" height="50" />
334
353
  <bpmndi:BPMNLabel>
335
- <dc:Bounds x="1062" y="62" width="53" height="28" />
354
+ <dc:Bounds x="1022" y="150" width="53" height="28" />
355
+ </bpmndi:BPMNLabel>
356
+ </bpmndi:BPMNShape>
357
+ <bpmndi:BPMNShape id="BPMNShape_eg-landed" bpmnElement="eg-landed" isMarkerVisible="true">
358
+ <dc:Bounds x="1238" y="255" width="50" height="50" />
359
+ <bpmndi:BPMNLabel>
360
+ <dc:Bounds x="1231" y="222" width="64" height="28" />
336
361
  </bpmndi:BPMNLabel>
337
362
  </bpmndi:BPMNShape>
338
363
  <bpmndi:BPMNShape id="BPMNShape_wait-landed" bpmnElement="wait-landed">
339
- <dc:Bounds x="1245" y="262" width="36" height="36" />
364
+ <dc:Bounds x="1420" y="262" width="36" height="36" />
340
365
  <bpmndi:BPMNLabel>
341
- <dc:Bounds x="1221" y="229" width="85" height="28" />
366
+ <dc:Bounds x="1396" y="303" width="85" height="28" />
367
+ </bpmndi:BPMNLabel>
368
+ </bpmndi:BPMNShape>
369
+ <bpmndi:BPMNShape id="BPMNShape_wait-evicted" bpmnElement="wait-evicted">
370
+ <dc:Bounds x="1420" y="422" width="36" height="36" />
371
+ <bpmndi:BPMNLabel>
372
+ <dc:Bounds x="1394" y="375" width="88" height="42" />
342
373
  </bpmndi:BPMNLabel>
343
374
  </bpmndi:BPMNShape>
344
375
  <bpmndi:BPMNShape id="BPMNShape_mark-merged" bpmnElement="mark-merged">
345
- <dc:Bounds x="1388" y="80" width="100" height="80" />
376
+ <dc:Bounds x="1588" y="80" width="100" height="80" />
346
377
  </bpmndi:BPMNShape>
347
378
  <bpmndi:BPMNShape id="BPMNShape_MergeEnd" bpmnElement="MergeEnd">
348
- <dc:Bounds x="1588" y="102" width="36" height="36" />
379
+ <dc:Bounds x="1788" y="102" width="36" height="36" />
349
380
  <bpmndi:BPMNLabel>
350
- <dc:Bounds x="1581" y="143" width="50" height="14" />
381
+ <dc:Bounds x="1781" y="143" width="50" height="14" />
351
382
  </bpmndi:BPMNLabel>
352
383
  </bpmndi:BPMNShape>
353
384
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
354
- <dc:Bounds x="1038" y="560" width="100" height="80" />
385
+ <dc:Bounds x="1038" y="720" width="100" height="80" />
355
386
  </bpmndi:BPMNShape>
356
387
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
357
- <dc:Bounds x="1388" y="400" width="100" height="80" />
388
+ <dc:Bounds x="1388" y="560" width="100" height="80" />
358
389
  </bpmndi:BPMNShape>
359
390
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
360
- <dc:Bounds x="1588" y="422" width="36" height="36" />
391
+ <dc:Bounds x="1620" y="582" width="36" height="36" />
361
392
  <bpmndi:BPMNLabel>
362
- <dc:Bounds x="1649" y="463" width="74" height="42" />
393
+ <dc:Bounds x="1661" y="579" width="74" height="42" />
363
394
  </bpmndi:BPMNLabel>
364
395
  </bpmndi:BPMNShape>
365
396
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
366
- <dc:Bounds x="863" y="575" width="50" height="50" />
397
+ <dc:Bounds x="863" y="735" width="50" height="50" />
367
398
  <bpmndi:BPMNLabel>
368
- <dc:Bounds x="844" y="556" width="89" height="14" />
399
+ <dc:Bounds x="844" y="716" width="89" height="14" />
369
400
  </bpmndi:BPMNLabel>
370
401
  </bpmndi:BPMNShape>
371
402
  <bpmndi:BPMNShape id="BPMNShape_fix-ci" bpmnElement="fix-ci">
372
- <dc:Bounds x="1038" y="720" width="100" height="80" />
403
+ <dc:Bounds x="1038" y="880" width="100" height="80" />
373
404
  </bpmndi:BPMNShape>
374
405
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-result" bpmnElement="gw-ci-result" isMarkerVisible="true">
375
- <dc:Bounds x="1238" y="735" width="50" height="50" />
406
+ <dc:Bounds x="1238" y="895" width="50" height="50" />
376
407
  <bpmndi:BPMNLabel>
377
- <dc:Bounds x="1240" y="716" width="46" height="14" />
408
+ <dc:Bounds x="1240" y="876" width="46" height="14" />
378
409
  </bpmndi:BPMNLabel>
379
410
  </bpmndi:BPMNShape>
380
411
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase" bpmnElement="gw-rebase" isMarkerVisible="true">
@@ -384,12 +415,12 @@
384
415
  </bpmndi:BPMNLabel>
385
416
  </bpmndi:BPMNShape>
386
417
  <bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
387
- <dc:Bounds x="1038" y="880" width="100" height="80" />
418
+ <dc:Bounds x="1038" y="1040" width="100" height="80" />
388
419
  </bpmndi:BPMNShape>
389
420
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
390
- <dc:Bounds x="1238" y="895" width="50" height="50" />
421
+ <dc:Bounds x="1238" y="1055" width="50" height="50" />
391
422
  <bpmndi:BPMNLabel>
392
- <dc:Bounds x="1233" y="876" width="60" height="14" />
423
+ <dc:Bounds x="1233" y="1036" width="60" height="14" />
393
424
  </bpmndi:BPMNLabel>
394
425
  </bpmndi:BPMNShape>
395
426
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
@@ -421,69 +452,78 @@
421
452
  </bpmndi:BPMNEdge>
422
453
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gMerged" bpmnElement="f_m_gMerged">
423
454
  <di:waypoint x="1113" y="120" />
424
- <di:waypoint x="1388" y="120" />
455
+ <di:waypoint x="1588" y="120" />
425
456
  <bpmndi:BPMNLabel>
426
- <dc:Bounds x="1216" y="98" width="49" height="14" />
457
+ <dc:Bounds x="1326" y="98" width="49" height="14" />
427
458
  </bpmndi:BPMNLabel>
428
459
  </bpmndi:BPMNEdge>
429
460
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_done" bpmnElement="f_m_done">
430
- <di:waypoint x="1488" y="120" />
431
- <di:waypoint x="1588" y="120" />
461
+ <di:waypoint x="1688" y="120" />
462
+ <di:waypoint x="1788" y="120" />
432
463
  </bpmndi:BPMNEdge>
433
464
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
434
- <di:waypoint x="913" y="600" />
435
- <di:waypoint x="1038" y="600" />
465
+ <di:waypoint x="913" y="760" />
466
+ <di:waypoint x="1038" y="760" />
436
467
  <bpmndi:BPMNLabel>
437
- <dc:Bounds x="942" y="605" width="67" height="28" />
468
+ <dc:Bounds x="942" y="765" width="67" height="28" />
438
469
  </bpmndi:BPMNLabel>
439
470
  </bpmndi:BPMNEdge>
440
471
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
441
- <di:waypoint x="1288" y="760" />
442
- <di:waypoint x="1438" y="760" />
443
- <di:waypoint x="1438" y="480" />
472
+ <di:waypoint x="1288" y="920" />
473
+ <di:waypoint x="1438" y="920" />
474
+ <di:waypoint x="1438" y="640" />
444
475
  <bpmndi:BPMNLabel>
445
- <dc:Bounds x="1319" y="738" width="89" height="14" />
476
+ <dc:Bounds x="1319" y="898" width="89" height="14" />
446
477
  </bpmndi:BPMNLabel>
447
478
  </bpmndi:BPMNEdge>
448
479
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
449
480
  <di:waypoint x="913" y="280" />
450
481
  <di:waypoint x="1088" y="280" />
451
- <di:waypoint x="1088" y="560" />
482
+ <di:waypoint x="1088" y="720" />
452
483
  <bpmndi:BPMNLabel>
453
484
  <dc:Bounds x="977" y="247" width="67" height="28" />
454
485
  </bpmndi:BPMNLabel>
455
486
  </bpmndi:BPMNEdge>
456
487
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
457
- <di:waypoint x="1288" y="920" />
458
- <di:waypoint x="1438" y="920" />
459
- <di:waypoint x="1438" y="480" />
488
+ <di:waypoint x="1288" y="1080" />
489
+ <di:waypoint x="1438" y="1080" />
490
+ <di:waypoint x="1438" y="640" />
460
491
  <bpmndi:BPMNLabel>
461
- <dc:Bounds x="1331" y="887" width="64" height="28" />
492
+ <dc:Bounds x="1331" y="1047" width="64" height="28" />
462
493
  </bpmndi:BPMNLabel>
463
494
  </bpmndi:BPMNEdge>
495
+ <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
496
+ <di:waypoint x="1288" y="280" />
497
+ <di:waypoint x="1420" y="280" />
498
+ </bpmndi:BPMNEdge>
499
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_landed" bpmnElement="f_m_landed">
500
+ <di:waypoint x="1456" y="280" />
501
+ <di:waypoint x="1638" y="280" />
502
+ <di:waypoint x="1638" y="160" />
503
+ </bpmndi:BPMNEdge>
464
504
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
465
- <di:waypoint x="1138" y="580" />
466
- <di:waypoint x="1158" y="580" />
467
- <di:waypoint x="1158" y="380" />
468
- <di:waypoint x="1606" y="380" />
469
- <di:waypoint x="1606" y="422" />
505
+ <di:waypoint x="1138" y="740" />
506
+ <di:waypoint x="1158" y="740" />
507
+ <di:waypoint x="1158" y="540" />
508
+ <di:waypoint x="1638" y="540" />
509
+ <di:waypoint x="1638" y="582" />
470
510
  </bpmndi:BPMNEdge>
471
511
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
472
- <di:waypoint x="1488" y="440" />
473
- <di:waypoint x="1588" y="440" />
512
+ <di:waypoint x="1488" y="600" />
513
+ <di:waypoint x="1620" y="600" />
474
514
  </bpmndi:BPMNEdge>
475
515
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
476
- <di:waypoint x="1606" y="458" />
477
- <di:waypoint x="1606" y="500" />
478
- <di:waypoint x="402" y="500" />
516
+ <di:waypoint x="1638" y="618" />
517
+ <di:waypoint x="1638" y="660" />
518
+ <di:waypoint x="402" y="660" />
479
519
  <di:waypoint x="402" y="160" />
480
520
  </bpmndi:BPMNEdge>
481
521
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mCiFix" bpmnElement="f_m_mCiFix">
482
522
  <di:waypoint x="713" y="145" />
483
- <di:waypoint x="713" y="600" />
484
- <di:waypoint x="863" y="600" />
523
+ <di:waypoint x="713" y="760" />
524
+ <di:waypoint x="863" y="760" />
485
525
  <bpmndi:BPMNLabel>
486
- <dc:Bounds x="718" y="438" width="60" height="42" />
526
+ <dc:Bounds x="718" y="518" width="60" height="42" />
487
527
  </bpmndi:BPMNLabel>
488
528
  </bpmndi:BPMNEdge>
489
529
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mRebase" bpmnElement="f_m_mRebase">
@@ -498,18 +538,18 @@
498
538
  <di:waypoint x="713" y="145" />
499
539
  <di:waypoint x="713" y="318" />
500
540
  <di:waypoint x="1018" y="318" />
501
- <di:waypoint x="1018" y="580" />
502
- <di:waypoint x="1038" y="580" />
541
+ <di:waypoint x="1018" y="740" />
542
+ <di:waypoint x="1038" y="740" />
503
543
  <bpmndi:BPMNLabel>
504
544
  <dc:Bounds x="823" y="326" width="85" height="14" />
505
545
  </bpmndi:BPMNLabel>
506
546
  </bpmndi:BPMNEdge>
507
547
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
508
- <di:waypoint x="888" y="625" />
509
- <di:waypoint x="888" y="760" />
510
- <di:waypoint x="1038" y="760" />
548
+ <di:waypoint x="888" y="785" />
549
+ <di:waypoint x="888" y="920" />
550
+ <di:waypoint x="1038" y="920" />
511
551
  <bpmndi:BPMNLabel>
512
- <dc:Bounds x="893" y="679" width="49" height="28" />
552
+ <dc:Bounds x="893" y="839" width="49" height="28" />
513
553
  </bpmndi:BPMNLabel>
514
554
  </bpmndi:BPMNEdge>
515
555
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
@@ -517,8 +557,8 @@
517
557
  <di:waypoint x="933" y="280" />
518
558
  <di:waypoint x="933" y="305" />
519
559
  <di:waypoint x="1158" y="305" />
520
- <di:waypoint x="1158" y="920" />
521
- <di:waypoint x="1138" y="920" />
560
+ <di:waypoint x="1158" y="1080" />
561
+ <di:waypoint x="1138" y="1080" />
522
562
  <bpmndi:BPMNLabel>
523
563
  <dc:Bounds x="1101" y="310" width="49" height="28" />
524
564
  </bpmndi:BPMNLabel>
@@ -526,60 +566,64 @@
526
566
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
527
567
  <di:waypoint x="1088" y="145" />
528
568
  <di:waypoint x="1088" y="280" />
529
- <di:waypoint x="1245" y="280" />
569
+ <di:waypoint x="1238" y="280" />
530
570
  <bpmndi:BPMNLabel>
531
571
  <dc:Bounds x="1093" y="206" width="46" height="14" />
532
572
  </bpmndi:BPMNLabel>
533
573
  </bpmndi:BPMNEdge>
574
+ <bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
575
+ <di:waypoint x="1263" y="305" />
576
+ <di:waypoint x="1263" y="440" />
577
+ <di:waypoint x="1420" y="440" />
578
+ </bpmndi:BPMNEdge>
534
579
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gBlocked" bpmnElement="f_m_gBlocked">
535
- <di:waypoint x="1113" y="120" />
536
- <di:waypoint x="1368" y="120" />
537
- <di:waypoint x="1368" y="440" />
538
- <di:waypoint x="1388" y="440" />
580
+ <di:waypoint x="1088" y="95" />
581
+ <di:waypoint x="1088" y="75" />
582
+ <di:waypoint x="1844" y="75" />
583
+ <di:waypoint x="1844" y="478" />
584
+ <di:waypoint x="1368" y="478" />
585
+ <di:waypoint x="1368" y="580" />
586
+ <di:waypoint x="1388" y="580" />
539
587
  <bpmndi:BPMNLabel>
540
- <dc:Bounds x="1373" y="273" width="53" height="14" />
588
+ <dc:Bounds x="1849" y="270" width="53" height="14" />
541
589
  </bpmndi:BPMNLabel>
542
590
  </bpmndi:BPMNEdge>
543
591
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_done" bpmnElement="f_ci_done">
544
- <di:waypoint x="1138" y="760" />
545
- <di:waypoint x="1238" y="760" />
592
+ <di:waypoint x="1138" y="920" />
593
+ <di:waypoint x="1238" y="920" />
546
594
  </bpmndi:BPMNEdge>
547
595
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
548
- <di:waypoint x="1138" y="940" />
549
- <di:waypoint x="1158" y="940" />
550
- <di:waypoint x="1158" y="965" />
551
- <di:waypoint x="1238" y="965" />
552
- <di:waypoint x="1263" y="965" />
553
- <di:waypoint x="1263" y="945" />
554
- </bpmndi:BPMNEdge>
555
- <bpmndi:BPMNEdge id="BPMNEdge_f_m_landed" bpmnElement="f_m_landed">
556
- <di:waypoint x="1263" y="298" />
557
- <di:waypoint x="1263" y="318" />
558
- <di:waypoint x="1281" y="318" />
559
- <di:waypoint x="1281" y="540" />
560
- <di:waypoint x="1644" y="540" />
561
- <di:waypoint x="1644" y="180" />
562
- <di:waypoint x="1468" y="180" />
563
- <di:waypoint x="1468" y="160" />
596
+ <di:waypoint x="1138" y="1100" />
597
+ <di:waypoint x="1158" y="1100" />
598
+ <di:waypoint x="1158" y="1125" />
599
+ <di:waypoint x="1238" y="1125" />
600
+ <di:waypoint x="1263" y="1125" />
601
+ <di:waypoint x="1263" y="1105" />
564
602
  </bpmndi:BPMNEdge>
565
603
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
566
- <di:waypoint x="1263" y="785" />
567
- <di:waypoint x="1263" y="820" />
568
- <di:waypoint x="402" y="820" />
604
+ <di:waypoint x="1263" y="945" />
605
+ <di:waypoint x="1263" y="980" />
606
+ <di:waypoint x="402" y="980" />
569
607
  <di:waypoint x="402" y="160" />
570
608
  <bpmndi:BPMNLabel>
571
- <dc:Bounds x="813" y="798" width="39" height="14" />
609
+ <dc:Bounds x="813" y="958" width="39" height="14" />
572
610
  </bpmndi:BPMNLabel>
573
611
  </bpmndi:BPMNEdge>
574
612
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
575
- <di:waypoint x="1263" y="945" />
576
- <di:waypoint x="1263" y="980" />
577
- <di:waypoint x="402" y="980" />
613
+ <di:waypoint x="1263" y="1105" />
614
+ <di:waypoint x="1263" y="1140" />
615
+ <di:waypoint x="402" y="1140" />
578
616
  <di:waypoint x="402" y="160" />
579
617
  <bpmndi:BPMNLabel>
580
- <dc:Bounds x="806" y="958" width="53" height="14" />
618
+ <dc:Bounds x="806" y="1118" width="53" height="14" />
581
619
  </bpmndi:BPMNLabel>
582
620
  </bpmndi:BPMNEdge>
621
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
622
+ <di:waypoint x="1438" y="458" />
623
+ <di:waypoint x="1438" y="478" />
624
+ <di:waypoint x="402" y="478" />
625
+ <di:waypoint x="402" y="160" />
626
+ </bpmndi:BPMNEdge>
583
627
  </bpmndi:BPMNPlane>
584
628
  </bpmndi:BPMNDiagram>
585
629
  </bpmn:definitions>