@nanobpm/nano-workforce 0.99.0 → 0.100.0
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 +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/convergeGate.test.ts +8 -4
- package/app/convergenceEscalationGuard.test.ts +106 -0
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/persist-escalation.test.ts +32 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/roundProgress.test.ts +7 -4
- package/app/service.ts +60 -132
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/resources/processes/convergence-loop.bpmn +114 -118
- package/workers/persist-escalation/worker.ts +7 -4
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- package/operations/answerFeatureEscalation.ts +0 -68
package/app/feature.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
18
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
19
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
21
21
|
|
|
22
22
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
23
23
|
export const FEATURE_PROCESS_ID = "feature";
|
|
@@ -52,35 +52,6 @@ export interface FeatureRun {
|
|
|
52
52
|
* outcome is written to `status` itself; this carries the sub-state / note (e.g. "merged",
|
|
53
53
|
* "waiting_review", or "operator: <note>"). */
|
|
54
54
|
delivery_label: string | null;
|
|
55
|
-
/** The parked `feature-escalation` user task's `question`, persisted at escalation entry by the
|
|
56
|
-
* `record-feature-escalation` worker (NOT by `pollFeatureEscalations` while parked, which
|
|
57
|
-
* deliberately never writes it) so the pages can show what the agent asked. NULL whenever the run
|
|
58
|
-
* is not parked at an escalation — cleared on the exit paths (`record-feature` / the answer
|
|
59
|
-
* operation), and, as a self-heal, by `pollFeatureEscalations` when a previously-observed task is
|
|
60
|
-
* completed out-of-band (see `deriveFeatureEscalationPatch`). */
|
|
61
|
-
escalation_question: string | null;
|
|
62
|
-
/** The completable native `feature-escalation` user-task key the answer affordance posts to
|
|
63
|
-
* (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
|
|
64
|
-
* `pollFeatureEscalations` while parked; NULL otherwise. */
|
|
65
|
-
escalation_user_task_key: string | null;
|
|
66
|
-
/** Gateway projection (issue #272): the single fail-closed "open escalation" display signal, `1` iff
|
|
67
|
-
* ALL THREE escalation columns agree the run is parked at an answerable escalation
|
|
68
|
-
* (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question` non-NULL),
|
|
69
|
-
* else `0`. Derived by `deriveEscalationOpen` at write time. The escalation tuple is spread across
|
|
70
|
-
* three independently-written columns, so an interim read can see a TORN state (a live pointer with a
|
|
71
|
-
* blank question, or a status lagging behind a cleared question); the pages gate the Abandon action
|
|
72
|
-
* and the answer form on THIS conjunction instead of on `escalation_user_task_key` alone, so a torn
|
|
73
|
-
* tuple renders as not-escalated rather than escalated-but-blank. NULL only on legacy rows before the
|
|
74
|
-
* projection reached them (backfilled once at boot). */
|
|
75
|
-
escalation_open: number | null;
|
|
76
|
-
/** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
|
|
77
|
-
* to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
|
|
78
|
-
* Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
|
|
79
|
-
* blocked-run acknowledgement) are never conflated. Set by `pollFeatureBlocked` while a run is parked
|
|
80
|
-
* at `feature-blocked` (status `awaiting_operator`); NULL otherwise — cleared on the exit paths
|
|
81
|
-
* (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
|
|
82
|
-
* when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
|
|
83
|
-
blocked_user_task_key: string | null;
|
|
84
55
|
/** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
|
|
85
56
|
* set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
|
|
86
57
|
acknowledged_at: string | null;
|
|
@@ -107,7 +78,7 @@ export interface FeatureRun {
|
|
|
107
78
|
export const FEATURE_RUN_STATUSES = [
|
|
108
79
|
"running", // the agent is implementing
|
|
109
80
|
"escalated", // NON-terminal: the run is parked at the `feature-escalation` operator user task,
|
|
110
|
-
// waiting on a human answer (
|
|
81
|
+
// waiting on a human answer (set by record-feature-escalation; surfaced on the Tasks inbox by pollUserTasks)
|
|
111
82
|
"opened", // a PR was raised and the run ends here (converge was not requested)
|
|
112
83
|
"converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
|
|
113
84
|
"awaiting_operator", // NON-terminal: the run is blocked and parked at the feature-blocked operator user task
|
|
@@ -177,7 +148,8 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
|
|
|
177
148
|
}
|
|
178
149
|
|
|
179
150
|
/** The `feature-escalation` user-task element id (feature.bpmn) — the native operator wait a run
|
|
180
|
-
* parks on when the agent escalates. `
|
|
151
|
+
* parks on when the agent escalates. `pollUserTasks` reads the engine's open task for this element to
|
|
152
|
+
* project it onto the `user_tasks` Tasks inbox. */
|
|
181
153
|
export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
|
|
182
154
|
|
|
183
155
|
/** One append-only audit row per `feature-escalation` ENTRY (issue #305). Mirrors the surviving
|
|
@@ -223,111 +195,12 @@ export async function recordFeatureEscalation(
|
|
|
223
195
|
});
|
|
224
196
|
}
|
|
225
197
|
|
|
226
|
-
/** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
|
|
227
|
-
* `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
|
|
228
|
-
* drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
|
|
229
|
-
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task.
|
|
230
|
-
*
|
|
231
|
-
* The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
|
|
232
|
-
* task's `zeebe:ioMapping`-mapped local variables through the user-task query, so relying on it would
|
|
233
|
-
* make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
|
|
234
|
-
* persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
|
|
235
|
-
export interface FeatureEscalationParked {
|
|
236
|
-
userTaskKey: string;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Pure source of truth for the escalation read-model reconcile (`pollFeatureEscalations`): given a
|
|
240
|
-
* run and whether it is currently parked at `feature-escalation`, return the minimal `feature_runs`
|
|
241
|
-
* patch reconciling the run's LIVENESS (status + completable-task pointer) with the observed park
|
|
242
|
-
* state (or null when nothing changed, so the poller skips the write). Idempotent, and — crucially —
|
|
243
|
-
* self-healing across the brief window between the `record-feature-escalation` service task and the
|
|
244
|
-
* user task actually appearing: a premature "not parked" reset to `running` is re-flipped to
|
|
245
|
-
* `escalated` on the next pass once the task is observed.
|
|
246
|
-
*
|
|
247
|
-
* - parked → flip `status` to `escalated` and denormalise the completable `userTaskKey` so the pages
|
|
248
|
-
* can drive an attributed answer. It never writes `escalation_question` while parked — that is the
|
|
249
|
-
* service task's to own (set) and the exit paths' to clear (record-feature / the answer operation),
|
|
250
|
-
* so the poller can never clobber the persisted question during that self-healing window.
|
|
251
|
-
* - un-parked → clear the completable-task pointer; a run still marked `escalated` has resumed
|
|
252
|
-
* (answered / looped back to implement-task), so it returns to `running`. A run already advanced
|
|
253
|
-
* past `escalated` by a downstream worker keeps that status — only the pointer is cleared. Once the
|
|
254
|
-
* pointer was actually OBSERVED (non-NULL) and the task is now gone, `escalation_question` is also
|
|
255
|
-
* cleared here, self-healing a question left populated when the task was completed out-of-band
|
|
256
|
-
* (bypassing the answer operation). This is gated on the observed pointer precisely so it cannot
|
|
257
|
-
* fire in the pre-observation self-healing window, where the pointer is still NULL. */
|
|
258
|
-
export function deriveFeatureEscalationPatch(
|
|
259
|
-
run: Pick<FeatureRun, "status" | "escalation_user_task_key">,
|
|
260
|
-
parked: FeatureEscalationParked | null,
|
|
261
|
-
): Partial<FeatureRun> | null {
|
|
262
|
-
const patch: Partial<FeatureRun> = {};
|
|
263
|
-
if (parked) {
|
|
264
|
-
if (run.status !== "escalated") patch.status = "escalated";
|
|
265
|
-
if (run.escalation_user_task_key !== parked.userTaskKey) patch.escalation_user_task_key = parked.userTaskKey;
|
|
266
|
-
} else {
|
|
267
|
-
if (run.status === "escalated") patch.status = "running";
|
|
268
|
-
// Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL)
|
|
269
|
-
// and it is now gone. This self-heals a `question` left populated when the task was completed
|
|
270
|
-
// out-of-band (e.g. an external task UI, bypassing the answer operation that normally clears it),
|
|
271
|
-
// which would otherwise keep the UI showing an Escalation on a run that has resumed. Gating on
|
|
272
|
-
// the pointer being non-NULL is what makes it safe: during the brief self-healing window between
|
|
273
|
-
// `record-feature-escalation` (which persists the question but leaves the pointer NULL) and the
|
|
274
|
-
// task appearing, the pointer is NULL, so this never clobbers the freshly-persisted question.
|
|
275
|
-
if (run.escalation_user_task_key !== null) {
|
|
276
|
-
patch.escalation_user_task_key = null;
|
|
277
|
-
patch.escalation_question = null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return Object.keys(patch).length > 0 ? patch : null;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
198
|
/** The `feature-blocked` user-task element id (feature.bpmn) — the native operator wait a run parks on
|
|
284
199
|
* when the agent reports a `blocked` outcome (it gave up / the escalation was abandoned or timed out).
|
|
285
|
-
* `
|
|
200
|
+
* `pollUserTasks` reads the engine's open task for this element to project it onto the `user_tasks`
|
|
201
|
+
* Tasks inbox. */
|
|
286
202
|
export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
|
|
287
203
|
|
|
288
|
-
/** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `openUserTasks`
|
|
289
|
-
* (the open-task-scoped query — issue #294): the completable user-task key the pages drive an
|
|
290
|
-
* attributed acknowledgement against. Scoping to `state:"CREATED"` keeps a re-blocked run — which
|
|
291
|
-
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task. */
|
|
292
|
-
export interface FeatureBlockedParked {
|
|
293
|
-
userTaskKey: string;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Pure source of truth for the blocked read-model reconcile (`pollFeatureBlocked`), the blocked twin
|
|
297
|
-
* of `deriveFeatureEscalationPatch`: given a run and whether it is currently parked at `feature-blocked`,
|
|
298
|
-
* return the minimal `feature_runs` patch reconciling the completable-task pointer with the observed park
|
|
299
|
-
* state (or null when nothing changed, so the poller skips the write). Idempotent and self-healing.
|
|
300
|
-
*
|
|
301
|
-
* Unlike the escalation reconcile, the STATUS flip is NOT owned here: `record-feature` already persists
|
|
302
|
-
* the row as `awaiting_operator` in the same token path before the `feature-blocked` user task is
|
|
303
|
-
* created, and `record-blocked-ack` settles it to the terminal `blocked` on completion. So this only
|
|
304
|
-
* reconciles the completable-task POINTER — never the status — so it can never overwrite the terminal
|
|
305
|
-
* `blocked` the acknowledgement worker has already written.
|
|
306
|
-
*
|
|
307
|
-
* - parked → denormalise the completable `userTaskKey` so the pages can drive an attributed acknowledge.
|
|
308
|
-
* - un-parked → clear the pointer ONLY once it was actually OBSERVED (non-NULL) and the task is now gone.
|
|
309
|
-
* Gating on the observed pointer is what makes it safe across the brief self-healing window between
|
|
310
|
-
* `record-feature` (which persists `awaiting_operator` but leaves the pointer NULL) and the user task
|
|
311
|
-
* appearing: in that window the pointer is NULL, so this never fires, and the next pass fills it in once
|
|
312
|
-
* the task is observable. Once observed and then gone (e.g. an out-of-band completion), the stale
|
|
313
|
-
* pointer is cleared so the pages stop offering an acknowledge control for a task that no longer exists. */
|
|
314
|
-
export function deriveFeatureBlockedPatch(
|
|
315
|
-
run: Pick<FeatureRun, "blocked_user_task_key">,
|
|
316
|
-
parked: FeatureBlockedParked | null,
|
|
317
|
-
): Partial<FeatureRun> | null {
|
|
318
|
-
const patch: Partial<FeatureRun> = {};
|
|
319
|
-
if (parked) {
|
|
320
|
-
if (run.blocked_user_task_key !== parked.userTaskKey) patch.blocked_user_task_key = parked.userTaskKey;
|
|
321
|
-
} else {
|
|
322
|
-
// Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL) and
|
|
323
|
-
// it is now gone. Gating on the pointer being non-NULL is what makes it safe: during the brief
|
|
324
|
-
// self-healing window between `record-feature` (which persists `awaiting_operator` but leaves the
|
|
325
|
-
// pointer NULL) and the task appearing, the pointer is NULL, so this never clears prematurely.
|
|
326
|
-
if (run.blocked_user_task_key !== null) patch.blocked_user_task_key = null;
|
|
327
|
-
}
|
|
328
|
-
return Object.keys(patch).length > 0 ? patch : null;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
204
|
/** The feature_runs fields the projection reads. A patch touching none of these cannot change the
|
|
332
205
|
* derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
|
|
333
206
|
* read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
|
|
@@ -337,9 +210,6 @@ const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
|
337
210
|
"pr_key",
|
|
338
211
|
"converge",
|
|
339
212
|
"auto_merge",
|
|
340
|
-
"escalation_question",
|
|
341
|
-
"escalation_user_task_key",
|
|
342
|
-
"blocked_user_task_key",
|
|
343
213
|
"acknowledged_at",
|
|
344
214
|
];
|
|
345
215
|
|
|
@@ -353,7 +223,6 @@ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
|
353
223
|
"stage_skipped",
|
|
354
224
|
"attention",
|
|
355
225
|
"list_bucket",
|
|
356
|
-
"escalation_open",
|
|
357
226
|
];
|
|
358
227
|
|
|
359
228
|
/** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
|
|
@@ -374,9 +243,6 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
|
|
|
374
243
|
pr_key: row.pr_key ?? null,
|
|
375
244
|
converge: row.converge ?? null,
|
|
376
245
|
auto_merge: row.auto_merge ?? null,
|
|
377
|
-
escalation_question: row.escalation_question ?? null,
|
|
378
|
-
escalation_user_task_key: row.escalation_user_task_key ?? null,
|
|
379
|
-
blocked_user_task_key: row.blocked_user_task_key ?? null,
|
|
380
246
|
});
|
|
381
247
|
return {
|
|
382
248
|
stage,
|
|
@@ -384,13 +250,6 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
|
|
|
384
250
|
stage_skipped: skipped,
|
|
385
251
|
attention,
|
|
386
252
|
list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
|
|
387
|
-
escalation_open: deriveEscalationOpen({
|
|
388
|
-
status: row.status,
|
|
389
|
-
escalation_question: row.escalation_question ?? null,
|
|
390
|
-
escalation_user_task_key: row.escalation_user_task_key ?? null,
|
|
391
|
-
})
|
|
392
|
-
? 1
|
|
393
|
-
: 0,
|
|
394
253
|
};
|
|
395
254
|
}
|
|
396
255
|
|
|
@@ -436,24 +295,20 @@ export const featureRuns = (data: DataLayer) => {
|
|
|
436
295
|
};
|
|
437
296
|
|
|
438
297
|
/** Re-project every feature_runs row through the gateway so rows missing any projection column get
|
|
439
|
-
* correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy rows.
|
|
444
|
-
* Reprojecting on a missing `escalation_open` matters for a run parked at a LIVE escalation when
|
|
445
|
-
* migration 040 lands: `pollFeatureEscalations` writes nothing while it stays parked (no change), so
|
|
446
|
-
* without this the fail-closed signal would stay NULL and hide a genuinely-open escalation. */
|
|
298
|
+
* correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` values. Catches rows written
|
|
299
|
+
* before migration 039 (the pipeline columns). Idempotent and safe to re-run: it re-derives from each
|
|
300
|
+
* row's own stored fields, so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps
|
|
301
|
+
* every future write fresh, so this only needs to catch legacy rows. */
|
|
447
302
|
export async function backfillFeatureStages(data: DataLayer): Promise<number> {
|
|
448
303
|
const table = featureRuns(data);
|
|
449
304
|
const rows = await table.all();
|
|
450
305
|
let stamped = 0;
|
|
451
306
|
for (const row of rows) {
|
|
452
|
-
// Only touch rows the projection has never
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
if (row.stage != null
|
|
307
|
+
// Only touch rows the projection has never reached — a legacy row whose `stage` (pre-039) column is
|
|
308
|
+
// still NULL. The gateway keeps every write fresh, so a fully-projected row needs no re-write;
|
|
309
|
+
// skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
|
|
310
|
+
// rows actually backfilled (not the total row count).
|
|
311
|
+
if (row.stage != null) continue;
|
|
457
312
|
// Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
|
|
458
313
|
// would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
|
|
459
314
|
// so backfill projects explicitly rather than relying on an empty-patch reproject.)
|
|
@@ -522,9 +377,6 @@ export async function startFeature(
|
|
|
522
377
|
// `deriveListBucket` flip the row to 'history' the moment it completes again, skipping the
|
|
523
378
|
// intended operator dismissal. A fresh run must re-earn its tick-off.
|
|
524
379
|
acknowledged_at: null,
|
|
525
|
-
escalation_question: null,
|
|
526
|
-
escalation_user_task_key: null,
|
|
527
|
-
blocked_user_task_key: null,
|
|
528
380
|
updated_at: ts,
|
|
529
381
|
});
|
|
530
382
|
} else {
|
|
@@ -542,9 +394,6 @@ export async function startFeature(
|
|
|
542
394
|
auto_merge: autoMerge ? 1 : 0,
|
|
543
395
|
outcome: null,
|
|
544
396
|
delivery_label: null,
|
|
545
|
-
escalation_question: null,
|
|
546
|
-
escalation_user_task_key: null,
|
|
547
|
-
blocked_user_task_key: null,
|
|
548
397
|
created_at: ts,
|
|
549
398
|
updated_at: ts,
|
|
550
399
|
});
|
|
@@ -72,56 +72,6 @@ test("update {status:'escalated'} on a row with no pr_key projects Implementing
|
|
|
72
72
|
assertEquals(rows[0].stage_state, null);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
// issue #272: the fail-closed open-escalation projection. The escalation tuple is spread across three
|
|
76
|
-
// independently-written columns; the gateway projects `escalation_open=1` ONLY when all three agree.
|
|
77
|
-
test("escalation_open is 1 only when status=escalated AND pointer AND question all present", async () => {
|
|
78
|
-
const { data, rows } = memData();
|
|
79
|
-
rows.push({ feature_key: "o/r#esc", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
80
|
-
// A complete, answerable escalation → 1.
|
|
81
|
-
await featureRuns(data).update("o/r#esc", {
|
|
82
|
-
status: "escalated",
|
|
83
|
-
escalation_user_task_key: "ut-9",
|
|
84
|
-
escalation_question: "which base branch?",
|
|
85
|
-
});
|
|
86
|
-
assertEquals(rows[0].escalation_open, 1);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("a torn escalation tuple projects escalation_open=0 (renders not-escalated, fail closed)", async () => {
|
|
90
|
-
const { data, rows } = memData();
|
|
91
|
-
// Simulate the exit-path/poller race: the question was cleared while status + pointer still lag in the
|
|
92
|
-
// escalated projection (the mirror tear observed on nwf#270). The page must NOT show an escalation.
|
|
93
|
-
rows.push({
|
|
94
|
-
feature_key: "o/r#torn",
|
|
95
|
-
status: "escalated",
|
|
96
|
-
pr_key: null,
|
|
97
|
-
converge: 1,
|
|
98
|
-
auto_merge: 1,
|
|
99
|
-
escalation_user_task_key: "ut-9",
|
|
100
|
-
escalation_question: "which base?",
|
|
101
|
-
escalation_open: 1,
|
|
102
|
-
});
|
|
103
|
-
await featureRuns(data).update("o/r#torn", { escalation_question: null });
|
|
104
|
-
assertEquals(rows[0].escalation_open, 0);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("clearing the escalation tuple flips escalation_open to 0 without waiting a poll pass", async () => {
|
|
108
|
-
const { data, rows } = memData();
|
|
109
|
-
// The answer operation eagerly clears pointer + question; the gateway reprojects on that same write,
|
|
110
|
-
// so the affordance disappears immediately (issue #272 acceptance: no poll-pass lag).
|
|
111
|
-
rows.push({
|
|
112
|
-
feature_key: "o/r#ans",
|
|
113
|
-
status: "escalated",
|
|
114
|
-
pr_key: null,
|
|
115
|
-
converge: 1,
|
|
116
|
-
auto_merge: 1,
|
|
117
|
-
escalation_user_task_key: "ut-9",
|
|
118
|
-
escalation_question: "which base?",
|
|
119
|
-
escalation_open: 1,
|
|
120
|
-
});
|
|
121
|
-
await featureRuns(data).update("o/r#ans", { escalation_user_task_key: null, escalation_question: null });
|
|
122
|
-
assertEquals(rows[0].escalation_open, 0);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
75
|
test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
|
|
126
76
|
const { data, rows } = memData();
|
|
127
77
|
rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
|
|
@@ -212,9 +162,9 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
|
|
|
212
162
|
// Legacy rows written before migration 039 → projection columns absent/NULL.
|
|
213
163
|
rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
214
164
|
rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
|
|
215
|
-
// A legacy row parked at a LIVE escalation
|
|
216
|
-
//
|
|
217
|
-
rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1
|
|
165
|
+
// A legacy row parked at a LIVE escalation: its projection columns are absent/NULL, so backfill MUST
|
|
166
|
+
// reproject its pipeline stage (issue #254). Issue #332 dropped the escalation_* denormalisation.
|
|
167
|
+
rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1 });
|
|
218
168
|
|
|
219
169
|
const stamped = await backfillFeatureStages(data);
|
|
220
170
|
assertEquals(stamped, 3);
|
|
@@ -229,24 +179,23 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
|
|
|
229
179
|
assertEquals(live.stage_state, null);
|
|
230
180
|
assertEquals(live.stage_skipped, "Converging Merging");
|
|
231
181
|
assertEquals(live.list_bucket, "active");
|
|
232
|
-
assertEquals(live.escalation_open, 0);
|
|
233
182
|
|
|
234
183
|
const escalated = rows.find((r) => r.feature_key === "o/r#esc");
|
|
235
|
-
assertEquals(escalated.
|
|
184
|
+
assertEquals(escalated.stage, "Implementing");
|
|
185
|
+
assertEquals(escalated.attention, "⚠");
|
|
236
186
|
});
|
|
237
187
|
|
|
238
188
|
test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
|
|
239
189
|
const { data, rows } = memData();
|
|
240
190
|
// One legacy row (no projection) + one already-projected row (gateway kept it fresh).
|
|
241
191
|
rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
242
|
-
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active",
|
|
192
|
+
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
|
|
243
193
|
|
|
244
194
|
const stamped = await backfillFeatureStages(data);
|
|
245
195
|
// Only the legacy row is stamped; the already-projected row is skipped.
|
|
246
196
|
assertEquals(stamped, 1);
|
|
247
197
|
const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
|
|
248
198
|
assertEquals(legacy.stage, "Done");
|
|
249
|
-
assertEquals(legacy.escalation_open, 0);
|
|
250
199
|
// The already-projected row was not re-written (its sentinel updated_at is untouched).
|
|
251
200
|
const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
|
|
252
201
|
assertEquals(fresh.updated_at, "0");
|
|
@@ -180,3 +180,35 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
|
|
|
180
180
|
"the running agent's abandon token is preserved from abandonUrl, not re-minted",
|
|
181
181
|
);
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
// #333 — the control-flow escalation arms (no-progress / review-stalled / unaddressed-comments /
|
|
185
|
+
// max-rounds) each set an explicit `status="blocked"` + a concrete `question` via `zeebe:input`
|
|
186
|
+
// (recordRound=false for the three that run after `persist-round`). They now route through
|
|
187
|
+
// `gw-escalated`, which branches on the worker's `escalated` output. This pins the contract that
|
|
188
|
+
// gateway depends on: a control-flow arm with a real question OPENS an escalation and returns
|
|
189
|
+
// `escalated:true` + the (trimmed) question, so gw-escalated parks a wait carrying that question —
|
|
190
|
+
// never a dead wait with a null question (the #333 defect).
|
|
191
|
+
test("a control-flow arm with a concrete question opens an escalation gw-escalated can park", async () => {
|
|
192
|
+
const { app, inserts } = fakeApp();
|
|
193
|
+
const question = "No review arrived within the review-wait timeout (PT20M). A human must decide how to proceed.";
|
|
194
|
+
const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question, recordRound: false } };
|
|
195
|
+
const out = await handler(job as any, app as any);
|
|
196
|
+
assertEquals((out as any).escalated, true, "a real control-flow escalation reports escalated:true");
|
|
197
|
+
assertEquals((out as any).question, question, "the question is returned for the wait-answer form");
|
|
198
|
+
assertEquals(inserts.escalations.length, 1, "an escalation row is opened");
|
|
199
|
+
assertEquals(inserts.rounds.length, 0, "recordRound=false suppresses a duplicate round row");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// #333 — conversely, the `persist-escalation-blockedcomments` arm maps its question from the
|
|
203
|
+
// OPTIONAL `convergeBlockReason`. A blank reason is a NON-escalation: the worker opens nothing and
|
|
204
|
+
// returns `escalated:false`, so gw-escalated's default RE-ENTERS the loop instead of parking a dead
|
|
205
|
+
// `wait-answer` with a null question. This is the exact wedge the guard eliminates.
|
|
206
|
+
test("a control-flow arm with a blank question opens nothing so gw-escalated re-enters", async () => {
|
|
207
|
+
const { app, inserts, updates } = fakeApp();
|
|
208
|
+
const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question: " ", recordRound: false } };
|
|
209
|
+
const out = await handler(job as any, app as any);
|
|
210
|
+
assertEquals((out as any).escalated, false, "a blank-reason arm reports escalated:false");
|
|
211
|
+
assertEquals((out as any).escalationId, null, "no escalation id is minted");
|
|
212
|
+
assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
|
|
213
|
+
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
|
|
214
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Integration test for `pollUserTasks` (issue #236) — the reconcile that projects the engine's
|
|
2
2
|
// currently-open native user-task escalations onto the unified `user_tasks` read-model the Tasks page
|
|
3
|
-
// reads. It
|
|
4
|
-
//
|
|
5
|
-
// (`wait-answer`). A completed task's row is removed on the
|
|
3
|
+
// reads. It reads each in-flight subject's open tasks from the engine directly: feature runs
|
|
4
|
+
// (`feature-escalation` / `feature-blocked`, issue #332), in-flight plans (`plan-review-decision` /
|
|
5
|
+
// `trial-merge-decision`), and in-flight PRs (`wait-answer`). A completed task's row is removed on the
|
|
6
|
+
// next pass so `showCount` tracks live work.
|
|
6
7
|
import { test } from "node:test";
|
|
7
8
|
import { assertEquals } from "#test-assert";
|
|
8
9
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
@@ -79,12 +80,12 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
79
80
|
process_key: "fp-10",
|
|
80
81
|
issue_url: "https://github.com/o/r/issues/10",
|
|
81
82
|
title: "Add the framework selector",
|
|
82
|
-
escalation_user_task_key: "ut-feat",
|
|
83
|
-
escalation_question: "which framework?",
|
|
84
|
-
blocked_user_task_key: null,
|
|
85
83
|
delivery_label: null,
|
|
86
84
|
},
|
|
87
85
|
],
|
|
86
|
+
feature_escalations: [
|
|
87
|
+
{ id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
88
|
+
],
|
|
88
89
|
plans: [
|
|
89
90
|
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
|
|
90
91
|
{ plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
|
|
@@ -102,6 +103,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
102
103
|
escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
|
|
103
104
|
});
|
|
104
105
|
const engine = fakeEngine({
|
|
106
|
+
"fp-10": [{ userTaskKey: "ut-feat", elementId: "feature-escalation" }],
|
|
105
107
|
"pp-20": [
|
|
106
108
|
{ userTaskKey: "ut-plan", elementId: "plan-review-decision" },
|
|
107
109
|
{ userTaskKey: "ut-trial", elementId: "trial-merge-decision" },
|
|
@@ -152,10 +154,10 @@ test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into use
|
|
|
152
154
|
assertEquals(byKey["ut-merge"].question, "not mergeable — resolve the conflict");
|
|
153
155
|
});
|
|
154
156
|
|
|
155
|
-
test("pollUserTasks: sources the feature-escalation question from the feature_escalations audit log (issue #305)", async () => {
|
|
156
|
-
// The canonical source is the append-only `feature_escalations` log
|
|
157
|
-
// writes
|
|
158
|
-
//
|
|
157
|
+
test("pollUserTasks: sources the feature-escalation question from the feature_escalations audit log (issue #305/#332)", async () => {
|
|
158
|
+
// The canonical (and now sole) source is the append-only `feature_escalations` log — what
|
|
159
|
+
// `record-feature-escalation` writes; issue #332 dropped the denormalised `feature_runs.escalation_question`
|
|
160
|
+
// column. When several audit rows exist the newest wins, so a re-escalation shows the latest question.
|
|
159
161
|
const { data, stores } = memData({
|
|
160
162
|
feature_runs: [
|
|
161
163
|
{
|
|
@@ -164,9 +166,6 @@ test("pollUserTasks: sources the feature-escalation question from the feature_es
|
|
|
164
166
|
process_key: "fp-42",
|
|
165
167
|
issue_url: "https://github.com/o/r/issues/42",
|
|
166
168
|
title: "Wire the audit log",
|
|
167
|
-
escalation_user_task_key: "ut-feat",
|
|
168
|
-
escalation_question: "stale legacy question",
|
|
169
|
-
blocked_user_task_key: null,
|
|
170
169
|
delivery_label: null,
|
|
171
170
|
},
|
|
172
171
|
],
|
|
@@ -183,6 +182,33 @@ test("pollUserTasks: sources the feature-escalation question from the feature_es
|
|
|
183
182
|
assertEquals(byKey["ut-feat"].question, "latest ask");
|
|
184
183
|
});
|
|
185
184
|
|
|
185
|
+
test("pollUserTasks: projects a blocked feature run (feature-blocked) with the delivery_label as its question (issue #332)", async () => {
|
|
186
|
+
// A blocked run parks on the native `feature-blocked` operator task at the non-terminal
|
|
187
|
+
// `awaiting_operator` status. The poller reads it from the engine directly (no denormalised pointer)
|
|
188
|
+
// and projects it onto the Tasks inbox, sourcing the display text from the run's `delivery_label`.
|
|
189
|
+
const { data, stores } = memData({
|
|
190
|
+
feature_runs: [
|
|
191
|
+
{
|
|
192
|
+
feature_key: "o/r#60",
|
|
193
|
+
status: "awaiting_operator",
|
|
194
|
+
process_key: "fp-60",
|
|
195
|
+
issue_url: "https://github.com/o/r/issues/60",
|
|
196
|
+
title: "Blocked slice",
|
|
197
|
+
delivery_label: "agent gave up: no PR",
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
const engine = fakeEngine({ "fp-60": [{ userTaskKey: "ut-blocked", elementId: "feature-blocked" }] });
|
|
202
|
+
|
|
203
|
+
await pollUserTasks(data, engine);
|
|
204
|
+
|
|
205
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
206
|
+
assertEquals(Object.keys(byKey), ["ut-blocked"]);
|
|
207
|
+
assertEquals(byKey["ut-blocked"].element_id, "feature-blocked");
|
|
208
|
+
assertEquals(byKey["ut-blocked"].subject_type, "feature");
|
|
209
|
+
assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
|
|
210
|
+
});
|
|
211
|
+
|
|
186
212
|
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
187
213
|
const { data, stores } = memData({
|
|
188
214
|
user_tasks: [
|
|
@@ -210,11 +210,14 @@ test("gw-progress default arm re-enters the review wait with no condition", () =
|
|
|
210
210
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
test("the no-progress escalation
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
test("the no-progress escalation routes through gw-escalated toward the human wait-answer task", () => {
|
|
214
|
+
// #333: the arm no longer flows UNCONDITIONALLY into wait-answer — it routes through the
|
|
215
|
+
// gw-escalated guard, which parks wait-answer only when the worker opened a real escalation
|
|
216
|
+
// (escalated=true) and otherwise re-enters the loop (never a dead wait with a null question).
|
|
217
|
+
const f = flowElement("f_noprogressGate");
|
|
218
|
+
assert(f, "f_noprogressGate flow missing");
|
|
216
219
|
assertStringIncludes(f, 'sourceRef="persist-escalation-noprogress"');
|
|
217
|
-
assertStringIncludes(f, 'targetRef="
|
|
220
|
+
assertStringIncludes(f, 'targetRef="gw-escalated"');
|
|
218
221
|
// It opens a real, answerable escalation (blocked status + a concrete question) so it is never a
|
|
219
222
|
// blank-question non-escalation that would wedge the token on the wait.
|
|
220
223
|
const task = flat.match(
|