@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/CHANGELOG.md +12 -0
- package/app/contracts.ts +7 -0
- package/app/deliveryGraphRun.test.ts +1 -1
- package/app/epicPhase.test.ts +86 -3
- package/app/epicPhase.ts +98 -4
- package/app/mergeLandedWait.test.ts +39 -0
- package/app/mergeLandedWait.ts +39 -0
- package/app/mergeLoopBehaviour.test.ts +28 -1
- package/app/pollEpicPhase.test.ts +159 -0
- package/app/service.ts +85 -7
- package/package.json +2 -2
- package/resources/processes/merge-loop.bpmn +277 -209
- package/workers/record-plan/worker.ts +0 -6
- package/workers/record-results/worker.ts +7 -8
- package/workers/record-wave/worker.test.ts +6 -5
- package/workers/record-wave/worker.ts +5 -21
- package/workers/select-wave/worker.test.ts +4 -3
- package/workers/select-wave/worker.ts +15 -29
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
|
|
2320
|
-
*
|
|
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" | "
|
|
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,
|
|
2406
|
+
const [snapshots, parks] = await Promise.all([
|
|
2330
2407
|
engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
|
|
2331
|
-
engine.
|
|
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,
|
|
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.
|
|
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.
|
|
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",
|