@nanobpm/nano-workforce 0.111.1 → 0.112.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/app/service.ts CHANGED
@@ -85,6 +85,8 @@ import {
85
85
  prEscalations,
86
86
  reconcileUserTasks,
87
87
  TRIAL_MERGE_ELEMENT,
88
+ USER_TASK_KIND_LABELS,
89
+ type UserTaskContext,
88
90
  type UserTaskRow,
89
91
  userTasks,
90
92
  } from "./userTasks.ts";
@@ -2101,215 +2103,228 @@ function toFeatureRunStatus(status: string): FeatureRunStatus {
2101
2103
  export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
2102
2104
  activeStatusesFor("feature_runs").map(toFeatureRunStatus);
2103
2105
 
2106
+ /** The subset of a Camunda-8 `/v2/user-tasks/search` result item this app reads for the engine-first
2107
+ * sweep. `userTaskKey` is the completable key; `elementId` is the BPMN element (the escalation kind);
2108
+ * `processInstanceKey` is the instance the task parks on (the raw REST surface carries it — the typed
2109
+ * `EngineClient.openUserTasks` seam deliberately omits it from `UserTaskSummary`); `state` is the task
2110
+ * lifecycle (`CREATED` while open/answerable). Keys are stringified defensively (the wire may send
2111
+ * either a JSON number or string). */
2112
+ interface UserTaskSearchItem {
2113
+ userTaskKey?: string | number;
2114
+ elementId?: string;
2115
+ processInstanceKey?: string | number;
2116
+ state?: string;
2117
+ }
2118
+
2119
+ /** One discovered open escalation user task, normalised for projection. */
2120
+ interface OpenUserTask {
2121
+ userTaskKey: string;
2122
+ elementId: string;
2123
+ processInstanceKey: string;
2124
+ }
2125
+
2126
+ /** Engine-first sweep (issue #358): read EVERY open (`CREATED`) native user task from the engine over
2127
+ * the raw Camunda-8 `/v2/user-tasks/search` surface (the same raw-REST search surface
2128
+ * `pollIncidents`/`pollJobActivation`/`pollWaveGates` use) and keep those whose `elementId` is a
2129
+ * surfaced escalation kind (`USER_TASK_KIND_LABELS`). This is the authoritative "what is open" set —
2130
+ * a task is discovered IFF the ENGINE reports it open, regardless of whether any tracked subject row
2131
+ * references its instance — so an escalation on an untracked/orphaned instance (the reported 19153
2132
+ * case) is surfaced too. Pages defensively so a large open set is never silently truncated to the
2133
+ * first page; best-effort transport (a failed page projects what was gathered and retries next pass).
2134
+ * Deduped by `userTaskKey` so a page overlap can't double-project one task. */
2135
+ async function sweepOpenEscalationTasks(base: string, headers: Record<string, string>): Promise<OpenUserTask[]> {
2136
+ const out: OpenUserTask[] = [];
2137
+ const seen = new Set<string>();
2138
+ const limit = 100;
2139
+ let from = 0;
2140
+ for (let guard = 0; guard < 1000; guard++) {
2141
+ let items: UserTaskSearchItem[];
2142
+ try {
2143
+ const res = await fetch(`${base}/user-tasks/search`, {
2144
+ method: "POST",
2145
+ headers,
2146
+ body: JSON.stringify({ filter: { state: "CREATED" }, page: { from, limit } }),
2147
+ });
2148
+ if (!res.ok) break; // engine unhappy → project what we have, retry next pass
2149
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
2150
+ const body = (await res.json()) as { items?: UserTaskSearchItem[] };
2151
+ items = body.items ?? [];
2152
+ } catch (err) {
2153
+ console.error(`[poller] user-task sweep: ${err}`);
2154
+ break;
2155
+ }
2156
+ for (const it of items) {
2157
+ // Re-filter state defensively in case the wire filter is ignored — only OPEN (`CREATED`) tasks are
2158
+ // answerable, so a lagging COMPLETED/CANCELED read must never surface a dead affordance (#294).
2159
+ if (typeof it.state === "string" && it.state.toUpperCase() !== "CREATED") continue;
2160
+ const elementId = typeof it.elementId === "string" ? it.elementId : undefined;
2161
+ if (!elementId || !Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) continue;
2162
+ const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
2163
+ if (!userTaskKey || seen.has(userTaskKey)) continue;
2164
+ seen.add(userTaskKey);
2165
+ out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey) });
2166
+ }
2167
+ if (items.length < limit) break; // last page
2168
+ from += items.length;
2169
+ }
2170
+ return out;
2171
+ }
2172
+
2104
2173
  /** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
2105
- * native user-task escalations (issue #236). The Tasks page lists EVERY open escalation awaiting a
2106
- * human decision — the feature kinds (`feature-escalation` / `feature-blocked`) plus the epic/PR kinds
2107
- * (`plan-review-decision`, `trial-merge-decision`, `wait-answer`) that otherwise had no app-side
2108
- * pointer, so the pages could not drive their completion. For each in-flight feature / plan / PR it
2109
- * reads the instance's open user tasks and projects one `user_tasks` row per escalation, enriching the
2110
- * display `question` from the audit tables each kind already records. `reconcileUserTasks` then diffs
2111
- * the desired open set against the persisted rows so a completed task's row is deleted (answered here,
2112
- * via the task inbox, or out-of-band) and `showCount` reflects live pending work. Best-effort +
2113
- * idempotent — per-instance failures are isolated so one bad instance never stalls the pass. */
2114
- export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
2174
+ * native user-task escalations (issues #236, #358). The Tasks page lists EVERY open escalation awaiting
2175
+ * a human decision — the feature kinds (`feature-escalation` / `feature-blocked`), the epic/PR kinds
2176
+ * (`plan-review-decision`, `trial-merge-decision`, `wait-answer` / `wait-merge-answer`), and the
2177
+ * conformance ack (`conformance-escalation`) that otherwise had no app-side pointer, so the pages
2178
+ * could not drive their completion.
2179
+ *
2180
+ * The source of truth for WHICH escalations are open is the ENGINE, not the tracked subject set (#358):
2181
+ * When the raw-REST surface is available (`engineRest`, always supplied in production), an
2182
+ * engine-first sweep (`sweepOpenEscalationTasks`) lists every open escalation the engine reports
2183
+ * tracked OR orphaned — so a task on an untracked/orphaned instance (the reported 19153 case) is
2184
+ * surfaced and answerable, not stranded invisible.
2185
+ * • Subject rows only ENRICH (they never gate): `subjectByInstance` maps the task's
2186
+ * `processInstanceKey` to its feature/plan/PR/conformance subject for `subject_title` / `subject_url`
2187
+ * / `question`. A task whose instance no subject row references falls back to a stable non-blank
2188
+ * subject (the instance key) and a null question, so it still renders.
2189
+ *
2190
+ * Without `engineRest` (unit tests / a degraded no-REST host) it falls back to the typed-seam
2191
+ * per-active-subject scan: the `openUserTasks` seam carries no `processInstanceKey`, so a task can only
2192
+ * be reached THROUGH a tracked subject whose instance key we already hold — hence that path is
2193
+ * tracked-only. Both paths feed the SAME `project`/`contextFor` enrichment derivation, so a tracked task
2194
+ * projects identically however it was discovered; only DISCOVERY differs by capability (no duplicate
2195
+ * enrichment). `reconcileUserTasks` then diffs the desired open set against the persisted rows so a
2196
+ * completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
2197
+ * reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
2198
+ * instance never stalls the pass. */
2199
+ export async function pollUserTasks(
2200
+ data: DataLayer,
2201
+ engine: EngineClient,
2202
+ engineRest?: { restAddress: string; token?: string },
2203
+ ) {
2115
2204
  const at = now();
2116
- const desired: UserTaskRow[] = [];
2117
- const push = (row: UserTaskRow | null) => {
2118
- if (row) desired.push(row);
2119
- };
2120
2205
 
2121
- // Feature-run escalations / blocked runs read each in-flight feature run's open user tasks directly
2122
- // from the engine (issue #332: the denormalised `feature_runs.escalation_user_task_key` /
2123
- // `blocked_user_task_key` pointers were dropped in the contract phase, so this now reads the live
2124
- // task exactly as the plan/PR scans below do one canonical read, no denormalised mirror). Dedupe by
2125
- // `feature_key` across the status queries so a run whose status transitions mid-pass is not processed
2126
- // twice.
2127
- const featureSeen = new Set<string>();
2128
- for (const status of FEATURE_ACTIVE_STATUSES) {
2129
- for (const run of await featureRuns(data).find({ status })) {
2130
- if (!run.process_key) continue;
2131
- if (featureSeen.has(run.feature_key)) continue;
2132
- featureSeen.add(run.feature_key);
2133
- let tasks: { userTaskKey: string; elementId?: string }[];
2134
- try {
2135
- tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
2136
- } catch (err) {
2137
- console.error(`[poller] user tasks (feature ${run.feature_key}): ${err}`);
2138
- continue;
2139
- }
2140
- for (const t of tasks) {
2141
- if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
2142
- // Source the question from the canonical append-only `feature_escalations` audit log (issue
2143
- // #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
2144
- // plan-review/trial-merge/PR-loop question enrichment below.
2145
- const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
2146
- push(
2147
- buildUserTaskRow(
2148
- {
2149
- userTaskKey: t.userTaskKey,
2150
- elementId: FEATURE_ESCALATION_ELEMENT,
2151
- subjectType: "feature",
2152
- subjectKey: run.feature_key,
2153
- subjectTitle: run.title,
2154
- subjectUrl: run.issue_url,
2155
- question,
2156
- processKey: run.process_key,
2157
- },
2158
- at,
2159
- ),
2160
- );
2161
- } else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
2162
- push(
2163
- buildUserTaskRow(
2164
- {
2165
- userTaskKey: t.userTaskKey,
2166
- elementId: FEATURE_BLOCKED_ELEMENT,
2167
- subjectType: "feature",
2168
- subjectKey: run.feature_key,
2169
- subjectTitle: run.title,
2170
- subjectUrl: run.issue_url,
2171
- question: run.delivery_label,
2172
- processKey: run.process_key,
2173
- },
2174
- at,
2175
- ),
2176
- );
2177
- }
2178
- }
2206
+ // ── Enrichment: subject descriptors keyed by the ENGINE process-instance the task parks on ────────
2207
+ // Built from EVERY subject row (regardless of status), so a task on a subject whose row already went
2208
+ // terminal is still enriched from it. `activeConformanceReviews` carries the retro instance + audit
2209
+ // summary for the `conformance-escalation` ack (its instance is tracked on `plan_conformance`, not a
2210
+ // delivery aggregate).
2211
+ interface Subject {
2212
+ type: "feature" | "plan" | "pr";
2213
+ key: string;
2214
+ title?: string | null;
2215
+ url?: string | null;
2216
+ deliveryLabel?: string | null;
2217
+ conformanceSummary?: string | null;
2218
+ }
2219
+ const subjectByInstance = new Map<string, Subject>();
2220
+ for (const run of await featureRuns(data).all()) {
2221
+ if (run.process_key) {
2222
+ subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label });
2179
2223
  }
2180
2224
  }
2225
+ for (const plan of await plans(data).all()) {
2226
+ if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url });
2227
+ }
2228
+ for (const pr of await prs(data).all()) {
2229
+ if (pr.process_key) subjectByInstance.set(pr.process_key, { type: "pr", key: pr.pr_key, title: pr.title, url: pr.url });
2230
+ }
2231
+ for (const review of await activeConformanceReviews(data)) {
2232
+ if (!review.process_key) continue;
2233
+ const plan = await plans(data).get(review.plan_key);
2234
+ subjectByInstance.set(review.process_key, { type: "plan", key: review.plan_key, title: plan?.title ?? null, url: plan?.issue_url ?? null, conformanceSummary: review.summary });
2235
+ }
2181
2236
 
