@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,308 @@
1
+ // pr.record-wave — the current wave's parallel `implement` fan-out has finished (issue #20).
2
+ //
3
+ // The MI activity aggregated one result per dispatched task into `waveResults`, index-aligned
4
+ // with the `waveTasks` `select-wave` emitted (the engine writes each child's output at its loop
5
+ // index, regardless of completion order). This worker:
6
+ // • records each slice's outcome (`opened` / `blocked`) on its `plan_tasks` row,
7
+ // • HANDS OFF each opened PR into the review-convergence loop (reusing `submitPr`), declaring
8
+ // its dependency tasks' PRs as `dependsOn` — so the merge-ordering DAG (`pr_dependencies`)
9
+ // matches the task DAG for free,
10
+ // • advances `currentWave` and emits `hasMoreWaves` so the loop either runs the next wave
11
+ // (`select-wave`) or falls through to `record-results`; if `select-wave` left a task pending
12
+ // behind a non-fatal wait (e.g. `waiting-for-lane`), the loop parks and retries this wave.
13
+ //
14
+ // Enrollment lives here (not in the finalizer) so a PR is enrolled the moment its wave lands —
15
+ // and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
16
+ import type { AppJobHandler } from "@nanobpm/urban";
17
+ import {
18
+ type PlanTask,
19
+ type PlanTaskStatus,
20
+ planTaskDeps,
21
+ planTasks,
22
+ plans,
23
+ } from "../../app/plan.ts";
24
+ import { parsePr, submitPr } from "../../app/service.ts";
25
+ import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
26
+ import { appendEntry } from "../../app/blackboard.ts";
27
+ import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
28
+ import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
29
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
30
+ import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
31
+
32
+ interface Result {
33
+ status?: unknown;
34
+ summary?: unknown;
35
+ pr?: unknown;
36
+ delta?: unknown;
37
+ }
38
+ interface WaveTaskIn {
39
+ id?: unknown;
40
+ }
41
+ interface In extends Record<string, unknown> {
42
+ planKey: string;
43
+ currentWave: number;
44
+ waveCount: number;
45
+ waveTasks?: WaveTaskIn[];
46
+ waveResults?: Result[];
47
+ }
48
+ interface Out extends Record<string, unknown> {
49
+ currentWave: number;
50
+ hasMoreWaves: boolean;
51
+ waveOpenHeads: TrialMergeHead[];
52
+ runTrialMerge: boolean;
53
+ trialMergeWave: number;
54
+ trialMergeSkipReason?: string;
55
+ }
56
+
57
+ const str = (v: unknown): string | undefined =>
58
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
59
+
60
+ // The implementation agent reports one of these (see prompts/feature.md). Anything else —
61
+ // including a missing status — is treated as `blocked`: we must not assume a PR was opened,
62
+ // and we only hand off / persist a PR when the status is `opened`.
63
+ type WaveResultStatus = Extract<PlanTaskStatus, "opened" | "blocked" | "skipped">;
64
+ const ALLOWED_STATUSES = new Set<WaveResultStatus>(["opened", "blocked", "skipped"]);
65
+ const isWaveResultStatus = (s: string): s is WaveResultStatus =>
66
+ ALLOWED_STATUSES.has(s as WaveResultStatus);
67
+
68
+ // Coerce a wave index/count to a non-negative integer, falling back to 0. A NaN here would make
69
+ // `nextWave < waveCount` mis-evaluate and end the loop early, leaving tasks `pending`.
70
+ const toWave = (v: unknown): number => {
71
+ const n = Math.trunc(Number(v));
72
+ return Number.isFinite(n) && n >= 0 ? n : 0;
73
+ };
74
+
75
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
76
+ const planKey = job.variables.planKey;
77
+ const currentWave = toWave(job.variables.currentWave);
78
+ const waveCount = toWave(job.variables.waveCount);
79
+ const waveTasks = Array.isArray(job.variables.waveTasks) ? job.variables.waveTasks : [];
80
+ const results = Array.isArray(job.variables.waveResults) ? job.variables.waveResults : [];
81
+ const ts = new Date().toISOString();
82
+
83
+ const taskTable = planTasks(app.data);
84
+ const rows = await taskTable.find({ plan_key: planKey });
85
+ const byTaskId = new Map<string, PlanTask>();
86
+ for (const r of rows) byTaskId.set(r.task_id, r);
87
+
88
+ const deps = await planTaskDeps(app.data).find({ plan_key: planKey });
89
+ const depsByTask = new Map<string, string[]>();
90
+ for (const d of deps) {
91
+ const list = depsByTask.get(d.task_id) ?? [];
92
+ list.push(d.depends_on_task_id);
93
+ depsByTask.set(d.task_id, list);
94
+ }
95
+
96
+ // The tasks with a concurrently-open PR in THIS wave — the set the D2 conflict-scan runs over
97
+ // (cross-wave pairs are moot: the wave barrier merges earlier waves before later ones start).
98
+ // This includes both `opened` PRs (also handed off below) AND `escalated` tasks' work-preserving
99
+ // DRAFT PRs (feature.md): a draft's changed files can still overlap a sibling's, so omitting it
100
+ // would silently under-approximate the merge-exclusion graph (the scan is a deliberate
101
+ // over-approximation). Escalated drafts are scanned but NEVER handed off (not ready for review).
102
+ const openedThisWave: { taskId: string; repo: string; number: number | string }[] = [];
103
+ const readyHeadsThisWave: { repo: string; number: number | string }[] = [];
104
+
105
+ for (let i = 0; i < waveTasks.length; i++) {
106
+ const taskId = str((waveTasks[i] ?? {}).id);
107
+ if (!taskId) continue;
108
+ const res = results[i] ?? {};
109
+ const rawStatus = str(res.status);
110
+ const status: WaveResultStatus = rawStatus && isWaveResultStatus(rawStatus)
111
+ ? rawStatus
112
+ : "blocked";
113
+ const summary = str(res.summary);
114
+ const prRef = str(res.pr);
115
+ // Only trust a PR ref when the agent reports it actually opened one.
116
+ const parsed = status === "opened" && prRef ? parsePr(prRef) : null;
117
+ // A keyless "opened" is effectively blocked: downstream waves gate on `opened` meaning
118
+ // "this dependency has an opened PR", so an "opened" with no usable PR key must NOT satisfy
119
+ // a dependant (it would let dependents run with a phantom, un-mergeable dependency).
120
+ const effectiveStatus = status === "opened" && !parsed ? "blocked" : status;
121
+
122
+ const row = byTaskId.get(taskId);
123
+ if (row) {
124
+ const patch: Partial<PlanTask> = { status: effectiveStatus, updated_at: ts };
125
+ if (summary !== undefined) patch.summary = summary;
126
+ if (parsed?.prKey) {
127
+ patch.pr_key = parsed.prKey;
128
+ // Keep the in-memory row current so a same-wave dependant (rare) sees the PR key.
129
+ row.pr_key = parsed.prKey;
130
+ }
131
+ await taskTable.update(row.id, patch);
132
+ }
133
+
134
+ // D5 (issue #55): capture the agent's structured scope/impl-change delta, then broadcast the
135
+ // file/constraint facts onto the D4 coordination blackboard so later waves + the operator see
136
+ // them (and D2 conflict-scan can consume them). Both are best-effort and idempotent — a failed
137
+ // or retried delta write must never fail the wave. `recordTaskDelta` upserts per (plan, task);
138
+ // the blackboard posts are dedupe-keyed, so a worker retry is a no-op.
139
+ const delta = parseTaskDelta(res.delta);
140
+ if (delta) {
141
+ try {
142
+ await recordTaskDelta(app.data, planKey, taskId, delta, { wave: currentWave });
143
+ if (delta.newlyTouches.length > 0) {
144
+ const why = delta.contractChange ?? delta.constraint ??
145
+ `${taskId} now also edits ${delta.newlyTouches.join(", ")}`;
146
+ await appendEntry(app.data, planKey, {
147
+ author_task: taskId,
148
+ kind: "file-claim",
149
+ files: delta.newlyTouches,
150
+ body: why,
151
+ wave: currentWave,
152
+ dedupe_key: `delta:${taskId}:touch`,
153
+ });
154
+ }
155
+ const constraintBody = [delta.contractChange, delta.constraint].filter(Boolean).join(" — ");
156
+ if (constraintBody) {
157
+ await appendEntry(app.data, planKey, {
158
+ author_task: taskId,
159
+ kind: "constraint-change",
160
+ body: delta.affectsTasks.length
161
+ ? `${constraintBody} (affects: ${delta.affectsTasks.join(", ")})`
162
+ : constraintBody,
163
+ wave: currentWave,
164
+ dedupe_key: `delta:${taskId}:constraint`,
165
+ });
166
+ }
167
+ } catch (err) {
168
+ app.log("error", `record-wave: recording delta for ${taskId} failed`, {
169
+ err: String(err),
170
+ });
171
+ }
172
+ }
173
+
174
+ // Include this task's PR in the D2 conflict-scan set when it is concurrently open in the wave:
175
+ // an `opened` PR (also handed off below), OR an `escalated` task's work-preserving DRAFT PR
176
+ // (feature.md — `status: "escalated"` may carry the draft `pr` it opened to preserve work).
177
+ // The draft's changed files can overlap a sibling's, so scanning it keeps the merge-exclusion
178
+ // graph a conservative over-approximation instead of silently missing those overlaps.
179
+ const scanPr = parsed ?? (rawStatus === "escalated" && prRef ? parsePr(prRef) : null);
180
+ if (scanPr) {
181
+ openedThisWave.push({ taskId, repo: scanPr.repo, number: scanPr.number });
182
+ }
183
+ if (parsed) readyHeadsThisWave.push({ repo: parsed.repo, number: parsed.number });
184
+ // Handoff: enroll each opened PR into the convergence loop. Best-effort — a failed handoff
185
+ // must not fail the wave; the PR is recorded and can be resubmitted. `submitPr` is idempotent
186
+ // on prKey (a PR already converging is a no-op), so a retry of this worker won't double-start.
187
+ // Only `opened` PRs are handed off — an escalated draft is not yet ready for review.
188
+ if (parsed) {
189
+ const depPrKeys: string[] = [];
190
+ for (const depTaskId of depsByTask.get(taskId) ?? []) {
191
+ const depRow = byTaskId.get(depTaskId);
192
+ if (depRow?.pr_key) depPrKeys.push(depRow.pr_key);
193
+ }
194
+
195
+ try {
196
+ await submitPr(app.data, app.engine, parsed, depPrKeys);
197
+ } catch (err) {
198
+ app.log("error", `record-wave: handoff failed for ${parsed.prKey}`, {
199
+ err: String(err),
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ // D3 trial-merge gate (issue #69): expose the concurrently-open READY PR heads for the BPMN
206
+ // agent step, and decide whether to dispatch it at all. Single-head waves are just ordinary PR
207
+ // CI, and Mergify queue repos already perform their own batch trial merge, so both skip.
208
+ let waveOpenHeads: TrialMergeHead[] = readyHeadsThisWave.map((h) => ({ repo: h.repo, prNumber: h.number }));
209
+ const stillPendingCurrentWave = (await taskTable.find({ plan_key: planKey }))
210
+ .some((t) => (t.wave ?? 0) === currentWave && t.status === "pending");
211
+ let runTrialMerge = false;
212
+ let trialMergeSkipReason: string | undefined;
213
+ if (stillPendingCurrentWave) {
214
+ trialMergeSkipReason = "wave-still-pending";
215
+ } else if (waveOpenHeads.length < 2) {
216
+ trialMergeSkipReason = "fewer-than-two-open-heads";
217
+ } else {
218
+ waveOpenHeads = await Promise.all(waveOpenHeads.map(async (head) => {
219
+ try {
220
+ const meta = await fetchPrHead(head.repo, head.prNumber, process.env.GITHUB_TOKEN ?? "");
221
+ if (meta?.headRef) head.headRef = meta.headRef;
222
+ if (meta?.headSha) head.headSha = meta.headSha;
223
+ } catch (err) {
224
+ app.log("error", `record-wave: pr head fetch failed for ${head.repo}#${head.prNumber}`, { err: String(err) });
225
+ }
226
+ return head;
227
+ }));
228
+ const repo = waveOpenHeads[0]?.repo ?? planKey.split("#")[0];
229
+ const protocol = await loadMergeProtocol(repo, process.env.GITHUB_TOKEN ?? "");
230
+ runTrialMerge = shouldRunTrialMerge(waveOpenHeads.length, protocol);
231
+ if (!runTrialMerge) trialMergeSkipReason = "mergify-queue";
232
+ }
233
+
234
+ // D2 conflict-scan (issue #58): derive the merge-exclusion graph (D1/#57) for this wave's
235
+ // concurrently-open PRs from FILE-OVERLAP — any two that touch the same path can't land
236
+ // independently. Each task's file set = its reported `newlyTouches` (D5, zero I/O) ∪ its PR's
237
+ // actual changed files (best-effort via gh/token). Whole block is best-effort + idempotent
238
+ // (upsert per pair): a transport failure or a retry must never fail the wave.
239
+ if (openedThisWave.length >= 2) {
240
+ try {
241
+ const deltas = await readTaskDeltas(app.data, planKey);
242
+ const touchesByTask = new Map<string, Set<string>>();
243
+ const openedIds = new Set(openedThisWave.map((o) => o.taskId));
244
+ for (const d of deltas) {
245
+ if (!openedIds.has(d.taskId) || d.newlyTouches.length === 0) continue;
246
+ touchesByTask.set(d.taskId, new Set(d.newlyTouches));
247
+ }
248
+ const token = process.env.GITHUB_TOKEN ?? "";
249
+ for (const o of openedThisWave) {
250
+ try {
251
+ const files = await fetchPrFiles(o.repo, o.number, token);
252
+ if (!files || files.length === 0) continue;
253
+ const set = touchesByTask.get(o.taskId) ?? new Set<string>();
254
+ for (const f of files) set.add(f);
255
+ touchesByTask.set(o.taskId, set);
256
+ } catch (err) {
257
+ app.log("error", `record-wave: pr files fetch failed for ${o.repo}#${o.number}`, {
258
+ err: String(err),
259
+ });
260
+ }
261
+ }
262
+ const edges = deriveExclusions(touchesByTask);
263
+ if (edges.length > 0) {
264
+ const { inserted, updated } = await recordExclusions(app.data, planKey, edges);
265
+ app.log("info", `record-wave: merge-exclusion scan wave ${currentWave}`, {
266
+ planKey,
267
+ edges: edges.length,
268
+ inserted,
269
+ updated,
270
+ });
271
+ }
272
+ } catch (err) {
273
+ app.log("error", `record-wave: merge-exclusion scan failed for ${planKey}`, {
274
+ err: String(err),
275
+ });
276
+ }
277
+ }
278
+
279
+ const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
280
+ const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
281
+
282
+ // Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
283
+ // `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
284
+ // `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
285
+ // `wave-merged` once the wave has landed. Clear it on the final wave so a re-planned issue can't
286
+ // inherit a stale gate. Best-effort: a failed marker write must not fail the wave (the poller
287
+ // reconciles from `plan_tasks`/`pull_requests`), but the loop still relies on it to know which
288
+ // wave to watch, so we log a failure loudly.
289
+ try {
290
+ await plans(app.data).update(planKey, {
291
+ gate_wave: hasMoreWaves ? currentWave : null,
292
+ updated_at: ts,
293
+ });
294
+ } catch (err) {
295
+ app.log("error", `record-wave: arming wave gate failed for ${planKey}`, { err: String(err) });
296
+ }
297
+
298
+ return {
299
+ currentWave: nextWave,
300
+ hasMoreWaves,
301
+ waveOpenHeads,
302
+ runTrialMerge,
303
+ trialMergeWave: currentWave,
304
+ trialMergeSkipReason,
305
+ };
306
+ };
307
+
308
+ export default handler;
@@ -0,0 +1,130 @@
1
+ // Red/green regression for `waiting-for-lane` in the wave dependency cascade (D7 / issue #63).
2
+ //
3
+ // A predecessor parked behind a merge lane is not a failed slice: dependents must remain pending
4
+ // so a later retry can dispatch them once the lane clears. Real non-open failures still cascade
5
+ // to `skipped` as before.
6
+ import { assertEquals } from "jsr:@std/assert@1";
7
+ import handler from "./worker.ts";
8
+ import type { PlanTaskStatus } from "../../app/plan.ts";
9
+
10
+ interface Row {
11
+ id: number;
12
+ plan_key: string;
13
+ task_id: string;
14
+ title?: string | null;
15
+ prompt?: string | null;
16
+ status: PlanTaskStatus;
17
+ wave?: number | null;
18
+ summary?: string | null;
19
+ }
20
+
21
+ interface DepRow {
22
+ plan_key: string;
23
+ task_id: string;
24
+ depends_on_task_id: string;
25
+ }
26
+
27
+ function fakeApp(rows: Row[], deps: DepRow[]) {
28
+ return {
29
+ data: {
30
+ table(name: string, key: string) {
31
+ const store = name === "plan_tasks" ? rows : deps;
32
+ return {
33
+ // deno-lint-ignore no-explicit-any
34
+ find: (q: any) =>
35
+ Promise.resolve(
36
+ store.filter((r) =>
37
+ Object.entries(q).every(([f, v]) =>
38
+ ((r as unknown) as Record<string, unknown>)[f] === v
39
+ )
40
+ ),
41
+ ),
42
+ // deno-lint-ignore no-explicit-any
43
+ update: (k: any, patch: any) => {
44
+ const row = store.find((r) =>
45
+ ((r as unknown) as Record<string, unknown>)[key] === k
46
+ );
47
+ if (row) Object.assign(row, patch);
48
+ return Promise.resolve(row);
49
+ },
50
+ };
51
+ },
52
+ },
53
+ // deno-lint-ignore no-explicit-any
54
+ } as any;
55
+ }
56
+
57
+ async function selectWave(rows: Row[], deps: DepRow[]) {
58
+ const out = await handler(
59
+ // deno-lint-ignore no-explicit-any
60
+ { variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
61
+ fakeApp(rows, deps),
62
+ );
63
+ return out as { waveTasks: unknown[] };
64
+ }
65
+
66
+ Deno.test("select-wave leaves dependents pending behind a waiting-for-lane dependency", async () => {
67
+ const rows: Row[] = [
68
+ {
69
+ id: 1,
70
+ plan_key: "owner/repo#63",
71
+ task_id: "a",
72
+ status: "waiting-for-lane",
73
+ wave: 0,
74
+ },
75
+ {
76
+ id: 2,
77
+ plan_key: "owner/repo#63",
78
+ task_id: "b",
79
+ title: "B",
80
+ prompt: "do B",
81
+ status: "pending",
82
+ wave: 1,
83
+ },
84
+ ];
85
+ const deps: DepRow[] = [{
86
+ plan_key: "owner/repo#63",
87
+ task_id: "b",
88
+ depends_on_task_id: "a",
89
+ }];
90
+
91
+ const out = await selectWave(rows, deps);
92
+
93
+ assertEquals(out.waveTasks, []);
94
+ assertEquals(rows[1].status, "pending");
95
+ assertEquals(rows[1].summary, undefined);
96
+ });
97
+
98
+ Deno.test("select-wave still skips dependents behind failed or otherwise non-open dependencies", async (t) => {
99
+ for (const depStatus of ["blocked", "skipped", "pending"] as const) {
100
+ await t.step(depStatus, async () => {
101
+ const rows: Row[] = [
102
+ {
103
+ id: 1,
104
+ plan_key: "owner/repo#63",
105
+ task_id: "a",
106
+ status: depStatus,
107
+ wave: 0,
108
+ },
109
+ {
110
+ id: 2,
111
+ plan_key: "owner/repo#63",
112
+ task_id: "b",
113
+ status: "pending",
114
+ wave: 1,
115
+ },
116
+ ];
117
+ const deps: DepRow[] = [{
118
+ plan_key: "owner/repo#63",
119
+ task_id: "b",
120
+ depends_on_task_id: "a",
121
+ }];
122
+
123
+ const out = await selectWave(rows, deps);
124
+
125
+ assertEquals(out.waveTasks, []);
126
+ assertEquals(rows[1].status, "skipped");
127
+ assertEquals(rows[1].summary, "dependency not opened: a");
128
+ });
129
+ }
130
+ });
@@ -0,0 +1,84 @@
1
+ // pr.select-wave — pick the tasks to run in the current wave (issue #20).
2
+ //
3
+ // The plan-fanout wave loop calls this before each parallel `implement` fan-out. It loads
4
+ // `plan_tasks` for the plan, keeps the still-`pending` tasks whose `wave` equals the process's
5
+ // `currentWave`, and emits them as `waveTasks: [{ id, title, prompt }]` — the multi-instance
6
+ // input collection.
7
+ //
8
+ // A task is only runnable when EVERY dependency it declared has an `opened` PR. If any
9
+ // dependency ended `blocked` / `skipped` (or is otherwise not opened), the dependent can't be
10
+ // built: this worker marks it `skipped` (recording which deps were unmet) and excludes it from
11
+ // the wave, so the failure cascades forward instead of dispatching an agent that can't succeed.
12
+ // A dependency in `waiting-for-lane` is different: its PR is good but parked behind a merge lane,
13
+ // so the dependent stays `pending` and simply waits for a later wave retry.
14
+ //
15
+ // Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
16
+ // immediately (the same 0-task path the flat fan-out already relied on).
17
+ import type { AppJobHandler } from "@nanobpm/urban";
18
+ import { planTaskDeps, planTasks } from "../../app/plan.ts";
19
+
20
+ interface In extends Record<string, unknown> {
21
+ planKey: string;
22
+ currentWave: number;
23
+ }
24
+ interface WaveTaskOut {
25
+ id: string;
26
+ title: string;
27
+ prompt: string;
28
+ }
29
+ interface Out extends Record<string, unknown> {
30
+ waveTasks: WaveTaskOut[];
31
+ }
32
+
33
+ // Coerce a wave index to a non-negative integer, falling back to 0. A NaN currentWave would make
34
+ // the `(r.wave ?? 0) !== currentWave` filter always true, silently emitting an empty wave.
35
+ const toWave = (v: unknown): number => {
36
+ const n = Math.trunc(Number(v));
37
+ return Number.isFinite(n) && n >= 0 ? n : 0;
38
+ };
39
+
40
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
41
+ const planKey = job.variables.planKey;
42
+ const currentWave = toWave(job.variables.currentWave);
43
+ const ts = new Date().toISOString();
44
+
45
+ const taskTable = planTasks(app.data);
46
+ const rows = await taskTable.find({ plan_key: planKey });
47
+ const statusById = new Map<string, string>();
48
+ for (const r of rows) statusById.set(r.task_id, r.status);
49
+
50
+ const deps = await planTaskDeps(app.data).find({ plan_key: planKey });
51
+ const depsByTask = new Map<string, string[]>();
52
+ for (const d of deps) {
53
+ const list = depsByTask.get(d.task_id) ?? [];
54
+ list.push(d.depends_on_task_id);
55
+ depsByTask.set(d.task_id, list);
56
+ }
57
+
58
+ const waveTasks: WaveTaskOut[] = [];
59
+ for (const r of rows) {
60
+ if ((r.wave ?? 0) !== currentWave) continue;
61
+ // Only fresh tasks are dispatchable; a retry of this wave must not re-run resolved ones.
62
+ if (r.status !== "pending") continue;
63
+
64
+ const depIds = depsByTask.get(r.task_id) ?? [];
65
+ const unmet = depIds.filter((d) => {
66
+ const status = statusById.get(d);
67
+ return status !== "opened" && status !== "waiting-for-lane";
68
+ });
69
+ if (unmet.length > 0) {
70
+ await taskTable.update(r.id, {
71
+ status: "skipped",
72
+ summary: `dependency not opened: ${unmet.join(", ")}`,
73
+ updated_at: ts,
74
+ });
75
+ continue;
76
+ }
77
+ if (depIds.some((d) => statusById.get(d) === "waiting-for-lane")) continue;
78
+ waveTasks.push({ id: r.task_id, title: r.title ?? r.task_id, prompt: r.prompt ?? "" });
79
+ }
80
+
81
+ return { waveTasks };
82
+ };
83
+
84
+ export default handler;