@nanobpm/nano-workforce 0.162.2 → 0.163.1
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 +14 -2
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +11 -1
- package/app/enginePreflight.ts +15 -1
- package/app/epicPhase.test.ts +2 -0
- package/app/epicPhase.ts +1 -0
- package/app/instanceTracking.ts +17 -0
- package/app/pollTasklessPlanTermination.test.ts +188 -0
- package/app/reconcile.test.ts +212 -0
- package/app/reconcile.ts +326 -0
- package/app/service.ts +53 -0
- package/app/userTasks.ts +8 -0
- package/db/migrations/092_engine_reconcile.sql +53 -0
- package/e2e/plan-fanout.e2e.ts +86 -11
- 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/forms/empty-plan-escalation.form +31 -0
- package/resources/processes/plan-fanout.bpmn +72 -17
- package/test/reconcileDb.ts +61 -0
- package/workers/record-plan/worker.test.ts +8 -4
- package/workers/record-plan/worker.ts +31 -8
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.1",
|
|
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,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "empty-plan-escalation",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"text": "The planner produced an **empty plan** (no tasks) for this epic. This can be a legitimate no-op (a meta/tracking epic, or one whose sub-issues are all closed) or a planning failure. It is parked here for an operator to decide — it does NOT auto-terminate and does NOT re-enter the adversarial plan-review loop."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "select",
|
|
12
|
+
"key": "directive",
|
|
13
|
+
"label": "Directive",
|
|
14
|
+
"values": [
|
|
15
|
+
{ "label": "Accept — a legitimate no-op epic; complete the run", "value": "accept" },
|
|
16
|
+
{ "label": "Revise — send it back to the planner to re-plan", "value": "revise" }
|
|
17
|
+
],
|
|
18
|
+
"validate": {
|
|
19
|
+
"required": true
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "textarea",
|
|
24
|
+
"key": "notes",
|
|
25
|
+
"label": "Revision guidance for the planner",
|
|
26
|
+
"conditional": {
|
|
27
|
+
"hide": "=directive != \"revise\""
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -293,8 +293,25 @@
|
|
|
293
293
|
<bpmn:outgoing>f_plan_empty</bpmn:outgoing>
|
|
294
294
|
<bpmn:outgoing>f_toReviewPlan</bpmn:outgoing>
|
|
295
295
|
</bpmn:exclusiveGateway>
|
|
296
|
-
<bpmn:
|
|
296
|
+
<bpmn:userTask id="empty-plan-escalation" name="Empty plan — operator attention (human)">
|
|
297
|
+
<bpmn:extensionElements>
|
|
298
|
+
<zeebe:formDefinition formId="empty-plan-escalation" />
|
|
299
|
+
<zeebe:userTask />
|
|
300
|
+
<zeebe:assignmentDefinition candidateGroups="operators" assignee="=escalationAssignee" />
|
|
301
|
+
<zeebe:ioMapping>
|
|
302
|
+
<zeebe:output source="=(if planFindings = null then "" else planFindings) + (if (notes = null or notes = "") then "" else " Human guidance: " + notes)" target="planFindings" />
|
|
303
|
+
</zeebe:ioMapping>
|
|
304
|
+
</bpmn:extensionElements>
|
|
297
305
|
<bpmn:incoming>f_plan_empty</bpmn:incoming>
|
|
306
|
+
<bpmn:outgoing>f_toGwEmptyAnswer</bpmn:outgoing>
|
|
307
|
+
</bpmn:userTask>
|
|
308
|
+
<bpmn:exclusiveGateway id="gw-empty-plan-answer" name="accept no-op?" default="f_empty_revise">
|
|
309
|
+
<bpmn:incoming>f_toGwEmptyAnswer</bpmn:incoming>
|
|
310
|
+
<bpmn:outgoing>f_empty_accept</bpmn:outgoing>
|
|
311
|
+
<bpmn:outgoing>f_empty_revise</bpmn:outgoing>
|
|
312
|
+
</bpmn:exclusiveGateway>
|
|
313
|
+
<bpmn:endEvent id="EndTasklessDone" name="Taskless done (no-op epic)">
|
|
314
|
+
<bpmn:incoming>f_empty_accept</bpmn:incoming>
|
|
298
315
|
</bpmn:endEvent>
|
|
299
316
|
<bpmn:serviceTask id="review-plan" name="Review plan (agent)">
|
|
300
317
|
<bpmn:extensionElements>
|
|
@@ -622,9 +639,14 @@
|
|
|
622
639
|
<bpmn:sequenceFlow id="f_toPlan" sourceRef="ensure-base-branch" targetRef="plan" />
|
|
623
640
|
<bpmn:sequenceFlow id="f_toRecordPlan" sourceRef="plan" targetRef="record-plan" />
|
|
624
641
|
<bpmn:sequenceFlow id="f_toPlanEmptyGw" sourceRef="record-plan" targetRef="gw-plan-empty" />
|
|
625
|
-
<bpmn:sequenceFlow id="f_plan_empty" name="no tasks" sourceRef="gw-plan-empty" targetRef="
|
|
642
|
+
<bpmn:sequenceFlow id="f_plan_empty" name="no tasks" sourceRef="gw-plan-empty" targetRef="empty-plan-escalation">
|
|
626
643
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=taskCount = 0</bpmn:conditionExpression>
|
|
627
644
|
</bpmn:sequenceFlow>
|
|
645
|
+
<bpmn:sequenceFlow id="f_toGwEmptyAnswer" sourceRef="empty-plan-escalation" targetRef="gw-empty-plan-answer" />
|
|
646
|
+
<bpmn:sequenceFlow id="f_empty_accept" name="accept (no-op)" sourceRef="gw-empty-plan-answer" targetRef="EndTasklessDone">
|
|
647
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=directive = "accept"</bpmn:conditionExpression>
|
|
648
|
+
</bpmn:sequenceFlow>
|
|
649
|
+
<bpmn:sequenceFlow id="f_empty_revise" name="revise" sourceRef="gw-empty-plan-answer" targetRef="plan" />
|
|
628
650
|
<bpmn:sequenceFlow id="f_toReviewPlan" name="has tasks" sourceRef="gw-plan-empty" targetRef="review-plan" />
|
|
629
651
|
<bpmn:sequenceFlow id="f_toRecordPlanReview" sourceRef="review-plan" targetRef="record-plan-review" />
|
|
630
652
|
<bpmn:sequenceFlow id="f_toGwPlanReview" sourceRef="record-plan-review" targetRef="gw-plan-review" />
|
|
@@ -700,13 +722,22 @@
|
|
|
700
722
|
<bpmndi:BPMNShape id="BPMNShape_gw-plan-empty" bpmnElement="gw-plan-empty" isMarkerVisible="true">
|
|
701
723
|
<dc:Bounds x="1166" y="284" width="50" height="50" />
|
|
702
724
|
<bpmndi:BPMNLabel>
|
|
703
|
-
<dc:Bounds x="
|
|
725
|
+
<dc:Bounds x="1163" y="359" width="57" height="28" />
|
|
726
|
+
</bpmndi:BPMNLabel>
|
|
727
|
+
</bpmndi:BPMNShape>
|
|
728
|
+
<bpmndi:BPMNShape id="BPMNShape_empty-plan-escalation" bpmnElement="empty-plan-escalation">
|
|
729
|
+
<dc:Bounds x="1316" y="589" width="100" height="80" />
|
|
730
|
+
</bpmndi:BPMNShape>
|
|
731
|
+
<bpmndi:BPMNShape id="BPMNShape_gw-empty-plan-answer" bpmnElement="gw-empty-plan-answer" isMarkerVisible="true">
|
|
732
|
+
<dc:Bounds x="1541" y="604" width="50" height="50" />
|
|
733
|
+
<bpmndi:BPMNLabel>
|
|
734
|
+
<dc:Bounds x="1543" y="571" width="46" height="28" />
|
|
704
735
|
</bpmndi:BPMNLabel>
|
|
705
736
|
</bpmndi:BPMNShape>
|
|
706
737
|
<bpmndi:BPMNShape id="BPMNShape_EndTasklessDone" bpmnElement="EndTasklessDone">
|
|
707
|
-
<dc:Bounds x="
|
|
738
|
+
<dc:Bounds x="1723" y="611" width="36" height="36" />
|
|
708
739
|
<bpmndi:BPMNLabel>
|
|
709
|
-
<dc:Bounds x="
|
|
740
|
+
<dc:Bounds x="1702" y="652" width="78" height="42" />
|
|
710
741
|
</bpmndi:BPMNLabel>
|
|
711
742
|
</bpmndi:BPMNShape>
|
|
712
743
|
<bpmndi:BPMNShape id="BPMNShape_review-plan" bpmnElement="review-plan">
|
|
@@ -718,7 +749,7 @@
|
|
|
718
749
|
<bpmndi:BPMNShape id="BPMNShape_gw-plan-review" bpmnElement="gw-plan-review" isMarkerVisible="true">
|
|
719
750
|
<dc:Bounds x="1716" y="284" width="50" height="50" />
|
|
720
751
|
<bpmndi:BPMNLabel>
|
|
721
|
-
<dc:Bounds x="1668" y="
|
|
752
|
+
<dc:Bounds x="1668" y="359" width="67" height="28" />
|
|
722
753
|
</bpmndi:BPMNLabel>
|
|
723
754
|
</bpmndi:BPMNShape>
|
|
724
755
|
<bpmndi:BPMNShape id="BPMNShape_plan-review-decision" bpmnElement="plan-review-decision">
|
|
@@ -727,7 +758,7 @@
|
|
|
727
758
|
<bpmndi:BPMNShape id="BPMNShape_gw-plan-answer" bpmnElement="gw-plan-answer" isMarkerVisible="true">
|
|
728
759
|
<dc:Bounds x="2066" y="444" width="50" height="50" />
|
|
729
760
|
<bpmndi:BPMNLabel>
|
|
730
|
-
<dc:Bounds x="
|
|
761
|
+
<dc:Bounds x="2058" y="411" width="67" height="28" />
|
|
731
762
|
</bpmndi:BPMNLabel>
|
|
732
763
|
</bpmndi:BPMNShape>
|
|
733
764
|
<bpmndi:BPMNShape id="BPMNShape_select-wave" bpmnElement="select-wave">
|
|
@@ -844,7 +875,7 @@
|
|
|
844
875
|
<di:waypoint x="1766" y="309" />
|
|
845
876
|
<di:waypoint x="2216" y="309" />
|
|
846
877
|
<bpmndi:BPMNLabel>
|
|
847
|
-
<dc:Bounds x="
|
|
878
|
+
<dc:Bounds x="1961" y="287" width="60" height="14" />
|
|
848
879
|
</bpmndi:BPMNLabel>
|
|
849
880
|
</bpmndi:BPMNEdge>
|
|
850
881
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toImplement" bpmnElement="f_toImplement">
|
|
@@ -877,6 +908,15 @@
|
|
|
877
908
|
<di:waypoint x="6004" y="309" />
|
|
878
909
|
<di:waypoint x="6104" y="309" />
|
|
879
910
|
</bpmndi:BPMNEdge>
|
|
911
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_empty_revise" bpmnElement="f_empty_revise">
|
|
912
|
+
<di:waypoint x="1566" y="654" />
|
|
913
|
+
<di:waypoint x="1566" y="689" />
|
|
914
|
+
<di:waypoint x="816" y="689" />
|
|
915
|
+
<di:waypoint x="816" y="349" />
|
|
916
|
+
<bpmndi:BPMNLabel>
|
|
917
|
+
<dc:Bounds x="1168" y="667" width="46" height="14" />
|
|
918
|
+
</bpmndi:BPMNLabel>
|
|
919
|
+
</bpmndi:BPMNEdge>
|
|
880
920
|
<bpmndi:BPMNEdge id="BPMNEdge_f_plan_revise" bpmnElement="f_plan_revise">
|
|
881
921
|
<di:waypoint x="1741" y="284" />
|
|
882
922
|
<di:waypoint x="1741" y="249" />
|
|
@@ -892,7 +932,7 @@
|
|
|
892
932
|
<di:waypoint x="816" y="549" />
|
|
893
933
|
<di:waypoint x="816" y="349" />
|
|
894
934
|
<bpmndi:BPMNLabel>
|
|
895
|
-
<dc:Bounds x="1431" y="
|
|
935
|
+
<dc:Bounds x="1431" y="527" width="46" height="14" />
|
|
896
936
|
</bpmndi:BPMNLabel>
|
|
897
937
|
</bpmndi:BPMNEdge>
|
|
898
938
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecordTrialMerge" bpmnElement="f_toRecordTrialMerge">
|
|
@@ -930,10 +970,14 @@
|
|
|
930
970
|
</bpmndi:BPMNEdge>
|
|
931
971
|
<bpmndi:BPMNEdge id="BPMNEdge_f_plan_empty" bpmnElement="f_plan_empty">
|
|
932
972
|
<di:waypoint x="1191" y="334" />
|
|
933
|
-
<di:waypoint x="1191" y="
|
|
934
|
-
<di:waypoint x="
|
|
973
|
+
<di:waypoint x="1191" y="354" />
|
|
974
|
+
<di:waypoint x="2136" y="354" />
|
|
975
|
+
<di:waypoint x="2136" y="558" />
|
|
976
|
+
<di:waypoint x="1296" y="558" />
|
|
977
|
+
<di:waypoint x="1296" y="609" />
|
|
978
|
+
<di:waypoint x="1316" y="609" />
|
|
935
979
|
<bpmndi:BPMNLabel>
|
|
936
|
-
<dc:Bounds x="
|
|
980
|
+
<dc:Bounds x="2141" y="489" width="57" height="14" />
|
|
937
981
|
</bpmndi:BPMNLabel>
|
|
938
982
|
</bpmndi:BPMNEdge>
|
|
939
983
|
<bpmndi:BPMNEdge id="BPMNEdge_f_plan_escalate" bpmnElement="f_plan_escalate">
|
|
@@ -945,11 +989,11 @@
|
|
|
945
989
|
</bpmndi:BPMNLabel>
|
|
946
990
|
</bpmndi:BPMNEdge>
|
|
947
991
|
<bpmndi:BPMNEdge id="BPMNEdge_f_plan_answer_proceed" bpmnElement="f_plan_answer_proceed">
|
|
948
|
-
<di:waypoint x="
|
|
949
|
-
<di:waypoint x="
|
|
950
|
-
<di:waypoint x="
|
|
992
|
+
<di:waypoint x="2116" y="469" />
|
|
993
|
+
<di:waypoint x="2266" y="469" />
|
|
994
|
+
<di:waypoint x="2266" y="349" />
|
|
951
995
|
<bpmndi:BPMNLabel>
|
|
952
|
-
<dc:Bounds x="
|
|
996
|
+
<dc:Bounds x="2165" y="447" width="53" height="14" />
|
|
953
997
|
</bpmndi:BPMNLabel>
|
|
954
998
|
</bpmndi:BPMNEdge>
|
|
955
999
|
<bpmndi:BPMNEdge id="BPMNEdge_f_runTrialMerge" bpmnElement="f_runTrialMerge">
|
|
@@ -1005,6 +1049,17 @@
|
|
|
1005
1049
|
<di:waypoint x="616" y="469" />
|
|
1006
1050
|
<di:waypoint x="616" y="349" />
|
|
1007
1051
|
</bpmndi:BPMNEdge>
|
|
1052
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toGwEmptyAnswer" bpmnElement="f_toGwEmptyAnswer">
|
|
1053
|
+
<di:waypoint x="1416" y="629" />
|
|
1054
|
+
<di:waypoint x="1541" y="629" />
|
|
1055
|
+
</bpmndi:BPMNEdge>
|
|
1056
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_empty_accept" bpmnElement="f_empty_accept">
|
|
1057
|
+
<di:waypoint x="1591" y="629" />
|
|
1058
|
+
<di:waypoint x="1723" y="629" />
|
|
1059
|
+
<bpmndi:BPMNLabel>
|
|
1060
|
+
<dc:Bounds x="1631" y="596" width="53" height="28" />
|
|
1061
|
+
</bpmndi:BPMNLabel>
|
|
1062
|
+
</bpmndi:BPMNEdge>
|
|
1008
1063
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toGwPlanAnswer" bpmnElement="f_toGwPlanAnswer">
|
|
1009
1064
|
<di:waypoint x="1966" y="469" />
|
|
1010
1065
|
<di:waypoint x="2066" y="469" />
|
|
@@ -1029,7 +1084,7 @@
|
|
|
1029
1084
|
<di:waypoint x="816" y="547" />
|
|
1030
1085
|
<di:waypoint x="816" y="349" />
|
|
1031
1086
|
<bpmndi:BPMNLabel>
|
|
1032
|
-
<dc:Bounds x="
|
|
1087
|
+
<dc:Bounds x="1326" y="514" width="81" height="28" />
|
|
1033
1088
|
</bpmndi:BPMNLabel>
|
|
1034
1089
|
</bpmndi:BPMNEdge>
|
|
1035
1090
|
<bpmndi:BPMNEdge id="BPMNEdge_f_trial_sla" bpmnElement="f_trial_sla">
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A test-only DataLayer over a real in-memory `node:sqlite` db with the WHOLE migration set applied,
|
|
2
|
+
// for exercising the engine-reset reconciliation surface (`app/reconcile.ts`, issue #622) and its
|
|
3
|
+
// operator-command delegate (`operations/reconcileEngineState.ts`) against the REAL shipping schema —
|
|
4
|
+
// so the tables/columns/indexes reconcile reads and writes are exactly what deploys, not a fake.
|
|
5
|
+
//
|
|
6
|
+
// Canonical harness shared by `app/reconcile.test.ts` and `operations/reconcileEngineState.test.ts`
|
|
7
|
+
// (derivation over duplication: one in-memory DataLayer builder, not two divergent copies).
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { afterEach } from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { type DataLayer, makeGateway, type SqliteDb } from "@nanobpm/urban";
|
|
13
|
+
import { applyMigrationSet } from "#test-migrations";
|
|
14
|
+
|
|
15
|
+
const MIGRATIONS_DIR = fileURLToPath(new URL("../db/migrations", import.meta.url));
|
|
16
|
+
|
|
17
|
+
// Every raw handle `freshData()` opens is tracked here and released after each test, so call sites
|
|
18
|
+
// (all of them) don't leak native SQLite handles across the run. Mirrors `test/worldDb.ts` and
|
|
19
|
+
// `test/blackboardDb.ts` (derivation over duplication: the same auto-close idiom, not a new one).
|
|
20
|
+
const openDbs = new Set<DatabaseSync>();
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const raw of openDbs) {
|
|
23
|
+
if (openDbs.delete(raw)) raw.close();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Adapt a raw `node:sqlite` handle to urban's tiny `SqliteDb` seam so `makeGateway` yields the real
|
|
28
|
+
* record-oriented `DataSource` reconcile binds to (no fakes — the shipping gateway). */
|
|
29
|
+
export function sqliteDb(raw: DatabaseSync): SqliteDb {
|
|
30
|
+
return {
|
|
31
|
+
exec: (sql) => raw.exec(sql),
|
|
32
|
+
run: (sql, params = []) => {
|
|
33
|
+
const r = raw.prepare(sql).run(...(params as never[]));
|
|
34
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
35
|
+
},
|
|
36
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
37
|
+
raw.prepare(sql).all(...(params as never[])) as T[],
|
|
38
|
+
close: () => raw.close(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readMigrationFiles(): { name: string; sql: string }[] {
|
|
43
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
44
|
+
.filter((n) => n.endsWith(".sql"))
|
|
45
|
+
.map((name) => ({ name, sql: readFileSync(`${MIGRATIONS_DIR}/${name}`, "utf8") }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A DataLayer over a fresh in-memory DB with the whole migration set applied. The raw handle is
|
|
49
|
+
* tracked and auto-closed after each test (see `openDbs`), and FK enforcement is enabled so the
|
|
50
|
+
* migrations are exercised under the real constraints they ship with. */
|
|
51
|
+
export function freshData(): { data: DataLayer; raw: DatabaseSync } {
|
|
52
|
+
const raw = new DatabaseSync(":memory:");
|
|
53
|
+
openDbs.add(raw);
|
|
54
|
+
// SQLite disables FK enforcement by default; enable it so migrations with foreign keys are
|
|
55
|
+
// exercised (and any FK violations surface) exactly as they would on the shipping schema.
|
|
56
|
+
raw.exec("PRAGMA foreign_keys = ON;");
|
|
57
|
+
applyMigrationSet(raw, readMigrationFiles());
|
|
58
|
+
const gw = makeGateway(sqliteDb(raw));
|
|
59
|
+
const data = { open: () => gw } as unknown as DataLayer;
|
|
60
|
+
return { data, raw };
|
|
61
|
+
}
|
|
@@ -84,15 +84,19 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
|
|
|
84
84
|
assertEquals(plans[0].wave_label, undefined);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
test("record-plan
|
|
87
|
+
test("record-plan keeps a taskless plan NON-terminal (planning) with an outcome note (issue #624)", async () => {
|
|
88
88
|
const { app, plans } = fakeApp();
|
|
89
89
|
const out = await handler(
|
|
90
90
|
{ variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
|
|
91
91
|
app,
|
|
92
92
|
);
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
93
|
+
// A taskless plan is INTERMEDIATE, not terminal: the plan-fanout instance is still live (may
|
|
94
|
+
// re-plan / escalate / be cancelled). Terminal `done` follows engine liveness (reconciled by the
|
|
95
|
+
// poller), never this empty-plan heuristic, so the status stays non-terminal here (issue #624).
|
|
96
|
+
assertEquals(plans[0].status, "planning");
|
|
97
|
+
assertEquals(plans[0].task_count, 0);
|
|
98
|
+
// `taskCount` still drives the plan-fanout gateway (`gw-plan-empty`): zero routes to the operator
|
|
99
|
+
// empty-plan escalation instead of the adversarial plan-review loop (issues #623/#624).
|
|
96
100
|
assertEquals((out as any).taskCount, 0);
|
|
97
101
|
assertEquals(plans[0].outcome, "planner emitted no tasks");
|
|
98
102
|
assertEquals(plans[0].wave_count, undefined);
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
// • records the task count, moves the plan to `dispatched`, and emits `currentWave = 0`
|
|
11
11
|
// plus `waveCount` so the wave loop (`select-wave → implement → record-wave`) can run.
|
|
12
12
|
//
|
|
13
|
+
// A TASKLESS plan (the planner emitted no tasks) is NOT terminal here (issue #624): "the planner
|
|
14
|
+
// produced nothing this pass" is an intermediate state, not an ended process — the plan-fanout
|
|
15
|
+
// instance is still live and may re-plan, escalate, or be cancelled. The plan stays NON-terminal
|
|
16
|
+
// (`planning`, with an `outcome` note for observability); terminal `plans.status` follows ENGINE
|
|
17
|
+
// instance liveness, reconciled by the poller (COMPLETED → `done`; TERMINATED → `abandoned`), never
|
|
18
|
+
// this empty-plan heuristic.
|
|
19
|
+
//
|
|
13
20
|
// If the planner emits a malformed DAG (cycle / unknown or self dependency / duplicate id),
|
|
14
21
|
// levelization can't order the tasks. Rather than dead-lock the plan we DEGRADE to the old
|
|
15
22
|
// flat behaviour — a single wave (wave 0) of all tasks, run fully in parallel — and log a
|
|
@@ -38,10 +45,13 @@ interface NormalTask {
|
|
|
38
45
|
interface Out extends Record<string, unknown> {
|
|
39
46
|
currentWave: number;
|
|
40
47
|
waveCount: number;
|
|
41
|
-
// Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
48
|
+
// Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to route
|
|
49
|
+
// an intentionally-empty plan (`{tasks:[]}`) to the OPERATOR empty-plan escalation
|
|
50
|
+
// (`empty-plan-escalation`) — a human decides Accept (no-op done) or Revise (re-plan) — instead of
|
|
51
|
+
// the adversarial plan-review gate (issues #623/#624). Feeding an empty plan into review caused a
|
|
52
|
+
// plan↔plan-review livelock (it can neither be approved nor produce findings), and auto-terminating
|
|
53
|
+
// it (issue #625) reached a terminal verdict from an intermediate signal while the instance was
|
|
54
|
+
// still live; escalating for operator attention resolves both.
|
|
45
55
|
taskCount: number;
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -143,7 +153,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
const patch: Record<string, unknown> = {
|
|
146
|
-
status: tasks.length > 0 ? "dispatched" : "done",
|
|
147
156
|
task_count: tasks.length,
|
|
148
157
|
// Operator-visibility wave progress (wave_count / current_wave / wave_label) was RETIRED as a
|
|
149
158
|
// stored projection (epic #412) — the epics-index reads it from the `plan_wave_label` /
|
|
@@ -151,11 +160,25 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
151
160
|
// denormalises it onto the `plans` row.
|
|
152
161
|
updated_at: ts,
|
|
153
162
|
};
|
|
154
|
-
if (tasks.length
|
|
163
|
+
if (tasks.length > 0) {
|
|
164
|
+
patch.status = "dispatched";
|
|
165
|
+
} else {
|
|
166
|
+
// Taskless plan: DO NOT collapse to terminal `done` (issue #624). "The planner produced nothing
|
|
167
|
+
// this pass" is an INTERMEDIATE state, NOT an ended process — the plan-fanout instance is still
|
|
168
|
+
// live and may re-plan, escalate, or be cancelled. Terminal `plans.status` must follow ENGINE
|
|
169
|
+
// instance liveness, not this per-pass empty-plan heuristic, or the epic renders "Done" over a
|
|
170
|
+
// still-active (in fact looping) instance. So the plan stays NON-terminal (`planning`) until the
|
|
171
|
+
// poller sees the instance actually end: COMPLETED → `done` (pollTasklessPlanTermination,
|
|
172
|
+
// app/service.ts — the same poller that owns the delivery-graph COMPLETED→done transition) or
|
|
173
|
+
// TERMINATED → `abandoned` (instanceTracking's `onTerminated` derived edge). The outcome note is
|
|
174
|
+
// still recorded for observability; it is phase/label copy, not a terminal-status signal.
|
|
175
|
+
patch.status = "planning";
|
|
176
|
+
patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
177
|
+
}
|
|
155
178
|
await plans(app.data).update(planKey, patch);
|
|
156
179
|
|
|
157
|
-
// Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway
|
|
158
|
-
//
|
|
180
|
+
// Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway (`gw-plan-empty`) route an
|
|
181
|
+
// empty plan to the operator empty-plan escalation instead of the review loop (issues #623/#624).
|
|
159
182
|
return { currentWave: 0, waveCount, taskCount: tasks.length };
|
|
160
183
|
};
|
|
161
184
|
|