2182
- // Plan escalations (`plan-review-decision` / `trial-merge-decision`) — read each in-flight plan's
2183
- // open user tasks and pair them with the open audit row's question/findings. Dedupe by `plan_key`
2184
- // across the status queries (mirroring the feature-run scan above): a plan whose status transitions
2185
- // mid-pass could otherwise match twice and push duplicate `desired` rows for one `user_task_key`.
2186
- const planSeen = new Set<string>();
2187
- for (const status of PLAN_ACTIVE_STATUSES) {
2188
- for (const plan of await plans(data).find({ status })) {
2189
- if (!plan.process_key) continue;
2190
- if (planSeen.has(plan.plan_key)) continue;
2191
- planSeen.add(plan.plan_key);
2192
- let tasks: { userTaskKey: string; elementId?: string }[];
2193
- try {
2194
- tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
2195
- } catch (err) {
2196
- console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
2197
- continue;
2198
- }
2199
- for (const t of tasks) {
2200
- if (t.elementId === PLAN_REVIEW_ELEMENT) {
2201
- const question = latestPlanReviewFindings(await planReviews(data).find({ plan_key: plan.plan_key }));
2202
- push(
2203
- buildUserTaskRow(
2204
- {
2205
- userTaskKey: t.userTaskKey,
2206
- elementId: PLAN_REVIEW_ELEMENT,
2207
- subjectType: "plan",
2208
- subjectKey: plan.plan_key,
2209
- subjectTitle: plan.title,
2210
- subjectUrl: plan.issue_url,
2211
- question,
2212
- processKey: plan.process_key,
2213
- },
2214
- at,
2215
- ),
2216
- );
2217
- } else if (t.elementId === TRIAL_MERGE_ELEMENT) {
2218
- const question = latestTrialMergeQuestion(await trialMergeAudits(data, plan.plan_key));
2219
- push(
2220
- buildUserTaskRow(
2221
- {
2222
- userTaskKey: t.userTaskKey,
2223
- elementId: TRIAL_MERGE_ELEMENT,
2224
- subjectType: "plan",
2225
- subjectKey: plan.plan_key,
2226
- subjectTitle: plan.title,
2227
- subjectUrl: plan.issue_url,
2228
- question,
2229
- processKey: plan.process_key,
2230
- },
2231
- at,
2232
- ),
2233
- );
2234
- }
2235
- }
2237
+ // Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
2238
+ // when tracking is lost, so the fallback row still buckets correctly on the page.
2239
+ const DEFAULT_SUBJECT_TYPE: Readonly<Record<string, "feature" | "plan" | "pr">> = {
2240
+ [FEATURE_ESCALATION_ELEMENT]: "feature",
2241
+ [FEATURE_BLOCKED_ELEMENT]: "feature",
2242
+ [PLAN_REVIEW_ELEMENT]: "plan",
2243
+ [TRIAL_MERGE_ELEMENT]: "plan",
2244
+ [CONFORMANCE_ESCALATION_ELEMENT]: "plan",
2245
+ [PR_WAIT_ANSWER_ELEMENT]: "pr",
2246
+ [PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
2247
+ };
2248
+
2249
+ // The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
2250
+ // element + the instance it parks on) into its desired-row context, enriching from its subject row
2251
+ // when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
2252
+ // non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
2253
+ const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
2254
+ if (!Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) return null;
2255
+ const subj = subjectByInstance.get(processInstanceKey);
2256
+ const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? "plan";
2257
+ const subjectKey = subj?.key ?? processInstanceKey;
2258
+ let question: string | null = null;
2259
+ switch (elementId) {
2260
+ case FEATURE_ESCALATION_ELEMENT:
2261
+ // The escalate arm writes the synthesised question to `feature_escalations` keyed by the subject
2262
+ // (a standalone slice's `feature_key`, or the epic's `plan_key` for a plan-embedded slice, which
2263
+ // has no standalone `feature_runs` row) — the same key `subjectKey` resolves to for either subject.
2264
+ question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: subjectKey }));
2265
+ break;
2266
+ case FEATURE_BLOCKED_ELEMENT:
2267
+ question = subj?.deliveryLabel ?? null;
2268
+ break;
2269
+ case PLAN_REVIEW_ELEMENT:
2270
+ question = latestPlanReviewFindings(await planReviews(data).find({ plan_key: subjectKey }));
2271
+ break;
2272
+ case TRIAL_MERGE_ELEMENT:
2273
+ question = latestTrialMergeQuestion(await trialMergeAudits(data, subjectKey));
2274
+ break;
2275
+ case PR_WAIT_ANSWER_ELEMENT:
2276
+ case PR_WAIT_MERGE_ANSWER_ELEMENT:
2277
+ question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: subjectKey, status: "open" }));
2278
+ break;
2279
+ case CONFORMANCE_ESCALATION_ELEMENT:
2280
+ question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
2281
+ break;
2236
2282
  }
