@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.
@@ -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->implement-task"),
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("ensure-base-branch->implement-task"),
193
+ flows.includes("record-feature-implementing->implement-task"),
193
194
  "an ungated feature reaches the implement agent",
194
195
  );
195
196
  } finally {
@@ -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->implement-task"),
249
- `answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
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->implement-task"), "the answer loop was NOT taken");
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->implement-task"),
326
- `the answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
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.0",
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.87.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
  };