@nanobpm/nano-workforce 0.99.1 → 0.101.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +92 -6
  3. package/app/agentCompletion.ts +86 -58
  4. package/app/contracts.ts +18 -2
  5. package/app/feature.ts +15 -166
  6. package/app/featureGateway.test.ts +6 -57
  7. package/app/migration049.test.ts +112 -0
  8. package/app/pollUserTasks.test.ts +39 -13
  9. package/app/service.test.ts +16 -0
  10. package/app/service.ts +107 -136
  11. package/app/stage.test.ts +7 -53
  12. package/app/stage.ts +5 -37
  13. package/app/userTasks.test.ts +1 -1
  14. package/app/userTasks.ts +3 -3
  15. package/app/world/checkpoint.test.ts +193 -0
  16. package/app/world/checkpoint.ts +142 -0
  17. package/app/world/effect-ledger.test.ts +86 -0
  18. package/app/world/effect-ledger.ts +103 -0
  19. package/app/world/git.ts +53 -0
  20. package/app/world/index.ts +26 -0
  21. package/app/world/store.test.ts +443 -0
  22. package/app/world/store.ts +320 -0
  23. package/app/world-marker.test.ts +79 -0
  24. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  25. package/db/migrations/049_world_checkpoint.sql +84 -0
  26. package/e2e/feature-run.e2e.ts +52 -41
  27. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  28. package/openapi.yaml +7 -108
  29. package/operations/agentCompleteEscalation.ts +2 -2
  30. package/operations/completeUserTask.test.ts +25 -6
  31. package/operations/completeUserTask.ts +12 -10
  32. package/package.json +2 -2
  33. package/pages/feature.page.json +1 -37
  34. package/pages/overview.page.json +1 -39
  35. package/pages/tasks.page.json +78 -93
  36. package/resources/forms/feature-escalation.form +3 -0
  37. package/test/worldDb.ts +103 -0
  38. package/workers/persist-round/worker.ts +68 -0
  39. package/workers/record-blocked-ack/worker.test.ts +1 -4
  40. package/workers/record-blocked-ack/worker.ts +0 -5
  41. package/workers/record-feature/worker.ts +0 -6
  42. package/workers/record-feature-escalation/worker.test.ts +14 -27
  43. package/workers/record-feature-escalation/worker.ts +16 -22
  44. package/app/featureBlocked.test.ts +0 -182
  45. package/app/featureEscalation.test.ts +0 -235
  46. package/operations/acknowledgeBlocked.test.ts +0 -111
  47. package/operations/acknowledgeBlocked.ts +0 -62
  48. package/operations/answerFeatureEscalation.ts +0 -68
package/app/service.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
24
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
- import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
25
+ import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
26
  import {
27
27
  classifyMergeability,
28
28
  coalesceTitle,
@@ -81,6 +81,7 @@ import {
81
81
  } from "./userTasks.ts";
82
82
  import { deriveWaitGate } from "./waitGate.ts";
83
83
  import { waveMergeTargets } from "./waves.ts";
84
+ import { isCommitSha, WorldStore } from "./world/index.ts";
84
85
 
85
86
  /** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
86
87
  export const PROCESS_ID = "convergence-loop";
@@ -373,8 +374,20 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
373
374
  * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
374
375
  * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
375
376
  * is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
376
- * that base reachable for the diff. */
377
- export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
377
+ * that base reachable for the diff.
378
+ *
379
+ * World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
380
+ * `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
381
+ * reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
382
+ * `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
383
+ * have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
384
+ * head branch normally. */
385
+ export function repoEnvelopeVars(
386
+ repo: string,
387
+ ref: string | null,
388
+ baseRef: string | null = null,
389
+ commitSha: string | null = null,
390
+ ): Record<string, unknown> {
378
391
  if (!ref) return {};
379
392
  // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
380
393
  // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
@@ -399,11 +412,33 @@ export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: stri
399
412
  // The base branch this PR targets — emitted so the harness fetches its tip alongside the
400
413
  // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
401
414
  ...(baseRef ? { baseRef } : {}),
415
+ // World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
416
+ // working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
417
+ // a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
418
+ // checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
419
+ // unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
420
+ // omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
421
+ // when the PR has no durable push-checkpoint yet.
422
+ ...(isCommitSha(commitSha) ? { commitSha } : {}),
402
423
  },
403
424
  },
