@nanobpm/nano-workforce 0.111.0 → 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.
@@ -414,6 +414,8 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
414
414
  failingCheckNames: [],
415
415
  totalChecks: 0,
416
416
  presentCheckNames: [],
417
+ pendingCheckNames: [],
418
+ checkConclusions: {},
417
419
  isDraft: false,
418
420
  headRefOid: "abc123",
419
421
  mergedSha: null,
package/app/readiness.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // any credential is read at execution time from the typed env-contract (`credentialEnv` names a
19
19
  // declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
20
20
  import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
21
- import { allCheckNames, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
21
+ import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
22
22
  import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
23
23
 
24
24
  /** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
@@ -565,6 +565,8 @@ export function parsePrView(payload: unknown): PrObservation {
565
565
  failingCheckNames: names,
566
566
  totalChecks: rollup.length,
567
567
  presentCheckNames: allCheckNames(rollup),
568
+ pendingCheckNames: pending,
569
+ checkConclusions: checkConclusions(rollup),
568
570
  isDraft: j.isDraft === true,
569
571
  headRefOid: str(j.headRefOid).trim() || null,
570
572
  mergedSha,
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";
@@ -1150,7 +1152,11 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1150
1152
  // (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
1151
1153
  // path the whole class (#368) now shares.
1152
1154
  if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1153
- const verdict = classifyMergeability(st);
1155
+ // Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
1156
+ // protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
1157
+ // the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
1158
+ const protocol = await loadMergeProtocol(repo, token).catch(() => null);
1159
+ const verdict = classifyMergeability(st, protocol ?? undefined);
1154
1160
  if (verdict === "waiting") {
1155
1161
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
1156
1162
  // head run and the PR has NO required head run yet, review has converged but the last push
@@ -1161,7 +1167,6 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1161
1167
  // `pull_request` run once per head (mark ready / close+reopen); rebases change
1162
1168
  // `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
1163
1169
  // landing attempt.
1164
- const protocol = await loadMergeProtocol(repo, token).catch(() => null);
1165
1170
  if (protocol) {
1166
1171
  const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
1167
1172
  headRefOid: st.headRefOid,
@@ -2098,215 +2103,228 @@ function toFeatureRunStatus(status: string): FeatureRunStatus {
2098
2103
  export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
2099
2104
  activeStatusesFor("feature_runs").map(toFeatureRunStatus);
2100
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
+
2101
2173
  /** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
2102
- * native user-task escalations (issue #236). The Tasks page lists EVERY open escalation awaiting a
2103
- * human decision — the feature kinds (`feature-escalation` / `feature-blocked`) plus the epic/PR kinds
2104
- * (`plan-review-decision`, `trial-merge-decision`, `wait-answer`) that otherwise had no app-side
2105
- * pointer, so the pages could not drive their completion. For each in-flight feature / plan / PR it
2106
- * reads the instance's open user tasks and projects one `user_tasks` row per escalation, enriching the
2107
- * display `question` from the audit tables each kind already records. `reconcileUserTasks` then diffs
2108
- * the desired open set against the persisted rows so a completed task's row is deleted (answered here,
2109
- * via the task inbox, or out-of-band) and `showCount` reflects live pending work. Best-effort +
2110
- * idempotent — per-instance failures are isolated so one bad instance never stalls the pass. */
2111
- 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
+ ) {
2112
2204
  const at = now();
2113
- const desired: UserTaskRow[] = [];
2114
- const push = (row: UserTaskRow | null) => {
2115
- if (row) desired.push(row);
2116
- };
2117
2205
 
2118
- // Feature-run escalations / blocked runs read each in-flight feature run's open user tasks directly
2119
- // from the engine (issue #332: the denormalised `feature_runs.escalation_user_task_key` /
2120
- // `blocked_user_task_key` pointers were dropped in the contract phase, so this now reads the live
2121
- // task exactly as the plan/PR scans below do one canonical read, no denormalised mirror). Dedupe by
2122
- // `feature_key` across the status queries so a run whose status transitions mid-pass is not processed
2123
- // twice.
2124
- const featureSeen = new Set<string>();
2125
- for (const status of FEATURE_ACTIVE_STATUSES) {
2126
- for (const run of await featureRuns(data).find({ status })) {
2127
- if (!run.process_key) continue;
2128
- if (featureSeen.has(run.feature_key)) continue;
2129
- featureSeen.add(run.feature_key);
2130
- let tasks: { userTaskKey: string; elementId?: string }[];
2131
- try {
2132
- tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
2133
- } catch (err) {
2134
- console.error(`[poller] user tasks (feature ${run.feature_key}): ${err}`);
2135
- continue;
2136
- }
2137
- for (const t of tasks) {
2138
- if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
2139
- // Source the question from the canonical append-only `feature_escalations` audit log (issue
2140
- // #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
2141
- // plan-review/trial-merge/PR-loop question enrichment below.
2142
- const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
2143
- push(
2144
- buildUserTaskRow(
2145
- {
2146
- userTaskKey: t.userTaskKey,
2147
- elementId: FEATURE_ESCALATION_ELEMENT,
2148
- subjectType: "feature",
2149
- subjectKey: run.feature_key,
2150
- subjectTitle: run.title,
2151
- subjectUrl: run.issue_url,
2152
- question,
2153
- processKey: run.process_key,
2154
- },
2155
- at,
2156
- ),
2157
- );
2158
- } else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
2159
- push(
2160
- buildUserTaskRow(
2161
- {
2162
- userTaskKey: t.userTaskKey,
2163
- elementId: FEATURE_BLOCKED_ELEMENT,
2164
- subjectType: "feature",
2165
- subjectKey: run.feature_key,
2166
- subjectTitle: run.title,
2167
- subjectUrl: run.issue_url,
2168
- question: run.delivery_label,
2169
- processKey: run.process_key,
2170
- },
2171
- at,
2172
- ),
2173
- );
2174
- }
2175
- }
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 });
2176
2223
  }