2237
- }
2283
+ return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey };
2284
+ };
2285
+
2286
+ // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
2287
+ // subject seen under two statuses mid-pass).
2288
+ const desiredByKey = new Map<string, UserTaskRow>();
2289
+ const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string) => {
2290
+ if (!elementId) return;
2291
+ const rowKey = userTaskKey.trim();
2292
+ if (!rowKey || desiredByKey.has(rowKey)) return;
2293
+ const ctx = await contextFor(elementId, userTaskKey, processInstanceKey);
2294
+ if (!ctx) return;
2295
+ const row = buildUserTaskRow(ctx, at);
2296
+ if (row) desiredByKey.set(rowKey, row);
2297
+ };
2238
2298
 
2239
- // PR review-loop escalations (`wait-answer`) — read each in-flight PR's open user tasks and pair the
2240
- // escalation with the open audit row's question. Dedupe by `pr_key` across the status queries (as the
2241
- // feature-run / plan scans do): a PR whose status transitions mid-pass could otherwise be processed
2242
- // twice and push duplicate `desired` rows for one `user_task_key`.
2243
- const prSeen = new Set<string>();
2244
- for (const status of PR_ACTIVE_STATUSES) {
2245
- for (const pr of await prs(data).find({ status })) {
2246
- if (!pr.process_key) continue;
2247
- if (prSeen.has(pr.pr_key)) continue;
2248
- prSeen.add(pr.pr_key);
2299
+ if (engineRest) {
2300
+ const base = engineRest.restAddress.replace(/\/+$/, "");
2301
+ const headers: Record<string, string> = { "content-type": "application/json" };
2302
+ if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
2303
+ for (const t of await sweepOpenEscalationTasks(base, headers)) {
2304
+ await project(t.elementId, t.userTaskKey, t.processInstanceKey);
2305
+ }
2306
+ } else {
2307
+ // Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
2308
+ const seen = new Set<string>();
2309
+ const scanInstance = async (processKey: string | null | undefined) => {
2310
+ if (!processKey || seen.has(processKey)) return;
2311
+ seen.add(processKey);
2249
2312
  let tasks: { userTaskKey: string; elementId?: string }[];
2250
2313
  try {
2251
- tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
2314
+ tasks = await engine.openUserTasks({ processInstanceKey: processKey });
2252
2315
  } catch (err) {
2253
- console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
2254
- continue;
2316
+ console.error(`[poller] user tasks (${processKey}): ${err}`);
2317
+ return;
2255
2318
  }
2256
- for (const t of tasks) {
2257
- if (t.elementId !== PR_WAIT_ANSWER_ELEMENT && t.elementId !== PR_WAIT_MERGE_ANSWER_ELEMENT) continue;
2258
- const question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: pr.pr_key, status: "open" }));
2259
- push(
2260
- buildUserTaskRow(
2261
- {
2262
- userTaskKey: t.userTaskKey,
2263
- elementId: t.elementId,
2264
- subjectType: "pr",
2265
- subjectKey: pr.pr_key,
2266
- subjectTitle: pr.title,
2267
- subjectUrl: pr.url,
2268
- question,
2269
- processKey: pr.process_key,
2270
- },
2271
- at,
2272
- ),
2273
- );
2274
- }
2275
- }
2276
- }
2277
-
2278
- // Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
2279
- // human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
2280
- // #216). retro is not one of the delivery aggregates above, so its instance is tracked on
2281
- // `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
2282
- // project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
2283
- for (const review of await activeConformanceReviews(data)) {
2284
- if (!review.process_key) continue;
2285
- let tasks: { userTaskKey: string; elementId?: string }[];
2286
- try {
2287
- tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
2288
- } catch (err) {
2289
- console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
2290
- continue;
2291
- }
2292
- const plan = await plans(data).get(review.plan_key);
2293
- for (const t of tasks) {
2294
- if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
2295
- push(
2296
- buildUserTaskRow(
2297
- {
2298
- userTaskKey: t.userTaskKey,
2299
- elementId: CONFORMANCE_ESCALATION_ELEMENT,
2300
- subjectType: "plan",
2301
- subjectKey: review.plan_key,
2302
- subjectTitle: plan?.title ?? null,
2303
- subjectUrl: plan?.issue_url ?? null,
2304
- question: conformanceEscalationQuestion(review),
2305
- processKey: review.process_key,
2306
- },
2307
- at,
2308
- ),
2309
- );
2310
- }
2319
+ for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey);
2320
+ };
2321
+ for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
2322
+ for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
2323
+ for (const status of PR_ACTIVE_STATUSES) for (const pr of await prs(data).find({ status })) await scanInstance(pr.process_key);
2324
+ for (const review of await activeConformanceReviews(data)) await scanInstance(review.process_key);
2311
2325
  }