404
425
  };
405
426
  }
406
427
 
428
+ /** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
429
+ * has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
430
+ * pushed tree. Best-effort: any store read failure (a legacy DB predating migration 049, an in-flight
431
+ * desync) degrades to `null` — the harness then clones the head branch tip, the pre-#324 behaviour —
432
+ * rather than blocking a submit/merge on the world store. */
433
+ async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | null> {
434
+ try {
435
+ return (await new WorldStore(data).lastCheckpoint(prKey))?.commitSha ?? null;
436
+ } catch (err) {
437
+ console.warn(`[world] ${prKey} last-checkpoint read: ${err}`);
438
+ return null;
439
+ }
440
+ }
441
+
407
442
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
408
443
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
409
444
  * recorded as the PR's merge-stage dependency set. */
@@ -508,6 +543,11 @@ export async function submitPr(
508
543
  });
509
544
  }
510
545
  const abUrl = abandonUrl(abandonToken);
546
+ // World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
547
+ // pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
548
+ // fresh worktree reconstructs the tree to the EXACT pushed SHA. Absent (null) on a first submit,
549
+ // which leaves the envelope unchanged.
550
+ const worldSha = await lastPushedSha(data, parsed.prKey);
511
551
  const { processInstanceKey } = await engine.createInstance({
512
552
  processDefinitionId: PROCESS_ID,
513
553
  variables: {
@@ -533,7 +573,7 @@ export async function submitPr(
533
573
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
534
574
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
535
575
  // unresolved head (`{}`) leaves the other vars untouched.
536
- ...repoEnvelopeVars(parsed.repo, headRef, baseRef),
576
+ ...repoEnvelopeVars(parsed.repo, headRef, baseRef, worldSha),
537
577
  },
538
578
  });
539
579
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -580,6 +620,9 @@ export async function startMerge(
580
620
  if (!headRef) {
581
621
  console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
582
622
  }
623
+ // World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
624
+ // last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
625
+ const worldSha = await lastPushedSha(data, pr.prKey);
583
626
  const { processInstanceKey } = await engine.createInstance({
584
627
  processDefinitionId: MERGE_PROCESS_ID,
585
628
  variables: {
@@ -601,7 +644,7 @@ export async function startMerge(
601
644
  abandonBrief: renderAbandonBrief(abUrl),
602
645
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
603
646
  // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
604
- ...repoEnvelopeVars(pr.repo, headRef, baseRef),
647
+ ...repoEnvelopeVars(pr.repo, headRef, baseRef, worldSha),
605
648
  },
606
649
  });
607
650
  if (processInstanceKey != null) {
@@ -1779,83 +1822,6 @@ export async function pollFeatureDelivery(data: DataLayer) {
1779
1822
  }
1780
1823
  }
1781
1824
 
1782
- /** Reconcile each in-flight FEATURE run against its native `feature-escalation` user task (issue
1783
- * #210 — feature-run escalations were invisible in the nwf UI). When a feature run escalates it parks
1784
- * on the `feature-escalation` operator user task (an engine wait); no worker runs, so `feature_runs`
1785
- * — which the schema-driven pages read — stayed `running` with nothing to show. This is the
1786
- * `feature_runs` twin of `pollFeatureDelivery`: for each run that can be parked at (or resuming from)
1787
- * the escalation, read its open user tasks and project the parked task onto the row via the pure
1788
- * `deriveFeatureEscalationPatch` — flipping `status` to `escalated` and denormalising the escalation's
1789
- * completable `userTaskKey` so the pages can drive an answer, and flipping back to `running` (clearing
1790
- * the pointer) once it un-parks. It never writes `escalation_question` — that is persisted by the
1791
- * `record-feature-escalation` worker at escalation entry and cleared on the exit paths, so the poller
1792
- * can never clobber the source of truth for the question.
1793
- *
1794
- * Candidates are only the runs that could be parked here — `running` (may have just escalated) and
1795
- * `escalated` (may have just resumed) — queried via the `feature_runs(status)` index, so the pass
1796
- * stays O(in-flight), not O(total runs). Terminal-ward transitions THROUGH `record-feature` (answer
1797
- * → abandon, SLA auto-abandon, done) clear the pointer in that worker, so a run that has already left
1798
- * `escalated` never needs sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1799
- export async function pollFeatureEscalations(data: DataLayer, engine: EngineClient) {
1800
- const seen = new Set<string>();
1801
- const candidates: FeatureRun[] = [];
1802
- for (const status of ["running", "escalated"] as const) {
1803
- for (const run of await featureRuns(data).find({ status })) {
1804
- if (seen.has(run.feature_key)) continue;
1805
- seen.add(run.feature_key);
1806
- candidates.push(run);
1807
- }
1808
- }
1809
- for (const run of candidates) {
1810
- if (!run.process_key) continue;
1811
- try {
1812
- const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1813
- const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
1814
- const parked = task ? { userTaskKey: task.userTaskKey } : null;
1815
- const patch = deriveFeatureEscalationPatch(run, parked);
1816
- if (patch) {
1817
- await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1818
- }
1819
- } catch (err) {
1820
- console.error(`[poller] feature escalation ${run.feature_key}: ${err}`);
1821
- }
1822
- }
1823
- }
1824
-
1825
- /** Reconcile each BLOCKED FEATURE run against its native `feature-blocked` user task (issue #220 —
1826
- * a blocked run parked at `feature-blocked` had no completion affordance in nwf). When a feature run
1827
- * reaches a `blocked` outcome `record-feature` holds the row at the NON-terminal `awaiting_operator`
1828
- * status and it parks on the `feature-blocked` operator user task (an engine wait); no worker runs, so
1829
- * the schema-driven pages — which read `feature_runs` — had a status to show but NO pointer to drive a
1830
- * completion action, so the run sat parked forever unless completed out-of-band. This is the blocked
1831
- * twin of `pollFeatureEscalations`: for each run parked at (or resuming from) the blocked wait, read its
1832
- * open user tasks and project the parked task's completable `userTaskKey` onto the row via the pure
1833
- * `deriveFeatureBlockedPatch`, so the pages can drive an "Acknowledge blocked" action, and clear the
1834
- * pointer once it un-parks. It never touches `status` — `record-feature` owns the `awaiting_operator`
1835
- * flip and `record-blocked-ack` owns the terminal `blocked`, so the poller can never clobber either.
1836
- *
1837
- * Candidates are only the runs that could be parked here — `awaiting_operator` (parked at, or just
1838
- * un-parked from, the blocked wait) — queried via the `feature_runs(status)` index, so the pass stays
1839
- * O(in-flight), not O(total runs). The terminal-ward transition THROUGH `record-blocked-ack` (and the
1840
- * acknowledge operation) clears the pointer, so a run that has already settled to `blocked` never needs
1841
- * sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1842
- export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient) {
1843
- for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
1844
- if (!run.process_key) continue;
1845
- try {
1846
- const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1847
- const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
1848
- const parked = task ? { userTaskKey: task.userTaskKey } : null;
1849
- const patch = deriveFeatureBlockedPatch(run, parked);
1850
- if (patch) {
1851
- await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1852
- }
1853
- } catch (err) {
1854
- console.error(`[poller] feature blocked ${run.feature_key}: ${err}`);
1855
- }
1856
- }
1857
- }
1858
-
1859
1825
  /** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
1860
1826
  * three times during module initialization (the PR/plan/feature constants below); parsing here keeps
1861
1827
  * that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
@@ -1904,16 +1870,14 @@ export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
1904
1870
 
1905
1871
  /** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
1906
1872
  * native user-task escalations (issue #236). The Tasks page lists EVERY open escalation awaiting a
1907
- * human decision — the feature kinds (already denormalised onto `feature_runs` by the two feature
1908
- * pollers, which run earlier in this pass) plus the epic/PR kinds (`plan-review-decision`,
1909
- * `trial-merge-decision`, `wait-answer`) that had no app-side pointer at all, so the pages could not
1910
- * drive their completion. This is the generalisation of `pollFeatureEscalations`/`pollFeatureBlocked`
1911
- * across all subjects: for each in-flight plan / PR it reads the instance's open user tasks and
1912
- * projects one `user_tasks` row per escalation, enriching the display `question` from the audit
1913
- * tables each kind already records. `reconcileUserTasks` then diffs the desired open set against the
1914
- * persisted rows so a completed task's row is deleted (answered here, via the task inbox, or
1915
- * out-of-band) and `showCount` reflects live pending work. Best-effort + idempotent — per-instance
1916
- * failures are isolated so one bad instance never stalls the pass. */
1873
+ * human decision — the feature kinds (`feature-escalation` / `feature-blocked`) plus the epic/PR kinds
1874
+ * (`plan-review-decision`, `trial-merge-decision`, `wait-answer`) that otherwise had no app-side
1875
+ * pointer, so the pages could not drive their completion. For each in-flight feature / plan / PR it
1876
+ * reads the instance's open user tasks and projects one `user_tasks` row per escalation, enriching the
1877
+ * display `question` from the audit tables each kind already records. `reconcileUserTasks` then diffs
1878
+ * the desired open set against the persisted rows so a completed task's row is deleted (answered here,
1879
+ * via the task inbox, or out-of-band) and `showCount` reflects live pending work. Best-effort +
1880
+ * idempotent per-instance failures are isolated so one bad instance never stalls the pass. */
1917
1881
  export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1918
1882
  const at = now();
1919
1883
  const desired: UserTaskRow[] = [];
@@ -1921,54 +1885,63 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1921
1885
  if (row) desired.push(row);
1922
1886
  };
1923
1887
 
1924
- // Feature-run escalations — the completable keys are denormalised onto `feature_runs` by
1925
- // pollFeatureEscalations/pollFeatureBlocked (earlier in this pass), so no per-instance engine read
1926
- // is needed here.
1888
+ // Feature-run escalations / blocked runs read each in-flight feature run's open user tasks directly
1889
+ // from the engine (issue #332: the denormalised `feature_runs.escalation_user_task_key` /
1890
+ // `blocked_user_task_key` pointers were dropped in the contract phase, so this now reads the live
1891
+ // task exactly as the plan/PR scans below do — one canonical read, no denormalised mirror). Dedupe by
1892
+ // `feature_key` across the status queries so a run whose status transitions mid-pass is not processed
1893
+ // twice.
1927
1894
  const featureSeen = new Set<string>();
1928
1895
  for (const status of FEATURE_ACTIVE_STATUSES) {
1929
1896
  for (const run of await featureRuns(data).find({ status })) {
1897
+ if (!run.process_key) continue;
1930
1898
  if (featureSeen.has(run.feature_key)) continue;
1931
1899
  featureSeen.add(run.feature_key);
1932
- if (run.escalation_user_task_key) {
1933
- // Source the question from the canonical append-only `feature_escalations` audit log (issue
1934
- // #305) the surviving table `record-feature-escalation` writes — falling back to the legacy
1935
- // denormalised `feature_runs.escalation_question` while both coexist (expand phase). This is the
1936
- // feature analogue of the plan-review/trial-merge/PR-loop question enrichment below, and lets the
1937
- // denormalised column be dropped in the contract phase without the Tasks grid losing the text.
1938
- const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key })) ??
1939
- run.escalation_question;
1940
- push(
1941
- buildUserTaskRow(
1942
- {
1943
- userTaskKey: run.escalation_user_task_key,
1944
- elementId: FEATURE_ESCALATION_ELEMENT,
1945
- subjectType: "feature",
1946
- subjectKey: run.feature_key,
1947
- subjectTitle: run.title,
1948
- subjectUrl: run.issue_url,
1949
- question,
1950
- processKey: run.process_key,
1951
- },
1952
- at,
1953
- ),
1954
- );
1900
+ let tasks: { userTaskKey: string; elementId?: string }[];
1901
+ try {
1902
+ tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1903
+ } catch (err) {
1904
+ console.error(`[poller] user tasks (feature ${run.feature_key}): ${err}`);
1905
+ continue;
1955
1906
  }
1956
- if (run.blocked_user_task_key) {
1957
- push(
1958
- buildUserTaskRow(
1959
- {
1960
- userTaskKey: run.blocked_user_task_key,
1961
- elementId: FEATURE_BLOCKED_ELEMENT,
1962
- subjectType: "feature",
1963
- subjectKey: run.feature_key,
1964
- subjectTitle: run.title,
1965
- subjectUrl: run.issue_url,
1966
- question: run.delivery_label,
1967
- processKey: run.process_key,
1968
- },
1969
- at,
1970
- ),
1971
- );
1907
+ for (const t of tasks) {
1908
+ if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
1909
+ // Source the question from the canonical append-only `feature_escalations` audit log (issue
1910
+ // #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
1911
+ // plan-review/trial-merge/PR-loop question enrichment below.
1912
+ const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
1913
+ push(
1914
+ buildUserTaskRow(
1915
+ {
1916
+ userTaskKey: t.userTaskKey,
1917
+ elementId: FEATURE_ESCALATION_ELEMENT,
1918
+ subjectType: "feature",
1919
+ subjectKey: run.feature_key,
1920
+ subjectTitle: run.title,
1921
+ subjectUrl: run.issue_url,
1922
+ question,
1923
+ processKey: run.process_key,
1924
+ },
1925
+ at,
1926
+ ),
1927
+ );
1928
+ } else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
1929
+ push(
1930
+ buildUserTaskRow(
1931
+ {
1932
+ userTaskKey: t.userTaskKey,
1933
+ elementId: FEATURE_BLOCKED_ELEMENT,
1934
+ subjectType: "feature",
1935
+ subjectKey: run.feature_key,
1936
+ subjectTitle: run.title,
1937
+ subjectUrl: run.issue_url,
1938
+ question: run.delivery_label,
1939
+ processKey: run.process_key,
1940
+ },
1941
+ at,
1942
+ ),
1943
+ );
1944
+ }
1972
1945
  }
