@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/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.162.2",
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:endEvent id="EndTasklessDone" name="Taskless done (no-op epic)">
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 &#34;&#34; else planFindings) + (if (notes = null or notes = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;Human guidance:&#10;&#34; + 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="EndTasklessDone">
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="1123" y="339" width="57" height="28" />
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="1348" y="451" width="36" height="36" />
738
+ <dc:Bounds x="1723" y="611" width="36" height="36" />
708
739
  <bpmndi:BPMNLabel>
709
- <dc:Bounds x="1327" y="492" width="78" height="42" />
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="339" width="67" height="28" />
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="2121" y="455" width="67" height="28" />
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="1899" y="287" width="60" height="14" />
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="557" width="46" height="14" />
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="469" />
934
- <di:waypoint x="1348" y="469" />
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="1196" y="395" width="57" height="14" />
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="2091" y="444" />
949
- <di:waypoint x="2091" y="309" />
950
- <di:waypoint x="2216" y="309" />
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="2096" y="370" width="53" height="14" />
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="1426" y="514" width="81" height="28" />
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 marks a taskless plan done (no wave-progress columns written)", async () => {
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
- assertEquals(plans[0].status, "done");
94
- // taskCount 0 routes the plan-fanout gateway (`gw-plan-empty`) to the terminal taskless-done arm,
95
- // short-circuiting the adversarial plan-review loop that would otherwise livelock (issue #623).
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
- // SHORT-CIRCUIT an intentionally-empty plan (`{tasks:[]}`) to a terminal taskless-done arm
43
- // BEFORE the adversarial plan-review gate (issue #623). Feeding an empty plan into review
44
- // caused a plan↔plan-review livelock it can neither be approved nor produce findings.
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 === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
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 terminate an empty plan
158
- // before the review loop (issue #623).
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