2312
2326
 
2327
+ const desired = [...desiredByKey.values()];
2313
2328
  const persisted = await userTasks(data).all();
2314
2329
  const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
2315
2330
  for (const row of inserts) await userTasks(data).insert(row);
@@ -2368,7 +2383,7 @@ export async function pollOnce(
2368
2383
  await pollFeatureDelivery(data);
2369
2384
  await pollLineage(data);
2370
2385
  await pollMergesPerDay(data);
2371
- await pollUserTasks(data, engine);
2386
+ await pollUserTasks(data, engine, engineRest);
2372
2387
  if (engineRest) {
2373
2388
  const base = engineRest.restAddress.replace(/\/+$/, "");
2374
2389
  const headers: Record<string, string> = { "content-type": "application/json" };
@@ -91,13 +91,31 @@ test("buildUserTaskRow: an unknown (non-escalation) element yields null — no a
91
91
  assertEquals(row, null);
92
92
  });
93
93
 
94
- test("buildUserTaskRow: a blank userTaskKey or subjectKey yields null", () => {
94
+ test("buildUserTaskRow: a blank userTaskKey yields null; a known kind with a blank subject key still builds (issue #358)", () => {
95
+ // The completable key is load-bearing (the page completes THROUGH it), so a blank one is still null.
95
96
  assertEquals(
96
97
  buildUserTaskRow({ userTaskKey: " ", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: "o/r#4" }, AT),
97
98
  null,
98
99
  );
100
+ // But a KNOWN-kind escalation with no resolved subject (orphaned/untracked instance) is NO LONGER
101
+ // dropped — it falls back to a stable, non-blank subject: the instance (`processKey`) when known…
102
+ const orphaned = buildUserTaskRow(
103
+ { userTaskKey: "ut-4", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: " ", processKey: "pik-4" },
104
+ AT,
105
+ );
106
+ assert(orphaned !== null);
107
+ assertEquals(orphaned?.subject_key, "pik-4");
108
+ assertEquals(orphaned?.subject_title, "pik-4");
109
+ assertEquals(orphaned?.kind_label, "PR review");
110
+ // …else the completable key itself, so a row always renders.
111
+ const noInstance = buildUserTaskRow(
112
+ { userTaskKey: "ut-5", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: "" },
113
+ AT,
114
+ );
115
+ assertEquals(noInstance?.subject_key, "ut-5");
116
+ // The leak guard still holds: an unknown element is null regardless of subject fallback.
99
117
  assertEquals(
100
- buildUserTaskRow({ userTaskKey: "ut-4", elementId: PR_WAIT_ANSWER_ELEMENT, subjectType: "pr", subjectKey: " " }, AT),
118
+ buildUserTaskRow({ userTaskKey: "ut-6", elementId: "some-internal-task", subjectType: "plan", subjectKey: "", processKey: "pik-6" }, AT),
101
119
  null,
102
120
  );
103
121
  });
package/app/userTasks.ts CHANGED
@@ -95,13 +95,20 @@ export interface UserTaskContext {
95
95
 
96
96
  /** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
97
97
  * element is not one of the surfaced escalation kinds (so a non-escalation user task is never listed)
98
- * or the required keys are blank. `created_at`/`updated_at` default to now for a fresh row; the
99
- * reconcile preserves the original `created_at` on an update. */
98
+ * or the completable key is blank. `created_at`/`updated_at` default to now for a fresh row; the
99
+ * reconcile preserves the original `created_at` on an update.
100
+ *
101
+ * Engine-first sweep (#358): a KNOWN-kind escalation is NOT dropped when no tracked subject row
102
+ * resolved a subject key for it (an orphaned/untracked instance — the reported 19153 case). The
103
+ * subject key falls back to a stable, non-blank value — the instance (`processKey`) if known, else the
104
+ * completable `userTaskKey` — so the row still renders and stays answerable. The blank-`userTaskKey`
105
+ * and unknown-kind guards below remain (the `USER_TASK_KIND_LABELS` gate keeps arbitrary internal user
106
+ * tasks out of the inbox). */
100
107
  export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
101
108
  const userTaskKey = ctx.userTaskKey.trim();
102
- const subjectKey = ctx.subjectKey.trim();
103
109
  const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
104
- if (!userTaskKey || !subjectKey || !kindLabel) return null;
110
+ if (!userTaskKey || !kindLabel) return null;
111
+ const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
105
112
  const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
106
113
  const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
107
114
  return {
package/nano.app.json CHANGED
@@ -140,6 +140,10 @@
140
140
  "taskType": "pr.record-wave",
141
141
  "handler": "workers/record-wave/worker.ts"
142
142
  },
143
+ {
144
+ "taskType": "pr.record-wave-escalation",
145
+ "handler": "workers/record-wave-escalation/worker.ts"
146
+ },
143
147
  {
144
148
  "taskType": "pr.record-feature",
145
149
  "handler": "workers/record-feature/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.111.1",
3
+ "version": "0.112.0",
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",