@nanobpm/nano-workforce 0.126.0 → 0.128.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/.github/workflows/invariants.yml +8 -0
- package/.github/workflows/pr-title-lint.yml +9 -1
- package/.github/workflows/release.yml +37 -8
- package/AGENTS.md +37 -0
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.ts +11 -0
- package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
- package/app/agentic/cockpit/cockpit-route.ts +17 -0
- package/app/agentic/cockpit/index.ts +16 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
- package/app/agentic/cockpit/supply-boot.test.ts +2 -2
- package/app/agentic/cockpit/supply-boot.ts +76 -10
- package/app/agentic/cockpit/supply-render.test.ts +13 -4
- package/app/agentic/cockpit/supply-render.ts +14 -2
- package/app/agentic/cockpit/transcript-render.ts +6 -2
- package/app/agentic/cockpit/transcript-view.ts +9 -0
- package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
- package/app/agentic/cockpit/worker-detail-render.ts +88 -0
- package/app/agentic/cockpit/worker-detail-view.ts +43 -0
- package/app/agentic/correlation-store.test.ts +99 -0
- package/app/agentic/correlation-store.ts +162 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +14 -0
- package/app/agentic/families/relay.family.test.ts +72 -0
- package/app/agentic/families/relay.family.ts +130 -1
- package/app/agentic/transcript-read.test.ts +55 -3
- package/app/agentic/transcript-read.ts +49 -9
- package/app/agentic/vocab/demand-report.test.ts +23 -10
- package/app/pollUserTasks.test.ts +66 -2
- package/app/service.ts +21 -8
- package/app/tasksPage.test.ts +44 -0
- package/app/userTasks.test.ts +19 -0
- package/app/userTasks.ts +13 -1
- package/db/migrations/077_user_tasks_form_key.sql +25 -0
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/docs/adr/0006-delivery-units-one-representation.md +59 -20
- package/e2e/delivery-graph.e2e.ts +7 -0
- package/e2e/feature-preflight.e2e.ts +7 -0
- package/e2e/inter-epic-dependency.e2e.ts +8 -0
- package/e2e/plan-fanout-preflight.e2e.ts +7 -0
- package/e2e/plan-fanout-sla.e2e.ts +4 -3
- package/e2e/plan-fanout.e2e.ts +2 -2
- package/e2e/readiness-gate.e2e.ts +8 -37
- package/e2e/support/probe-exec.test.ts +78 -0
- package/e2e/support/probe-exec.ts +69 -0
- package/e2e/support/time.test.ts +42 -0
- package/e2e/support/time.ts +33 -0
- package/openapi.yaml +26 -0
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
- package/pages/tasks.page.json +24 -554
package/app/service.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
12
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
|
+
import { escalationFormId } from "./agentCompletion.ts";
|
|
13
14
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
15
|
import {
|
|
15
16
|
CAPS_RESOLVED_MESSAGE,
|
|
@@ -2186,6 +2187,10 @@ interface UserTaskSearchItem {
|
|
|
2186
2187
|
elementId?: string;
|
|
2187
2188
|
processInstanceKey?: string | number;
|
|
2188
2189
|
state?: string;
|
|
2190
|
+
/** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
|
|
2191
|
+
* key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
|
|
2192
|
+
* the deployed form per row (issue #461). The wire may send a JSON number or string. */
|
|
2193
|
+
formKey?: string | number;
|
|
2189
2194
|
}
|
|
2190
2195
|
|
|
2191
2196
|
/** One discovered open escalation user task, normalised for projection. */
|
|
@@ -2193,6 +2198,9 @@ interface OpenUserTask {
|
|
|
2193
2198
|
userTaskKey: string;
|
|
2194
2199
|
elementId: string;
|
|
2195
2200
|
processInstanceKey: string;
|
|
2201
|
+
/** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
|
|
2202
|
+
* kind's static `.form` linkage). */
|
|
2203
|
+
formKey: string;
|
|
2196
2204
|
}
|
|
2197
2205
|
|
|
2198
2206
|
/** Engine-first sweep (issue #358): read EVERY open (`CREATED`) native user task from the engine over
|
|
@@ -2234,7 +2242,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2234
2242
|
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2235
2243
|
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2236
2244
|
seen.add(userTaskKey);
|
|
2237
|
-
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey) });
|
|
2245
|
+
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2238
2246
|
}
|
|
2239
2247
|
if (items.length < limit) break; // last page
|
|
2240
2248
|
from += items.length;
|
|
@@ -2379,7 +2387,7 @@ export async function pollUserTasks(
|
|
|
2379
2387
|
// element + the instance it parks on) into its desired-row context, enriching from its subject row
|
|
2380
2388
|
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2381
2389
|
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2382
|
-
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
|
|
2390
|
+
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
|
|
2383
2391
|
if (userTaskKindLabel(elementId) === undefined) return null;
|
|
2384
2392
|
const subj = subjectByInstance.get(processInstanceKey);
|
|
2385
2393
|
// Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
|
|
@@ -2387,6 +2395,11 @@ export async function pollUserTasks(
|
|
|
2387
2395
|
// derived from the predicate rather than the static per-element table.
|
|
2388
2396
|
const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
|
|
2389
2397
|
const subjectKey = subj?.key ?? processInstanceKey;
|
|
2398
|
+
// Denormalise the engine `formKey` so the collapsed Tasks grid renders the deployed `.form` per row
|
|
2399
|
+
// (issue #461). Prefer the engine-resolved key the search reported; fall back to the fixed-form kind's
|
|
2400
|
+
// static `.form` linkage (`escalationFormId`) when the search omitted it — the delivery-graph `human`
|
|
2401
|
+
// node has no static form (varies per node), so it relies wholly on the engine-reported key.
|
|
2402
|
+
const resolvedFormKey = formKey.trim() || escalationFormId(elementId) || null;
|
|
2390
2403
|
let question: string | null = null;
|
|
2391
2404
|
switch (elementId) {
|
|
2392
2405
|
case FEATURE_ESCALATION_ELEMENT:
|
|
@@ -2412,17 +2425,17 @@ export async function pollUserTasks(
|
|
|
2412
2425
|
question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
|
|
2413
2426
|
break;
|
|
2414
2427
|
}
|
|
2415
|
-
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey };
|
|
2428
|
+
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
2416
2429
|
};
|
|
2417
2430
|
|
|
2418
2431
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2419
2432
|
// subject seen under two statuses mid-pass).
|
|
2420
2433
|
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2421
|
-
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string) => {
|
|
2434
|
+
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
|
|
2422
2435
|
if (!elementId) return;
|
|
2423
2436
|
const rowKey = userTaskKey.trim();
|
|
2424
2437
|
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2425
|
-
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey);
|
|
2438
|
+
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
|
|
2426
2439
|
if (!ctx) return;
|
|
2427
2440
|
const row = buildUserTaskRow(ctx, at);
|
|
2428
2441
|
if (row) desiredByKey.set(rowKey, row);
|
|
@@ -2433,7 +2446,7 @@ export async function pollUserTasks(
|
|
|
2433
2446
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2434
2447
|
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2435
2448
|
for (const t of await sweepOpenEscalationTasks(base, headers)) {
|
|
2436
|
-
await project(t.elementId, t.userTaskKey, t.processInstanceKey);
|
|
2449
|
+
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
|
|
2437
2450
|
}
|
|
2438
2451
|
} else {
|
|
2439
2452
|
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
|
|
@@ -2441,14 +2454,14 @@ export async function pollUserTasks(
|
|
|
2441
2454
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2442
2455
|
if (!processKey || seen.has(processKey)) return;
|
|
2443
2456
|
seen.add(processKey);
|
|
2444
|
-
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2457
|
+
let tasks: { userTaskKey: string; elementId?: string; formKey?: string }[];
|
|
2445
2458
|
try {
|
|
2446
2459
|
tasks = await engine.openUserTasks({ processInstanceKey: processKey });
|
|
2447
2460
|
} catch (err) {
|
|
2448
2461
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2449
2462
|
return;
|
|
2450
2463
|
}
|
|
2451
|
-
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey);
|
|
2464
|
+
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
|
|
2452
2465
|
};
|
|
2453
2466
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2454
2467
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Structure guard for the collapsed Tasks page (issue #461). The Tasks page is ONE `user_tasks`
|
|
2
|
+
// dataGrid (`filter: []`, `orderBy updated_at desc`) with `kind_label` as a "Type" column, completing
|
|
3
|
+
// each row via its ENGINE-declared form (nano-ide#457's `detail.engineForm`) — not seven
|
|
4
|
+
// `element_id`-allowlisted grids each with a hand-authored `detail.form` that duplicates a deployed
|
|
5
|
+
// `.form` resource AND leaves dynamic-id delivery-graph tasks (counted by the `filter: []` badge)
|
|
6
|
+
// rendered by no grid. This test pins that end-state so the anti-pattern can't creep back:
|
|
7
|
+
// • exactly one `dataGrid`, over `user_tasks`, unfiltered, recency-ordered → badge ≡ list;
|
|
8
|
+
// • a "Type" column bound to `kind_label`;
|
|
9
|
+
// • an engine-form detail (`form_key` / `user_task_key`), and NO page-local `detail.form`.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: reading an untyped page manifest for structural assertions
|
|
16
|
+
const page: any = JSON.parse(readFileSync(fileURLToPath(new URL("../pages/tasks.page.json", import.meta.url)), "utf8"));
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
18
|
+
const nodes: any[] = page.nodes ?? [];
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
20
|
+
const grids: any[] = nodes.filter((n) => n.type === "dataGrid");
|
|
21
|
+
|
|
22
|
+
test("Tasks page is exactly ONE dataGrid over user_tasks (filter [], orderBy updated_at desc)", () => {
|
|
23
|
+
assertEquals(grids.length, 1);
|
|
24
|
+
const [grid] = grids;
|
|
25
|
+
assertEquals(grid.props.data.table, "user_tasks");
|
|
26
|
+
assertEquals(grid.props.data.filter, []);
|
|
27
|
+
assertEquals(grid.props.data.orderBy, { field: "updated_at", dir: "desc" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Tasks grid surfaces kind_label as a 'Type' column", () => {
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
32
|
+
const typeCol = grids[0].props.columns.find((c: any) => c.field === "kind_label");
|
|
33
|
+
assert(typeCol, "a kind_label column must exist");
|
|
34
|
+
assertEquals(typeCol.header, "Type");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Tasks grid completes each row via its engine-declared form (nano-ide#457), not a page-local detail.form", () => {
|
|
38
|
+
const { detail } = grids[0].props;
|
|
39
|
+
assert(detail?.engineForm, "the grid detail must opt into engineForm rendering");
|
|
40
|
+
assertEquals(detail.engineForm.formKeyField, "form_key");
|
|
41
|
+
assertEquals(detail.engineForm.userTaskKeyField, "user_task_key");
|
|
42
|
+
// The seven bespoke per-type detail.form blocks (each a copy of a deployed `.form`) are gone.
|
|
43
|
+
for (const g of grids) assert(!g.props.detail?.form, "no grid may carry a page-local detail.form");
|
|
44
|
+
});
|
package/app/userTasks.test.ts
CHANGED
|
@@ -49,11 +49,30 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
|
|
|
49
49
|
subject_url: "https://github.com/o/r/issues/1",
|
|
50
50
|
question: "cap reached: revise scope",
|
|
51
51
|
process_key: "pk-1",
|
|
52
|
+
form_key: null,
|
|
52
53
|
created_at: AT,
|
|
53
54
|
updated_at: AT,
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
test("buildUserTaskRow: denormalises the engine form_key, trimming blanks to null (issue #461)", () => {
|
|
59
|
+
const withForm = buildUserTaskRow(
|
|
60
|
+
{ userTaskKey: "ut-f", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " form-9 " },
|
|
61
|
+
AT,
|
|
62
|
+
);
|
|
63
|
+
assertEquals(withForm?.form_key, "form-9");
|
|
64
|
+
const blank = buildUserTaskRow(
|
|
65
|
+
{ userTaskKey: "ut-b", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " " },
|
|
66
|
+
AT,
|
|
67
|
+
);
|
|
68
|
+
assertEquals(blank?.form_key, null);
|
|
69
|
+
const absent = buildUserTaskRow(
|
|
70
|
+
{ userTaskKey: "ut-n", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1" },
|
|
71
|
+
AT,
|
|
72
|
+
);
|
|
73
|
+
assertEquals(absent?.form_key, null);
|
|
74
|
+
});
|
|
75
|
+
|
|
57
76
|
test("buildUserTaskRow: a blank question / missing url normalises to null", () => {
|
|
58
77
|
const row = buildUserTaskRow(
|
|
59
78
|
{ userTaskKey: "ut-2", elementId: TRIAL_MERGE_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", question: " " },
|
package/app/userTasks.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface UserTaskRow {
|
|
|
60
60
|
subject_url: string | null;
|
|
61
61
|
question: string | null;
|
|
62
62
|
process_key: string | null;
|
|
63
|
+
/** The engine `formKey` of the parked user task's engine-declared form, denormalised so the single
|
|
64
|
+
* collapsed Tasks grid can resolve and render the deployed `.form` per row (nano-ide#457). Derived in
|
|
65
|
+
* the poller from the `/v2/user-tasks/search` result, falling back to the fixed-form kinds' static
|
|
66
|
+
* `.form` linkage; NULL when neither resolves (the grid degrades to bare completion). */
|
|
67
|
+
form_key: string | null;
|
|
63
68
|
created_at: string;
|
|
64
69
|
updated_at: string;
|
|
65
70
|
}
|
|
@@ -103,6 +108,10 @@ export interface UserTaskContext {
|
|
|
103
108
|
subjectUrl?: string | null;
|
|
104
109
|
question?: string | null;
|
|
105
110
|
processKey?: string | null;
|
|
111
|
+
/** The engine-resolved `formKey` of the task's engine-declared form, as the poller read it from the
|
|
112
|
+
* `/v2/user-tasks/search` result (or the fixed-form fallback). Optional/blank tolerated —
|
|
113
|
+
* `buildUserTaskRow` normalises a blank to NULL. */
|
|
114
|
+
formKey?: string | null;
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
|
|
@@ -123,6 +132,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
123
132
|
const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
|
|
124
133
|
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
125
134
|
const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
|
|
135
|
+
const formKey = typeof ctx.formKey === "string" && ctx.formKey.trim() ? ctx.formKey.trim() : null;
|
|
126
136
|
return {
|
|
127
137
|
user_task_key: userTaskKey,
|
|
128
138
|
element_id: ctx.elementId,
|
|
@@ -133,6 +143,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
133
143
|
subject_url: ctx.subjectUrl ?? null,
|
|
134
144
|
question,
|
|
135
145
|
process_key: ctx.processKey ?? null,
|
|
146
|
+
form_key: formKey,
|
|
136
147
|
created_at: at,
|
|
137
148
|
updated_at: at,
|
|
138
149
|
};
|
|
@@ -158,7 +169,8 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
|
|
|
158
169
|
a.subject_title === b.subject_title &&
|
|
159
170
|
a.subject_url === b.subject_url &&
|
|
160
171
|
a.question === b.question &&
|
|
161
|
-
a.process_key === b.process_key
|
|
172
|
+
a.process_key === b.process_key &&
|
|
173
|
+
a.form_key === b.form_key
|
|
162
174
|
);
|
|
163
175
|
}
|
|
164
176
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Denormalise the engine `form_key` onto the unified Tasks-inbox read-model (issue #461) — additive,
|
|
2
|
+
-- nullable. The collapsed Tasks page renders ONE `user_tasks` grid and completes each heterogeneous row
|
|
3
|
+
-- via its ENGINE-declared form (nano-ide#457's `detail.engineForm`), instead of seven
|
|
4
|
+
-- `element_id`-allowlisted grids each with a hand-authored `detail.form` copy of a deployed `.form`.
|
|
5
|
+
-- Rendering the deployed form per row needs the task's engine `formKey` on the row, so the grid can
|
|
6
|
+
-- resolve `GET /app/actions/form?formKey=<row.form_key>` — the SAME single source of truth
|
|
7
|
+
-- `taskInbox` uses (no page-local field duplication).
|
|
8
|
+
--
|
|
9
|
+
-- Derived in the canonical poller path exactly as `kind_label` is (no drift surface): `pollUserTasks`
|
|
10
|
+
-- (app/service.ts) reads the engine-resolved `formKey` from the Camunda `/v2/user-tasks/search` result
|
|
11
|
+
-- and `buildUserTaskRow` (app/userTasks.ts) writes it, falling back to the fixed-form kinds' static
|
|
12
|
+
-- `.form` linkage (`ESCALATION_FORM_BY_ELEMENT`, app/agentCompletion.ts) when the search omits it.
|
|
13
|
+
-- NULL when neither resolves — the grid degrades to bare completion, matching `taskInbox`.
|
|
14
|
+
--
|
|
15
|
+
-- Forward-only, additive (expand): a new nullable column on an existing table, no shape rewrite and no
|
|
16
|
+
-- backfill (the poller repopulates every open row on its next pass). Numbered after the current highest
|
|
17
|
+
-- prefix (076); the runner wraps each file in its own transaction, so this file must NOT contain
|
|
18
|
+
-- BEGIN/COMMIT.
|
|
19
|
+
ALTER TABLE user_tasks ADD COLUMN form_key TEXT;
|
|
20
|
+
|
|
21
|
+
-- The collapsed Tasks page reads `user_tasks` UNFILTERED ordered by `updated_at desc` (pages/tasks.page.json),
|
|
22
|
+
-- an access pattern the existing composite indexes (`(element_id, updated_at)`, `(subject, element_id)`) can't
|
|
23
|
+
-- serve — SQLite would scan + sort the whole table as the inbox grows. Front the unified inbox's sort with a
|
|
24
|
+
-- single-column index on `updated_at`. Additive and idempotent.
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_user_tasks_updated ON user_tasks(updated_at);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Durable per-job worker attribution + engine context (#485, provisioning #232).
|
|
2
|
+
--
|
|
3
|
+
-- The in-memory correlation registry (app/agentic/correlation.ts) is the live jobKey ⇄ worker join,
|
|
4
|
+
-- but it is RELEASED on job end / worker disconnect and is empty after a restart. So a COMPLETED
|
|
5
|
+
-- (past) session — what the cockpit "past sessions" / worker-history view reads — otherwise loses
|
|
6
|
+
-- which worker ran it (instance / identity / host) and its process-instance / plan context. The
|
|
7
|
+
-- package-mirrored transcript store (024_agentic_transcript.sql, byte-for-byte guarded) carries no
|
|
8
|
+
-- correlation columns, so this app-side table closes the gap WITHOUT touching that mirrored schema.
|
|
9
|
+
--
|
|
10
|
+
-- The relay slice records a row here at job-completion time; the transcript read path falls back to
|
|
11
|
+
-- it when the live registry has released the job. Advisory / read-only (ADR 0056) — it NEVER gates a
|
|
12
|
+
-- BPMN sequence flow.
|
|
13
|
+
--
|
|
14
|
+
-- Single source of truth: this DDL mirrors AGENTIC_CORRELATION_SCHEMA_SQL in
|
|
15
|
+
-- app/agentic/correlation-store.ts byte-for-byte; a drift-guard test (correlation-store.test.ts) pins
|
|
16
|
+
-- the two together.
|
|
17
|
+
CREATE TABLE IF NOT EXISTS agentic_correlation (
|
|
18
|
+
job_key TEXT PRIMARY KEY,
|
|
19
|
+
stream TEXT NOT NULL,
|
|
20
|
+
instance TEXT NOT NULL,
|
|
21
|
+
identity TEXT,
|
|
22
|
+
host TEXT,
|
|
23
|
+
process_instance_key TEXT,
|
|
24
|
+
bpmn_process_id TEXT,
|
|
25
|
+
element_id TEXT,
|
|
26
|
+
plan_key TEXT,
|
|
27
|
+
linked_at TEXT,
|
|
28
|
+
completed_at TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
|
|
@@ -20,7 +20,7 @@ to collapse three bespoke status unions into one),
|
|
|
20
20
|
nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
|
|
21
21
|
nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
|
|
22
22
|
— the *process-level* unlock),
|
|
23
|
-
nano-workforce **#464** (the tracking issue with slices S1–
|
|
23
|
+
nano-workforce **#464** (the tracking issue with slices S1–S6),
|
|
24
24
|
nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3).
|
|
25
25
|
|
|
26
26
|
## Context
|
|
@@ -89,7 +89,7 @@ because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**).
|
|
|
89
89
|
**Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
|
|
90
90
|
`COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
|
|
91
91
|
`@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
|
|
92
|
-
testkit, not the broker/runtime that will execute future `callActivity` models. S4
|
|
92
|
+
testkit, not the broker/runtime that will execute future `callActivity` models. S4–S6 therefore also
|
|
93
93
|
carry a **deployment-runtime prerequisite** — the deployed broker's `engine-core` must carry the same
|
|
94
94
|
`callActivity` support — which green testkit CI does not by itself guarantee.
|
|
95
95
|
|
|
@@ -129,18 +129,52 @@ a copy — expressed here as the **target** state:
|
|
|
129
129
|
content digest (`app/deliveryGraphRun.ts` `computeRunKey`) — so S2's compatibility VIEWs map each
|
|
130
130
|
legacy key onto the new identity, ensuring unrelated runs are never merged onto one row.
|
|
131
131
|
|
|
132
|
-
### 2. Process encoding —
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
### 2. Process encoding — **fine-grained** cells composed by `callActivity`
|
|
133
|
+
|
|
134
|
+
The primitive is a set of **small, single-purpose cells** — `implement-cell`, `converge-cell`,
|
|
135
|
+
`merge-cell`, plus the sibling wait-gate and human-escalation cells — each a standalone process
|
|
136
|
+
(`resources/processes/implement-cell.bpmn`, …), composed by reference and gated by edges. This is a
|
|
137
|
+
deliberate choice of granularity **over** a single coarse whole-feature subprocess with opaque
|
|
138
|
+
completion flags.
|
|
139
|
+
|
|
140
|
+
- Compose by reference, replacing the inlined segments, not the surrounding orchestration: in
|
|
141
|
+
**feature** (`feature.bpmn`) the readiness preflight, base-branch setup, and `record-feature` remain;
|
|
142
|
+
the implement/escalation segment and the convergence/merge tail each become `callActivity`s. **Epic**
|
|
143
|
+
= the multi-instance `implement` body is a `callActivity`; **delivery graph** = the compiler *emits*
|
|
144
|
+
`callActivity` references, not inlined subprocess copies.
|
|
145
|
+
- `feature` / `epic` are **derived macros** over the cells, not hand-written processes: a `feature`
|
|
146
|
+
expands to `implement → converge? → merge?`, so the cells stay the single source of truth and the
|
|
147
|
+
common case is still one node. (Same derivation discipline as the data encoding: compose by
|
|
148
|
+
reference, never inline a copy.)
|
|
149
|
+
- **Why fine-grained, not coarse.** A coarse whole-feature `callActivity` with `converge?`/`merge?`
|
|
150
|
+
flags buries those steps *inside* the black box, so a graph can never insert a gate *between*
|
|
151
|
+
"converged" and "merged". Fine-grained cells let a graph converge features A **and** B, then hold and
|
|
152
|
+
land both behind a single `human`/`wait` fan-in — the integration-branch / gated-landing pattern.
|
|
153
|
+
`feature.bpmn` already separates `converge` (its `gw-converge` gateway) from `autoMerge`, so the seam
|
|
154
|
+
exists today; this promotes it to a **node boundary** the graph can wire (see §3).
|
|
141
155
|
- This is gated on the engine-wasm 0.7.2 unlock, which is now live on `main`.
|
|
142
156
|
|
|
143
|
-
### 3.
|
|
157
|
+
### 3. Node completion policy — `converge` / `merge` are first-class cells, not smuggled state
|
|
158
|
+
|
|
159
|
+
Today the "get to green, then land" tail lives in two places the delivery graph cannot reach: a gateway
|
|
160
|
+
*inside* `feature.bpmn` (`gw-converge` + the `autoMerge` boolean on `ConvergeFeatureIn`), and — for a
|
|
161
|
+
delivery-graph `agent` node — **free text in a prompt** (`{ jobType: "senior:feature", prompt: "un-draft
|
|
162
|
+
+ merge #B" }`), with the graph only *observing* the result via a downstream `wait` node that emits
|
|
163
|
+
`mergedSha`. A graph therefore drives merge by asking an agent nicely, not structurally. Promote them to
|
|
164
|
+
first-class, edge-gated cells:
|
|
165
|
+
|
|
166
|
+
- **`converge`** = drive the PR through its review-convergence loop to green. **`merge`** = land it.
|
|
167
|
+
These are deliberately **separable phases** (`feature.bpmn` already splits `gw-converge` from
|
|
168
|
+
`autoMerge`), so a graph can stop at "green" and gate the landing behind any upstream node.
|
|
169
|
+
- A `feature`/`epic` node's `converge?` / `merge?` selectors choose whether the derived macro (§2)
|
|
170
|
+
includes those cells; omitting `merge` and wiring an explicit `merge` cell downstream of a gate is the
|
|
171
|
+
advanced case.
|
|
172
|
+
- **`merge` is two-level (ADR 0003 base-branch admission).** A unit's `merge` cell lands onto the
|
|
173
|
+
epic/graph **base branch**, never `main` directly; the graph's final merge-to-`main` is a *separate*
|
|
174
|
+
top-level step. `feature.bpmn`'s `autoMerge` is exactly this per-unit knob — do not collapse the two
|
|
175
|
+
levels.
|
|
176
|
+
|
|
177
|
+
### 4. Status lifecycle — one derived union
|
|
144
178
|
|
|
145
179
|
The three bespoke status unions collapse into **one derived union** via ADR 0065's `defineReadModel`,
|
|
146
180
|
so a change to lifecycle semantics is made once and derived everywhere, not re-declared per
|
|
@@ -153,7 +187,7 @@ representation. These unions are **not** identical today — features use
|
|
|
153
187
|
separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
|
|
154
188
|
behavior — not merely projecting an existing value.
|
|
155
189
|
|
|
156
|
-
###
|
|
190
|
+
### 5. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
|
|
157
191
|
|
|
158
192
|
This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
|
|
159
193
|
distinction stays intact: **plan-fanout remains adaptive** (agent-discovered slices, waves that adapt),
|
|
@@ -183,8 +217,8 @@ their topology is produced. Unifying that axis is explicitly out of scope here.
|
|
|
183
217
|
|
|
184
218
|
## Rollout (see #464 for the live checklist)
|
|
185
219
|
|
|
186
|
-
Each slice is independently shippable; the process slices (S4
|
|
187
|
-
0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4
|
|
220
|
+
Each slice is independently shippable; the process slices (S4–S6) are sequenced behind the engine-wasm
|
|
221
|
+
0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4–S6
|
|
188
222
|
additionally gate on the **deployed broker/runtime** carrying verified `callActivity` support (the
|
|
189
223
|
deployment-runtime prerequisite noted above), not on #416 alone.
|
|
190
224
|
|
|
@@ -207,15 +241,20 @@ deployment-runtime prerequisite noted above), not on #416 alone.
|
|
|
207
241
|
bindings and every other writer off the legacy tables does the table-to-VIEW contract phase retire the
|
|
208
242
|
legacy write paths.
|
|
209
243
|
- **S3 · collapse doors** — unify the three `instanceTracking` bindings + `senior:*` dispatch doors.
|
|
210
|
-
- **S4 ·
|
|
211
|
-
human-escalation cells** (Decision §2) into standalone processes;
|
|
212
|
-
MI body compose them via `callActivity
|
|
213
|
-
|
|
244
|
+
- **S4 · fine-grained cells** — extract `implement-cell.bpmn`, `converge-cell.bpmn`, `merge-cell.bpmn`
|
|
245
|
+
**and the sibling wait-gate and human-escalation cells** (Decision §2) into standalone processes;
|
|
246
|
+
`feature.bpmn` + the `plan-fanout` MI body compose them via `callActivity`, with `feature`/`epic` as
|
|
247
|
+
derived macros over the cells.
|
|
248
|
+
- **S5 · `converge?` / `merge?` as first-class node policy** — promote convergence + landing from the
|
|
249
|
+
`feature.bpmn` `gw-converge`/`autoMerge` gateway and the delivery-graph *prompt prose* into edge-gated
|
|
250
|
+
`converge`/`merge` cell nodes on the delivery vocabulary (Decision §3); honour ADR 0003 two-level
|
|
251
|
+
merge (unit → base branch; graph → `main`).
|
|
252
|
+
- **S6 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
|
|
214
253
|
per-node copies.
|
|
215
254
|
|
|
216
255
|
## Non-goals / deferred
|
|
217
256
|
|
|
218
|
-
- **Unifying the static-vs-adaptive execution axis** (see Decision §
|
|
257
|
+
- **Unifying the static-vs-adaptive execution axis** (see Decision §5) — preserved deliberately.
|
|
219
258
|
- **Changing the downstream PR/convergence loop** — already single-sourced (`pull_requests`); untouched.
|
|
220
259
|
- **Cross-repo/platform representation** — this ADR is nano-workforce-local; any platform-wide delivery
|
|
221
260
|
aggregate would be a separate nano-bpm ADR.
|
|
@@ -24,6 +24,7 @@ import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } fr
|
|
|
24
24
|
import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
|
|
25
25
|
import { runDeliveryGraph } from "../app/deliveryRunner.ts";
|
|
26
26
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
27
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
27
28
|
|
|
28
29
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
29
30
|
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
@@ -57,9 +58,15 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
57
58
|
apps.push(app);
|
|
58
59
|
return app;
|
|
59
60
|
};
|
|
61
|
+
// The `wait` nodes drive `command: true`/`false` probes through the shared readiness-probe worker.
|
|
62
|
+
// Inject the deterministic exec so they resolve within the virtual clock's drain fixpoint instead
|
|
63
|
+
// of racing a real subprocess `settle()` cannot await (issue #450).
|
|
64
|
+
const probeSeam = deterministicProbeSeam("delivery-graph e2e");
|
|
65
|
+
before(() => probeSeam.install());
|
|
60
66
|
after(async () => {
|
|
61
67
|
for (const app of apps) await app.stop?.();
|
|
62
68
|
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
69
|
+
probeSeam.restoreAndAssertHermetic();
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
test("runs end-to-end: agent, wait, human execute; edges gate; fan-in works; human fact late-binds into the connector", async () => {
|
|
@@ -24,6 +24,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
26
26
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
27
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
27
28
|
|
|
28
29
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
30
|
|
|
@@ -92,12 +93,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
92
93
|
|
|
93
94
|
describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)", () => {
|
|
94
95
|
let restoreGithub: (() => void) | undefined;
|
|
96
|
+
const probeSeam = deterministicProbeSeam("feature-preflight e2e");
|
|
95
97
|
|
|
96
98
|
before(() => {
|
|
97
99
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
98
100
|
savedEnv.set(k, process.env[k]);
|
|
99
101
|
process.env[k] = v;
|
|
100
102
|
}
|
|
103
|
+
// Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
|
|
104
|
+
// clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
|
|
105
|
+
// `settle()` cannot await — the flake behind this suite (issue #450).
|
|
106
|
+
probeSeam.install();
|
|
101
107
|
// `pr.ensure-base-branch` reads the base ref via the token transport, which would throw
|
|
102
108
|
// `no GitHub transport available` under an empty token. Pin the shared hermetic admit-github
|
|
103
109
|
// stub (dummy token + fetch intercept) like the sibling preflight e2e so base-branch admission
|
|
@@ -106,6 +112,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
106
112
|
});
|
|
107
113
|
after(() => {
|
|
108
114
|
restoreGithub?.();
|
|
115
|
+
probeSeam.restoreAndAssertHermetic();
|
|
109
116
|
for (const [k, v] of savedEnv) {
|
|
110
117
|
if (v === undefined) delete process.env[k];
|
|
111
118
|
else process.env[k] = v;
|
|
@@ -23,6 +23,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
25
25
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
26
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
26
27
|
|
|
27
28
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
29
|
|
|
@@ -89,8 +90,14 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
89
90
|
|
|
90
91
|
describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #292 S5)", () => {
|
|
91
92
|
let restoreGithub: (() => void) | undefined;
|
|
93
|
+
// The adversarial gates poll `command: false` (never-green) probes through the readiness-probe
|
|
94
|
+
// worker; inject the deterministic exec so each poll resolves inside the virtual clock's drain
|
|
95
|
+
// fixpoint (the escalation/timeout path is driven by engine-clock advancement, not a real
|
|
96
|
+
// subprocess) — issue #450.
|
|
97
|
+
const probeSeam = deterministicProbeSeam("inter-epic adversarial e2e");
|
|
92
98
|
|
|
93
99
|
before(() => {
|
|
100
|
+
probeSeam.install();
|
|
94
101
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
95
102
|
savedEnv.set(k, process.env[k]);
|
|
96
103
|
process.env[k] = v;
|
|
@@ -105,6 +112,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
|
|
|
105
112
|
if (v === undefined) delete process.env[k];
|
|
106
113
|
else process.env[k] = v;
|
|
107
114
|
}
|
|
115
|
+
probeSeam.restoreAndAssertHermetic();
|
|
108
116
|
});
|
|
109
117
|
|
|
110
118
|
// ── S5 P1 — the gate HOLDS wave 0 ───────────────────────────────────────────────────────────────
|
|
@@ -22,6 +22,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
24
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
import { deterministicProbeSeam } from "./support/probe-exec.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
28
|
|
|
@@ -80,12 +81,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
|
|
|
80
81
|
|
|
81
82
|
describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #292 S3)", () => {
|
|
82
83
|
let restoreGithub: (() => void) | undefined;
|
|
84
|
+
const probeSeam = deterministicProbeSeam("plan-fanout-preflight e2e");
|
|
83
85
|
|
|
84
86
|
before(() => {
|
|
85
87
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
86
88
|
savedEnv.set(k, process.env[k]);
|
|
87
89
|
process.env[k] = v;
|
|
88
90
|
}
|
|
91
|
+
// Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
|
|
92
|
+
// clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
|
|
93
|
+
// `settle()` cannot await — the flake behind this suite (issue #450).
|
|
94
|
+
probeSeam.install();
|
|
89
95
|
// The fan-out head (`pr.ensure-base-branch`, ADR 0003) reads/creates the base ref via the token
|
|
90
96
|
// transport, which would throw `no GitHub transport available` under an empty token. Pin the
|
|
91
97
|
// shared hermetic admit-github stub (dummy token + fetch intercept) like the sibling plan-fanout
|
|
@@ -94,6 +100,7 @@ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #
|
|
|
94
100
|
});
|
|
95
101
|
after(() => {
|
|
96
102
|
restoreGithub?.();
|
|
103
|
+
probeSeam.restoreAndAssertHermetic();
|
|
97
104
|
for (const [k, v] of savedEnv) {
|
|
98
105
|
if (v === undefined) delete process.env[k];
|
|
99
106
|
else process.env[k] = v;
|
|
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
26
26
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
27
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
28
|
+
import { advancePastTimer } from "./support/time.ts";
|
|
28
29
|
|
|
29
30
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
31
|
|
|
@@ -154,7 +155,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
154
155
|
|
|
155
156
|
// Never answer — let the SLA elapse. The interrupting boundary cancels the parked task and
|
|
156
157
|
// routes to the task-done end (the safe auto-abandon default).
|
|
157
|
-
await app
|
|
158
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
158
159
|
|
|
159
160
|
const flows = takenFlows(app);
|
|
160
161
|
assert.ok(
|
|
@@ -180,7 +181,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
180
181
|
await openTask(app, processKey, "plan-review-decision");
|
|
181
182
|
await assertAssignmentFilterable(app, processKey, "plan-review-decision");
|
|
182
183
|
|
|
183
|
-
await app
|
|
184
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
184
185
|
|
|
185
186
|
const flows = takenFlows(app);
|
|
186
187
|
assert.ok(
|
|
@@ -221,7 +222,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
221
222
|
await openTask(app, processKey, "trial-merge-decision");
|
|
222
223
|
await assertAssignmentFilterable(app, processKey, "trial-merge-decision");
|
|
223
224
|
|
|
224
|
-
await app
|
|
225
|
+
await advancePastTimer(app, PAST_SLA_MS);
|
|
225
226
|
|
|
226
227
|
const flows = takenFlows(app);
|
|
227
228
|
assert.ok(
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
24
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
import { advancePastTimer } from "./support/time.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
28
|
|
|
@@ -413,8 +414,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
413
414
|
|
|
414
415
|
// Never publish caps-resolved — let the bound (default P1D) elapse. Advancing past it is the
|
|
415
416
|
// ONLY way the token can move, so this proves the wait is genuinely bounded.
|
|
416
|
-
await app
|
|
417
|
-
await app.settle();
|
|
417
|
+
await advancePastTimer(app, 25 * 60 * 60 * 1000);
|
|
418
418
|
|
|
419
419
|
const flows = takenFlows(app);
|
|
420
420
|
assert.ok(
|