@nanobpm/nano-workforce 0.186.0 → 0.186.2
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/SPEC.md +14 -3
- package/app/contracts.ts +8 -1
- package/app/deliveryHuman.test.ts +123 -0
- package/app/deliveryHuman.ts +29 -0
- package/app/mergeLoopBehaviour.test.ts +90 -0
- package/app/mergeableWait.test.ts +42 -1
- package/app/mergeableWait.ts +23 -0
- package/app/pollUserTasks.test.ts +56 -0
- package/app/service.ts +28 -3
- package/package.json +1 -1
- package/resources/forms/delivery-human-generic.form +3 -32
- package/resources/processes/merge-loop.bpmn +291 -235
- package/workers/merge-stall-probe/worker.ts +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.186.2](https://github.com/nanobpm/nano-workforce/compare/v0.186.1...v0.186.2) (2026-09-11)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **merge-loop:** re-poll a 'waiting' mergeability verdict instead of escalating ([#775](https://github.com/nanobpm/nano-workforce/issues/775)) ([4812fe9](https://github.com/nanobpm/nano-workforce/commit/4812fe9689088f21e41ef50ba8f1dbec2093fb23)), closes [#774](https://github.com/nanobpm/nano-workforce/issues/774) [#774](https://github.com/nanobpm/nano-workforce/issues/774) [#774](https://github.com/nanobpm/nano-workforce/issues/774)
|
|
6
|
+
|
|
7
|
+
## [0.186.1](https://github.com/nanobpm/nano-workforce/compare/v0.186.0...v0.186.1) (2026-09-11)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* delivery-graph human-node Tasks form renders context-free ([#773](https://github.com/nanobpm/nano-workforce/issues/773)) ([91a8065](https://github.com/nanobpm/nano-workforce/commit/91a806566e157257cc264310e18d7df0bba3e350)), closes [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#772](https://github.com/nanobpm/nano-workforce/issues/772)
|
|
12
|
+
|
|
1
13
|
## [0.186.0](https://github.com/nanobpm/nano-workforce/compare/v0.185.1...v0.186.0) (2026-09-08)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/SPEC.md
CHANGED
|
@@ -216,7 +216,7 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
|
|
|
216
216
|
| `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
|
|
217
217
|
| `readiness-ready` | `prKey` | **poller** | `{ready, detail?}` (ADR 0001 §2 wait-gate; the review-ready wait, re-expressed on the ReadinessProbe gate — #259) |
|
|
218
218
|
| `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
|
|
219
|
-
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
|
|
219
|
+
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked` \| `draft`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch. A transient `UNKNOWN`/`waiting` verdict is NOT published — the poller keeps polling (fast self-heal); the merge loop's `gw-mergeable` sees `waiting` only via the dead-poller stall-probe re-derivation, where it re-polls (bounded, #774) instead of escalating |
|
|
220
220
|
| `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
|
|
221
221
|
|
|
222
222
|
The `escalation-answered` message was retired (#256): the merge-loop escalation is
|
|
@@ -466,10 +466,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
466
466
|
time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
|
|
467
467
|
parks at *wait: deps merged*; the poller checks each dependency (own tracked row
|
|
468
468
|
first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
|
|
469
|
-
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus
|
|
469
|
+
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus`, but a
|
|
470
|
+
first-class `isDraft` guard runs *first* (`classifyMergeability`, `app/github.ts`):
|
|
471
|
+
a draft PR is `draft` regardless of `mergeStateStatus`. Otherwise, by status:
|
|
470
472
|
`CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
|
|
471
473
|
`BLOCKED` → `blocked` if a required check is failing, else keep waiting;
|
|
472
|
-
`
|
|
474
|
+
`UNKNOWN`/empty (and any other status, including `DRAFT`) → keep polling (GitHub
|
|
475
|
+
still computing). It
|
|
476
|
+
publishes `merge-ready {mergeState}` for the settled verdicts. When the live
|
|
477
|
+
poller is dead the `wait-mergeable-timeout` backstop's stall-probe re-derives
|
|
478
|
+
mergeability; a still-unsettled `UNKNOWN`/`waiting` verdict then routes to a
|
|
479
|
+
**bounded re-poll** (`wait-mergeable-repoll`, `NANO_PR_MERGEABLE_REPOLL_INTERVAL`)
|
|
480
|
+
rather than escalating (bounded by `NANO_PR_MAX_MERGE_STALL_ROUNDS`), and an
|
|
481
|
+
unclassified/default verdict routes to auto-rebase (`gw-rebase`, bounded by its
|
|
482
|
+
own `NANO_PR_MAX_REBASE_ROUNDS` budget); once either budget is exhausted the loop
|
|
483
|
+
escalates to a human (#774).
|
|
473
484
|
- **Merge** — `pr.merge` attempts the merge (`NANO_PR_MERGE_METHOD`, default
|
|
474
485
|
`squash`). GitHub auto-enqueues on merge-queue-required branches → the process
|
|
475
486
|
waits for `merge-landed` (poller detects the landed PR). Every attempt is
|
package/app/contracts.ts
CHANGED
|
@@ -137,7 +137,7 @@ export const ENV_CONTRACTS = {
|
|
|
137
137
|
name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
|
|
138
138
|
owner: "app/service.ts",
|
|
139
139
|
semantics:
|
|
140
|
-
"Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) before escalating; 0 escalates on the first stall.",
|
|
140
|
+
"Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) AND `waiting`-verdict re-poll probes (#774) before escalating; 0 escalates on the first stall.",
|
|
141
141
|
default: "3",
|
|
142
142
|
},
|
|
143
143
|
NANO_PR_REVIEW_WAIT_TIMEOUT: {
|
|
@@ -166,6 +166,13 @@ export const ENV_CONTRACTS = {
|
|
|
166
166
|
semantics:
|
|
167
167
|
"How long the merge loop waits for the poller's `merge-ready` before the stall-probe timer arm fires (dead-poller backstop, #636; FEEL/ISO-8601 duration).",
|
|
168
168
|
},
|
|
169
|
+
NANO_PR_MERGEABLE_REPOLL_INTERVAL: {
|
|
170
|
+
category: "env",
|
|
171
|
+
name: "NANO_PR_MERGEABLE_REPOLL_INTERVAL",
|
|
172
|
+
owner: "app/service.ts",
|
|
173
|
+
semantics:
|
|
174
|
+
"How long the merge loop waits before re-deriving mergeability when `gw-mergeable` sees an async-`UNKNOWN` `\"waiting\"` verdict, instead of escalating to a human (#774; bounded by `NANO_PR_MAX_MERGE_STALL_ROUNDS`; FEEL/ISO-8601 duration).",
|
|
175
|
+
},
|
|
169
176
|
NANO_PR_AUTO_MERGE: {
|
|
170
177
|
category: "env",
|
|
171
178
|
name: "NANO_PR_AUTO_MERGE",
|
|
@@ -10,6 +10,7 @@ import type { DeliveryFact } from "../nano-generated/api-io.d.ts";
|
|
|
10
10
|
import {
|
|
11
11
|
bindHumanEmits,
|
|
12
12
|
DELIVERY_HUMAN_ELEMENT,
|
|
13
|
+
deliveryHumanContextQuestion,
|
|
13
14
|
deriveHumanCategory,
|
|
14
15
|
GENERIC_HUMAN_FORM,
|
|
15
16
|
HUMAN_ACK_FORM,
|
|
@@ -304,3 +305,125 @@ test("drift guard: the human element is completer-answerable and surfaces on the
|
|
|
304
305
|
"USER_TASK_KIND_LABELS must label the delivery human node",
|
|
305
306
|
);
|
|
306
307
|
});
|
|
308
|
+
|
|
309
|
+
// ── issue #772: Tasks-inbox "Decision context" for a parked delivery-graph human node ─────────────
|
|
310
|
+
|
|
311
|
+
test("deliveryHumanContextQuestion: derives the node instruction from human_labels (base + __esc twin)", () => {
|
|
312
|
+
const labels = { "delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban" };
|
|
313
|
+
// The parked base task looks up its own id.
|
|
314
|
+
assertEquals(
|
|
315
|
+
deliveryHumanContextQuestion(labels, "delivery-human-task__n7"),
|
|
316
|
+
"Run the manual OTP publish for @nanobpm/urban",
|
|
317
|
+
);
|
|
318
|
+
// The bounded-timeout escalation twin parks on `…__esc`; strip it to find the same stamped label.
|
|
319
|
+
assertEquals(
|
|
320
|
+
deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"),
|
|
321
|
+
"Run the manual OTP publish for @nanobpm/urban",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => {
|
|
326
|
+
// A parked human step must never render a blank Decision context — an untracked/absent label still
|
|
327
|
+
// yields actionable guidance rather than null (which would leave the panel empty, issue #772).
|
|
328
|
+
assertEquals(
|
|
329
|
+
deliveryHumanContextQuestion({}, "delivery-human-task__n1"),
|
|
330
|
+
"A scheduled delivery-graph step is waiting to be completed.",
|
|
331
|
+
);
|
|
332
|
+
assertEquals(
|
|
333
|
+
deliveryHumanContextQuestion(undefined, "delivery-human-task__n1"),
|
|
334
|
+
"A scheduled delivery-graph step is waiting to be completed.",
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("deliveryHumanContextQuestion: a non-human node's escalation twin (no stored label) gets the node-NEUTRAL fallback, not a 'human step' claim", () => {
|
|
339
|
+
// `isDeliveryHumanElement` also matches the `__esc`/`__contract` escalation twins that bounded
|
|
340
|
+
// `agent`/`wait`/`connector` nodes schedule; those carry NO stored human label. The fallback must
|
|
341
|
+
// not mislabel them as a "human step" (issue #772 review, comment on service.ts contextFor arm).
|
|
342
|
+
const humanLabels = { "delivery-human-task__n7": "Run the manual OTP publish" };
|
|
343
|
+
assertEquals(
|
|
344
|
+
deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__esc"),
|
|
345
|
+
"A scheduled delivery-graph step is waiting to be completed.",
|
|
346
|
+
);
|
|
347
|
+
assertEquals(
|
|
348
|
+
deliveryHumanContextQuestion(humanLabels, "delivery-human-task__agent5__contract"),
|
|
349
|
+
"A scheduled delivery-graph step is waiting to be completed.",
|
|
350
|
+
);
|
|
351
|
+
// A real human node's own `__esc` timeout twin still resolves the base label.
|
|
352
|
+
assertEquals(
|
|
353
|
+
deliveryHumanContextQuestion(humanLabels, "delivery-human-task__n7__esc"),
|
|
354
|
+
"Run the manual OTP publish",
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("deliveryHumanContextQuestion: a human node whose id itself ends in __esc resolves its EXACT label, not a sibling's", () => {
|
|
359
|
+
// Node ids may legitimately end in `__esc` (`deliveryGraph.ts` NODE_ID_PATTERN), so such a node is
|
|
360
|
+
// stamped under the exact key `delivery-human-task__<id>` (…__esc). The exact lookup must win over
|
|
361
|
+
// the `__esc`-stripped base, otherwise a sibling `n7` node's label would shadow real node `n7__esc`.
|
|
362
|
+
const labels = {
|
|
363
|
+
"delivery-human-task__n7": "Sibling n7 label",
|
|
364
|
+
"delivery-human-task__n7__esc": "The real n7__esc node label",
|
|
365
|
+
};
|
|
366
|
+
assertEquals(
|
|
367
|
+
deliveryHumanContextQuestion(labels, "delivery-human-task__n7__esc"),
|
|
368
|
+
"The real n7__esc node label",
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── issue #772: the generic form must be static / input-only on the Tasks surface ─────────────────
|
|
373
|
+
|
|
374
|
+
const genericForm = readFileSync("resources/forms/delivery-human-generic.form", "utf8");
|
|
375
|
+
|
|
376
|
+
test("form-structure guard: delivery-human-generic.form uses node-neutral wording", () => {
|
|
377
|
+
// This shared form is also attached to the `__esc`/`__contract` escalation tasks that bounded
|
|
378
|
+
// agent/wait/connector nodes create (`app/deliveryGraphCompiler.ts`), not only scheduled `human`
|
|
379
|
+
// nodes. Copy that calls the task a "scheduled human step" is inaccurate for the escalation family
|
|
380
|
+
// and can obscure that the task is an escalation, so the static text must stay node-neutral.
|
|
381
|
+
assert(
|
|
382
|
+
!/scheduled human step/i.test(genericForm),
|
|
383
|
+
"the shared generic form must use node-neutral wording (it also serves escalation tasks)",
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("form-structure guard: delivery-human-generic.form carries no {{…}} tokens", () => {
|
|
388
|
+
// The Tasks surface (`engineForm`) seeds NO form variables, so any `{{token}}` renders literally and
|
|
389
|
+
// any data-dependent `conditional` mis-fires. Deploy-time `{{token}}` templating is removed too, so
|
|
390
|
+
// such a token could never resolve. Assert the surface stays context-free-safe (issue #772).
|
|
391
|
+
assert(!genericForm.includes("{{"), "the generic delivery-human form must not carry {{…}} tokens");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("form-structure guard: delivery-human-generic.form has no data-dependent conditional and an OPTIONAL value", () => {
|
|
395
|
+
const parsed = JSON.parse(genericForm) as {
|
|
396
|
+
components: { key?: string; conditional?: unknown; validate?: { required?: boolean } }[];
|
|
397
|
+
};
|
|
398
|
+
// No component may gate on form data — those blurbs render contradictorily against empty data.
|
|
399
|
+
for (const c of parsed.components) {
|
|
400
|
+
assert(c.conditional === undefined, "no component may carry a data-dependent conditional");
|
|
401
|
+
}
|
|
402
|
+
// `value` must be OPTIONAL — a no-emit ("click done") node completes without entering a value. This
|
|
403
|
+
// S3 generic surface deploys a single static default form and cannot know per-node whether a fact is
|
|
404
|
+
// required, so it must not client-require `value`; per-node typed-emit binding is the S4 form-
|
|
405
|
+
// selection path's job (`bindHumanEmits`), not a client-required field on this generic surface.
|
|
406
|
+
const value = parsed.components.find((c) => c.key === "value");
|
|
407
|
+
assert(value, "the generic form must keep a single `value` field");
|
|
408
|
+
assert(value!.validate?.required !== true, "`value` must be optional on this surface");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("form-structure guard: delivery-human-generic.form has no task-variable-dependent readonly input", () => {
|
|
412
|
+
// The Tasks surface (`engineForm`) seeds NO task-local variables, so a readonly INPUT component
|
|
413
|
+
// (e.g. the old `prompt` textarea) renders permanently blank — a confusing empty "Now do this".
|
|
414
|
+
// The instruction reaches the operator through the read-model Decision context column instead, so
|
|
415
|
+
// the surface must carry no keyed readonly control; a static keyless `text` block may point to it.
|
|
416
|
+
const parsed = JSON.parse(genericForm) as {
|
|
417
|
+
components: { type?: string; key?: string; readonly?: boolean }[];
|
|
418
|
+
};
|
|
419
|
+
for (const c of parsed.components) {
|
|
420
|
+
assert(
|
|
421
|
+
!(c.readonly === true && typeof c.key === "string"),
|
|
422
|
+
`keyed readonly input '${c.key}' renders blank on the variable-free Tasks surface`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
assert(
|
|
426
|
+
!parsed.components.some((c) => c.key === "prompt"),
|
|
427
|
+
"the never-seeded readonly `prompt` control must not reappear on the Tasks surface",
|
|
428
|
+
);
|
|
429
|
+
});
|
package/app/deliveryHuman.ts
CHANGED
|
@@ -54,6 +54,35 @@ export function isDeliveryHumanElement(elementId: string): boolean {
|
|
|
54
54
|
return elementId === DELIVERY_HUMAN_ELEMENT || elementId.startsWith(`${DELIVERY_HUMAN_ELEMENT}__`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The read-model "Decision context" for a parked delivery-graph `human` node (issue #772). The Tasks
|
|
58
|
+
* surface renders the deployed `.form` against EMPTY data (it seeds no task-local variables), so the
|
|
59
|
+
* node's instruction cannot reach the operator through the form's readonly `prompt` field — it must
|
|
60
|
+
* arrive through the read-model `question` column, exactly like every other escalation kind. The run
|
|
61
|
+
* row already stamps each human node's instruction label in `human_labels` (`buildHumanLabels`), keyed
|
|
62
|
+
* by the node's base user-task element id (`delivery-human-task__<node>`); the bounded-timeout
|
|
63
|
+
* escalation twin parks on the `…__esc` variant. A node id may itself legitimately END in `__esc`
|
|
64
|
+
* (`deliveryGraph.ts` NODE_ID_PATTERN allows it), so the twin-suffix strip is a FALLBACK, not the
|
|
65
|
+
* first probe: look up the EXACT reported element id first (a real `…__esc` human node stores its
|
|
66
|
+
* label under that exact key), and only then the `__esc`-stripped base (the bounded-timeout twin,
|
|
67
|
+
* whose exact id is never stamped, resolves to its real node's label). Returns a static fallback when
|
|
68
|
+
* no label is stored so a parked step is never left with a blank panel.
|
|
69
|
+
*
|
|
70
|
+
* The fallback is deliberately node-NEUTRAL ("…delivery-graph step…", not "…human step…"): only
|
|
71
|
+
* real `human` nodes are stamped into `human_labels`, but `isDeliveryHumanElement` (and hence this
|
|
72
|
+
* helper's caller) also matches the `__esc`/`__contract` escalation twins that BOUNDED `agent`/`wait`/
|
|
73
|
+
* `connector` nodes schedule — those carry no stored label and would otherwise be mislabeled as a
|
|
74
|
+
* "human step". A found label is always a real human node's instruction; the fallback must read true
|
|
75
|
+
* for both an untracked human run AND a non-human escalation twin. */
|
|
76
|
+
export function deliveryHumanContextQuestion(
|
|
77
|
+
humanLabels: Record<string, string> | undefined,
|
|
78
|
+
elementId: string,
|
|
79
|
+
): string {
|
|
80
|
+
const labels = humanLabels ?? {};
|
|
81
|
+
const base = elementId.replace(/__esc$/, "");
|
|
82
|
+
const label = (labels[elementId] ?? labels[base] ?? "").trim();
|
|
83
|
+
return label || "A scheduled delivery-graph step is waiting to be completed.";
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
/** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
|
|
58
87
|
* declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
|
|
59
88
|
export const GENERIC_HUMAN_FORM = "delivery-human-generic";
|
|
@@ -43,6 +43,7 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
|
43
43
|
const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
|
|
44
44
|
const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
|
|
45
45
|
const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
|
|
46
|
+
const MERGEABLE_REPOLL_MS = 2 * 60 * 1000; // matches the PT2M mergeableRepollInterval we start instances with
|
|
46
47
|
|
|
47
48
|
type Output = Record<string, unknown>;
|
|
48
49
|
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
@@ -85,6 +86,7 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
85
86
|
agentSlaTimeout: "PT30M",
|
|
86
87
|
landedWaitTimeout: "PT30M",
|
|
87
88
|
mergeableWaitTimeout: "PT30M",
|
|
89
|
+
mergeableRepollInterval: "PT2M",
|
|
88
90
|
mergeStallRounds: 0,
|
|
89
91
|
mergeStallMax: 3,
|
|
90
92
|
abandonBrief: null,
|
|
@@ -349,6 +351,94 @@ test("the stall-round cap escalates to a human instead of looping forever (#636)
|
|
|
349
351
|
assert(!completedElementIds(engine).has("mark-merged"), "an exhausted stall must not mark-merged");
|
|
350
352
|
});
|
|
351
353
|
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Async-UNKNOWN "waiting" verdict → bounded re-poll, NOT a human (issue #774)
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// GitHub returns `mergeable=UNKNOWN` transiently while it computes mergeability in the background,
|
|
358
|
+
// which `classifyMergeability` maps to `"waiting"`. Before the fix, a `"waiting"` verdict at
|
|
359
|
+
// `gw-mergeable` hit the DEFAULT arm and escalated straight to the `wait-merge-answer` human task —
|
|
360
|
+
// so a PR whose real state was still settling (e.g. to `"conflict"`, which would auto-fire
|
|
361
|
+
// `senior:rebase`) paged a human instead of self-healing. The fix routes `"waiting"` to a bounded
|
|
362
|
+
// re-poll (`wait-mergeable-repoll` timer → `merge-stall-probe` → the existing `gw-mergeable` arms),
|
|
363
|
+
// bounded by the shared `mergeStallMax` budget, and routes the true DEFAULT to auto-rebase.
|
|
364
|
+
|
|
365
|
+
test("a 'waiting' (async UNKNOWN) verdict re-polls instead of escalating to a human (#774)", async () => {
|
|
366
|
+
// RED (before the fix): `waiting` → DEFAULT → `merge-esc-conflict` → `wait-merge-answer`. GREEN:
|
|
367
|
+
// `waiting` parks on the re-poll timer; when it fires, `merge-stall-probe` re-derives `ready` and
|
|
368
|
+
// the PR merges — no human ever touched.
|
|
369
|
+
const engine = await boot({
|
|
370
|
+
responses: {
|
|
371
|
+
"pr.merge-stall-probe": { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
372
|
+
"pr.merge": { mergeStatus: "merged" },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
376
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
377
|
+
// Parks on the bounded re-poll timer — NOT on a human task, NOT escalated.
|
|
378
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable-repoll");
|
|
379
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a waiting verdict must NOT escalate to a human");
|
|
380
|
+
const openTasks = await engine.searchUserTasks({});
|
|
381
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "a waiting verdict must not park a user task");
|
|
382
|
+
// The timer fires → the probe re-derives ground truth (`ready`) → the PR merges.
|
|
383
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
384
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
385
|
+
.hasCompleted()
|
|
386
|
+
.hasNoIncident()
|
|
387
|
+
.hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "attempt-merge", "mark-merged");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("a 'waiting' verdict that settles to conflict on re-poll reaches the rebase arm (#774)", async () => {
|
|
391
|
+
// The incident (Magikcraft/nano-bpm#1166): the PR was really CONFLICTING, but GitHub reported
|
|
392
|
+
// UNKNOWN transiently. The re-poll must let it settle to `conflict` → `senior:rebase`, the arm that
|
|
393
|
+
// would have fixed it, rather than paging a human on the transient UNKNOWN.
|
|
394
|
+
const engine = await boot({
|
|
395
|
+
responses: {
|
|
396
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
|
|
397
|
+
"senior:rebase": { status: "rebased" },
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
401
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
402
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
403
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
404
|
+
.isActive()
|
|
405
|
+
.hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "rebase")
|
|
406
|
+
.hasVariable("rebaseRound", 1);
|
|
407
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "settling to conflict must rebase, not escalate");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to a human (#774)", async () => {
|
|
411
|
+
// A permanently-UNKNOWN PR cannot re-poll forever: the re-poll shares the `mergeStallMax` budget.
|
|
412
|
+
// `mergeStallMax: 0` — escalate on the first re-poll. The probe runs once, `gw-merge-stall` finds
|
|
413
|
+
// the budget exhausted, and routes to the human `wait-merge-answer` (last resort, not first).
|
|
414
|
+
const engine = await boot({
|
|
415
|
+
vars: { mergeStallMax: 0 },
|
|
416
|
+
responses: {
|
|
417
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "" },
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
421
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
422
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "must re-poll before ever escalating");
|
|
423
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
424
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
425
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "merge-esc-conflict");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#774)", async () => {
|
|
429
|
+
// The DEFAULT arm is a last-resort self-heal, not a page: an unexpected/garbage `mergeState` routes
|
|
430
|
+
// to the bounded rebase agent (which reconciles from ground truth), never straight to a human.
|
|
431
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } } });
|
|
432
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
433
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "bogus" } });
|
|
434
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
435
|
+
.isActive()
|
|
436
|
+
.hasCompletedElements("rebase")
|
|
437
|
+
.hasVariable("rebaseRound", 1);
|
|
438
|
+
const openTasks = await engine.searchUserTasks({});
|
|
439
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
|
|
440
|
+
});
|
|
441
|
+
|
|
352
442
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
353
443
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
354
444
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { test } from "node:test";
|
|
9
|
-
import { DEFAULT_MERGEABLE_WAIT_TIMEOUT, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
9
|
+
import { DEFAULT_MERGEABLE_REPOLL_INTERVAL, DEFAULT_MERGEABLE_WAIT_TIMEOUT, mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
10
|
+
import { isoDurationToMs } from "./reviewWait.ts";
|
|
10
11
|
|
|
11
12
|
test("mergeableWaitTimeout: blank / absent / malformed → default", () => {
|
|
12
13
|
assert.equal(mergeableWaitTimeout(undefined), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
|
|
@@ -37,3 +38,43 @@ test("the default is itself a well-formed ISO-8601 duration (never an uninterpre
|
|
|
37
38
|
assert.notEqual(DEFAULT_MERGEABLE_WAIT_TIMEOUT, sentinel);
|
|
38
39
|
assert.equal(mergeableWaitTimeout(DEFAULT_MERGEABLE_WAIT_TIMEOUT, sentinel), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
|
|
39
40
|
});
|
|
41
|
+
|
|
42
|
+
// --- Mergeability re-poll interval (issue #774) -----------------------------------------------
|
|
43
|
+
// Baked into every merge-loop instance's `mergeableRepollInterval` process variable and evaluated by
|
|
44
|
+
// the `wait-mergeable-repoll` timer catch, which bounds the `"waiting"` (async UNKNOWN) verdict's
|
|
45
|
+
// re-poll before it re-derives mergeability from ground truth — so a malformed operator env must
|
|
46
|
+
// never deploy an uninterpretable `<bpmn:timeDuration>`.
|
|
47
|
+
|
|
48
|
+
test("mergeableRepollInterval: blank / absent / malformed → default", () => {
|
|
49
|
+
assert.equal(mergeableRepollInterval(undefined), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
50
|
+
assert.equal(mergeableRepollInterval(""), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
51
|
+
assert.equal(mergeableRepollInterval(" "), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
52
|
+
assert.equal(mergeableRepollInterval("2m"), DEFAULT_MERGEABLE_REPOLL_INTERVAL); // missing leading P/T
|
|
53
|
+
assert.equal(mergeableRepollInterval("garbage"), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("mergeableRepollInterval: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
57
|
+
assert.equal(mergeableRepollInterval("PT2M"), "PT2M");
|
|
58
|
+
assert.equal(mergeableRepollInterval("pt90s"), "PT90S");
|
|
59
|
+
assert.equal(mergeableRepollInterval(" pt5m "), "PT5M");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("mergeableRepollInterval: the re-poll interval is shorter than the poller-death backstop", () => {
|
|
63
|
+
// The re-poll (GitHub settling UNKNOWN, ~seconds) must fire far sooner than the 30-minute
|
|
64
|
+
// dead-poller backstop, so a transient UNKNOWN self-heals briskly rather than parking for tens of
|
|
65
|
+
// minutes per round.
|
|
66
|
+
assert.equal(DEFAULT_MERGEABLE_REPOLL_INTERVAL, "PT2M");
|
|
67
|
+
// `notEqual` only proves the values differ — a future `PT1H` would pass despite being LONGER than
|
|
68
|
+
// the backstop. Assert the actual ordering invariant on parsed durations via the canonical helper.
|
|
69
|
+
assert.ok(
|
|
70
|
+
isoDurationToMs(DEFAULT_MERGEABLE_REPOLL_INTERVAL, "PT0S") <
|
|
71
|
+
isoDurationToMs(DEFAULT_MERGEABLE_WAIT_TIMEOUT, "PT0S"),
|
|
72
|
+
"the re-poll interval must be strictly shorter than the poller-death backstop",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("mergeableRepollInterval: the default is itself a well-formed ISO-8601 duration", () => {
|
|
77
|
+
const sentinel = "PT1S";
|
|
78
|
+
assert.notEqual(DEFAULT_MERGEABLE_REPOLL_INTERVAL, sentinel);
|
|
79
|
+
assert.equal(mergeableRepollInterval(DEFAULT_MERGEABLE_REPOLL_INTERVAL, sentinel), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
80
|
+
});
|
package/app/mergeableWait.ts
CHANGED
|
@@ -36,3 +36,26 @@ export function mergeableWaitTimeout(
|
|
|
36
36
|
): string {
|
|
37
37
|
return isoDuration(raw, def);
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Default mergeability re-poll interval (ISO-8601 duration): how long the merge loop waits before
|
|
41
|
+
* re-deriving mergeability from ground truth when `gw-mergeable` sees an async-`UNKNOWN` `"waiting"`
|
|
42
|
+
* verdict (issue #774). GitHub returns `mergeable=UNKNOWN` transiently while it computes mergeability
|
|
43
|
+
* in the background; a `"waiting"` verdict must therefore route to a *bounded re-poll* — not straight
|
|
44
|
+
* to a human — so the loop self-heals once GitHub settles (to `"conflict"` → `senior:rebase`, etc.).
|
|
45
|
+
* Deliberately SHORT (unlike the 30-minute {@link DEFAULT_MERGEABLE_WAIT_TIMEOUT} poller-death
|
|
46
|
+
* backstop): GitHub usually settles UNKNOWN within seconds, so re-poll briskly rather than parking
|
|
47
|
+
* the PR for tens of minutes per round. The re-poll is bounded by the shared `mergeStallMax` budget
|
|
48
|
+
* (`NANO_PR_MAX_MERGE_STALL_ROUNDS`): once exhausted, `gw-merge-stall` escalates to a human. */
|
|
49
|
+
export const DEFAULT_MERGEABLE_REPOLL_INTERVAL = "PT2M";
|
|
50
|
+
|
|
51
|
+
/** Validate the operator-supplied mergeability re-poll interval (env
|
|
52
|
+
* `NANO_PR_MERGEABLE_REPOLL_INTERVAL`, ISO-8601 duration), falling back to
|
|
53
|
+
* {@link DEFAULT_MERGEABLE_REPOLL_INTERVAL} when absent, blank, or malformed — a bad env value must
|
|
54
|
+
* never deploy an uninterpretable timer expression. Derives its validation from the single canonical
|
|
55
|
+
* {@link isoDuration}. */
|
|
56
|
+
export function mergeableRepollInterval(
|
|
57
|
+
raw: string | undefined,
|
|
58
|
+
def: string = DEFAULT_MERGEABLE_REPOLL_INTERVAL,
|
|
59
|
+
): string {
|
|
60
|
+
return isoDuration(raw, def);
|
|
61
|
+
}
|
|
@@ -661,6 +661,62 @@ test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human tas
|
|
|
661
661
|
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
662
662
|
});
|
|
663
663
|
|
|
664
|
+
test("pollUserTasks: a parked delivery-human node carries its instruction as `question` (Decision context, issue #772)", async () => {
|
|
665
|
+
// The Tasks surface seeds no form variables, so a delivery-graph `human` node's instruction can only
|
|
666
|
+
// reach the operator through the read-model `question` (rendered as "Decision context"). Source it
|
|
667
|
+
// from the run's stamped `human_labels`, keyed by the parked user-task element id — else the panel is
|
|
668
|
+
// blank and the human has no idea what the run is waiting on.
|
|
669
|
+
const { data, stores } = memData({
|
|
670
|
+
delivery_graph_runs: [
|
|
671
|
+
{
|
|
672
|
+
run_key: "delivery-graph-403eb22e",
|
|
673
|
+
process_key: "dg-1",
|
|
674
|
+
status: "running",
|
|
675
|
+
title: "release runbook",
|
|
676
|
+
human_labels: JSON.stringify({
|
|
677
|
+
"delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban",
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
});
|
|
682
|
+
const restore = stubUserTaskSearch([
|
|
683
|
+
{ userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
|
|
684
|
+
// the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base label
|
|
685
|
+
{ userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
|
|
686
|
+
]);
|
|
687
|
+
try {
|
|
688
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
689
|
+
} finally {
|
|
690
|
+
restore();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
694
|
+
assertEquals(byKey["20411"].question, "Run the manual OTP publish for @nanobpm/urban");
|
|
695
|
+
assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
|
|
699
|
+
// An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
|
|
700
|
+
// node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
|
|
701
|
+
// for a non-human escalation twin too, so it must not claim "human step".
|
|
702
|
+
const { data, stores } = memData({});
|
|
703
|
+
const restore = stubUserTaskSearch([
|
|
704
|
+
{ userTaskKey: "20411", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
|
|
705
|
+
// a bounded `agent`/`wait`/`connector` node's escalation twin: `isDeliveryHumanElement` matches it,
|
|
706
|
+
// but it carries no stored human label, so it gets the neutral fallback, not a "human step" claim.
|
|
707
|
+
{ userTaskKey: "20499", elementId: "delivery-human-task__agent5__esc", processInstanceKey: "dg-9", state: "CREATED" },
|
|
708
|
+
]);
|
|
709
|
+
try {
|
|
710
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
711
|
+
} finally {
|
|
712
|
+
restore();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
716
|
+
assertEquals(byKey["20411"].question, "A scheduled delivery-graph step is waiting to be completed.");
|
|
717
|
+
assertEquals(byKey["20499"].question, "A scheduled delivery-graph step is waiting to be completed.");
|
|
718
|
+
});
|
|
719
|
+
|
|
664
720
|
test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
|
|
665
721
|
// Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
|
|
666
722
|
// the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
|
package/app/service.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
|
30
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
|
-
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
|
+
import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
34
34
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
35
35
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
36
36
|
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
} from "./github.ts";
|
|
54
54
|
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
55
55
|
import { pollLineage } from "./lineage.ts";
|
|
56
|
-
import { mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
56
|
+
import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
57
57
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
58
58
|
import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
|
|
59
59
|
import {
|
|
@@ -206,6 +206,19 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
|
|
|
206
206
|
process.env.NANO_PR_MERGEABLE_WAIT_TIMEOUT,
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
/** How long the merge loop waits before re-deriving mergeability from ground truth when
|
|
210
|
+
* `gw-mergeable` sees an async-`UNKNOWN` `"waiting"` verdict (issue #774). Seeded as the
|
|
211
|
+
* `mergeableRepollInterval` process variable at merge start and evaluated by the merge-loop's
|
|
212
|
+
* `wait-mergeable-repoll` timer catch. A `"waiting"` verdict means GitHub is still computing
|
|
213
|
+
* mergeability in the background — so route it to a *bounded re-poll* (this timer → `merge-stall-probe`
|
|
214
|
+
* → the existing `gw-mergeable` arms), not to a human. The re-poll shares the `mergeStallMax` budget
|
|
215
|
+
* (`NANO_PR_MAX_MERGE_STALL_ROUNDS`) with the dead-poller stall backstop; once exhausted,
|
|
216
|
+
* `gw-merge-stall` escalates. ISO-8601 duration; a malformed `NANO_PR_MERGEABLE_REPOLL_INTERVAL`
|
|
217
|
+
* falls back to the default so an uninterpretable timer is never deployed. */
|
|
218
|
+
export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
|
|
219
|
+
process.env.NANO_PR_MERGEABLE_REPOLL_INTERVAL,
|
|
220
|
+
);
|
|
221
|
+
|
|
209
222
|
/** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
|
|
210
223
|
* Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
|
|
211
224
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
@@ -678,6 +691,7 @@ export async function startMerge(
|
|
|
678
691
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
679
692
|
landedWaitTimeout: MERGE_LANDED_WAIT_TIMEOUT,
|
|
680
693
|
mergeableWaitTimeout: MERGEABLE_WAIT_TIMEOUT,
|
|
694
|
+
mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
|
|
681
695
|
mergeStallRounds: 0,
|
|
682
696
|
mergeStallMax: MAX_MERGE_STALL_ROUNDS,
|
|
683
697
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
@@ -2613,6 +2627,10 @@ export async function pollUserTasks(
|
|
|
2613
2627
|
url?: string | null;
|
|
2614
2628
|
deliveryLabel?: string | null;
|
|
2615
2629
|
conformanceSummary?: string | null;
|
|
2630
|
+
/** The parked human-node instruction labels for a delivery run, keyed by user-task element id
|
|
2631
|
+
* (`delivery-human-task__<node>`) — the run row's stamped `human_labels`, denormalised so the
|
|
2632
|
+
* Tasks-inbox "Decision context" can explain a parked delivery-graph `human` node (issue #772). */
|
|
2633
|
+
humanLabels?: Record<string, string>;
|
|
2616
2634
|
/** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
|
|
2617
2635
|
* escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
|
|
2618
2636
|
* feature run's `delivery_label` for the inline preflight. */
|
|
@@ -2640,7 +2658,7 @@ export async function pollUserTasks(
|
|
|
2640
2658
|
// feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
|
|
2641
2659
|
// the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
|
|
2642
2660
|
for (const run of await deliveryGraphRuns(data).all()) {
|
|
2643
|
-
if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
|
|
2661
|
+
if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null, humanLabels: parseHumanLabels(run.human_labels) });
|
|
2644
2662
|
}
|
|
2645
2663
|
|
|
2646
2664
|
// Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
|
|
@@ -2719,6 +2737,13 @@ export async function pollUserTasks(
|
|
|
2719
2737
|
question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
|
|
2720
2738
|
break;
|
|
2721
2739
|
}
|
|
2740
|
+
// A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
|
|
2741
|
+
// and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
|
|
2742
|
+
// context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
|
|
2743
|
+
// surface seeds no form variables, the operator has no idea what the run is waiting on.
|
|
2744
|
+
if (question === null && isDeliveryHumanElement(elementId)) {
|
|
2745
|
+
question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
|
|
2746
|
+
}
|
|
2722
2747
|
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
2723
2748
|
};
|
|
2724
2749
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.186.
|
|
3
|
+
"version": "0.186.2",
|
|
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",
|
|
@@ -5,43 +5,14 @@
|
|
|
5
5
|
"components": [
|
|
6
6
|
{
|
|
7
7
|
"type": "text",
|
|
8
|
-
"text": "### Delivery graph — node `{{nodeId}}`",
|
|
9
|
-
"conditional": {
|
|
10
|
-
"hide": "=(nodeId = null) or (nodeId = \"\")"
|
|
11
|
-
}
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"type": "textarea",
|
|
15
|
-
"key": "prompt",
|
|
16
8
|
"label": "Now do this",
|
|
17
|
-
"
|
|
18
|
-
"readonly": true
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
"type": "text",
|
|
22
|
-
"text": "**Emits:** {{emitLabel}} — enter its typed value below (validated against the node's declared fact).",
|
|
23
|
-
"conditional": {
|
|
24
|
-
"hide": "=emitMode != \"typed\""
|
|
25
|
-
}
|
|
9
|
+
"text": "This task's instruction is shown in the Decision context above."
|
|
26
10
|
},
|
|
27
11
|
{
|
|
28
12
|
"type": "textfield",
|
|
29
13
|
"key": "value",
|
|
30
|
-
"label": "Emitted value",
|
|
31
|
-
"description": "The typed value this step hands forward to its downstream dependents.
|
|
32
|
-
"conditional": {
|
|
33
|
-
"hide": "=emitMode != \"typed\""
|
|
34
|
-
},
|
|
35
|
-
"validate": {
|
|
36
|
-
"required": true
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"type": "text",
|
|
41
|
-
"text": "_This step emits no typed fact (N/A) — just complete it to unblock its dependents._",
|
|
42
|
-
"conditional": {
|
|
43
|
-
"hide": "=emitMode = \"typed\""
|
|
44
|
-
}
|
|
14
|
+
"label": "Emitted value (only if this step emits a fact)",
|
|
15
|
+
"description": "The typed value this step hands forward to its downstream dependents. Leave blank for a click-done step that emits nothing."
|
|
45
16
|
},
|
|
46
17
|
{
|
|
47
18
|
"type": "textarea",
|