@nanobpm/nano-workforce 0.144.0 → 0.145.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/app/service.ts CHANGED
@@ -27,11 +27,12 @@ import {
27
27
  conformanceEscalationQuestion,
28
28
  } from "./conformance.ts";
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
- import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
30
+ import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
32
32
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
33
33
  import { isDeliveryHumanElement } from "./deliveryHuman.ts";
34
34
  import { fleetSupportsDurableResume } from "./durableResume.ts";
35
+ import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
35
36
  import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
36
37
  import {
37
38
  classifyMergeability,
@@ -53,6 +54,7 @@ import {
53
54
  import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
54
55
  import { pollLineage } from "./lineage.ts";
55
56
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
57
+ import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
56
58
  import {
57
59
  freshHeadRunAction,
58
60
  headRunPresenceCount,
@@ -155,6 +157,18 @@ export const AGENT_SLA_TIMEOUT = agentSlaTimeout(process.env.NANO_PR_AGENT_SLA_T
155
157
  * default so an uninterpretable timer is never deployed. */
156
158
  export const REVIEW_WAIT_TIMEOUT = reviewWaitTimeout(process.env.NANO_PR_REVIEW_WAIT_TIMEOUT);
157
159
 
160
+ /** How long the merge loop waits for a `queued` PR to actually land before escalating to a human.
161
+ * Seeded as the `landedWaitTimeout` process variable at merge start and evaluated by the merge-loop's
162
+ * `wait-landed-timeout` timer catch (the timer arm of the `eg-landed` event-based gateway, racing the
163
+ * `merge-landed` / `merge-evicted` messages). `attempt-merge` classifies a merge as `queued` on an
164
+ * ambiguous "merge queue" signal without verifying a real enqueue, so on a repo that never actually
165
+ * queues a plain `gh pr merge` (e.g. Mergify) the loop would otherwise park at `wait-landed` forever
166
+ * (issue #556). ISO-8601 duration; a malformed `NANO_PR_MERGE_LANDED_WAIT_TIMEOUT` falls back to the
167
+ * default so an uninterpretable timer is never deployed. */
168
+ export const MERGE_LANDED_WAIT_TIMEOUT = mergeLandedWaitTimeout(
169
+ process.env.NANO_PR_MERGE_LANDED_WAIT_TIMEOUT,
170
+ );
171
+
158
172
  /** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
159
173
  * Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
160
174
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
@@ -707,6 +721,7 @@ export async function startMerge(
707
721
  mergeRetryRound: 0,
708
722
  mergeRetryMax: MAX_MERGE_RETRIES,
709
723
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
724
+ landedWaitTimeout: MERGE_LANDED_WAIT_TIMEOUT,
710
725
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
711
726
  rootRequestKey,
712
727
  abandonUrl: abUrl,
@@ -2309,6 +2324,65 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2309
2324
  * completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
2310
2325
  * reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
2311
2326
  * instance never stalls the pass. */
2327
+ /** Poll pass (S8, #542 / ADR 0006 §4b): reconcile each LIVE epic's `plans.epic_phase` from the engine
2328
+ * element-instance model — the PURE read-model derivation that RETIRES the write-time stamp the spine
2329
+ * workers used to write. For each plan still live (`EPIC_LIVE_STATUSES`) with a running instance, read
2330
+ * its element instances (`searchElementInstances`, nano-ide#473) and project the furthest-reached
2331
+ * active spine element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts — the SAME
2332
+ * `ELEMENT_PHASE` structural map the stamp used). The wave label rides the `plan_wave_progress` rollup
2333
+ * VIEW (the single wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a
2334
+ * second wave derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the
2335
+ * last phase untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing
2336
+ * never clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to
2337
+ * read once the instance ends), so a second pass derives it from the durable terminal status
2338
+ * (`deriveTerminalEpicPhase` over `done` epics) rather than the fleeting ACTIVE `record-results` token
2339
+ * a coarse poll would miss. Best-effort + idempotent — a per-plan failure is isolated. */
2340
+ export async function pollEpicPhase(
2341
+ data: DataLayer,
2342
+ engine: Pick<EngineClient, "searchElementInstances">,
2343
+ ) {
2344
+ const waveByPlan = new Map<string, { current: number | null; total: number | null }>();
2345
+ for (const w of await data
2346
+ .table<{ plan_key: string; wave_count: number | null; current_wave: number | null }>(
2347
+ "plan_wave_progress",
2348
+ "plan_key",
2349
+ )
2350
+ .all()) {
2351
+ // Coerce SQL NULL to `null` so a missing `current_wave`/`wave_count` stays MISSING through the
2352
+ // wave label — a wave number that coerced to `0` would otherwise mislabel an unknown wave as
2353
+ // `wave 1/t` (the derivation guards this too, see `toWave`, which treats `null`/`undefined` alike).
2354
+ waveByPlan.set(w.plan_key, {
2355
+ current: w.current_wave ?? null,
2356
+ total: w.wave_count ?? null,
2357
+ });
2358
+ }
2359
+ for (const status of EPIC_LIVE_STATUSES) {
2360
+ for (const plan of await plans(data).find({ status })) {
2361
+ if (!plan.process_key) continue;
2362
+ try {
2363
+ const elements = await engine.searchElementInstances({ processInstanceKey: plan.process_key });
2364
+ const phase = deriveEpicPhaseLive(elements, waveByPlan.get(plan.plan_key) ?? undefined);
2365
+ if (phase !== null && phase !== plan.epic_phase) {
2366
+ await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
2367
+ }
2368
+ } catch (err) {
2369
+ console.error(`[poller] epic phase ${plan.plan_key}: ${err}`);
2370
+ }
2371
+ }
2372
+ }
2373
+ // Terminal "Fleet dispatched" phase: a COMPLETION marker, derived from the durable terminal status
2374
+ // rather than a fleeting ACTIVE `record-results` token a coarse poll would miss (#542 review). A
2375
+ // `done` epic that dispatched ≥1 slice freezes at Dispatched; a taskless `done` and any non-`done`
2376
+ // terminal are left untouched (`deriveTerminalEpicPhase` returns null). Idempotent — writes only on
2377
+ // a real change, so a steady-state pass over already-Dispatched rows is a no-op.
2378
+ for (const plan of await plans(data).find({ status: "done" })) {
2379
+ const phase = deriveTerminalEpicPhase(plan.status, plan.task_count);
2380
+ if (phase !== null && phase !== plan.epic_phase) {
2381
+ await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
2382
+ }
2383
+ }
2384
+ }
2385
+
2312
2386
  /** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
2313
2387
  * engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
2314
2388
  * process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
@@ -2316,22 +2390,25 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2316
2390
  * transition (instanceTracking's `onTerminated` edge reconciles only TERMINATED, never COMPLETED, so
2317
2391
  * a graph that ends normally would otherwise stay `running` forever). Generalises the `epic_phase`
2318
2392
  * derived-phase machinery to a graph whose element ids aren't known ahead of time: the parked-node
2319
- * label is derived from the run row's stamped `human_labels` + the instance's OPEN user tasks. Scoped
2320
- * to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
2393
+ * label is derived from the run row's stamped `human_labels` + the instance's live USER_TASK parks.
2394
+ * The parked node is now sourced from the unified element-instance wait-state channel
2395
+ * (`searchElementInstanceWaitStates`, nano-ide#473) rather than a separate user-task search, folding
2396
+ * this read onto the same live element-instance model the epic derivation uses (S8, #542). Scoped to
2397
+ * `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
2321
2398
  export async function pollDeliveryGraphPhase(
2322
2399
  data: DataLayer,
2323
- engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
2400
+ engine: Pick<EngineClient, "searchProcessInstances" | "searchElementInstanceWaitStates">,
2324
2401
  ) {
2325
2402
  for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
2326
2403
  if (!run.process_key) continue;
2327
2404
  const processKey = run.process_key;
2328
2405
  try {
2329
- const [snapshots, tasks] = await Promise.all([
2406
+ const [snapshots, parks] = await Promise.all([
2330
2407
  engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
2331
- engine.searchUserTasks({ processInstanceKey: processKey, state: "CREATED" }),
2408
+ engine.searchElementInstanceWaitStates({ processInstanceKey: processKey, waitStateType: "USER_TASK" }),
2332
2409
  ]);
2333
2410
  const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
2334
- const projection = deriveDeliveryPhase(state, tasks, parseHumanLabels(run.human_labels));
2411
+ const projection = deriveDeliveryPhase(state, parks, parseHumanLabels(run.human_labels));
2335
2412
  if (run.status !== projection.status || run.phase !== projection.phase || run.phase_node_id !== projection.phase_node_id) {
2336
2413
  await deliveryGraphRuns(data).update(run.run_key, {
2337
2414
  status: projection.status,
@@ -2538,6 +2615,7 @@ export async function pollOnce(
2538
2615
  await pollFeatureDelivery(data);
2539
2616
  await pollLineage(data);
2540
2617
  await pollUserTasks(data, engine, engineRest);
2618
+ await pollEpicPhase(data, engine);
2541
2619
  await pollDeliveryGraphPhase(data, engine);
2542
2620
  await pollDeliveryProposals(data);
2543
2621
  if (engineRest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.144.0",
3
+ "version": "0.145.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",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.11",
67
- "@nanobpm/urban-testkit": "^0.13.1",
67
+ "@nanobpm/urban-testkit": "^0.14.0",
68
68
  "@nanobpm/workflow": "^0.14.0",
69
69
  "@semantic-release/changelog": "^7.0.0",
70
70
  "@semantic-release/git": "^11.0.0",