@nanobpm/nano-workforce 0.57.0 → 0.58.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 (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -0,0 +1,72 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_spine_demo" targetNamespace="http://nanobpm.io/nano-workforce">
3
+ <bpmn:process id="spine-demo" name="User-task Spine Demo" isExecutable="true">
4
+ <bpmn:startEvent id="start">
5
+ <bpmn:outgoing>flow-to-decide</bpmn:outgoing>
6
+ </bpmn:startEvent>
7
+ <bpmn:userTask id="decide" name="Decide">
8
+ <bpmn:extensionElements>
9
+ <zeebe:formDefinition formId="spine-demo" />
10
+ <zeebe:userTask />
11
+ </bpmn:extensionElements>
12
+ <bpmn:incoming>flow-to-decide</bpmn:incoming>
13
+ <bpmn:outgoing>flow-to-gateway</bpmn:outgoing>
14
+ </bpmn:userTask>
15
+ <bpmn:exclusiveGateway id="gw-decision" name="decision?" default="flow-rejected">
16
+ <bpmn:incoming>flow-to-gateway</bpmn:incoming>
17
+ <bpmn:outgoing>flow-approved</bpmn:outgoing>
18
+ <bpmn:outgoing>flow-rejected</bpmn:outgoing>
19
+ </bpmn:exclusiveGateway>
20
+ <bpmn:endEvent id="end-approved">
21
+ <bpmn:incoming>flow-approved</bpmn:incoming>
22
+ </bpmn:endEvent>
23
+ <bpmn:endEvent id="end-rejected">
24
+ <bpmn:incoming>flow-rejected</bpmn:incoming>
25
+ </bpmn:endEvent>
26
+ <bpmn:sequenceFlow id="flow-to-decide" sourceRef="start" targetRef="decide" />
27
+ <bpmn:sequenceFlow id="flow-to-gateway" sourceRef="decide" targetRef="gw-decision" />
28
+ <bpmn:sequenceFlow id="flow-approved" sourceRef="gw-decision" targetRef="end-approved">
29
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=decision = "approve"</bpmn:conditionExpression>
30
+ </bpmn:sequenceFlow>
31
+ <bpmn:sequenceFlow id="flow-rejected" sourceRef="gw-decision" targetRef="end-rejected" />
32
+ </bpmn:process>
33
+ <bpmndi:BPMNDiagram id="BPMNDiagram_spine-demo">
34
+ <bpmndi:BPMNPlane id="BPMNPlane_spine-demo" bpmnElement="spine-demo">
35
+ <bpmndi:BPMNShape id="BPMNShape_start" bpmnElement="start">
36
+ <dc:Bounds x="80" y="102" width="36" height="36" />
37
+ </bpmndi:BPMNShape>
38
+ <bpmndi:BPMNShape id="BPMNShape_decide" bpmnElement="decide">
39
+ <dc:Bounds x="216" y="80" width="100" height="80" />
40
+ </bpmndi:BPMNShape>
41
+ <bpmndi:BPMNShape id="BPMNShape_gw-decision" bpmnElement="gw-decision" isMarkerVisible="true">
42
+ <dc:Bounds x="416" y="95" width="50" height="50" />
43
+ <bpmndi:BPMNLabel>
44
+ <dc:Bounds x="408" y="76" width="67" height="14" />
45
+ </bpmndi:BPMNLabel>
46
+ </bpmndi:BPMNShape>
47
+ <bpmndi:BPMNShape id="BPMNShape_end-approved" bpmnElement="end-approved">
48
+ <dc:Bounds x="566" y="262" width="36" height="36" />
49
+ </bpmndi:BPMNShape>
50
+ <bpmndi:BPMNShape id="BPMNShape_end-rejected" bpmnElement="end-rejected">
51
+ <dc:Bounds x="566" y="102" width="36" height="36" />
52
+ </bpmndi:BPMNShape>
53
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-to-decide" bpmnElement="flow-to-decide">
54
+ <di:waypoint x="116" y="120" />
55
+ <di:waypoint x="216" y="120" />
56
+ </bpmndi:BPMNEdge>
57
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-to-gateway" bpmnElement="flow-to-gateway">
58
+ <di:waypoint x="316" y="120" />
59
+ <di:waypoint x="416" y="120" />
60
+ </bpmndi:BPMNEdge>
61
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-rejected" bpmnElement="flow-rejected">
62
+ <di:waypoint x="466" y="120" />
63
+ <di:waypoint x="566" y="120" />
64
+ </bpmndi:BPMNEdge>
65
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-approved" bpmnElement="flow-approved">
66
+ <di:waypoint x="441" y="145" />
67
+ <di:waypoint x="441" y="280" />
68
+ <di:waypoint x="566" y="280" />
69
+ </bpmndi:BPMNEdge>
70
+ </bpmndi:BPMNPlane>
71
+ </bpmndi:BPMNDiagram>
72
+ </bpmn:definitions>
@@ -0,0 +1,68 @@
1
+ // check-migrations — merge-safety gate for the forward-only SQLite migrations under db/migrations/.
2
+ //
3
+ // Migrations are numbered by a leading 3-digit prefix and applied in prefix order (nano.app.json
4
+ // data.sources.app.migrations). AGENTS.md tells every author to "number a new migration after the
5
+ // current highest prefix" — but that number is computed independently in each worktree. When an
6
+ // epic fans a fleet out across parallel branches, two siblings both take "the next" free prefix
7
+ // and emit e.g. 023_agentic_presence.sql and 023_agentic_transcript.sql. Those filenames don't
8
+ // textually conflict, so git merges both cleanly and the collision is SILENT: two migrations now
9
+ // share an apply-order slot. This exact failure mode already scarred the repo (the historical
10
+ // 004/005/006/007 duplicate pairs) and recurred on epic #142 despite the doc note added by the
11
+ // #160 retro (PR #178) — so we escalate the lesson from a doc line to a mechanical gate.
12
+ //
13
+ // The rule: no two migration files may share a numeric prefix. The pre-existing historical
14
+ // duplicates are forward-only and already applied, so they cannot be renamed — they are
15
+ // grandfathered in GRANDFATHERED_DUPES. Any NEW duplicate prefix fails the build.
16
+ import { readdirSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db", "migrations");
21
+
22
+ // Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
23
+ // renumbered. New duplicates are NOT allowed here — fix them before merge.
24
+ const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007"]);
25
+
26
+ const PREFIX = /^(\d{3})_[^/]*\.sql$/;
27
+
28
+ function main(): void {
29
+ const files = readdirSync(MIGRATIONS_DIR)
30
+ .filter((f) => f.endsWith(".sql"))
31
+ .sort();
32
+
33
+ const errors: string[] = [];
34
+ const byPrefix = new Map<string, string[]>();
35
+
36
+ for (const file of files) {
37
+ const m = PREFIX.exec(file);
38
+ if (!m) {
39
+ errors.push(
40
+ ` ${file}: does not match the required NNN_name.sql shape (3-digit prefix, underscore, name).`,
41
+ );
42
+ continue;
43
+ }
44
+ const prefix = m[1];
45
+ const group = byPrefix.get(prefix) ?? [];
46
+ group.push(file);
47
+ byPrefix.set(prefix, group);
48
+ }
49
+
50
+ for (const [prefix, group] of byPrefix) {
51
+ if (group.length > 1 && !GRANDFATHERED_DUPES.has(prefix)) {
52
+ errors.push(
53
+ ` prefix ${prefix} is used by ${group.length} files: ${group.join(", ")} — ` +
54
+ `two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
55
+ `free prefix (check origin/main, not your branch point).`,
56
+ );
57
+ }
58
+ }
59
+
60
+ if (errors.length > 0) {
61
+ console.error(`check-migrations: db/migrations has colliding prefixes:\n${errors.join("\n")}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ console.log(`check-migrations: OK (${files.length} migrations, no colliding prefixes).`);
66
+ }
67
+
68
+ main();
@@ -0,0 +1,78 @@
1
+ // pr.answer-escalation — retires the review-loop escalation the operator just answered.
2
+ //
3
+ // The review-loop escalation is a native `wait-answer` userTask (backed by `pr-escalation.form`).
4
+ // Completing that task resumes the token, but the engine folds the completed instance's variables
5
+ // away — so without this step the durable `escalations` audit row raised by `pr.persist-escalation`
6
+ // would stay `status="open"` forever, its `answer`/`answered_at` never recorded. That both loses the
7
+ // Q&A audit trail and (because `activePrs` derives `openEscalation` from the open-row status — the
8
+ // single source of truth, ADR "derivation over duplication") would keep surfacing a phantom open
9
+ // escalation on `/status` after it was answered.
10
+ //
11
+ // This mirrors the merge-loop's message-catch answer path (`answerEscalation` in app/service.ts):
12
+ // both answer the newest still-open `escalations` row, mark any duplicate open rows `stale` (a
13
+ // retry of `pr.persist-escalation` can leave more than one open), AND move the `pull_requests` row
14
+ // off `status="escalated"` back to `"converging"`, so an answered escalation is never left dangling
15
+ // and `/status` never shows an escalated PR with a null question or a phantom open row. The token
16
+ // resume itself is owned by the engine (userTask completion), so this worker only reconciles the
17
+ // durable rows; it returns no variables, leaving the submitted `answer` untouched so it flows on to
18
+ // the next review round.
19
+ import type { AppJobHandler } from "@nanobpm/urban";
20
+
21
+ interface Escalation extends Record<string, unknown> {
22
+ id: number;
23
+ pr_key: string;
24
+ status: string;
25
+ answer: string | null;
26
+ answered_at: string | null;
27
+ }
28
+
29
+ // The PR-row fields this worker reconciles when an escalation is answered. Only `status`/`updated_at`
30
+ // are written (mirroring `answerEscalation`); the rest of the row is untouched.
31
+ interface PullRequest extends Record<string, unknown> {
32
+ pr_key: string;
33
+ status: string;
34
+ updated_at: string;
35
+ }
36
+
37
+ interface In extends Record<string, unknown> {
38
+ prKey: string;
39
+ answer?: string;
40
+ }
41
+
42
+ // A string variable, or undefined when it is absent/blank. The form marks `answer` required, so a
43
+ // blank here would be an out-of-band completion; record it as NULL rather than an empty string.
44
+ function nonBlank(v: unknown): string | undefined {
45
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
46
+ }
47
+
48
+ const handler: AppJobHandler<In> = async (job, app) => {
49
+ const { prKey } = job.variables;
50
+ const answer = nonBlank(job.variables.answer);
51
+ const escs = app.data.table<Escalation>("escalations", "id");
52
+ // Retire EVERY still-open escalation for this PR. `pr.persist-escalation` always INSERTs a new
53
+ // open row, so a retry/duplicate activation can leave more than one open — answering only the
54
+ // newest would leave an older duplicate `open`, a phantom `activePrs` keeps deriving while the PR
55
+ // is still `escalated`. Answer the newest (it carries the operator's reply) and mark any remaining
56
+ // open rows `stale`, mirroring `answerEscalation` and `submitPr`'s resubmit cleanup.
57
+ const open = (await escs.find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
58
+ if (open.length > 0) {
59
+ const ts = new Date().toISOString();
60
+ await escs.update(open[0].id, {
61
+ answer: answer ?? null,
62
+ status: "answered",
63
+ answered_at: ts,
64
+ });
65
+ for (const dup of open.slice(1)) {
66
+ await escs.update(dup.id, { status: "stale" });
67
+ }
68
+ // Mirror the merge loop's `answerEscalation`: move the PR off `status="escalated"` back to
69
+ // `"converging"` now that the question is answered. Without this the row stays `escalated` (with
70
+ // a now-null derived `openEscalation`) until the re-entered round's `persist-round` runs — a
71
+ // `/status` inconsistency and a divergence from the merge path both loops are meant to share.
72
+ const prs = app.data.table<PullRequest>("pull_requests", "pr_key");
73
+ await prs.update(prKey, { status: "converging", updated_at: ts });
74
+ }
75
+ return {};
76
+ };
77
+
78
+ export default handler;
@@ -0,0 +1,51 @@
1
+ // pr.converge-feature — hand a single-issue run's opened PR to the convergence loop (issue #172).
2
+ //
3
+ // Reached only via the `converge?` gateway's true branch (converge requested AND the agent opened a
4
+ // keyed PR). Reuses `submitPr` — the SAME enrollment the epic waves and the standalone
5
+ // `startConvergenceLoop` action use — so a feature run's PR gets identical review-round + merge
6
+ // behaviour with no duplicated machinery. `autoMerge` maps to `submitPr`'s `convergeOnly`
7
+ // (inverted): auto-merge → drive the merge-loop; otherwise stop at `converged`.
8
+ //
9
+ // The feature-run's OWN process ends here; the PR's live convergence/merge state lives on the
10
+ // `pull_requests` row keyed by `pr_key`, which the feature page links to. We therefore leave the
11
+ // `feature_runs` row in the terminal `converging` status (NOT an active status), so the
12
+ // instanceTracking reconciler does not mark it `abandoned` when feature.bpmn completes.
13
+ import type { AppJobHandler } from "@nanobpm/urban";
14
+ import { featureRuns } from "../../app/feature.ts";
15
+ import { MAX_ROUNDS, parsePr, submitPr } from "../../app/service.ts";
16
+
17
+ interface In extends Record<string, unknown> {
18
+ featureKey: string;
19
+ prKey?: unknown;
20
+ autoMerge?: unknown;
21
+ }
22
+
23
+ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
24
+ const featureKey = job.variables.featureKey;
25
+ const prKey = typeof job.variables.prKey === "string" ? job.variables.prKey.trim() : "";
26
+ const autoMerge = job.variables.autoMerge === true;
27
+ const parsed = prKey ? parsePr(prKey) : null;
28
+ if (!parsed) {
29
+ // Should not happen — the gateway only routes here when record-feature emitted a parseable
30
+ // prKey — but never enroll a phantom PR. Leave the row `opened` and end.
31
+ app.log.warn("converge-feature: no parseable PR key, skipping hand-off", { featureKey, prKey });
32
+ await featureRuns(app.data).update(featureKey, { status: "opened", updated_at: new Date().toISOString() });
33
+ return {};
34
+ }
35
+ // `convergeOnly` is the inverse of auto-merge: converge-only stops at `converged`; auto-merge lets
36
+ // the merge-loop drive the merge. `submitPr` is idempotent on the PR key.
37
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge);
38
+ await featureRuns(app.data).update(featureKey, {
39
+ status: "converging",
40
+ pr_key: parsed.prKey,
41
+ updated_at: new Date().toISOString(),
42
+ });
43
+ app.log.info("converge-feature: enrolled PR into convergence loop", {
44
+ featureKey,
45
+ prKey: parsed.prKey,
46
+ convergeOnly: !autoMerge,
47
+ });
48
+ return {};
49
+ };
50
+
51
+ export default handler;
@@ -101,8 +101,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
101
101
  outcome: summary,
102
102
  converged_at: now,
103
103
  updated_at: now,
104
- open_escalation_id: null,
105
- open_escalation_question: null,
106
104
  });
107
105
 
108
106
  // Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
@@ -13,8 +13,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
13
13
  status: "merged",
14
14
  merged_at: now,
15
15
  updated_at: now,
16
- open_escalation_id: null,
17
- open_escalation_question: null,
18
16
  });