2177
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
+ }
2178
2236
 
2179
- // Plan escalations (`plan-review-decision` / `trial-merge-decision`) — read each in-flight plan's
2180
- // open user tasks and pair them with the open audit row's question/findings. Dedupe by `plan_key`
2181
- // across the status queries (mirroring the feature-run scan above): a plan whose status transitions
2182
- // mid-pass could otherwise match twice and push duplicate `desired` rows for one `user_task_key`.
2183
- const planSeen = new Set<string>();
2184
- for (const status of PLAN_ACTIVE_STATUSES) {
2185
- for (const plan of await plans(data).find({ status })) {
2186
- if (!plan.process_key) continue;
2187
- if (planSeen.has(plan.plan_key)) continue;
2188
- planSeen.add(plan.plan_key);
2189
- let tasks: { userTaskKey: string; elementId?: string }[];
2190
- try {
2191
- tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
2192
- } catch (err) {
2193
- console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
2194
- continue;
2195
- }
2196
- for (const t of tasks) {
2197
- if (t.elementId === PLAN_REVIEW_ELEMENT) {
2198
- const question = latestPlanReviewFindings(await planReviews(data).find({ plan_key: plan.plan_key }));
2199
- push(
2200
- buildUserTaskRow(
2201
- {
2202
- userTaskKey: t.userTaskKey,
2203
- elementId: PLAN_REVIEW_ELEMENT,
2204
- subjectType: "plan",
2205
- subjectKey: plan.plan_key,
2206
- subjectTitle: plan.title,
2207
- subjectUrl: plan.issue_url,
2208
- question,
2209
- processKey: plan.process_key,
2210
- },
2211
- at,
2212
- ),
2213
- );
2214
- } else if (t.elementId === TRIAL_MERGE_ELEMENT) {
2215
- const question = latestTrialMergeQuestion(await trialMergeAudits(data, plan.plan_key));
2216
- push(
2217
- buildUserTaskRow(
2218
- {
2219
- userTaskKey: t.userTaskKey,
2220
- elementId: TRIAL_MERGE_ELEMENT,
2221
- subjectType: "plan",
2222
- subjectKey: plan.plan_key,
2223
- subjectTitle: plan.title,
2224
- subjectUrl: plan.issue_url,
2225
- question,
2226
- processKey: plan.process_key,
2227
- },
2228
- at,
2229
- ),
2230
- );
2231
- }
2232
- }
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;
2233
2282
  }
2234
- }
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
+ };
2235
2298
 
2236
- // PR review-loop escalations (`wait-answer`) — read each in-flight PR's open user tasks and pair the
2237
- // escalation with the open audit row's question. Dedupe by `pr_key` across the status queries (as the
2238
- // feature-run / plan scans do): a PR whose status transitions mid-pass could otherwise be processed
2239
- // twice and push duplicate `desired` rows for one `user_task_key`.
2240
- const prSeen = new Set<string>();
2241
- for (const status of PR_ACTIVE_STATUSES) {
2242
- for (const pr of await prs(data).find({ status })) {
2243
- if (!pr.process_key) continue;
2244
- if (prSeen.has(pr.pr_key)) continue;
2245
- 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);
2246
2312
  let tasks: { userTaskKey: string; elementId?: string }[];
2247
2313
  try {
2248
- tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
2314
+ tasks = await engine.openUserTasks({ processInstanceKey: processKey });
2249
2315
  } catch (err) {
2250
- console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
2251
- continue;
2316
+ console.error(`[poller] user tasks (${processKey}): ${err}`);
2317
+ return;
2252
2318
  }
2253
- for (const t of tasks) {
2254
- if (t.elementId !== PR_WAIT_ANSWER_ELEMENT && t.elementId !== PR_WAIT_MERGE_ANSWER_ELEMENT) continue;
2255
- const question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: pr.pr_key, status: "open" }));
2256
- push(
2257
- buildUserTaskRow(
2258
- {
2259
- userTaskKey: t.userTaskKey,
2260
- elementId: t.elementId,
2261
- subjectType: "pr",
2262
- subjectKey: pr.pr_key,
2263
- subjectTitle: pr.title,
2264
- subjectUrl: pr.url,
2265
- question,
2266
- processKey: pr.process_key,
2267
- },
2268
- at,
2269
- ),
2270
- );
2271
- }
2272
- }
2273
- }
2274
-
2275
- // Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
2276
- // human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
2277
- // #216). retro is not one of the delivery aggregates above, so its instance is tracked on
2278
- // `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
2279
- // project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
2280
- for (const review of await activeConformanceReviews(data)) {
2281
- if (!review.process_key) continue;
2282
- let tasks: { userTaskKey: string; elementId?: string }[];
2283
- try {
2284
- tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
2285
- } catch (err) {
2286
- console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
2287
- continue;
2288
- }
2289
- const plan = await plans(data).get(review.plan_key);
2290
- for (const t of tasks) {
2291
- if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
2292
- push(
2293
- buildUserTaskRow(
2294
- {
2295
- userTaskKey: t.userTaskKey,
2296
- elementId: CONFORMANCE_ESCALATION_ELEMENT,
2297
- subjectType: "plan",
2298
- subjectKey: review.plan_key,
2299
- subjectTitle: plan?.title ?? null,
2300
- subjectUrl: plan?.issue_url ?? null,
2301
- question: conformanceEscalationQuestion(review),
2302
- processKey: review.process_key,
2303
- },
2304
- at,
2305
- ),
2306
- );
2307
- }
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);
2308
2325
  }
2309
2326
 
2327
+ const desired = [...desiredByKey.values()];
2310
2328
  const persisted = await userTasks(data).all();
2311
2329
  const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
2312
2330
  for (const row of inserts) await userTasks(data).insert(row);
@@ -2365,7 +2383,7 @@ export async function pollOnce(
2365
2383
  await pollFeatureDelivery(data);
2366
2384
  await pollLineage(data);
2367
2385
  await pollMergesPerDay(data);
2368
- await pollUserTasks(data, engine);
2386
+ await pollUserTasks(data, engine, engineRest);
2369
2387
  if (engineRest) {
2370
2388
  const base = engineRest.restAddress.replace(/\/+$/, "");
2371
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"