@nanobpm/nano-workforce 0.162.1 → 0.163.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 +12 -0
- package/app/enginePreflight.ts +15 -1
- package/app/instanceTracking.ts +17 -0
- package/app/reconcile.test.ts +212 -0
- package/app/reconcile.ts +326 -0
- package/db/migrations/092_engine_reconcile.sql +53 -0
- package/e2e/plan-fanout.e2e.ts +37 -0
- package/main.ts +25 -0
- package/openapi.yaml +92 -0
- package/operations/reconcileEngineState.test.ts +81 -0
- package/operations/reconcileEngineState.ts +44 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +246 -207
- package/test/reconcileDb.ts +61 -0
- package/workers/record-plan/worker.test.ts +8 -2
- package/workers/record-plan/worker.ts +8 -2
- package/workers/record-plan-review/worker.test.ts +18 -0
- package/workers/record-plan-review/worker.ts +27 -3
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -241,6 +241,43 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
241
241
|
);
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
// Empty-plan short-circuit (issue #623 — regression for Merlin instance-46 / epic #1067). A
|
|
245
|
+
// planner that legitimately emits `{tasks:[]}` (meta/tracking epic, or all sub-issues closed) must
|
|
246
|
+
// reach a terminal state WITHOUT entering the adversarial plan-review loop — feeding an empty plan
|
|
247
|
+
// into review caused a plan↔plan-review livelock (it can neither be approved nor produce findings).
|
|
248
|
+
test("empty plan short-circuits to the taskless-done end, never entering plan-review (issue #623)", async () => {
|
|
249
|
+
let reviewCalls = 0;
|
|
250
|
+
await withApp(
|
|
251
|
+
{
|
|
252
|
+
"senior:plan": () => ({ tasks: [], note: "all sub-issues closed" }),
|
|
253
|
+
// If this ever fires, the short-circuit failed and the empty plan entered the review loop.
|
|
254
|
+
"senior:plan-review": () => {
|
|
255
|
+
reviewCalls += 1;
|
|
256
|
+
return { approved: false, findings: "" };
|
|
257
|
+
},
|
|
258
|
+
"senior:feature": () => ({ status: "blocked", summary: "n/a" }),
|
|
259
|
+
},
|
|
260
|
+
async ({ app, planKey }) => {
|
|
261
|
+
const flows = takenFlows(app);
|
|
262
|
+
assert.ok(
|
|
263
|
+
flows.includes("gw-plan-empty->EndTasklessDone"),
|
|
264
|
+
`empty plan routed to the taskless-done end (flows: ${flows.join(", ")})`,
|
|
265
|
+
);
|
|
266
|
+
assert.ok(
|
|
267
|
+
!flows.includes("gw-plan-empty->review-plan"),
|
|
268
|
+
"the has-tasks flow into plan-review was NOT taken",
|
|
269
|
+
);
|
|
270
|
+
assert.equal(reviewCalls, 0, "the plan-review agent must never run for an empty plan");
|
|
271
|
+
|
|
272
|
+
const plan = await app.db
|
|
273
|
+
.table<{ plan_key: string; status: string; outcome: string | null }>("plans", "plan_key")
|
|
274
|
+
.findOne({ plan_key: planKey });
|
|
275
|
+
assert.equal(plan?.status, "done", "the empty plan reached a terminal done state");
|
|
276
|
+
assert.equal(plan?.outcome, "all sub-issues closed", "the planner note was recorded as the outcome");
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
244
281
|
// A two-task wave whose PRs both open triggers the D3 trial-merge gate; a suite-failed trial
|
|
245
282
|
// parks on the trial-merge decision user task.
|
|
246
283
|
const twoTaskPlan: Stub = () => ({
|
package/main.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urba
|
|
|
22
22
|
import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
|
|
23
23
|
import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
|
|
24
24
|
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
25
|
+
import { runEngineReconcile } from "./app/reconcile.ts";
|
|
25
26
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
26
27
|
import { envVar } from "./app/version.ts";
|
|
27
28
|
|
|
@@ -102,6 +103,30 @@ if (httpServer instanceof Server) {
|
|
|
102
103
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// Engine-reset reconciliation (issue #622). On boot, compare the engine's incarnation epoch against
|
|
107
|
+
// the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted its keys,
|
|
108
|
+
// Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined `orphaned`
|
|
109
|
+
// terminal WITH PROVENANCE — BEFORE the pollers below start projecting off stale, dead instances.
|
|
110
|
+
// Guarded: an unreachable engine is a no-op (it never orphans live work), and any failure degrades to
|
|
111
|
+
// a warn so reconcile can never block boot.
|
|
112
|
+
if (app.data) {
|
|
113
|
+
try {
|
|
114
|
+
const reconciled = await runEngineReconcile(
|
|
115
|
+
app.data,
|
|
116
|
+
{ restAddress: engineAddress.restAddress, token: process.env.CAMUNDA_TOKEN },
|
|
117
|
+
{ log: { info: (m) => app.log.info(m), warn: (m) => app.log.warn(m) } },
|
|
118
|
+
);
|
|
119
|
+
if (reconciled.orphanedCount > 0) {
|
|
120
|
+
app.log.warn(
|
|
121
|
+
`startup reconcile: engine reset detected — orphaned ${reconciled.orphanedCount} engine-backed ` +
|
|
122
|
+
`inflight row(s) [run ${reconciled.runId}].`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
app.log.warn(`startup reconcile skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
// Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
|
|
106
131
|
// overlap two passes (which could double-signal `readiness-ready`); the next pass is scheduled
|
|
107
132
|
// only after the previous one settles.
|
package/openapi.yaml
CHANGED
|
@@ -844,6 +844,64 @@ components:
|
|
|
844
844
|
type: string
|
|
845
845
|
uptimeSeconds:
|
|
846
846
|
type: integer
|
|
847
|
+
ReconcileReport:
|
|
848
|
+
type: object
|
|
849
|
+
description: The result of one engine-reset reconciliation pass (issue #622).
|
|
850
|
+
additionalProperties: false
|
|
851
|
+
required:
|
|
852
|
+
- runId
|
|
853
|
+
- reason
|
|
854
|
+
- observedEpoch
|
|
855
|
+
- recordedEpoch
|
|
856
|
+
- orphanedCount
|
|
857
|
+
- orphaned
|
|
858
|
+
properties:
|
|
859
|
+
runId:
|
|
860
|
+
type: string
|
|
861
|
+
description: The reconcile run id every orphaned transition's provenance is stamped with.
|
|
862
|
+
reason:
|
|
863
|
+
type: string
|
|
864
|
+
description: Why this pass acted (or did not).
|
|
865
|
+
enum:
|
|
866
|
+
- epoch-regression
|
|
867
|
+
- seed-epoch
|
|
868
|
+
- no-op
|
|
869
|
+
- engine-unreachable
|
|
870
|
+
observedEpoch:
|
|
871
|
+
type: integer
|
|
872
|
+
nullable: true
|
|
873
|
+
description: The engine incarnation epoch observed on this pass (null when the engine exposes
|
|
874
|
+
none, or was unreachable).
|
|
875
|
+
recordedEpoch:
|
|
876
|
+
type: integer
|
|
877
|
+
nullable: true
|
|
878
|
+
description: The previously-recorded epoch this pass compared against (null on the first run).
|
|
879
|
+
orphanedCount:
|
|
880
|
+
type: integer
|
|
881
|
+
description: How many engine-backed inflight rows were driven to `orphaned`.
|
|
882
|
+
orphaned:
|
|
883
|
+
type: array
|
|
884
|
+
description: The rows orphaned by this pass.
|
|
885
|
+
items:
|
|
886
|
+
type: object
|
|
887
|
+
additionalProperties: false
|
|
888
|
+
required:
|
|
889
|
+
- table
|
|
890
|
+
- pk
|
|
891
|
+
- key
|
|
892
|
+
- fromStatus
|
|
893
|
+
properties:
|
|
894
|
+
table:
|
|
895
|
+
type: string
|
|
896
|
+
pk:
|
|
897
|
+
type: string
|
|
898
|
+
key:
|
|
899
|
+
type: string
|
|
900
|
+
nullable: true
|
|
901
|
+
description: The engine instance key (e.g. process_key) the row projected.
|
|
902
|
+
fromStatus:
|
|
903
|
+
type: string
|
|
904
|
+
description: The non-terminal status the row carried before it was orphaned.
|
|
847
905
|
AgentInstructions:
|
|
848
906
|
type: object
|
|
849
907
|
description: The agent operator guide — how to drive (submit PRs/epics, answer escalations)
|
|
@@ -3189,6 +3247,40 @@ paths:
|
|
|
3189
3247
|
application/json:
|
|
3190
3248
|
schema:
|
|
3191
3249
|
$ref: "#/components/schemas/ErrorBody"
|
|
3250
|
+
/reconcile:
|
|
3251
|
+
post:
|
|
3252
|
+
operationId: reconcileEngineState
|
|
3253
|
+
summary: Reconcile engine-backed inflight projections after an engine reset/rewind (issue #622).
|
|
3254
|
+
description: >-
|
|
3255
|
+
The explicit operator command for the app-side reconciliation surface. Probes the engine's
|
|
3256
|
+
incarnation epoch (`/v2/topology`) and compares it to the last-seen value: on a REGRESSION (the
|
|
3257
|
+
#1065 reset/rewind signature) every NON-terminal engine-backed app row (an instanceTracking
|
|
3258
|
+
binding whose status is still active and whose engine key is populated) is driven to the
|
|
3259
|
+
defined `orphaned` terminal WITH PROVENANCE. Terminal history and non-engine-backed surfaces
|
|
3260
|
+
(presence, audit) are never touched. Idempotent — a second call with a matching epoch is a
|
|
3261
|
+
no-op — and safe: an unreachable engine orphans nothing. Runs automatically on startup too.
|
|
3262
|
+
security:
|
|
3263
|
+
- hookSecret: []
|
|
3264
|
+
- {}
|
|
3265
|
+
responses:
|
|
3266
|
+
"200":
|
|
3267
|
+
description: The reconcile pass result (what it observed and orphaned).
|
|
3268
|
+
content:
|
|
3269
|
+
application/json:
|
|
3270
|
+
schema:
|
|
3271
|
+
$ref: "#/components/schemas/ReconcileReport"
|
|
3272
|
+
"401":
|
|
3273
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3274
|
+
content:
|
|
3275
|
+
application/json:
|
|
3276
|
+
schema:
|
|
3277
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3278
|
+
"503":
|
|
3279
|
+
description: The app has no data source configured, so there is nothing to reconcile.
|
|
3280
|
+
content:
|
|
3281
|
+
application/json:
|
|
3282
|
+
schema:
|
|
3283
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3192
3284
|
/version:
|
|
3193
3285
|
get:
|
|
3194
3286
|
operationId: getVersion
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Delegate-level tests for POST /app/api/reconcile → operation `reconcileEngineState` (issue #622).
|
|
2
|
+
// Covers the shared-secret guard (401), the no-data-source guard (503), and a happy-path 200 that
|
|
3
|
+
// exercises the wiring to `runEngineReconcile` against the REAL shipping schema (the whole migration
|
|
4
|
+
// set on an in-memory SQLite) with the engine `/topology` probe stubbed — so the operator command's
|
|
5
|
+
// auth guard, status codes, and reconcile wiring are regression-covered by the Node test suite.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
9
|
+
import { noopLog } from "../test/log.ts";
|
|
10
|
+
import { freshData } from "../test/reconcileDb.ts";
|
|
11
|
+
import handler from "./reconcileEngineState.ts";
|
|
12
|
+
|
|
13
|
+
function input(headers: Record<string, string> = {}) {
|
|
14
|
+
return {
|
|
15
|
+
req: {
|
|
16
|
+
method: "POST",
|
|
17
|
+
path: "/app/api/reconcile",
|
|
18
|
+
query: new URLSearchParams(),
|
|
19
|
+
headers: new Headers(headers),
|
|
20
|
+
text: async () => "",
|
|
21
|
+
} as any,
|
|
22
|
+
params: {},
|
|
23
|
+
query: {},
|
|
24
|
+
body: undefined,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stub the global `/topology` probe so the delegate never touches the network; restore after. */
|
|
29
|
+
async function withEngineEpoch<T>(epoch: number | null, fn: () => Promise<T>): Promise<T> {
|
|
30
|
+
const prev = globalThis.fetch;
|
|
31
|
+
globalThis.fetch = (async () =>
|
|
32
|
+
({
|
|
33
|
+
ok: true,
|
|
34
|
+
json: async () => (epoch == null ? {} : { nano: { incarnation: epoch } }),
|
|
35
|
+
}) as unknown as Response) as typeof fetch;
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} finally {
|
|
39
|
+
globalThis.fetch = prev;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("returns 503 when no data source is configured", async () => {
|
|
44
|
+
const app = { log: noopLog() } as any as AppApi;
|
|
45
|
+
const res = (await handler(input(), app)) as any;
|
|
46
|
+
assertEquals(res.status, 503);
|
|
47
|
+
assert("error" in res.body);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("first observation seeds the epoch and returns 200 with a reconcile result", async () => {
|
|
51
|
+
const { data } = freshData();
|
|
52
|
+
const app = { data, log: noopLog() } as any as AppApi;
|
|
53
|
+
const res = await withEngineEpoch(7, async () => (await handler(input(), app)) as any);
|
|
54
|
+
assertEquals(res.status, 200);
|
|
55
|
+
assertEquals(res.body.reason, "seed-epoch");
|
|
56
|
+
assertEquals(res.body.orphanedCount, 0);
|
|
57
|
+
assert(typeof res.body.runId === "string" && res.body.runId.length > 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
61
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
62
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
63
|
+
try {
|
|
64
|
+
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
65
|
+
const mod = await import(`./reconcileEngineState.ts?guard=${Date.now()}`);
|
|
66
|
+
const guarded = mod.default as typeof handler;
|
|
67
|
+
const { data } = freshData();
|
|
68
|
+
const app = { data, log: noopLog() } as any as AppApi;
|
|
69
|
+
const bad = (await guarded(input(), app)) as any;
|
|
70
|
+
assertEquals(bad.status, 401);
|
|
71
|
+
const wrong = (await guarded(input({ "x-hook-secret": "nope" }), app)) as any;
|
|
72
|
+
assertEquals(wrong.status, 401);
|
|
73
|
+
const ok = await withEngineEpoch(7, async () =>
|
|
74
|
+
(await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any,
|
|
75
|
+
);
|
|
76
|
+
assertEquals(ok.status, 200);
|
|
77
|
+
} finally {
|
|
78
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
79
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// POST /app/api/reconcile → operationId `reconcileEngineState` (ADR 0058/0059, base /app/api).
|
|
2
|
+
//
|
|
3
|
+
// The explicit OPERATOR COMMAND for the app-side engine-reset reconciliation surface (issue #622) —
|
|
4
|
+
// the on-demand twin of the startup pass in main.ts, sharing the one `runEngineReconcile` seam so the
|
|
5
|
+
// two paths can never diverge. An operator (or a restore runbook) POSTs here after resetting /
|
|
6
|
+
// restoring / rewinding the engine to converge `app.db`: it probes the engine incarnation epoch and,
|
|
7
|
+
// on a regression, drives every dangling engine-backed inflight row to the defined `orphaned` terminal
|
|
8
|
+
// with provenance. Idempotent (a matching epoch is a no-op) and safe (an unreachable engine orphans
|
|
9
|
+
// nothing), so it is harmless to run at any time — a green "nothing to do" is the common case.
|
|
10
|
+
//
|
|
11
|
+
// The engine address is the canonical `resolveEngineAddress` (the same precedence the engine client
|
|
12
|
+
// and startup preflight use), so the operator command talks to exactly the engine the app runs
|
|
13
|
+
// against. The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI
|
|
14
|
+
// `security`): when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
|
|
15
|
+
|
|
16
|
+
import { resolveEngineAddress } from "../app/enginePreflight.ts";
|
|
17
|
+
import { runEngineReconcile } from "../app/reconcile.ts";
|
|
18
|
+
import { envVar } from "../app/version.ts";
|
|
19
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
20
|
+
|
|
21
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
22
|
+
|
|
23
|
+
export default defineOperation("reconcileEngineState", async ({ req }, app) => {
|
|
24
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
25
|
+
app.log.warn("reconcileEngineState rejected: missing/invalid shared secret");
|
|
26
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
27
|
+
}
|
|
28
|
+
if (!app.data) {
|
|
29
|
+
app.log.warn("reconcileEngineState: no data source configured — nothing to reconcile");
|
|
30
|
+
return { status: 503, body: { error: "no data source configured" } };
|
|
31
|
+
}
|
|
32
|
+
const engineAddress = resolveEngineAddress();
|
|
33
|
+
const result = await runEngineReconcile(
|
|
34
|
+
app.data,
|
|
35
|
+
{ restAddress: engineAddress.restAddress, token: envVar("CAMUNDA_TOKEN") ?? undefined },
|
|
36
|
+
{ log: { info: (m) => app.log.info(m), warn: (m) => app.log.warn(m) } },
|
|
37
|
+
);
|
|
38
|
+
app.log.info("reconcileEngineState complete", {
|
|
39
|
+
reason: result.reason,
|
|
40
|
+
orphanedCount: result.orphanedCount,
|
|
41
|
+
runId: result.runId,
|
|
42
|
+
});
|
|
43
|
+
return { status: 200, body: result };
|
|
44
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.163.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",
|