1973
1946
  }
1974
1947
  }
@@ -2126,8 +2099,6 @@ export async function pollOnce(
2126
2099
  await pollPromotion(data, engine, token);
2127
2100
  await pollFeatureDelivery(data);
2128
2101
  await pollLineage(data);
2129
- await pollFeatureEscalations(data, engine);
2130
- await pollFeatureBlocked(data, engine);
2131
2102
  await pollUserTasks(data, engine);
2132
2103
  if (engineRest) {
2133
2104
  const base = engineRest.restAddress.replace(/\/+$/, "");
package/app/stage.test.ts CHANGED
@@ -5,15 +5,12 @@
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import { FEATURE_RUN_STATUSES } from "./feature.ts";
8
- import { deriveEscalationOpen, deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
8
+ import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
9
9
 
10
10
  const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
11
11
  pr_key: null,
12
12
  converge: 1,
13
13
  auto_merge: 1,
14
- escalation_question: null,
15
- escalation_user_task_key: null,
16
- blocked_user_task_key: null,
17
14
  ...over,
18
15
  });
19
16
 
@@ -71,10 +68,11 @@ test("skipped: the three converge/auto_merge cases", () => {
71
68
  assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
72
69
  });
73
70
 
74
- test("attention: blocked, escalation, none", () => {
75
- assertEquals(deriveStage(base({ status: "awaiting_operator", blocked_user_task_key: "ut-1" })).attention, "blocked");
76
- assertEquals(deriveStage(base({ status: "escalated", escalation_user_task_key: "ut-2" })).attention, "⚠");
77
- assertEquals(deriveStage(base({ status: "escalated", escalation_question: "which base?" })).attention, "");
71
+ test("attention: derives from status alone (blocked, escalation, none)", () => {
72
+ // Issue #332 dropped the denormalised escalation pointer/question columns; `attention` is now a pure
73
+ // function of `status` — `awaiting_operator` (a parked blocked run) → "blocked", `escalated` "⚠".
74
+ assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, "blocked");
75
+ assertEquals(deriveStage(base({ status: "escalated" })).attention, "⚠");
78
76
  assertEquals(deriveStage(base({ status: "running" })).attention, null);
79
77
  });
80
78
 
@@ -92,7 +90,7 @@ test("escalated WITHOUT pr_key → Implementing / null", () => {
92
90
  });
93
91
 
94
92
  test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
95
- const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, blocked_user_task_key: "ut-3" }));
93
+ const d = deriveStage(base({ status: "awaiting_operator", pr_key: null }));
96
94
  assertEquals(d.stage, "Implementing");
