@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.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/contracts.ts +18 -2
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/migration049.test.ts +112 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/service.test.ts +16 -0
- package/app/service.ts +107 -136
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/app/world/checkpoint.test.ts +193 -0
- package/app/world/checkpoint.ts +142 -0
- package/app/world/effect-ledger.test.ts +86 -0
- package/app/world/effect-ledger.ts +103 -0
- package/app/world/git.ts +53 -0
- package/app/world/index.ts +26 -0
- package/app/world/store.test.ts +443 -0
- package/app/world/store.ts +320 -0
- package/app/world-marker.test.ts +79 -0
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/db/migrations/049_world_checkpoint.sql +84 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/test/worldDb.ts +103 -0
- package/workers/persist-round/worker.ts +68 -0
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- package/operations/answerFeatureEscalation.ts +0 -68
package/app/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,
|
|
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
|
-
|
|
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 (
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
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 —
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
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
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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 {
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
assertEquals(deriveStage(base({ status: "
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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). */
|
package/app/userTasks.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
17
|
-
//
|
|
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";
|