@nanobpm/nano-workforce 0.167.0 → 0.167.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/.github/workflows/mirror-install-dispatch.yml +54 -0
- package/CHANGELOG.md +7 -0
- package/app/contracts.ts +17 -1
- package/app/pollUserTasks.test.ts +165 -0
- package/app/service.ts +52 -0
- package/e2e/feature-preflight.e2e.ts +3 -2
- package/e2e/feature-run.e2e.ts +60 -5
- package/nano.app.json +4 -0
- package/package.json +2 -2
- package/pages/delivery-graphs/library.mount.js +59 -2
- package/pages/delivery-graphs/mount.js +88 -2
- package/resources/processes/feature.bpmn +90 -69
- package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
- package/test/delivery-graphs-library-embed.test.ts +1 -1
- package/workers/record-feature-implementing/worker.test.ts +57 -0
- package/workers/record-feature-implementing/worker.ts +31 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Refresh the mirrored install script on nanobpm.io the moment install.sh changes.
|
|
2
|
+
#
|
|
3
|
+
# https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
|
|
4
|
+
# install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
|
|
5
|
+
# built in a DIFFERENT repo (Magikcraft/nano-bpm) whose Pages workflow re-fetches
|
|
6
|
+
# install.sh on every deploy — but that deploy only triggers on changes to files
|
|
7
|
+
# in that repo, so an edit here would otherwise not reach the published mirror
|
|
8
|
+
# until the nightly backstop cron. This job pokes that repo's Pages deploy via a
|
|
9
|
+
# `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
|
|
10
|
+
# within minutes of merging an install.sh change.
|
|
11
|
+
#
|
|
12
|
+
# Requires a token that can write to Magikcraft/nano-bpm — the default
|
|
13
|
+
# GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
|
|
14
|
+
# App token) with `Contents: read and write` on Magikcraft/nano-bpm as the repo
|
|
15
|
+
# secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
|
|
16
|
+
# cron on the site keeps the mirror eventually-consistent).
|
|
17
|
+
name: Refresh nanobpm.io install mirror
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
branches: [main]
|
|
22
|
+
paths:
|
|
23
|
+
- 'install.sh'
|
|
24
|
+
workflow_dispatch:
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
dispatch:
|
|
31
|
+
name: dispatch install-sh-updated
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- name: Notify the nanobpm.io Pages deploy
|
|
35
|
+
env:
|
|
36
|
+
DISPATCH_TOKEN: ${{ secrets.PAGES_DISPATCH_TOKEN }}
|
|
37
|
+
run: |
|
|
38
|
+
set -eu
|
|
39
|
+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
|
|
40
|
+
echo "::warning::PAGES_DISPATCH_TOKEN is not set — skipping. nanobpm.io/install.sh will refresh on its nightly cron instead."
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
|
44
|
+
-X POST \
|
|
45
|
+
-H 'Accept: application/vnd.github+json' \
|
|
46
|
+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
|
|
47
|
+
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
|
48
|
+
https://api.github.com/repos/Magikcraft/nano-bpm/dispatches \
|
|
49
|
+
-d '{"event_type":"install-sh-updated"}')
|
|
50
|
+
if [ "$code" != "204" ]; then
|
|
51
|
+
echo "::error::repository_dispatch to Magikcraft/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
echo "dispatched install-sh-updated to Magikcraft/nano-bpm (HTTP 204)"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [0.167.1](https://github.com/nanobpm/nano-workforce/compare/v0.167.0...v0.167.1) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graphs:** honest Preview & Reuse toasts (drop optimistic ✓, ack-or-timeout, bump @nanobpm/urban) ([#645](https://github.com/nanobpm/nano-workforce/issues/645)) ([#650](https://github.com/nanobpm/nano-workforce/issues/650)) ([b9cd357](https://github.com/nanobpm/nano-workforce/commit/b9cd357335fc555224bb35c5043b5d5df9cc273d)), closes [#518](https://github.com/nanobpm/nano-workforce/issues/518) [#518](https://github.com/nanobpm/nano-workforce/issues/518)
|
|
6
|
+
* **feature:** reset status to running on answer re-entry to implement-task ([#642](https://github.com/nanobpm/nano-workforce/issues/642)) ([#647](https://github.com/nanobpm/nano-workforce/issues/647)) ([fd636ba](https://github.com/nanobpm/nano-workforce/commit/fd636ba482562269ab64a242c5f29d5c4b1e2020)), closes [#632](https://github.com/nanobpm/nano-workforce/issues/632)
|
|
7
|
+
|
|
1
8
|
## [0.167.0](https://github.com/nanobpm/nano-workforce/compare/v0.166.0...v0.167.0) (2026-08-31)
|
|
2
9
|
|
|
3
10
|
### Features
|
package/app/contracts.ts
CHANGED
|
@@ -420,7 +420,23 @@ export const WIRE_CONTRACTS = {
|
|
|
420
420
|
owner: "pages/delivery-graphs/mount.js",
|
|
421
421
|
semantics:
|
|
422
422
|
"The INBOUND reuse-fill host-bridge message that loads a saved `DeliveryGraph` JSON into the Delivery Graphs COMPOSE App-View textarea (`#dg-json`) — issue #523, epic #519 S4. The compose mount (consumer) registers a same-origin `window` `message` listener for this shape and routes it through its single `fillComposer()` seam; the producer is the Library App-View **Reuse** action (#523), which posts it across the App-View iframe boundary (the INBOUND twin of the existing OUTBOUND `nano-navigate` DI-preview bridge). The filesystem **Import** control (#524) is NOT a producer of this message — it lives in the same compose mount and fills directly through `fillComposer()`, no cross-frame hop. The `type` string is exported ONCE as `DG_COMPOSE_FILL_MESSAGE` from pages/delivery-graphs/mount.js — the Reuse producer imports it, never re-declares a synonym.",
|
|
423
|
-
shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string }',
|
|
423
|
+
shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string, token?: string }',
|
|
424
|
+
},
|
|
425
|
+
"deliveryGraph.compose.fill.ack": {
|
|
426
|
+
category: "wire",
|
|
427
|
+
name: "deliveryGraph.compose.fill.ack",
|
|
428
|
+
owner: "pages/delivery-graphs/mount.js",
|
|
429
|
+
semantics:
|
|
430
|
+
"The ACK half of the reuse-fill host-bridge message (issue #645). The compose App-View posts it back UP to the host — relayed across to the Library sibling App-View by the Urban App-View relay (nano-ide #518) — the moment it has actually filled `#dg-json` from a `deliveryGraph.compose.fill`. It exists to make the Library's success toast EARNED, not optimistic: the Library shows \"✓ Loaded…\" ONLY on this ack (matching its correlation `token`) and a clear \"Couldn't reach the composer\" on a short timeout, closing the #645 false-positive-toast defect. Its `token` echoes the producer's fill `token` so a stale ack from a prior Reuse can't complete a newer one. The `type` string is exported ONCE as `DG_COMPOSE_FILL_ACK_MESSAGE` from pages/delivery-graphs/mount.js — the Library consumer imports it, never re-declares a synonym.",
|
|
431
|
+
shape: '{ type: "nano-delivery-graph-compose-fill-ack", token: string | null }',
|
|
432
|
+
},
|
|
433
|
+
"nano.navigate.ack": {
|
|
434
|
+
category: "wire",
|
|
435
|
+
name: "nano.navigate.ack",
|
|
436
|
+
owner: "pages/delivery-graphs/mount.js",
|
|
437
|
+
semantics:
|
|
438
|
+
"The host's acknowledgment of a `nano-navigate` (issue #645). \"Preview generated DI\" posts `nano-navigate` UP to the console (forwarded to the host explorer by the Urban App-View relay, nano-ide #518), but the host does not synchronously confirm it navigated — so the old \"✓ Opening…\" toast printed right after the post claimed success even when the message was dropped (standalone, no relay, a console that never navigated). The compose view now treats Preview as fire-to-host with a bounded budget: a NEUTRAL in-progress status, resolved to \"✓ Opened…\" ONLY on this same-origin ack from the parent for the matching `target`, or to \"Couldn't reach the console explorer\" on a short timeout. Consumed same-origin from `window.parent`; the `type` string is `NANO_NAVIGATE_ACK_MESSAGE` in pages/delivery-graphs/mount.js.",
|
|
439
|
+
shape: '{ type: "nano-navigate-ack", target: string }',
|
|
424
440
|
},
|
|
425
441
|
"deliveryGraph.library.import.submit": {
|
|
426
442
|
category: "wire",
|
|
@@ -675,3 +675,168 @@ test("pollUserTasks (typed-seam fallback): denormalises the engine form_key from
|
|
|
675
675
|
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
676
676
|
assertEquals(byKey["ut-plan"].form_key, "form-77");
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test("pollUserTasks (engine-first): self-heals an escalated run stranded off its parked task, sparing a genuinely parked one (issue #642)", async () => {
|
|
680
|
+
// `status="escalated"` must hold ONLY while a `feature-escalation` task is open (parity with the PR
|
|
681
|
+
// contract). A run whose instance the engine no longer reports parked (its escalation was answered,
|
|
682
|
+
// or it predates the write-side reset — the #632 tear) is reconciled to `running`; a run whose task
|
|
683
|
+
// IS still open is left escalated. The engine's open set is the authority, not the raw status column.
|
|
684
|
+
const { data, stores } = memData({
|
|
685
|
+
feature_runs: [
|
|
686
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
687
|
+
{ feature_key: "o/r#77", status: "escalated", process_key: "fp-77", issue_url: null, title: "still parked", delivery_label: null },
|
|
688
|
+
],
|
|
689
|
+
});
|
|
690
|
+
const restore = stubUserTaskSearch([
|
|
691
|
+
// Only fp-77 is genuinely parked; the engine reports NO open task on fp-632.
|
|
692
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-77", state: "CREATED" },
|
|
693
|
+
]);
|
|
694
|
+
try {
|
|
695
|
+
await pollUserTasks(data, fakeEngine({ "fp-77": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] }), REST);
|
|
696
|
+
} finally {
|
|
697
|
+
restore();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
701
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
702
|
+
assertEquals(byKey["o/r#77"].status, "escalated", "the genuinely parked run stays escalated");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("pollUserTasks (engine-first): skips the per-instance open-task RPC for an escalated run already seen parked in this pass's sweep (issue #642)", async () => {
|
|
706
|
+
// Presence in THIS pass's swept `desired` set is POSITIVE evidence the run is genuinely parked — the
|
|
707
|
+
// best-effort sweep may truncate (drop tasks) but never invents one. Re-confirming such a run with a
|
|
708
|
+
// per-instance `openUserTasks` RPC is a redundant N+1 query on every poll tick; the self-heal must skip
|
|
709
|
+
// it. Only a run NOT confirmed parked by the sweep still needs the per-instance check (unchanged).
|
|
710
|
+
const { data, stores } = memData({
|
|
711
|
+
feature_runs: [
|
|
712
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "parked", delivery_label: null },
|
|
713
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
714
|
+
],
|
|
715
|
+
});
|
|
716
|
+
const restore = stubUserTaskSearch([
|
|
717
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-parked", state: "CREATED" },
|
|
718
|
+
]);
|
|
719
|
+
const openUserTasksCalls: string[] = [];
|
|
720
|
+
const engine = {
|
|
721
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
722
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) => {
|
|
723
|
+
if (filter?.processInstanceKey) openUserTasksCalls.push(filter.processInstanceKey);
|
|
724
|
+
return Promise.resolve([]); // no instance reports an open escalation via the per-instance seam
|
|
725
|
+
},
|
|
726
|
+
} as unknown as EngineClient;
|
|
727
|
+
try {
|
|
728
|
+
await pollUserTasks(data, engine, REST);
|
|
729
|
+
} finally {
|
|
730
|
+
restore();
|
|
731
|
+
}
|
|
732
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
733
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "the swept-parked run stays escalated without a per-instance query");
|
|
734
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "the run absent from the sweep is still confirmed per-instance and healed");
|
|
735
|
+
assertEquals(openUserTasksCalls.includes("fp-parked"), false, "no redundant per-instance RPC for the already-parked run");
|
|
736
|
+
assertEquals(openUserTasksCalls.includes("fp-stranded"), true, "the unconfirmed run still needs the per-instance RPC");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("pollUserTasks (engine-first): a TRUNCATED best-effort sweep never heals a genuinely-parked escalated run (issue #642)", async () => {
|
|
740
|
+
// `sweepOpenEscalationTasks` is explicitly best-effort — it BREAKS early on a paging/transport error
|
|
741
|
+
// and projects only what it had gathered. Healing `escalated -> running` on ABSENCE from that partial
|
|
742
|
+
// set is mutating durable state on negative evidence: a genuinely-parked human escalation whose task
|
|
743
|
+
// lived on an unreached page would be silently stolen. The self-heal must confirm per-instance against
|
|
744
|
+
// the engine's authoritative open set, NOT the (possibly truncated) global sweep.
|
|
745
|
+
const { data, stores } = memData({
|
|
746
|
+
feature_runs: [
|
|
747
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "genuinely parked", delivery_label: null },
|
|
748
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
// Page 1 fills the limit (forcing a second page) with unrelated open escalations; page 2 — which WOULD
|
|
752
|
+
// carry fp-parked's escalation — errors, so the sweep truncates and `desired` never sees fp-parked.
|
|
753
|
+
const page1: RawTask[] = Array.from({ length: 100 }, (_, i) => ({
|
|
754
|
+
userTaskKey: `other-${i}`,
|
|
755
|
+
elementId: "feature-escalation",
|
|
756
|
+
processInstanceKey: `other-${i}`,
|
|
757
|
+
state: "CREATED",
|
|
758
|
+
}));
|
|
759
|
+
const orig = globalThis.fetch;
|
|
760
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
761
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
762
|
+
if (!String(url).endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
|
|
763
|
+
const from: number = JSON.parse(init?.body ?? "{}")?.page?.from ?? 0;
|
|
764
|
+
if (from === 0) {
|
|
765
|
+
return new Response(JSON.stringify({ items: page1 }), { status: 200, headers: { "content-type": "application/json" } });
|
|
766
|
+
}
|
|
767
|
+
return new Response("boom", { status: 500 }); // page 2 transport error -> sweep truncates here
|
|
768
|
+
}) as typeof fetch;
|
|
769
|
+
// The engine's authoritative per-instance open set: fp-parked IS parked; fp-stranded is not.
|
|
770
|
+
const engine = fakeEngine({ "fp-parked": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] });
|
|
771
|
+
try {
|
|
772
|
+
await pollUserTasks(data, engine, REST);
|
|
773
|
+
} finally {
|
|
774
|
+
globalThis.fetch = orig;
|
|
775
|
+
}
|
|
776
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
777
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "a genuinely-parked run survives a truncated sweep");
|
|
778
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "a truly stranded run is still healed");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated run when the per-instance open-task query errors (issue #642)", async () => {
|
|
782
|
+
// A per-instance query error is not proof the run is unparked — mutating on that negative evidence would
|
|
783
|
+
// again steal a parked escalation. On query error the run must be left `escalated` for a later pass.
|
|
784
|
+
const { data, stores } = memData({
|
|
785
|
+
feature_runs: [{ feature_key: "o/r#err", status: "escalated", process_key: "fp-err", issue_url: null, title: "query errors", delivery_label: null }],
|
|
786
|
+
});
|
|
787
|
+
const restore = stubUserTaskSearch([]); // empty sweep -> old code would heal on absence
|
|
788
|
+
const engine = {
|
|
789
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
790
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
791
|
+
filter?.processInstanceKey === "fp-err" ? Promise.reject(new Error("engine down")) : Promise.resolve([]),
|
|
792
|
+
} as unknown as EngineClient;
|
|
793
|
+
try {
|
|
794
|
+
await pollUserTasks(data, engine, REST);
|
|
795
|
+
} finally {
|
|
796
|
+
restore();
|
|
797
|
+
}
|
|
798
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
799
|
+
assertEquals(byKey["o/r#err"].status, "escalated", "a failed per-instance query leaves the run escalated");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside the grace window before its user task exists (issue #642)", async () => {
|
|
803
|
+
// `record-feature-escalation` writes `status="escalated"` (stamping `updated_at`) on the `escalated`
|
|
804
|
+
// arm IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing in that
|
|
805
|
+
// window sees `openUserTasks` return none and would wrongly flip the fresh escalation back to `running`,
|
|
806
|
+
// making the just-raised escalation invisible. A short grace window on `updated_at` spares a just-written
|
|
807
|
+
// escalation while still healing genuinely-stranded (old) rows.
|
|
808
|
+
const fresh = new Date().toISOString();
|
|
809
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // an hour ago — comfortably past grace
|
|
810
|
+
const { data, stores } = memData({
|
|
811
|
+
feature_runs: [
|
|
812
|
+
{ feature_key: "o/r#fresh", status: "escalated", process_key: "fp-fresh", updated_at: fresh, issue_url: null, title: "just escalated", delivery_label: null },
|
|
813
|
+
{ feature_key: "o/r#old", status: "escalated", process_key: "fp-old", updated_at: stale, issue_url: null, title: "genuinely stranded", delivery_label: null },
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
const restore = stubUserTaskSearch([]); // engine reports no open escalation task for either instance
|
|
817
|
+
const engine = fakeEngine({}); // openUserTasks returns [] for every instance (task not yet created / gone)
|
|
818
|
+
try {
|
|
819
|
+
await pollUserTasks(data, engine, REST);
|
|
820
|
+
} finally {
|
|
821
|
+
restore();
|
|
822
|
+
}
|
|
823
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
824
|
+
assertEquals(byKey["o/r#fresh"].status, "escalated", "a just-escalated run inside the grace window is spared the heal");
|
|
825
|
+
assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
|
|
829
|
+
// The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
|
|
830
|
+
// so the per-instance open-task read is just as authoritative for the self-heal.
|
|
831
|
+
const { data, stores } = memData({
|
|
832
|
+
feature_runs: [
|
|
833
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
const engine = fakeEngine({ "fp-632": [] }); // instance active at implement-task, no open user task
|
|
837
|
+
|
|
838
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
839
|
+
|
|
840
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
841
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
842
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -198,6 +198,15 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
|
|
|
198
198
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
199
199
|
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
200
200
|
|
|
201
|
+
/** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
|
|
202
|
+
* writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
|
|
203
|
+
* `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
|
|
204
|
+
* in that window would see `openUserTasks` report none and wrongly heal the just-raised escalation back
|
|
205
|
+
* to `running`, hiding it until a future write. Only heal rows whose escalation is older than this
|
|
206
|
+
* window, by when the user task must already be observable — sparing the in-flight transition while
|
|
207
|
+
* still reconciling genuinely-stranded rows. */
|
|
208
|
+
export const FEATURE_ESCALATION_HEAL_GRACE_MS = 60_000;
|
|
209
|
+
|
|
201
210
|
/** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
|
|
202
211
|
* `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
|
|
203
212
|
export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
|
|
@@ -2677,6 +2686,49 @@ export async function pollUserTasks(
|
|
|
2677
2686
|
await userTasks(data).update(user_task_key, { ...patch, updated_at: at });
|
|
2678
2687
|
}
|
|
2679
2688
|
for (const key of deletes) await userTasks(data).delete(key);
|
|
2689
|
+
|
|
2690
|
+
// ── Self-heal: `escalated` holds ONLY while parked (issue #642) ──────────────────────────────────
|
|
2691
|
+
// `record-feature-escalation` is the sole writer of `status="escalated"`, but the answer loop-back
|
|
2692
|
+
// now stamps `running` (via `record-feature-implementing`). This read-side sweep heals any row that
|
|
2693
|
+
// is ALREADY stranded at `escalated` — e.g. runs that escalated before the write-side twin shipped
|
|
2694
|
+
// (#632), or any future missed transition — by reconciling against the ENGINE's authoritative
|
|
2695
|
+
// open-escalation set. But it must NOT decide on absence from THIS pass's `desired` set:
|
|
2696
|
+
// `sweepOpenEscalationTasks` is best-effort and breaks early on a paging/transport error, so a
|
|
2697
|
+
// truncated `desired` would wrongly flip a genuinely-parked run whose task lived on an unreached page.
|
|
2698
|
+
// Confirm each escalated run POSITIVELY, per-instance, against `openUserTasks` (which pins
|
|
2699
|
+
// `state:"CREATED"`), and skip healing when that query errors — durable state is mutated only on
|
|
2700
|
+
// positive evidence that no `feature-escalation` task is open for the instance (parity with the PR
|
|
2701
|
+
// `status="escalated"` contract). Confined to rows with a known `process_key` so an untracked instance
|
|
2702
|
+
// is never guessed at.
|
|
2703
|
+
//
|
|
2704
|
+
// Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
|
|
2705
|
+
// DROPS tasks, never invents one), so a run whose `feature-escalation` task was already swept is
|
|
2706
|
+
// genuinely parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every
|
|
2707
|
+
// tick). Only a run NOT confirmed parked by the sweep falls through to the per-instance check below.
|
|
2708
|
+
const sweptParkedEscalations = new Set(
|
|
2709
|
+
desired.filter((r) => r.element_id === FEATURE_ESCALATION_ELEMENT && r.process_key).map((r) => r.process_key),
|
|
2710
|
+
);
|
|
2711
|
+
for (const run of await featureRuns(data).find({ status: "escalated" })) {
|
|
2712
|
+
if (!run.process_key) continue;
|
|
2713
|
+
if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2714
|
+
// A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
|
|
2715
|
+
// `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
|
|
2716
|
+
// Skip healing inside the grace window so this pass never races that transition and steals a fresh
|
|
2717
|
+
// escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
|
|
2718
|
+
const escalatedAt = Date.parse(run.updated_at ?? "");
|
|
2719
|
+
if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
|
|
2720
|
+
let openTasks: { elementId?: string }[];
|
|
2721
|
+
try {
|
|
2722
|
+
openTasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
// Negative evidence from a failed query is not proof the run is unparked — leave it for a later pass.
|
|
2725
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
if (!openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT)) {
|
|
2729
|
+
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2680
2732
|
}
|
|
2681
2733
|
|
|
2682
2734
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
@@ -157,7 +157,8 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
157
157
|
"the green gate leads into the fan-out head",
|
|
158
158
|
);
|
|
159
159
|
assert.ok(
|
|
160
|
-
flows.includes("ensure-base-branch->
|
|
160
|
+
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
161
162
|
`the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
|
|
162
163
|
);
|
|
163
164
|
// A green probe never escalates.
|
|
@@ -189,7 +190,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
189
190
|
);
|
|
190
191
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
191
192
|
assert.ok(
|
|
192
|
-
flows.includes("
|
|
193
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
193
194
|
"an ungated feature reaches the implement agent",
|
|
194
195
|
);
|
|
195
196
|
} finally {
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -245,8 +245,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
245
245
|
|
|
246
246
|
const flows = takenFlows(app);
|
|
247
247
|
assert.ok(
|
|
248
|
-
flows.includes("w_gw_answer->
|
|
249
|
-
`answer re-dispatched the
|
|
248
|
+
flows.includes("w_gw_answer->record-feature-implementing"),
|
|
249
|
+
`answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
253
|
+
`the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
|
|
250
254
|
);
|
|
251
255
|
assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
|
|
252
256
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
@@ -254,6 +258,56 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
254
258
|
);
|
|
255
259
|
});
|
|
256
260
|
|
|
261
|
+
test("escalate + answer: the run reads `running` (not a stale escalated) through the post-answer re-implementation (issue #642)", async () => {
|
|
262
|
+
// The write-side twin of `record-feature-escalation`: `record-feature-implementing` sits on the
|
|
263
|
+
// answer loop-back and stamps `running` BEFORE implement-task re-runs, so `feature_runs.status` no
|
|
264
|
+
// longer holds a stale `escalated` for the entire re-implementation (the #632 tear). Assert the
|
|
265
|
+
// agent observes `running` at the moment it is re-dispatched. Boots inline (not `withApp`) so the
|
|
266
|
+
// re-implementation stub can read the row through `app.db`.
|
|
267
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-f642-"));
|
|
268
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
269
|
+
try {
|
|
270
|
+
let calls = 0;
|
|
271
|
+
let statusDuringReimpl: string | undefined;
|
|
272
|
+
await app.engine.registerWorker("senior:feature", async () => {
|
|
273
|
+
calls += 1;
|
|
274
|
+
if (calls === 1) {
|
|
275
|
+
return { status: "escalated", question: "Which API should I use?", summary: "parked for a human" };
|
|
276
|
+
}
|
|
277
|
+
const run = await app.db
|
|
278
|
+
.table<FeatureRow>("feature_runs", "feature_key")
|
|
279
|
+
.findOne({ feature_key: "owner/repo#7" });
|
|
280
|
+
statusDuringReimpl = run?.status;
|
|
281
|
+
return { status: "opened", pr: "owner/repo#642", summary: "resumed and opened" };
|
|
282
|
+
});
|
|
283
|
+
const featureKey = "owner/repo#7";
|
|
284
|
+
const started = await app.api?.call("startFeature", { body: { issue: featureKey, baseBranch: "epic/e2e" } });
|
|
285
|
+
assert.equal(started?.status, 202, "startFeature accepted the issue");
|
|
286
|
+
await app.settle();
|
|
287
|
+
|
|
288
|
+
const parked = await featureRow(app, featureKey);
|
|
289
|
+
assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
|
|
290
|
+
assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
|
|
291
|
+
|
|
292
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: parked.process_key! });
|
|
293
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
294
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
295
|
+
|
|
296
|
+
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
297
|
+
await app.settle();
|
|
298
|
+
|
|
299
|
+
assert.equal(
|
|
300
|
+
statusDuringReimpl,
|
|
301
|
+
"running",
|
|
302
|
+
"the reset task stamped `running` before implement-task re-ran — no stale escalated",
|
|
303
|
+
);
|
|
304
|
+
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
305
|
+
} finally {
|
|
306
|
+
await app.stop();
|
|
307
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
257
311
|
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
258
312
|
await withApp(
|
|
259
313
|
{
|
|
@@ -273,7 +327,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
273
327
|
flows.includes("w_gw_answer->record-feature"),
|
|
274
328
|
`abandon routed to record-feature (flows: ${flows.join(", ")})`,
|
|
275
329
|
);
|
|
276
|
-
assert.ok(!flows.includes("w_gw_answer->
|
|
330
|
+
assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
|
|
277
331
|
},
|
|
278
332
|
);
|
|
279
333
|
});
|
|
@@ -322,8 +376,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
322
376
|
|
|
323
377
|
const flows = takenFlows(app);
|
|
324
378
|
assert.ok(
|
|
325
|
-
flows.includes("w_gw_answer->
|
|
326
|
-
|
|
379
|
+
flows.includes("w_gw_answer->record-feature-implementing") &&
|
|
380
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
381
|
+
`the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
|
|
327
382
|
);
|
|
328
383
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
329
384
|
|
package/nano.app.json
CHANGED
|
@@ -235,6 +235,10 @@
|
|
|
235
235
|
{
|
|
236
236
|
"taskType": "pr.delivery-connector",
|
|
237
237
|
"handler": "workers/delivery-connector/worker.ts"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"taskType": "pr.record-feature-implementing",
|
|
241
|
+
"handler": "workers/record-feature-implementing/worker.ts"
|
|
238
242
|
}
|
|
239
243
|
],
|
|
240
244
|
"externalTaskTypes": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.167.
|
|
3
|
+
"version": "0.167.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",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@nanobpm/agentic": "^0.4.0",
|
|
64
|
-
"@nanobpm/urban": "^0.
|
|
64
|
+
"@nanobpm/urban": "^0.88.1",
|
|
65
65
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
// standalone — only the host element and injected endpoint config differ. The app has no browser build
|
|
14
14
|
// step, so this consumes the library doors straight off the wire.
|
|
15
15
|
|
|
16
|
-
import { DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
|
|
16
|
+
import { DG_COMPOSE_FILL_ACK_MESSAGE, DG_COMPOSE_FILL_MESSAGE } from "./mount.js";
|
|
17
|
+
|
|
18
|
+
// The bounded budget within which the compose App-View must ACK a Reuse fill before we surface an honest
|
|
19
|
+
// "Couldn't reach the composer" error (issue #645). Mirrors the compose mount's own ACK_TIMEOUT_MS: long
|
|
20
|
+
// enough for the App-View relay round-trip (nano-ide #518), short enough that a dropped fill fails fast
|
|
21
|
+
// instead of leaving a misleading "✓ Loaded…" up. Overridable via config for tests.
|
|
22
|
+
const REUSE_ACK_TIMEOUT_MS = 2000;
|
|
17
23
|
|
|
18
24
|
// The read behind the list: every saved library entry, newest first. Anchored to THIS MODULE's url
|
|
19
25
|
// (import.meta.url), NOT the document base. The library App-View shell (library-embed.html /
|
|
@@ -149,6 +155,8 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
|
|
|
149
155
|
|
|
150
156
|
const libraryUrl = config.libraryUrl ?? DEFAULT_LIBRARY_URL;
|
|
151
157
|
const refreshMs = typeof config.refreshMs === "number" && config.refreshMs > 0 ? config.refreshMs : DEFAULT_REFRESH_MS;
|
|
158
|
+
const ackTimeoutMs =
|
|
159
|
+
typeof config.ackTimeoutMs === "number" && config.ackTimeoutMs > 0 ? config.ackTimeoutMs : REUSE_ACK_TIMEOUT_MS;
|
|
152
160
|
const headers = (url) => ({
|
|
153
161
|
"content-type": "application/json",
|
|
154
162
|
...(config.hookSecret && isSameOrigin(url) ? { "x-hook-secret": config.hookSecret } : {}),
|
|
@@ -255,7 +263,22 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
|
|
|
255
263
|
// the host bridge, posting the shared `deliveryGraph.compose.fill` message UP to the console (the
|
|
256
264
|
// INBOUND twin of the outbound `nano-navigate` DI-preview bridge). Standalone (not embedded) there is
|
|
257
265
|
// no console to route it and no compose view to fill, so we say so instead of failing silently.
|
|
266
|
+
//
|
|
267
|
+
// The success toast is ACKNOWLEDGED, never optimistic (issue #645): a "✓ Loaded…" printed synchronously
|
|
268
|
+
// after the post lied whenever the fill was dropped (no relay, wrong origin, an unmounted compose view).
|
|
269
|
+
// Now we show a NEUTRAL in-progress status, tag the fill with a correlation `token`, and resolve it only
|
|
270
|
+
// when the compose mount echoes `DG_COMPOSE_FILL_ACK_MESSAGE` for that token — "✓ Loaded…" on the ack,
|
|
271
|
+
// "Couldn't reach the composer" on a short timeout. A fresh Reuse supersedes any pending one.
|
|
258
272
|
const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
|
|
273
|
+
let pendingReuse = null;
|
|
274
|
+
function clearPendingReuse() {
|
|
275
|
+
if (pendingReuse && pendingReuse.timer) clearTimeout(pendingReuse.timer);
|
|
276
|
+
pendingReuse = null;
|
|
277
|
+
}
|
|
278
|
+
function newReuseToken() {
|
|
279
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
280
|
+
return `reuse-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
281
|
+
}
|
|
259
282
|
function doReuse(id) {
|
|
260
283
|
const entry = entries.find((e) => e && e.id === id);
|
|
261
284
|
if (!entry) {
|
|
@@ -270,12 +293,42 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
|
|
|
270
293
|
setStatus("Open this page inside the console to reuse a saved graph in the composer.", "err");
|
|
271
294
|
return;
|
|
272
295
|
}
|
|
296
|
+
clearPendingReuse();
|
|
297
|
+
const token = newReuseToken();
|
|
273
298
|
window.parent.postMessage(
|
|
274
|
-
{ type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph },
|
|
299
|
+
{ type: DG_COMPOSE_FILL_MESSAGE, graphJson: entry.graph, token },
|
|
275
300
|
window.location.origin,
|
|
276
301
|
);
|
|
302
|
+
// NEUTRAL in-progress status (no "✓") — success is only earned on the compose ack below (#645).
|
|
303
|
+
setStatus("Loading into the composer above\u2026", "");
|
|
304
|
+
const timer = setTimeout(() => {
|
|
305
|
+
if (!pendingReuse || pendingReuse.token !== token) return;
|
|
306
|
+
pendingReuse = null;
|
|
307
|
+
setStatus("Couldn't reach the composer \u2014 is the composer open above this list?", "err");
|
|
308
|
+
}, ackTimeoutMs);
|
|
309
|
+
pendingReuse = { token, timer };
|
|
310
|
+
}
|
|
311
|
+
// The compose App-View's acknowledgment that it filled (#645): a same-origin `DG_COMPOSE_FILL_ACK_MESSAGE`
|
|
312
|
+
// from the parent (the console, which relays it across from the compose sibling) whose `token` matches the
|
|
313
|
+
// pending Reuse resolves it to success. A foreign origin, a non-parent source, or a stale/mismatched token
|
|
314
|
+
// is ignored — an unrelated page (or a prior Reuse's late ack) can't forge or misattribute a success toast.
|
|
315
|
+
function onReuseAck(ev) {
|
|
316
|
+
if (!ev || typeof window === "undefined") return;
|
|
317
|
+
if (ev.origin !== window.location.origin) return;
|
|
318
|
+
if (ev.source !== window.parent) return;
|
|
319
|
+
const data = ev.data;
|
|
320
|
+
if (!data || data.type !== DG_COMPOSE_FILL_ACK_MESSAGE) return;
|
|
321
|
+
// Require a string `token` that exactly matches the pending Reuse. A null/undefined-token ack
|
|
322
|
+
// (the compose mount emits `token: null` when a fill arrives without a token) can't be correlated
|
|
323
|
+
// to this Reuse and must NOT resolve it — otherwise it reintroduces the false-positive "✓ Loaded…"
|
|
324
|
+
// toast (#645).
|
|
325
|
+
if (!pendingReuse || typeof data.token !== "string" || data.token !== pendingReuse.token) return;
|
|
326
|
+
clearPendingReuse();
|
|
277
327
|
setStatus("\u2713 Loaded into the composer above \u2014 edit, Preview or Stage it.", "ok");
|
|
278
328
|
}
|
|
329
|
+
if (isEmbedded && typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
330
|
+
window.addEventListener("message", onReuseAck);
|
|
331
|
+
}
|
|
279
332
|
|
|
280
333
|
// "Export": a purely client-side download of a saved entry's graph JSON as `<name>.deliverygraph.json`
|
|
281
334
|
// (issue #525). No backend door — the list door already carries each entry's `graph` inline (the same
|
|
@@ -368,6 +421,10 @@ export function mountDeliveryGraphLibrary(host, config = {}) {
|
|
|
368
421
|
|
|
369
422
|
return () => {
|
|
370
423
|
disposed = true;
|
|
424
|
+
clearPendingReuse();
|
|
425
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
426
|
+
window.removeEventListener("message", onReuseAck);
|
|
427
|
+
}
|
|
371
428
|
clearInterval(timer);
|
|
372
429
|
root.innerHTML = "";
|
|
373
430
|
};
|