19
17
 
20
18
  // If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
@@ -11,9 +11,9 @@
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
12
  import { matchTags, tag } from "@nanobpm/urban/effect";
13
13
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
14
- import { checkBaseTarget } from "../../app/baseGuard.ts";
14
+ import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
15
15
  import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
16
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
+ import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
17
17
  import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
18
18
 
19
19
  interface In extends Record<string, unknown> {
@@ -77,7 +77,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
77
77
  // base (typical in a stacked epic) stays the target and reads CLEAN, so nothing else catches it.
78
78
  // Best-effort: a transport hiccup leaves `deadEnd:false`, so this never blocks a valid merge.
79
79
  const guard = await checkBaseTarget(repo, prNumber, token).catch(() => null);
80
- if (guard?.deadEnd) {
80
+ if (guard && classifyBaseGuard(guard) === "decision-required") {
81
81
  await app.data.table("merges", "id").insert({
82
82
  pr_key: prKey,
83
83
  outcome: "blocked",
@@ -112,8 +112,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
112
112
  outcome = ok ? "queued" : "blocked";
113
113
  detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
114
114
  auditMethod = "queue-comment";
115
- } else if (method === "ui") {
116
- // The repo requires a human to click Merge; Merlin can't. Escalate rather than pretend.
115
+ } else if (classifyMergeLanding(protocol ?? DEFAULT_MERGE_PROTOCOL) === "decision-required") {
116
+ // The repo requires a human to click Merge (`land.method=ui`); Merlin can't. This is the
117
+ // only decision-required land method — escalate rather than pretend.
117
118
  outcome = "blocked";
118
119
  detail = "repo merge protocol requires a manual UI merge (land.method=ui)";
119
120
  auditMethod = "ui";
@@ -2,8 +2,20 @@
2
2
  // row for a human to answer. Handles both the agent-raised path (status = needs_input | blocked)
3
3
  // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
4
  // `escalationId` for the UI.
5
+ //
6
+ // A BLANK question is a NON-escalation (nano-workforce ADR 0002 §1): it must never open an
7
+ // answerable escalation. The convergence-loop `gw-status` gateway enforces this upstream — its
8
+ // `f_escalate` arm now requires a non-blank question, so a blank-question round re-enters the
9
+ // durable review wait instead of routing here (see app/roundResultDefault.ts). This worker keeps
10
+ // the same rule as defence-in-depth via the canonical taxonomy: if a blank-question job ever
11
+ // reaches it, it opens NO escalation and reports `escalated:false` rather than FABRICATING a
12
+ // question (the retired failure mode) or throwing an un-remediable incident. The convergence-loop
13
+ // model branches on that `escalated` output (`gw-escalated`): a `false` return re-enters the loop
14
+ // via `gw-guard` instead of flowing into the `wait-answer` catch, so a non-escalation can never
15
+ // wedge a token on a durable wait that has no escalation for a human to answer.
5
16
  import type { AppJobHandler } from "@nanobpm/urban";
6
17
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
18
+ import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
7
19
  import { ensurePr, parsePr } from "../../app/service.ts";
8
20
 
9
21
  // Extends Record so the declared fields are typed while the job may still carry
@@ -51,42 +63,28 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
51
63
  return nonBlank(vars.agent);
52
64
  }
53
65
 
54
- // Synthesize a concrete, answerable question when the agent left one blank. A blank question is
55
- // almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
56
- // `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
57
- // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). Throwing here parked a
58
- // `JobNoRetries` incident that could NOT be diagnosed or remediated from the UI. Instead we open
59
- // an escalation a human can actually answer, with the agent's transcript attached below it.
60
- function fabricateQuestion(rawStatus: string | undefined, hasTranscript: boolean): string {
61
- const tail = hasTranscript
62
- ? " Review the agent's response shown below, then reply with how it should proceed — or cancel and resubmit."
63
- : " No agent response was captured. Reply with how it should proceed, or cancel and resubmit.";
64
- if (!rawStatus) {
65
- return "The review agent finished without a machine-readable result (no status was reported), " +
66
- "so this round could not be classified as converged, addressed, or a specific request." + tail;
67
- }
68
- return `The review agent reported status "${rawStatus}" without a question, so this round ` +
69
- "could not be resolved automatically." + tail;
70
- }
71
-
72
66
  const handler: AppJobHandler<In> = async (job, app) => {
73
67
  const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
74
68
  // `status` drives the escalation kind (control flow); a blank/absent status is an
75
- // unclassified escalation -> a question needing input. `question` is denormalised
76
- // onto pull_requests below and bound by the UI answer form, so it must be a
77
- // concrete, non-blank value. `summary` is left undefined so the write boundary
78
- // omits it and the nullable column stays NULL.
69
+ // unclassified escalation -> a question needing input. `question` is returned as a
70
+ // process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
71
+ // can display it, so it must be a concrete, non-blank value. `summary` is left undefined
72
+ // so the write boundary omits it and the nullable column stays NULL.
79
73
  const rawStatus = nonBlank(job.variables.status);
80
74
  const status = rawStatus ?? "needs_input";
81
75
  const transcript = transcriptOf(job.variables);
82
76
  const worker = workerOf(job.variables);
83
- // A blank question must never open an unanswerable escalation. Every legitimate arm sets a
84
- // concrete question the agent contract requires one for needs_input/blocked, and the
85
- // max-rounds + review-timeout arms set a literal via the model. When one is still missing
86
- // (a no-result round through the `gw-status` default), fabricate an actionable question that
87
- // references the attached transcript rather than throwing (which parked an un-remediable
88
- // incident). This keeps the loop recoverable entirely from the UI.
89
- const question = nonBlank(job.variables.question) ?? fabricateQuestion(rawStatus, transcript != null);
77
+ // Classify this job through the single canonical taxonomy (ADR 0002 §1). Only a
78
+ // decision-required escalation opens an answerable escalation row. A blank question (or any
79
+ // non-human-blocking status that reached here defensively) is a NON-escalation: open nothing
80
+ // and report `escalated:false` — never FABRICATE a question and never throw. The `gw-status`
81
+ // gateway already routes blank-question rounds back into the durable review wait, so this is
82
+ // defence-in-depth, not the primary guard.
83
+ const question = nonBlank(job.variables.question);
84
+ const disposition = classifyEscalation({ kind: "review-round", status: rawStatus, question });
85
+ if (disposition !== "decision-required" || question === undefined) {
86
+ return { escalationId: null, escalated: false };
87
+ }
90
88
  const kind = status === "needs_input" ? "question" : "blocker";
91
89
  const now = new Date().toISOString();
92
90
 
@@ -137,11 +135,9 @@ const handler: AppJobHandler<In> = async (job, app) => {
137
135
  status: "escalated",
138
136
  current_round: round,
139
137
  updated_at: now,
140
- open_escalation_id: Number(escalationId),
141
- open_escalation_question: question,
142
138
  });
143
139
 
144
- return { escalationId: Number(escalationId) };
140
+ return { escalationId: Number(escalationId), escalated: true, question };
145
141
  };
146
142
 
147
143
  export default handler;
@@ -0,0 +1,61 @@
1
+ // pr.record-feature — the single-issue `implement` block has finished (issue #172).
2
+ //
3
+ // The `senior:feature` agent reported one of `opened` / `blocked` / `skipped` (prompts/feature.md);
4
+ // anything else — including a missing status, or an `escalated` status that fell through to abandon
5
+ // (the human abandoned or the SLA fired) — is treated as `blocked`: we must not assume a PR was
6
+ // opened. This worker:
7
+ // • resolves the run's terminal status from the agent result,
8
+ // • records the PR key + outcome on the `feature_runs` row,
9
+ // • emits `featureStatus` + `prKey` so the `converge?` gateway can decide whether to hand the
10
+ // opened PR off to the convergence loop.
11
+ //
12
+ // Enrollment itself lives in the separate `pr.converge-feature` worker (gated by the `converge?`
13
+ // branch) so the hand-off is legible in the process, not buried in a persistence step.
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { type FeatureRunStatus, featureRuns } from "../../app/feature.ts";
16
+ import { parsePr } from "../../app/service.ts";
17
+
18
+ interface In extends Record<string, unknown> {
19
+ featureKey: string;
20
+ status?: unknown;
21
+ pr?: unknown;
22
+ summary?: unknown;
23
+ }
24
+ interface Out extends Record<string, unknown> {
25
+ featureStatus: FeatureRunStatus;
26
+ prKey: string | null;
27
+ }
28
+
29
+ const str = (v: unknown): string | undefined =>
30
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
31
+
32
+ // The implementation agent reports one of these; anything else is `blocked` (we never assume a PR).
33
+ type AgentStatus = "opened" | "blocked" | "skipped";
34
+ const isAgentStatus = (s: string): s is AgentStatus => s === "opened" || s === "blocked" || s === "skipped";
35
+
36
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
37
+ const featureKey = job.variables.featureKey;
38
+ const rawStatus = str(job.variables.status);
39
+ const status: AgentStatus = rawStatus && isAgentStatus(rawStatus) ? rawStatus : "blocked";
40
+ const summary = str(job.variables.summary);
41
+ const prRef = str(job.variables.pr);
42
+ // Only trust a PR ref when the agent reports it actually opened one.
43
+ const parsed = status === "opened" && prRef ? parsePr(prRef) : null;
44
+ // A keyless "opened" cannot be handed off, but a PR was still raised — keep the status `opened`
45
+ // (the run is complete), just with no `pr_key` to converge.
46
+ const prKey = parsed?.prKey ?? null;
47
+ const featureStatus: FeatureRunStatus = status;
48
+ const ts = new Date().toISOString();
49
+
50
+ await featureRuns(app.data).update(featureKey, {
51
+ status: featureStatus,
52
+ pr_key: prKey,
53
+ outcome: summary ?? null,
54
+ updated_at: ts,
55
+ });
56
+ app.log.info("record-feature", { featureKey, featureStatus, prKey });
57
+
58
+ return { featureStatus, prKey };
59
+ };
60
+
61
+ export default handler;
@@ -9,9 +9,8 @@ import { noopLog } from "../../test/log.ts";
9
9
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
10
10
  import handler from "./worker.ts";
11
11
 
12
- function fakeApp(existing: PlanReview[] = [], reviewEscalations: any[] = []) {
12
+ function fakeApp(existing: PlanReview[] = []) {
13
13
  const reviewRows: PlanReview[] = [...existing];
14
- const escalationRows = [...reviewEscalations];
15
14
  const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
16
15
  Object.entries(q).every(([f, v]) => r[f] === v);
17
16
  const table = (rows: any[]) => ({
@@ -25,14 +24,12 @@ function fakeApp(existing: PlanReview[] = [], reviewEscalations: any[] = []) {
25
24
  });
26
25
  return {
27
26
  data: {
28
- table(name: string) {
29
- if (name === "plan_review_escalations") return table(escalationRows);
27
+ table() {
30
28
  return table(reviewRows);
31
29
  },
32
30
  },
33
31
  log: noopLog(),
34
32
  _rows: reviewRows,
35
- _escalations: escalationRows,
36
33
  } as any;
37
34
  }
38
35
 
@@ -85,12 +82,14 @@ test("approved on the FINAL round still proceeds (no escalation)", async () => {
85
82
  assertEquals((out as any).planEscalated, false);
86
83
  });
87
84
 
88
- test("answered plan-review escalation starts a fresh epoch and round budget", async () => {
89
- const app = fakeApp(
90
- priorRounds("o/r#5", MAX_PLAN_REVIEW_ROUNDS, 0),
91
- [{ id: 1, plan_key: "o/r#5", status: "answered" }],
85
+ test("a bumped plan-review epoch starts a fresh round budget", async () => {
86
+ // The `plan-review-decision` user task bumped `planReviewEpoch` to 1 after a prior epoch's
87
+ // rounds; the next planner pass records round 0 of the new epoch, resetting the budget.
88
+ const app = fakeApp(priorRounds("o/r#5", MAX_PLAN_REVIEW_ROUNDS, 0));
89
+ const out = await call(
90
+ app,
91
+ { planKey: "o/r#5", approved: false, findings: "new epoch finding", planReviewEpoch: 1 },
92
92
  );
93
- const out = await call(app, { planKey: "o/r#5", approved: false, findings: "new epoch finding" });
94
93
  assertEquals((out as any).planApproved, false);
95
94
  assertEquals((out as any).planEscalated, false);
96
95
  assertEquals((out as any).planReviewEpoch, 1);
@@ -3,9 +3,10 @@
3
3
  //
4
4
  // The `senior:plan-review` agent critiqued the levelized plan and emitted `{ approved, findings }`.
5
5
  // This worker:
6
- // • derives the current epoch from answered plan-review escalations and the current round from
7
- // the append-only `plan_reviews` log for that epoch (no counter variable), using the engine
8
- // jobKey as an idempotency guard so a retried job reuses its row,
6
+ // • reads the current epoch from the durable `planReviewEpoch` process variable (bumped by the
7
+ // `plan-review-decision` user task each time a human answers a plan-review escalation) and
8
+ // derives the current round from the append-only `plan_reviews` log for that epoch (no counter
9
+ // variable), using the engine jobKey as an idempotency guard so a retried job reuses its row,
9
10
  // • records this round's verdict + findings,
10
11
  // • decides the loop: emits `planApproved` (reviewer said yes → the BPMN gateway proceeds to
11
12
  // `select-wave`) or, when unapproved, re-emits the findings as `planFindings` so a revise
@@ -19,7 +20,6 @@
19
20
 
20
21
  import type { AppJobHandler } from "@nanobpm/urban";
21
22
  import {
22
- currentPlanReviewEpoch,
23
23
  MAX_PLAN_REVIEW_ROUNDS,
24
24
  type PlanReview,
25
25
  planReviews,
@@ -29,6 +29,7 @@ interface In extends Record<string, unknown> {
29
29
  planKey: string;
30
30
  approved?: unknown;
31
31
  findings?: unknown;
32
+ planReviewEpoch?: unknown;
32
33
  }
33
34
  interface Out extends Record<string, unknown> {
34
35
  planApproved: boolean;
@@ -43,6 +44,15 @@ interface Out extends Record<string, unknown> {
43
44
  const isApproved = (v: unknown): boolean =>
44
45
  v === true || (typeof v === "string" && v.trim().toLowerCase() === "true");
45
46
 
47
+ // The plan-review epoch is a durable process variable. `startPlan` seeds it to 0 when creating the
48
+ // instance, and the `plan-review-decision` user task bumps it on each human answer. The 0-fallback
49
+ // here is backwards compatibility for older instances created before the variable was seeded (where
50
+ // it is null/absent): coerce anything unexpected to 0 so a round is always recorded under a valid epoch.
51
+ const coerceEpoch = (v: unknown): number => {
52
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : Number.NaN;
53
+ return Number.isInteger(n) && n >= 0 ? n : 0;
54
+ };
55
+
46
56
  export const str = (v: unknown): string => {
47
57
  if (typeof v === "string") return v;
48
58
  if (v == null) return "";
@@ -63,7 +73,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
63
73
  const jobKey = job.jobKey;
64
74
 
65
75
  const reviews = planReviews(app.data);
66
- const epoch = await currentPlanReviewEpoch(app.data, planKey);
76
+ const epoch = coerceEpoch(job.variables.planReviewEpoch);
67
77
 
68
78
  // Idempotency guard: deriving the epoch/round from count(plan_reviews) is not retry-safe on its
69
79
  // own. A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey —