@nanobpm/nano-workforce 0.26.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 (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,193 @@
1
+ // Per-repo merge protocol (issue #43).
2
+ //
3
+ // Merlin's merge stage was repo-blind: it ran a plain `gh pr merge` and, on refusal, escalated to
4
+ // a human. That breaks on any repo with a frugal-CI + on-demand-queue posture — e.g.
5
+ // `Magikcraft/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
6
+ // NOT re-run it), and the documented way to land is: produce a fresh head run (`gh pr ready` /
7
+ // close+reopen) → wait for it to go green → `@mergifyio queue`. See that repo's
8
+ // `AGENTS.md → ## Merging PRs`.
9
+ //
10
+ // This module lets a target repo PUBLISH its landing protocol in a form Merlin can execute
11
+ // deterministically. Discovery order (first hit wins):
12
+ // 1. a fenced ```merge-protocol JSON block inside the repo's `AGENTS.md` (preferred — the human
13
+ // doc and the machine descriptor live together and can't drift), else
14
+ // 2. `.github/merge-protocol.json`.
15
+ // A repo that publishes neither keeps today's behaviour (DEFAULT_MERGE_PROTOCOL).
16
+
17
+ import { fetchRepoFile } from "./github.ts";
18
+
19
+ /** How to give branch protection a fresh head `pull_request` run before landing. `none` = the
20
+ * repo re-runs CI on every push, so no synthetic run is needed. `ready` = mark a draft ready
21
+ * (`gh pr ready`). `reopen` = close+reopen. `ready-or-reopen` = ready when the PR is a draft,
22
+ * otherwise reopen. */
23
+ export type FreshHeadRun = "none" | "ready" | "reopen" | "ready-or-reopen";
24
+
25
+ /** How to actually land the PR once its head checks are green. `gh-merge` = `gh pr merge`.
26
+ * `admin` = `gh pr merge --admin` (bypass required checks). `mergify-queue` = post the enqueue
27
+ * comment (`land.comment`, default `@mergifyio queue`) and wait for the queue to land it.
28
+ * `ui` = a human clicks Merge (Merlin can't do it → escalate). */
29
+ export type LandMethod = "gh-merge" | "admin" | "mergify-queue" | "ui";
30
+
31
+ export interface MergeProtocol {
32
+ /** Does the repo auto-merge a PR once its checks go green? (Informational; Merlin never relies
33
+ * on auto-merge — it lands deliberately.) */
34
+ autoMerge: boolean;
35
+ /** Whether/how to produce a fresh head CI run before landing. */
36
+ freshHeadRun: FreshHeadRun;
37
+ /** Wait for the head run to go green before landing (the poller does this anyway). */
38
+ waitForChecks: boolean;
39
+ /** How to land the PR. */
40
+ land: { method: LandMethod; comment?: string };
41
+ /** Names of the required checks (informational; the poller reads live state). */
42
+ requiredChecks: string[];
43
+ /** Pointer to the human doc, for escalation messages. */
44
+ doc?: string;
45
+ }
46
+
47
+ /** Absent-descriptor behaviour = exactly what Merlin did before #43: no synthetic head run, a
48
+ * direct `gh pr merge`. Opting in is purely additive. */
49
+ export const DEFAULT_MERGE_PROTOCOL: MergeProtocol = {
50
+ autoMerge: true,
51
+ freshHeadRun: "none",
52
+ waitForChecks: false,
53
+ land: { method: "gh-merge" },
54
+ requiredChecks: [],
55
+ };
56
+
57
+ const FRESH_HEAD_RUNS: ReadonlySet<string> = new Set(["none", "ready", "reopen", "ready-or-reopen"]);
58
+ const LAND_METHODS: ReadonlySet<string> = new Set(["gh-merge", "admin", "mergify-queue", "ui"]);
59
+
60
+ function isRecord(v: unknown): v is Record<string, unknown> {
61
+ return typeof v === "object" && v !== null && !Array.isArray(v);
62
+ }
63
+ function str(v: unknown): string | undefined {
64
+ return typeof v === "string" ? v : undefined;
65
+ }
66
+ function bool(v: unknown): boolean | undefined {
67
+ return typeof v === "boolean" ? v : undefined;
68
+ }
69
+ function strArray(v: unknown): string[] | undefined {
70
+ if (!Array.isArray(v)) return undefined;
71
+ return v.filter((x): x is string => typeof x === "string");
72
+ }
73
+ function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
74
+ const s = str(v);
75
+ return s !== undefined && allowed.has(s) ? (s as T) : undefined;
76
+ }
77
+
78
+ /** Narrow arbitrary parsed JSON onto a MergeProtocol, defaulting every missing/invalid field.
79
+ * Never throws — an invalid descriptor degrades to the default rather than wedging the merge. */
80
+ export function parseMergeProtocol(raw: unknown): MergeProtocol {
81
+ if (!isRecord(raw)) return { ...DEFAULT_MERGE_PROTOCOL };
82
+ const landRaw = isRecord(raw.land) ? raw.land : {};
83
+ const method = oneOf<LandMethod>(landRaw.method, LAND_METHODS) ?? DEFAULT_MERGE_PROTOCOL.land.method;
84
+ const comment = str(landRaw.comment);
85
+ return {
86
+ autoMerge: bool(raw.autoMerge) ?? DEFAULT_MERGE_PROTOCOL.autoMerge,
87
+ freshHeadRun: oneOf<FreshHeadRun>(raw.freshHeadRun, FRESH_HEAD_RUNS) ?? DEFAULT_MERGE_PROTOCOL.freshHeadRun,
88
+ waitForChecks: bool(raw.waitForChecks) ?? DEFAULT_MERGE_PROTOCOL.waitForChecks,
89
+ land: comment !== undefined ? { method, comment } : { method },
90
+ requiredChecks: strArray(raw.requiredChecks) ?? [],
91
+ doc: str(raw.doc),
92
+ };
93
+ }
94
+
95
+ /** Extract the JSON body of the first ```merge-protocol fenced block in a markdown document, or
96
+ * `null` if there is none. The info-string may carry a second word (```merge-protocol json). */
97
+ export function extractProtocolBlock(markdown: string): string | null {
98
+ const m = markdown.match(/```merge-protocol(?:\s+\w+)?[ \t]*\r?\n([\s\S]*?)\r?\n```/);
99
+ return m ? m[1] : null;
100
+ }
101
+
102
+ /** Strip `//` line comments so a lightly-commented (JSONC-ish) descriptor still parses. Only
103
+ * whole-line comments are removed, to avoid mangling `//` inside a string value. */
104
+ function stripLineComments(text: string): string {
105
+ return text
106
+ .split("\n")
107
+ .filter((l) => !/^\s*\/\//.test(l))
108
+ .join("\n");
109
+ }
110
+
111
+ function parseJsonLoose(text: string): unknown {
112
+ try {
113
+ return JSON.parse(stripLineComments(text));
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ // The descriptor changes rarely; cache per repo so a busy merge poller doesn't re-fetch it every
120
+ // pass. TTL keeps a mid-flight edit from being ignored for long.
121
+ const CACHE_TTL_MS = 5 * 60_000;
122
+ const cache = new Map<string, { at: number; protocol: MergeProtocol }>();
123
+
124
+ /** Clear the in-memory descriptor cache (tests). */
125
+ export function _clearMergeProtocolCache(): void {
126
+ cache.clear();
127
+ }
128
+
129
+ /** Load a repo's merge protocol (AGENTS.md block first, then `.github/merge-protocol.json`).
130
+ * Best-effort: any fetch/parse failure yields DEFAULT_MERGE_PROTOCOL, so a merge never wedges on
131
+ * a missing or malformed descriptor. Results are cached per repo for a few minutes. */
132
+ export async function loadMergeProtocol(repo: string, token: string): Promise<MergeProtocol> {
133
+ const hit = cache.get(repo);
134
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.protocol;
135
+
136
+ let protocol = DEFAULT_MERGE_PROTOCOL;
137
+ try {
138
+ const agents = await fetchRepoFile(repo, "AGENTS.md", token).catch(() => null);
139
+ const block = agents ? extractProtocolBlock(agents) : null;
140
+ if (block !== null) {
141
+ protocol = parseMergeProtocol(parseJsonLoose(block));
142
+ } else {
143
+ const json = await fetchRepoFile(repo, ".github/merge-protocol.json", token).catch(() => null);
144
+ if (json !== null) protocol = parseMergeProtocol(parseJsonLoose(json));
145
+ }
146
+ } catch {
147
+ protocol = DEFAULT_MERGE_PROTOCOL;
148
+ }
149
+
150
+ cache.set(repo, { at: Date.now(), protocol });
151
+ return protocol;
152
+ }
153
+
154
+ export interface FreshHeadRunAttempt {
155
+ /** Current PR head commit. A rebase produces a new value, which starts a new landing attempt. */
156
+ headRefOid?: string | null;
157
+ /** Head commit for which this landing attempt already produced a synthetic run. */
158
+ lastActionHeadRefOid?: string | null;
159
+ }
160
+
161
+ /** Whether the merge poller should produce a synthetic fresh head run *now*, and how.
162
+ *
163
+ * Fires only when the protocol asks for a fresh run AND the PR currently has **no head check run
164
+ * at all** (`totalChecks === 0`) while GitHub still reports it un-landable-but-not-conflicting
165
+ * (`waiting`). That is exactly the frugal-CI stuck state: review converged, the last push produced
166
+ * no run, so branch protection's required checks read as *expected* forever. Once a run exists for
167
+ * the current head (`totalChecks > 0`, pending or done), or this same head already got its nudge,
168
+ * this returns `null`, so the poller never re-triggers inside one landing attempt. A rebase changes
169
+ * `headRefOid`, so the decision is re-derived and can fire again for the fresh post-rebase head.
170
+ * A genuinely-failing check (`blocked`) is left to the fix-ci arm, a conflict (`conflict`) to the
171
+ * rebase arm (#42). */
172
+ export function freshHeadRunAction(
173
+ protocol: MergeProtocol,
174
+ verdict: "ready" | "waiting" | "conflict" | "blocked",
175
+ totalChecks: number,
176
+ isDraft: boolean,
177
+ attempt: FreshHeadRunAttempt = {},
178
+ ): "ready" | "reopen" | null {
179
+ if (protocol.freshHeadRun === "none") return null;
180
+ if (verdict !== "waiting") return null; // ready = go land; blocked/conflict = other arms
181
+ if (totalChecks !== 0) return null; // a run already exists (or unknown in token mode) → wait
182
+ if (attempt.headRefOid && attempt.headRefOid === attempt.lastActionHeadRefOid) return null;
183
+ switch (protocol.freshHeadRun) {
184
+ case "ready":
185
+ return isDraft ? "ready" : null;
186
+ case "reopen":
187
+ return "reopen";
188
+ case "ready-or-reopen":
189
+ return isDraft ? "ready" : "reopen";
190
+ default:
191
+ return null;
192
+ }
193
+ }
@@ -0,0 +1,72 @@
1
+ // Structural regression guard for the merge-loop rebase remediation arm (issue #42).
2
+ //
3
+ // #42: the conflict arm routed a human's escalation answer straight back to `arm-merge`
4
+ // (`f_m_answer → arm-merge`) with NO actor that rebases the branch, so a `CONFLICTING`
5
+ // (moved-base) PR re-escalated forever — a human-in-the-loop livelock. The fix mirrors the
6
+ // CI-fix arm: a `mergeState = "conflict"` verdict goes to a budgeted `senior:rebase` agent, and
7
+ // escalates to a human only on the result gate.
8
+ //
9
+ // This test asserts the arm's topology on the committed model so it cannot regress silently the
10
+ // way it originally shipped. It is a pure text assertion over the BPMN (no engine), matching the
11
+ // repo's lightweight model-guard style.
12
+
13
+ import { assert, assertStringIncludes } from "jsr:@std/assert@1";
14
+
15
+ const bpmn = await Deno.readTextFile("resources/processes/merge-loop.bpmn");
16
+
17
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
18
+ const flat = bpmn.replace(/\s+/g, " ");
19
+
20
+ // A `<sequenceFlow>` whose source/target match, regardless of attribute order or an inline
21
+ // conditionExpression child. Returns true if such a flow is present.
22
+ function hasFlow(source: string, target: string): boolean {
23
+ const re = new RegExp(
24
+ `<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
25
+ `<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
26
+ );
27
+ return re.test(flat);
28
+ }
29
+
30
+ Deno.test("conflict routes to the rebase budget gate, not straight to a human", () => {
31
+ // The conflict verdict must reach the auto-rebase gate…
32
+ assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
33
+ // …guarded by the exact conflict condition (DIRTY → mergeState = "conflict").
34
+ assertStringIncludes(flat, 'mergeState = "conflict"');
35
+ });
36
+
37
+ Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
38
+ // Budget gate: within budget → the agent; exhausted → the (existing) conflict escalation.
39
+ assert(hasFlow("gw-rebase", "rebase"), "gw-rebase → rebase (within budget) missing");
40
+ assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
41
+ assertStringIncludes(flat, "rebaseRound &lt; rebaseMax");
42
+
43
+ // The agent is the senior:rebase fleet task, carrying its base prompt via the {{rebase}} header.
44
+ assertStringIncludes(flat, 'type="senior:rebase"');
45
+ assertStringIncludes(flat, 'value="{{rebase}}"');
46
+
47
+ // Agent → result gate; the round counter advances so the budget can actually be exhausted.
48
+ assert(hasFlow("rebase", "gw-rebase-result"), "rebase → gw-rebase-result missing");
49
+ assertStringIncludes(flat, "=rebaseRound + 1");
50
+ });
51
+
52
+ Deno.test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
53
+ // Success loops back to re-attempt the merge — forward progress, no human needed.
54
+ assert(hasFlow("gw-rebase-result", "arm-merge"), "gw-rebase-result → arm-merge (rebased) missing");
55
+ assertStringIncludes(flat, 'status = "rebased"');
56
+ // A genuine (semantic) conflict the agent can't resolve escalates to the human attempt path.
57
+ assert(
58
+ hasFlow("gw-rebase-result", "merge-esc-attempt"),
59
+ "gw-rebase-result → merge-esc-attempt (could not resolve) missing",
60
+ );
61
+ });
62
+
63
+ Deno.test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
64
+ // The original #42 livelock had the conflict verdict escalate to a human with no remediation
65
+ // actor. The primary target of the conflict verdict must now be the rebase gate.
66
+ assert(
67
+ hasFlow("gw-mergeable", "gw-rebase"),
68
+ "conflict must reach the rebase gate (the #42 remediation actor)",
69
+ );
70
+ // Sanity: the untouched ready path still lands the merge directly.
71
+ assert(hasFlow("gw-mergeable", "attempt-merge"), "ready → attempt-merge path should still exist");
72
+ });
@@ -0,0 +1,91 @@
1
+ // Red/green regression for D6's pure merge-train lane planner.
2
+ import { assertEquals } from "jsr:@std/assert@1";
3
+ import { planLane, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
4
+
5
+ const taskToPr = new Map([
6
+ ["gap-2", "o/r#2"],
7
+ ["gap-8", "o/r#8"],
8
+ ["gap-9", "o/r#9"],
9
+ ["gap-10", "o/r#10"],
10
+ ]);
11
+
12
+ Deno.test("planLane: dependency depth then task id picks the single lane head", () => {
13
+ const depth = new Map([["gap-8", 2], ["gap-2", 0], ["gap-9", 0]]);
14
+ assertEquals(planLane(["gap-8", "gap-9", "gap-2"], taskToPr, new Set(), depth), {
15
+ headTaskId: "gap-2",
16
+ headPrKey: "o/r#2",
17
+ heldTaskIds: ["gap-9", "gap-8"],
18
+ heldPrKeys: ["o/r#9", "o/r#8"],
19
+ });
20
+ });
21
+
22
+ Deno.test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
23
+ const depths = taskDependencyDepths([
24
+ { task_id: "b", depends_on_task_id: "a" },
25
+ { task_id: "c", depends_on_task_id: "b" },
26
+ { task_id: "d", depends_on_task_id: "a" },
27
+ { task_id: "e", depends_on_task_id: "c" },
28
+ { task_id: "e", depends_on_task_id: "d" },
29
+ ]);
30
+ assertEquals([depths.get("a"), depths.get("b"), depths.get("c"), depths.get("d"), depths.get("e")], [
31
+ 0,
32
+ 1,
33
+ 2,
34
+ 1,
35
+ 3,
36
+ ]);
37
+ });
38
+
39
+ Deno.test("planLane: task id is the deterministic tiebreaker", () => {
40
+ assertEquals(planLane(["gap-9", "gap-2", "gap-8"], taskToPr, new Set()), {
41
+ headTaskId: "gap-2",
42
+ headPrKey: "o/r#2",
43
+ heldTaskIds: ["gap-8", "gap-9"],
44
+ heldPrKeys: ["o/r#8", "o/r#9"],
45
+ });
46
+ });
47
+
48
+ Deno.test("planLane: already-merged head advances to the next unmerged member", () => {
49
+ assertEquals(planLane(["gap-2", "gap-8", "gap-9"], taskToPr, new Set(["o/r#2"])), {
50
+ headTaskId: "gap-8",
51
+ headPrKey: "o/r#8",
52
+ heldTaskIds: ["gap-9"],
53
+ heldPrKeys: ["o/r#9"],
54
+ });
55
+ });
56
+
57
+ Deno.test("planLane: single-member lanes never hold their PR", () => {
58
+ assertEquals(planLane(["gap-10"], taskToPr, new Set()), {
59
+ headTaskId: null,
60
+ headPrKey: null,
61
+ heldTaskIds: [],
62
+ heldPrKeys: [],
63
+ });
64
+ });
65
+
66
+ Deno.test("planLane: tasks without PR keys do not block PR-backed members", () => {
67
+ assertEquals(planLane(["scaffold", "gap-2", "gap-8"], taskToPr, new Set()), {
68
+ headTaskId: "gap-2",
69
+ headPrKey: "o/r#2",
70
+ heldTaskIds: ["gap-8"],
71
+ heldPrKeys: ["o/r#8"],
72
+ });
73
+ });
74
+
75
+ Deno.test("planPrLane: held PRs point at the current lane head", () => {
76
+ assertEquals(
77
+ planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#8"),
78
+ {
79
+ isHeld: true,
80
+ laneHeadOf: "o/r#2",
81
+ laneHeadTaskId: "gap-2",
82
+ laneTaskIds: ["gap-2", "gap-8"],
83
+ },
84
+ );
85
+ });
86
+
87
+ Deno.test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
88
+ assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#2").isHeld, false);
89
+ assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#10").isHeld, false);
90
+ assertEquals(planPrLane([["gap-2", "gap-8"]], taskToPr, new Set(), "o/r#404").isHeld, false);
91
+ });
@@ -0,0 +1,117 @@
1
+ // nano-workforce — D6 merge-side lane serialization (issue #49).
2
+ //
3
+ // The D2 merge-exclusion graph groups tasks into landing lanes: tasks in the same lane can
4
+ // implement concurrently but must merge serially. This pure planner chooses exactly one PR per
5
+ // non-singleton lane to proceed now. Order is dependency depth first (a task that depends on another
6
+ // lands later), then task id for stable tie-breaking; tasks with no PR key are ignored because there
7
+ // is nothing to land.
8
+
9
+ export interface LanePlan {
10
+ headTaskId: string | null;
11
+ headPrKey: string | null;
12
+ heldTaskIds: string[];
13
+ heldPrKeys: string[];
14
+ }
15
+
16
+ export interface PrLaneDecision {
17
+ isHeld: boolean;
18
+ laneHeadOf: string | null;
19
+ laneHeadTaskId: string | null;
20
+ laneTaskIds: string[];
21
+ }
22
+
23
+ export interface TaskDependencyEdge {
24
+ task_id: string;
25
+ depends_on_task_id: string;
26
+ }
27
+
28
+ /** Compute the same dependency depth used for lane order: roots at 0, dependants at
29
+ * `1 + max(dep depth)`. Cycles should already be rejected by plan review; if stale data contains
30
+ * one, break the recursive back-edge rather than wedging the poller. */
31
+ export function taskDependencyDepths(edges: readonly TaskDependencyEdge[]): Map<string, number> {
32
+ const depsByTask = new Map<string, string[]>();
33
+ const ids = new Set<string>();
34
+ for (const e of edges) {
35
+ ids.add(e.task_id);
36
+ ids.add(e.depends_on_task_id);
37
+ const deps = depsByTask.get(e.task_id) ?? [];
38
+ deps.push(e.depends_on_task_id);
39
+ depsByTask.set(e.task_id, deps);
40
+ }
41
+ const depthOf = new Map<string, number>();
42
+ const visiting = new Set<string>();
43
+ const depth = (taskId: string): number => {
44
+ const cached = depthOf.get(taskId);
45
+ if (cached !== undefined) return cached;
46
+ if (visiting.has(taskId)) return 0;
47
+ visiting.add(taskId);
48
+ let d = 0;
49
+ for (const dep of depsByTask.get(taskId) ?? []) d = Math.max(d, depth(dep) + 1);
50
+ visiting.delete(taskId);
51
+ depthOf.set(taskId, d);
52
+ return d;
53
+ };
54
+ for (const id of ids) depth(id);
55
+ return depthOf;
56
+ }
57
+
58
+ const depthOf = (taskDepth: ReadonlyMap<string, number> | undefined, taskId: string) =>
59
+ taskDepth?.get(taskId) ?? 0;
60
+
61
+ function compareTaskOrder(
62
+ a: string,
63
+ b: string,
64
+ taskDepth?: ReadonlyMap<string, number>,
65
+ ): number {
66
+ const da = depthOf(taskDepth, a);
67
+ const db = depthOf(taskDepth, b);
68
+ if (da !== db) return da - db;
69
+ return a < b ? -1 : a > b ? 1 : 0;
70
+ }
71
+
72
+ /** Plan one serial landing lane. Singleton lanes never hold their sole PR; lanes with no unmerged
73
+ * PR-backed members have no head. */
74
+ export function planLane(
75
+ laneTaskIds: readonly string[],
76
+ taskToPr: ReadonlyMap<string, string>,
77
+ mergedPrKeys: ReadonlySet<string>,
78
+ taskDepth?: ReadonlyMap<string, number>,
79
+ ): LanePlan {
80
+ if (laneTaskIds.length <= 1) {
81
+ return { headTaskId: null, headPrKey: null, heldTaskIds: [], heldPrKeys: [] };
82
+ }
83
+ const candidates = laneTaskIds
84
+ .filter((taskId) => {
85
+ const prKey = taskToPr.get(taskId);
86
+ return prKey && !mergedPrKeys.has(prKey);
87
+ })
88
+ .sort((a, b) => compareTaskOrder(a, b, taskDepth));
89
+ const headTaskId = candidates[0] ?? null;
90
+ const headPrKey = headTaskId ? taskToPr.get(headTaskId) ?? null : null;
91
+ const heldTaskIds = candidates.slice(1);
92
+ const heldPrKeys = heldTaskIds.map((taskId) => taskToPr.get(taskId)).filter((p): p is string => !!p);
93
+ return { headTaskId, headPrKey, heldTaskIds, heldPrKeys };
94
+ }
95
+
96
+ /** Decide whether a PR is currently held by its merge-exclusion lane. PRs outside a non-singleton
97
+ * exclusion lane are backward-compatible: they are never held. */
98
+ export function planPrLane(
99
+ lanes: readonly (readonly string[])[],
100
+ taskToPr: ReadonlyMap<string, string>,
101
+ mergedPrKeys: ReadonlySet<string>,
102
+ prKey: string,
103
+ taskDepth?: ReadonlyMap<string, number>,
104
+ ): PrLaneDecision {
105
+ for (const lane of lanes) {
106
+ if (lane.length <= 1) continue;
107
+ if (!lane.some((taskId) => taskToPr.get(taskId) === prKey)) continue;
108
+ const plan = planLane(lane, taskToPr, mergedPrKeys, taskDepth);
109
+ return {
110
+ isHeld: plan.heldPrKeys.includes(prKey),
111
+ laneHeadOf: plan.headPrKey,
112
+ laneHeadTaskId: plan.headTaskId,
113
+ laneTaskIds: [...lane],
114
+ };
115
+ }
116
+ return { isHeld: false, laneHeadOf: null, laneHeadTaskId: null, laneTaskIds: [] };
117
+ }
@@ -0,0 +1,119 @@
1
+ // Red/green regression for pr.persist-escalation's round-recording (PR #32 review).
2
+ //
3
+ // The "review stalled" timer arm runs *after* `persist-round` has already inserted an
4
+ // `addressed` row for this `round`. Re-inserting a `rounds` row there would record one round as
5
+ // both `addressed` and `blocked`, making round history/UI ambiguous. The stalled arm therefore
6
+ // passes `recordRound=false`, which must suppress the round insert while still opening the
7
+ // escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
8
+ // still record the round.
9
+ import { assert, assertEquals } from "jsr:@std/assert@1";
10
+ import handler from "../workers/persist-escalation/worker.ts";
11
+
12
+ function fakeApp() {
13
+ const inserts: Record<string, unknown[]> = { rounds: [], escalations: [] };
14
+ const updates: Record<string, unknown[]> = { pull_requests: [] };
15
+ const app = {
16
+ data: {
17
+ table(name: string, _key: string) {
18
+ return {
19
+ // deno-lint-ignore require-await
20
+ async insert(row: unknown) {
21
+ (inserts[name] ??= []).push(row);
22
+ return name === "escalations" ? 42 : 1;
23
+ },
24
+ // deno-lint-ignore require-await
25
+ async update(key: string, patch: unknown) {
26
+ (updates[name] ??= []).push({ key, patch });
27
+ },
28
+ };
29
+ },
30
+ },
31
+ };
32
+ return { app, inserts, updates };
33
+ }
34
+
35
+ Deno.test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
36
+ const { app, inserts } = fakeApp();
37
+ const job = {
38
+ variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "stalled", recordRound: false },
39
+ };
40
+ // deno-lint-ignore no-explicit-any
41
+ const out = await handler(job as any, app as any);
42
+ assertEquals(inserts.rounds.length, 0, "no round row when the round was already recorded");
43
+ assertEquals(inserts.escalations.length, 1, "escalation is still opened");
44
+ // deno-lint-ignore no-explicit-any
45
+ assertEquals((out as any).escalationId, 42);
46
+ });
47
+
48
+ Deno.test("escalation arm without the flag still records the round", async () => {
49
+ const { app, inserts } = fakeApp();
50
+ const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
51
+ // deno-lint-ignore no-explicit-any
52
+ await handler(job as any, app as any);
53
+ assertEquals(inserts.rounds.length, 1);
54
+ // deno-lint-ignore no-explicit-any
55
+ assertEquals((inserts.rounds[0] as any).round_no, 3);
56
+ });
57
+
58
+ // A blank question would have to be trimmed away, so a padded-but-non-blank question is stored
59
+ // trimmed (matching the sibling `persist-task-escalation`) — no whitespace drift into the UI/DB.
60
+ Deno.test("a padded question is persisted trimmed (no whitespace drift)", async () => {
61
+ const { app, inserts, updates } = fakeApp();
62
+ const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
63
+ // deno-lint-ignore no-explicit-any
64
+ await handler(job as any, app as any);
65
+ // deno-lint-ignore no-explicit-any
66
+ assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
67
+ // deno-lint-ignore no-explicit-any
68
+ assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
69
+ });
70
+
71
+ // A round that fell through the `gw-status` default (no `converged`/`addressed` status and no
72
+ // question — the prompt-less-agent failure behind the empty "(no question provided)" escalations
73
+ // on Magikcraft/nano-bpm #597/#599) must NOT throw (which parked an un-remediable JobNoRetries
74
+ // incident). It now opens an *answerable* escalation with a fabricated, concrete question and the
75
+ // agent's transcript attached, so a human can unblock the loop entirely from the UI.
76
+ Deno.test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
77
+ for (const question of [undefined, "", " "]) {
78
+ const { app, inserts, updates } = fakeApp();
79
+ const job = {
80
+ variables: {
81
+ prKey: "o/r#1",
82
+ round: 2,
83
+ ...(question === undefined ? {} : { question }),
84
+ "io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
85
+ },
86
+ };
87
+ // deno-lint-ignore no-explicit-any
88
+ const out = await handler(job as any, app as any);
89
+ // deno-lint-ignore no-explicit-any
90
+ assertEquals((out as any).escalationId, 42, "an escalation is opened, not refused");
91
+ assertEquals(inserts.escalations.length, 1, "escalation row written");
92
+ // deno-lint-ignore no-explicit-any
93
+ const esc = inserts.escalations[0] as any;
94
+ assert(esc.question.trim().length > 0, "fabricated question is concrete/non-blank");
95
+ assert(
96
+ esc.question.includes("machine-readable result"),
97
+ "no-result rounds explain the missing status",
98
+ );
99
+ assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
100
+ // Default status for an unclassified escalation is a question needing input.
101
+ assertEquals(esc.kind, "question");
102
+ // deno-lint-ignore no-explicit-any
103
+ const pr = updates.pull_requests![0] as any;
104
+ assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
105
+ }
106
+ });
107
+
108
+ // When a non-empty-but-unclassified status arrives with no question, the fabricated question
109
+ // names the status so the human sees what the agent reported.
110
+ Deno.test("unclassified status without a question names the status in the fabricated question", async () => {
111
+ const { app, inserts } = fakeApp();
112
+ const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
113
+ // deno-lint-ignore no-explicit-any
114
+ await handler(job as any, app as any);
115
+ // deno-lint-ignore no-explicit-any
116
+ const esc = inserts.escalations[0] as any;
117
+ assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
118
+ assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
119
+ });
@@ -0,0 +1,65 @@
1
+ // Red/green regression for pr.persist-round's round recording + parking behaviour.
2
+ //
3
+ // The convergence loop routes both `addressed` (the agent pushed changes) and the new `waiting`
4
+ // (nothing to triage yet — round 1, awaiting the first review) statuses through gw-guard into
5
+ // persist-round. Both must be recorded in `rounds` under their own status and both must park the
6
+ // PR in `waiting_review` so the deterministic poller (app/service.ts) starts soliciting a review.
7
+ // A `waiting` round is what replaced the old failure mode where an agent with nothing to do
8
+ // re-requested the review destructively and escalated `blocked`.
9
+ import { assertEquals } from "jsr:@std/assert@1";
10
+ import handler from "../workers/persist-round/worker.ts";
11
+
12
+ function fakeApp() {
13
+ const inserts: Record<string, unknown[]> = { rounds: [] };
14
+ const updates: Record<string, unknown[]> = { pull_requests: [] };
15
+ const app = {
16
+ data: {
17
+ table(name: string, _key: string) {
18
+ return {
19
+ // deno-lint-ignore require-await
20
+ async insert(row: unknown) {
21
+ (inserts[name] ??= []).push(row);
22
+ return 1;
23
+ },
24
+ // deno-lint-ignore require-await
25
+ async update(key: string, patch: unknown) {
26
+ (updates[name] ??= []).push({ key, patch });
27
+ },
28
+ };
29
+ },
30
+ },
31
+ };
32
+ return { app, inserts, updates };
33
+ }
34
+
35
+ for (const status of ["addressed", "waiting"]) {
36
+ Deno.test(`persist-round records a '${status}' round and parks the PR in waiting_review`, async () => {
37
+ const { app, inserts, updates } = fakeApp();
38
+ const job = { variables: { prKey: "o/r#1", round: 1, status, summary: `round was ${status}` } };
39
+ // deno-lint-ignore no-explicit-any
40
+ await handler(job as any, app as any);
41
+
42
+ assertEquals(inserts.rounds.length, 1, "the round is recorded");
43
+ // deno-lint-ignore no-explicit-any
44
+ const round = inserts.rounds[0] as any;
45
+ assertEquals(round.status, status, "the round carries the agent's status");
46
+ assertEquals(round.round_no, 1);
47
+
48
+ assertEquals(updates.pull_requests!.length, 1, "the PR is updated once");
49
+ // deno-lint-ignore no-explicit-any
50
+ const patch = (updates.pull_requests![0] as any).patch;
51
+ assertEquals(patch.status, "waiting_review", "the PR parks in waiting_review for the poller");
52
+ assertEquals(patch.current_round, 1);
53
+ });
54
+ }
55
+
56
+ // When the agent omits `status` (e.g. a fallback), persist-round defaults it to `addressed`
57
+ // rather than writing a NULL status — the round history stays readable.
58
+ Deno.test("persist-round defaults a missing status to 'addressed'", async () => {
59
+ const { app, inserts } = fakeApp();
60
+ const job = { variables: { prKey: "o/r#1", round: 2 } };
61
+ // deno-lint-ignore no-explicit-any
62
+ await handler(job as any, app as any);
63
+ // deno-lint-ignore no-explicit-any
64
+ assertEquals((inserts.rounds[0] as any).status, "addressed");
65
+ });