97
95
  assertEquals(d.state, null);
98
96
  assertEquals(d.attention, "blocked");
@@ -105,47 +103,3 @@ test("deriveListBucket: history iff terminal AND acknowledged, else active", ()
105
103
  assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
106
104
  assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
107
105
  });
108
-
109
- // deriveEscalationOpen (issue #272): the single fail-closed "open escalation" display signal. TRUE iff
110
- // all three independently-written escalation columns AGREE the run is parked at an answerable
111
- // escalation; any single missing/torn field yields FALSE so the pages render not-escalated.
112
- test("deriveEscalationOpen: true only when status, pointer AND question all present", () => {
113
- assertEquals(
114
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
115
- true,
116
- );
117
- });
118
-
119
- test("deriveEscalationOpen: torn tuple (pointer set, question blank) renders as NOT escalated", () => {
120
- // The mirror tear observed on nwf#270: status=escalated + live pointer + blank question.
121
- assertEquals(
122
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: null }),
123
- false,
124
- );
125
- assertEquals(
126
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "" }),
127
- false,
128
- );
129
- });
130
-
131
- test("deriveEscalationOpen: torn tuple (question set, pointer null) renders as NOT escalated", () => {
132
- // The entry-window tear: record-feature-escalation persisted the question but the poller has not yet
133
- // denormalised the pointer.
134
- assertEquals(
135
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: "which base?" }),
136
- false,
137
- );
138
- });
139
-
140
- test("deriveEscalationOpen: a resumed run whose status lags behind a cleared tuple is NOT escalated", () => {
141
- // status still 'escalated' but the answer op already cleared pointer + question → fail closed.
142
- assertEquals(
143
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: null }),
144
- false,
145
- );
146
- // A non-escalated status can never be open regardless of stray column values.
147
- assertEquals(
148
- deriveEscalationOpen({ status: "running", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
149
- false,
150
- );
151
- });
package/app/stage.ts CHANGED
@@ -43,9 +43,6 @@ export interface StageInput {
43
43
  pr_key?: string | null;
44
44
  converge?: number | boolean | null;
45
45
  auto_merge?: number | boolean | null;
46
- escalation_question?: string | null;
47
- escalation_user_task_key?: string | null;
48
- blocked_user_task_key?: string | null;
49
46
  }
50
47
 
51
48
  /** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
@@ -94,44 +91,15 @@ export function deriveStage(run: StageInput): DerivedStage {
94
91
 
95
92
  // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
96
93
  // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
97
- const attention = run.blocked_user_task_key
98
- ? "blocked"
99
- : run.escalation_user_task_key || run.escalation_question
100
- ? ""
101
- : null;
94
+ // Derived from `status` alone (issue #332): the parked-task pointers that used to source it were
95
+ // dropped with the denormalised escalation surface, and the authoritative "who is waiting on a human"
96
+ // list now lives on the `user_tasks` Tasks inbox. `awaiting_operator` (parked at `feature-blocked`)
97
+ // shows the blocked glyph; `escalated` (parked at `feature-escalation`) shows the badge.
98
+ const attention = status === "awaiting_operator" ? "blocked" : status === "escalated" ? "⚠" : null;
102
99
 
103
100
  return { stage, state, skipped: skippedKeys.join(" "), attention };
104
101
  }
105
102
 
106
- /** Derive the single fail-closed "open escalation" display signal for one feature run (issue #272).
107
- *
108
- * The open-escalation condition is jointly encoded by THREE independently-written columns —
109
- * `status='escalated'`, `escalation_user_task_key` (the completable pointer), and `escalation_question`
110
- * — owned by different writers on different schedules (the `record-feature-escalation` service task
111
- * sets the question; `pollFeatureEscalations`/`deriveFeatureEscalationPatch` sets status + pointer; the
112
- * answer operation clears the tuple). Because they are not written as one atomic tuple, a reader can
113
- * observe a TORN interim state (e.g. `status=escalated` + pointer set + `question=null`) and render a
114
- * self-contradictory escalation — an "answer me" affordance with nothing to answer.
115
- *
116
- * Collapse that class at the consumer: the pages gate the escalation affordances (Abandon / answer
117
- * form) on this ONE derived conjunction rather than on any single column, so a torn tuple renders as
118
- * NOT escalated (fail closed) instead of escalated-but-blank. `true` iff ALL THREE fields agree the run
119
- * is parked at an answerable escalation; any missing field yields `false`. Maintained as a write-time
120
- * projection by the feature_runs gateway (like `stage`/`list_bucket`), so it stays fresh on every write
121
- * — including the answer operation's eager tuple-clear, which makes the affordance disappear WITHOUT
122
- * waiting a poll pass. Pure and read-only. */
123
- export function deriveEscalationOpen(run: {
124
- status: string;
125
- escalation_question?: string | null;
126
- escalation_user_task_key?: string | null;
127
- }): boolean {
128
- return (
129
- run.status === "escalated" &&
130
- (run.escalation_user_task_key ?? "") !== "" &&
131
- (run.escalation_question ?? "") !== ""
132
- );
133
- }
134
-
135
103
  /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
136
104
  * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
137
105
  * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
@@ -2,7 +2,7 @@
2
2
  // one resolved open escalation user task into its desired `user_tasks` row (or null for a
3
3
  // non-escalation / blank key), and `reconcileUserTasks` diffs the desired open set against the
4
4
  // persisted rows into the minimal insert/update/delete plan the `pollUserTasks` reconcile applies.
5
- // These are the pure source of truth the poller projects, mirroring `deriveFeatureEscalationPatch`.
5
+ // These are the pure source of truth the poller projects.
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import type { PlanReview } from "./plan.ts";
package/app/userTasks.ts CHANGED
@@ -10,11 +10,11 @@
10
10
  // (app/service.ts) projects: `buildUserTaskRow` (one open task → a desired row) and
11
11
  // `reconcileUserTasks` (the desired set vs the persisted set → the minimal upserts + deletes). The
12
12
  // engine iteration + writes live in the poller; the decisions live here so they are unit-testable
13
- // without a host, mirroring `deriveFeatureEscalationPatch`.
13
+ // without a host.
14
14
  //
15
15
  // Completion is NOT owned here — the page posts the typed form variables to the ONE canonical human
16
- // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) / the existing feature answer &
17
- // acknowledge operations, the exact resume path the task inbox uses. This module only makes the open
16
+ // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) via the `complete-user-task` door,
17
+ // the exact resume path the task inbox uses. This module only makes the open
18
18
  // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";